From aa418d8a28bf1f782821d1f302ac1e3f1cd1a076 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 12 Feb 2023 17:29:15 +0000 Subject: [PATCH 01/63] Bump sqlalchemy from 1.2.11 to 1.3.0 Bumps [sqlalchemy](https://github.com/sqlalchemy/sqlalchemy) from 1.2.11 to 1.3.0. - [Release notes](https://github.com/sqlalchemy/sqlalchemy/releases) - [Changelog](https://github.com/sqlalchemy/sqlalchemy/blob/master/CHANGES) - [Commits](https://github.com/sqlalchemy/sqlalchemy/commits) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 01ab915cd35..0ab040173e3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ celery[redis]==5.2.7 Django==3.2.15 mysqlclient==2.1.1 -SQLAlchemy==1.2.11 +SQLAlchemy==1.3.0 requests==2.28.1 pycryptodome==3.15.0 PyJWT==2.3.0 From dcd9889716781c7450d4ef8f70fc2b50653fbe5c Mon Sep 17 00:00:00 2001 From: realVinayak Date: Sat, 18 May 2024 05:01:39 -0500 Subject: [PATCH 02/63] Begin refactoring deferred scope --- .../workbench/management/commands/upload.py | 4 -- specifyweb/workbench/upload/scoping.py | 56 ++++++++++++++++--- .../upload/tests/testdisambiguation.py | 2 +- specifyweb/workbench/upload/upload.py | 31 +++++++--- specifyweb/workbench/upload/upload_table.py | 14 ++--- specifyweb/workbench/upload/uploadable.py | 2 +- 6 files changed, 80 insertions(+), 29 deletions(-) diff --git a/specifyweb/workbench/management/commands/upload.py b/specifyweb/workbench/management/commands/upload.py index dd13d7a4b08..0a7e56333dd 100644 --- a/specifyweb/workbench/management/commands/upload.py +++ b/specifyweb/workbench/management/commands/upload.py @@ -1,15 +1,11 @@ -import csv import json from jsonschema import validate # type: ignore -from optparse import make_option from django.core.management.base import BaseCommand, CommandError -from django.db import transaction from specifyweb.specify import models from specifyweb.workbench.upload.upload import do_upload_dataset -from specifyweb.workbench.upload.upload_plan_schema import schema, parse_plan from specifyweb.workbench.models import Spdataset Collection = getattr(models, 'Collection') diff --git a/specifyweb/workbench/upload/scoping.py b/specifyweb/workbench/upload/scoping.py index 0c01f4b1efe..1d0ba27de51 100644 --- a/specifyweb/workbench/upload/scoping.py +++ b/specifyweb/workbench/upload/scoping.py @@ -11,6 +11,7 @@ from .tomany import ToManyRecord, ScopedToManyRecord from .treerecord import TreeRecord, ScopedTreeRecord from .column_options import ColumnOptions, ExtendedColumnOptions +from functools import reduce """ There are cases in which the scoping of records should be dependent on another record/column in a WorkBench dataset. @@ -115,21 +116,62 @@ def extend_columnoptions(colopts: ColumnOptions, collection, tablename: str, fie dateformat=get_date_format(), ) -def apply_scoping_to_uploadtable(ut: Union[UploadTable, DeferredScopeUploadTable], collection) -> ScopedUploadTable: +def get_deferred_scoping(key, table_name, uploadable, row): + deferred_key = (table_name, key) + deferred_scoping = DEFERRED_SCOPING.get(deferred_key, None) + + if deferred_scoping is None or row is None: + return True, uploadable + + related_key, filter_field, relationship_name = deferred_scoping + related_column_name = uploadable.wbcols['name'][0] #?????? why 'name'? seems like a hack in original implementation + filter_value = row[related_column_name][related_column_name] + filter_search = {filter_field: filter_value} + related_table = datamodel.get_table_strict(related_key) + + related = getattr(models, related_table.django_name).objects.get(**filter_search) + collection_id = getattr(related, relationship_name).id + + # don't cache anymore, since values can be dependent on rows. + return False, uploadable._replace(overrideScope = {'collection': collection_id}) + +def _apply_scoping_to_uploadtable(table, row, collection, callback): + def _update_to_one_upload(previous_pack, current): + can_cache, previous_to_ones = previous_pack + field, uploadable = current + can_cache_this, uploadable = get_deferred_scoping(field, table.django_name, uploadable, row) + return can_cache and can_cache_this, [*previous_to_ones, callback(uploadable.apply_scoping(collection))] + return _update_to_one_upload + +def _combine_to_many(previous, current): + return current[0] and previous[0], {**previous[1], **current[1]} + +# also returns if the scope returned can be cached or not. +def apply_scoping_to_uploadtable(ut: UploadTable, collection, row=None) -> Tuple[bool, ScopedUploadTable]: table = datamodel.get_table_strict(ut.name) - adjust_to_ones = to_one_adjustments(collection, table) - if ut.overrideScope is not None and isinstance(ut.overrideScope['collection'], int): collection = getattr(models, "Collection").objects.filter(id=ut.overrideScope['collection']).get() - - return ScopedUploadTable( + adjust_to_ones = to_one_adjustments(collection, table) + can_cache_to_one, to_one_uploadables = reduce( + lambda previous_pack, current: _apply_scoping_to_uploadtable(table, row, collection, lambda u: adjust_to_ones(u, current[0]))(previous_pack, current), list(ut.toOne.items()), (True, [])) + + to_many_can_cache, to_many_uploadables = reduce( + lambda previous, _current: + _combine_to_many(previous, reduce( + lambda previous_pack, current: _apply_scoping_to_uploadtable(table, row, collection, lambda u: set_order_number(current[0], u))(previous_pack, current), + enumerate(_current[1]), + (False, []) + )), list(ut.toMany.items()), (True, {})) + + + return to_many_can_cache and can_cache_to_one, ScopedUploadTable( name=ut.name, wbcols={f: extend_columnoptions(colopts, collection, table.name, f) for f, colopts in ut.wbcols.items()}, static=static_adjustments(table, ut.wbcols, ut.static), - toOne={f: adjust_to_ones(u.apply_scoping(collection), f) for f, u in ut.toOne.items()}, - toMany={f: [set_order_number(i, r.apply_scoping(collection)) for i, r in enumerate(rs)] for f, rs in ut.toMany.items()}, + toOne={f: u for f, u in to_one_uploadables}, + toMany=to_many_uploadables, scopingAttrs=scoping_relationships(collection, table), disambiguation=None, ) diff --git a/specifyweb/workbench/upload/tests/testdisambiguation.py b/specifyweb/workbench/upload/tests/testdisambiguation.py index 64de1b3da40..6d8c733069f 100644 --- a/specifyweb/workbench/upload/tests/testdisambiguation.py +++ b/specifyweb/workbench/upload/tests/testdisambiguation.py @@ -64,7 +64,7 @@ def test_disambiguation(self) -> None: toMany={} )}), ]} - ).apply_scoping(self.collection) + ) data = [ {'title': "A Natural History of Mung Beans 1", 'author1': "Philomungus", 'author2': "Mungophilius"}, diff --git a/specifyweb/workbench/upload/upload.py b/specifyweb/workbench/upload/upload.py index 8da7fddad09..ef5ab2eb889 100644 --- a/specifyweb/workbench/upload/upload.py +++ b/specifyweb/workbench/upload/upload.py @@ -4,7 +4,7 @@ import time from contextlib import contextmanager from datetime import datetime, timezone -from typing import List, Dict, Union, Callable, Optional, Sized, Tuple, Any +from typing import List, Dict, Union, Callable, Optional, Sized, Tuple, Any, cast from django.db import transaction from django.db.utils import OperationalError, IntegrityError @@ -15,13 +15,13 @@ from specifyweb.specify.auditlog import auditlog from specifyweb.specify.datamodel import Table from specifyweb.specify.tree_extras import renumber_tree, set_fullnames -from specifyweb.workbench.upload.upload_table import DeferredScopeUploadTable, ScopedUploadTable +from specifyweb.workbench.upload.upload_table import DeferredScopeUploadTable, ScopedUploadTable, UploadTable from . import disambiguation from .upload_plan_schema import schema, parse_plan_with_basetable from .upload_result import Uploaded, UploadResult, ParseFailures, \ json_to_UploadResult -from .uploadable import ScopedUploadable, Row, Disambiguation, Auditor +from .uploadable import ScopedUploadable, Row, Disambiguation, Auditor, Uploadable from ..models import Spdataset Rows = Union[List[Row], csv.DictReader] @@ -123,7 +123,7 @@ def do_upload_dataset( ncols = len(ds.columns) rows = [dict(zip(ds.columns, row)) for row in ds.data] disambiguation = [get_disambiguation_from_row(ncols, row) for row in ds.data] - base_table, upload_plan = get_ds_upload_plan(collection, ds) + base_table, upload_plan = get_raw_ds_upload_plan(collection, ds) results = do_upload(collection, rows, upload_plan, uploading_agent_id, disambiguation, no_commit, allow_partial, progress) success = not any(r.contains_failure() for r in results) @@ -177,7 +177,7 @@ def get_disambiguation_from_row(ncols: int, row: List) -> Disambiguation: extra = json.loads(row[ncols]) if row[ncols] else None return disambiguation.from_json(extra['disambiguation']) if extra and 'disambiguation' in extra else None -def get_ds_upload_plan(collection, ds: Spdataset) -> Tuple[Table, ScopedUploadable]: +def get_raw_ds_upload_plan(collection, ds: Spdataset) -> Tuple[Table, Uploadable]: if ds.uploadplan is None: raise Exception("no upload plan defined for dataset") @@ -188,6 +188,10 @@ def get_ds_upload_plan(collection, ds: Spdataset) -> Tuple[Table, ScopedUploadab validate(plan, schema) base_table, plan = parse_plan_with_basetable(collection, plan) + return base_table, plan + +def get_ds_upload_plan(collection, ds: Spdataset) -> Tuple[Table, ScopedUploadable]: + base_table, plan = get_raw_ds_upload_plan(collection, ds) return base_table, plan.apply_scoping(collection) def apply_deferred_scopes(upload_plan: ScopedUploadable, rows: Rows) -> ScopedUploadable: @@ -226,7 +230,7 @@ def collection_override_function(deferred_upload_plan: DeferredScopeUploadTable, def do_upload( collection, rows: Rows, - upload_plan: ScopedUploadable, + upload_plan: Uploadable, uploading_agent_id: int, disambiguations: Optional[List[Disambiguation]]=None, no_commit: bool=False, @@ -239,7 +243,7 @@ def do_upload( # during validation skip_create_permission_check=no_commit) total = len(rows) if isinstance(rows, Sized) else None - deffered_upload_plan = apply_deferred_scopes(upload_plan, rows) + cached_scope_table = None with savepoint("main upload"): tic = time.perf_counter() results: List[UploadResult] = [] @@ -247,7 +251,16 @@ def do_upload( _cache = cache.copy() if cache is not None and allow_partial else cache da = disambiguations[i] if disambiguations else None with savepoint("row upload") if allow_partial else no_savepoint(): - bind_result = deffered_upload_plan.disambiguate(da).bind(collection, row, uploading_agent_id, _auditor, cache, i) + # the fact that upload plan is cachable, is invariant across rows. + # so, we just apply scoping once. + if cached_scope_table is None: + can_cache, scoped_table = upload_plan.apply_scoping(collection, row) + if can_cache: + cached_scope_table = scoped_table + else: + scoped_table = cached_scope_table + + bind_result = scoped_table.disambiguate(da).bind(collection, row, uploading_agent_id, _auditor, cache, i) result = UploadResult(bind_result, {}, {}) if isinstance(bind_result, ParseFailures) else bind_result.process_row() results.append(result) if progress is not None: @@ -263,7 +276,7 @@ def do_upload( if no_commit: raise Rollback("no_commit option") else: - fixup_trees(deffered_upload_plan, results) + fixup_trees(scoped_table, results) return results diff --git a/specifyweb/workbench/upload/upload_table.py b/specifyweb/workbench/upload/upload_table.py index f299853b3f5..0a3efe8c66c 100644 --- a/specifyweb/workbench/upload/upload_table.py +++ b/specifyweb/workbench/upload/upload_table.py @@ -1,7 +1,7 @@ import logging from functools import reduce -from typing import List, Dict, Any, NamedTuple, Union, Optional, Set, Callable, Literal, cast +from typing import List, Dict, Any, NamedTuple, Union, Optional, Set, Callable, Literal, cast, Tuple from django.db import transaction, IntegrityError @@ -28,9 +28,9 @@ class UploadTable(NamedTuple): overrideScope: Optional[Dict[Literal['collection'], Optional[int]]] = None - def apply_scoping(self, collection) -> "ScopedUploadTable": + def apply_scoping(self, collection, row=None) -> Tuple[bool, "ScopedUploadTable"]: from .scoping import apply_scoping_to_uploadtable - return apply_scoping_to_uploadtable(self, collection) + return apply_scoping_to_uploadtable(self, collection, row) def get_cols(self) -> Set[str]: return set(cd.column for cd in self.wbcols.values()) \ @@ -278,8 +278,8 @@ def bind(self, collection, row: Row, uploadingAgentId: int, auditor: Auditor, ca ) class OneToOneTable(UploadTable): - def apply_scoping(self, collection) -> "ScopedOneToOneTable": - s = super().apply_scoping(collection) + def apply_scoping(self, collection, row=None) -> Tuple[bool, "ScopedOneToOneTable"]: + s = super().apply_scoping(collection, row) return ScopedOneToOneTable(*s) def to_json(self) -> Dict: @@ -292,8 +292,8 @@ def bind(self, collection, row: Row, uploadingAgentId: int, auditor: Auditor, ca return BoundOneToOneTable(*b) if isinstance(b, BoundUploadTable) else b class MustMatchTable(UploadTable): - def apply_scoping(self, collection) -> "ScopedMustMatchTable": - s = super().apply_scoping(collection) + def apply_scoping(self, collection, row=None) -> Tuple[bool, "ScopedMustMatchTable"]: + s = super().apply_scoping(collection, row) return ScopedMustMatchTable(*s) def to_json(self) -> Dict: diff --git a/specifyweb/workbench/upload/uploadable.py b/specifyweb/workbench/upload/uploadable.py index 17045aa6013..68139a8e3d5 100644 --- a/specifyweb/workbench/upload/uploadable.py +++ b/specifyweb/workbench/upload/uploadable.py @@ -5,7 +5,7 @@ from .auditor import Auditor class Uploadable(Protocol): - def apply_scoping(self, collection) -> "ScopedUploadable": + def apply_scoping(self, collection, row) -> "ScopedUploadable": ... def get_cols(self) -> Set[str]: From 23c6050bb2f10c14ca7dc491d0b4b1342afeb0fc Mon Sep 17 00:00:00 2001 From: realVinayak Date: Sat, 18 May 2024 05:16:24 -0500 Subject: [PATCH 03/63] get rid of deferred scoping --- specifyweb/workbench/upload/scoping.py | 2 +- .../workbench/upload/tests/test_bugs.py | 2 +- .../workbench/upload/tests/testscoping.py | 24 +-- specifyweb/workbench/upload/upload.py | 34 ----- .../workbench/upload/upload_plan_schema.py | 32 +--- specifyweb/workbench/upload/upload_table.py | 143 ------------------ 6 files changed, 12 insertions(+), 225 deletions(-) diff --git a/specifyweb/workbench/upload/scoping.py b/specifyweb/workbench/upload/scoping.py index 1d0ba27de51..e246a2d3dbc 100644 --- a/specifyweb/workbench/upload/scoping.py +++ b/specifyweb/workbench/upload/scoping.py @@ -7,7 +7,7 @@ from specifyweb.stored_queries.format import get_date_format from .uploadable import Uploadable, ScopedUploadable -from .upload_table import UploadTable, DeferredScopeUploadTable, ScopedUploadTable, OneToOneTable, ScopedOneToOneTable +from .upload_table import UploadTable, ScopedUploadTable, OneToOneTable, ScopedOneToOneTable from .tomany import ToManyRecord, ScopedToManyRecord from .treerecord import TreeRecord, ScopedTreeRecord from .column_options import ColumnOptions, ExtendedColumnOptions diff --git a/specifyweb/workbench/upload/tests/test_bugs.py b/specifyweb/workbench/upload/tests/test_bugs.py index 767c81ea413..d8334662547 100644 --- a/specifyweb/workbench/upload/tests/test_bugs.py +++ b/specifyweb/workbench/upload/tests/test_bugs.py @@ -170,6 +170,6 @@ def test_duplicate_refworks(self) -> None: } } ''')) - upload_results = do_upload_csv(self.collection, reader, plan.apply_scoping(self.collection), self.agent.id) + upload_results = do_upload_csv(self.collection, reader, plan, self.agent.id) rr = [r.record_result.__class__ for r in upload_results] self.assertEqual(expected, rr) diff --git a/specifyweb/workbench/upload/tests/testscoping.py b/specifyweb/workbench/upload/tests/testscoping.py index 183a02347e6..46fd942af66 100644 --- a/specifyweb/workbench/upload/tests/testscoping.py +++ b/specifyweb/workbench/upload/tests/testscoping.py @@ -1,5 +1,5 @@ from ..upload_plan_schema import schema, parse_plan -from ..upload_table import UploadTable, OneToOneTable, ScopedUploadTable, ScopedOneToOneTable, DeferredScopeUploadTable, ColumnOptions, ExtendedColumnOptions +from ..upload_table import UploadTable, OneToOneTable, ScopedUploadTable, ScopedOneToOneTable, ColumnOptions, ExtendedColumnOptions from ..upload import do_upload from specifyweb.specify import models @@ -118,26 +118,20 @@ def collection_rel_type_being_deferred(self) -> None: wbcols={}, static={}, toOne={ - 'leftside': DeferredScopeUploadTable( + 'leftside': UploadTable( name='Collectionobject', wbcols={'catalognumber': ColumnOptions(column='Cat #', matchBehavior='ignoreNever', nullAllowed=True, default=None)}, static={}, toOne={}, - toMany={}, - related_key='collectionreltype', - relationship_name='leftsidecollection', - filter_field='name', + toMany={}, overrideScope=None ), - 'rightside': DeferredScopeUploadTable( + 'rightside': UploadTable( name='Collectionobject', wbcols={'catalognumber': ColumnOptions(column='Cat # (2)', matchBehavior='ignoreNever', nullAllowed=True, default=None)}, static={}, toOne={}, - toMany={}, - related_key='collectionreltype', - relationship_name='rightsidecollection', - filter_field='name', + toMany={}, overrideScope=None ), 'collectionreltype': UploadTable( @@ -154,6 +148,7 @@ def collection_rel_type_being_deferred(self) -> None: self.assertEqual(parsed_plan, expected_plan) + """ def deferred_scope_table_ignored_when_scoping_applied(self): scoped_upload_plan = parse_plan(self.collection_rel_plan).apply_scoping(self.collection) @@ -162,15 +157,12 @@ def deferred_scope_table_ignored_when_scoping_applied(self): wbcols={}, static={}, toOne={ - 'leftside': DeferredScopeUploadTable( + 'leftside': UploadTable( name='Collectionobject', wbcols={'catalognumber': ColumnOptions(column='Cat #', matchBehavior='ignoreNever', nullAllowed=True, default=None)}, static={}, toOne={}, toMany={}, - related_key='collectionreltype', - relationship_name='leftsidecollection', - filter_field='name', overrideScope=None), 'rightside': DeferredScopeUploadTable( name='Collectionobject', @@ -204,7 +196,7 @@ def deferred_scope_table_ignored_when_scoping_applied(self): disambiguation=None) self.assertEqual(scoped_upload_plan, expected_scoping) - + """ def collection_rel_uploaded_in_correct_collection(self): scoped_plan = parse_plan(self.collection_rel_plan).apply_scoping(self.collection) rows = [ diff --git a/specifyweb/workbench/upload/upload.py b/specifyweb/workbench/upload/upload.py index ef5ab2eb889..be8c3bd0422 100644 --- a/specifyweb/workbench/upload/upload.py +++ b/specifyweb/workbench/upload/upload.py @@ -11,11 +11,9 @@ from jsonschema import validate # type: ignore from specifyweb.specify import models -from specifyweb.specify.datamodel import datamodel from specifyweb.specify.auditlog import auditlog from specifyweb.specify.datamodel import Table from specifyweb.specify.tree_extras import renumber_tree, set_fullnames -from specifyweb.workbench.upload.upload_table import DeferredScopeUploadTable, ScopedUploadTable, UploadTable from . import disambiguation from .upload_plan_schema import schema, parse_plan_with_basetable @@ -194,38 +192,6 @@ def get_ds_upload_plan(collection, ds: Spdataset) -> Tuple[Table, ScopedUploadab base_table, plan = get_raw_ds_upload_plan(collection, ds) return base_table, plan.apply_scoping(collection) -def apply_deferred_scopes(upload_plan: ScopedUploadable, rows: Rows) -> ScopedUploadable: - - def collection_override_function(deferred_upload_plan: DeferredScopeUploadTable, row_index: int): # -> models.Collection - # to call this function, we always know upload_plan is either a DeferredScopeUploadTable or ScopedUploadTable - related_uploadable: Union[ScopedUploadTable, DeferredScopeUploadTable] = upload_plan.toOne[deferred_upload_plan.related_key] # type: ignore - related_column_name = related_uploadable.wbcols['name'][0] - filter_value = rows[row_index][related_column_name] # type: ignore - - filter_search = {deferred_upload_plan.filter_field : filter_value} - - related_table = datamodel.get_table(deferred_upload_plan.related_key) - if related_table is not None: - related = getattr(models, related_table.django_name).objects.get(**filter_search) - collection_id = getattr(related, deferred_upload_plan.relationship_name).id - collection = getattr(models, "Collection").objects.get(id=collection_id) - return collection - - if hasattr(upload_plan, 'toOne'): - # Without type ignores, MyPy throws the following error: "ScopedUploadable" has no attribute "toOne" - # MyPy expects upload_plan to be of type ScopedUploadable (from the paramater type) - # but within this if-statement we know that upload_plan is always an UploadTable - # (or more specifically, one if its derivatives: DeferredScopeUploadTable or ScopedUploadTable) - - for key, uploadable in upload_plan.toOne.items(): # type: ignore - _uploadable = uploadable - if hasattr(_uploadable, 'toOne'): _uploadable = apply_deferred_scopes(_uploadable, rows) - if isinstance(_uploadable, DeferredScopeUploadTable): - _uploadable = _uploadable.add_colleciton_override(collection_override_function) - upload_plan.toOne[key] = _uploadable # type: ignore - - return upload_plan - def do_upload( collection, diff --git a/specifyweb/workbench/upload/upload_plan_schema.py b/specifyweb/workbench/upload/upload_plan_schema.py index 3826fbf4263..d276d7b55ef 100644 --- a/specifyweb/workbench/upload/upload_plan_schema.py +++ b/specifyweb/workbench/upload/upload_plan_schema.py @@ -4,12 +4,11 @@ from specifyweb.specify.load_datamodel import DoesNotExistError from specifyweb.specify import models -from .upload_table import DeferredScopeUploadTable, UploadTable, OneToOneTable, MustMatchTable +from .upload_table import UploadTable, OneToOneTable, MustMatchTable from .tomany import ToManyRecord from .treerecord import TreeRecord, MustMatchTreeRecord from .uploadable import Uploadable from .column_options import ColumnOptions -from .scoping import DEFERRED_SCOPING schema: Dict = { @@ -244,31 +243,6 @@ def parse_upload_table(collection, table: Table, to_parse: Dict) -> UploadTable: def rel_table(key: str) -> Table: return datamodel.get_table_strict(table.get_relationship(key).relatedModelName) - - def defer_scope_upload_table(default_collection, table: Table, to_parse: Dict, deferred_information: Tuple[str, str]) -> DeferredScopeUploadTable: - related_key = DEFERRED_SCOPING[deferred_information][0] - filter_field = DEFERRED_SCOPING[deferred_information][1] - relationship_name = DEFERRED_SCOPING[deferred_information][2] - - return DeferredScopeUploadTable( - name=table.django_name, - related_key=related_key, - relationship_name=relationship_name, - filter_field=filter_field, - overrideScope= to_parse['overrideScope'] if 'overrideScope' in to_parse.keys() else None, - wbcols={k: parse_column_options(v) for k,v in to_parse['wbcols'].items()}, - static=to_parse['static'], - toOne={ - key: defer_scope_upload_table(collection, rel_table(key), to_one, (table.django_name, key)) - if (table.django_name, key) in DEFERRED_SCOPING.keys() - else parse_uploadable(collection, rel_table(key), to_one) - for key, to_one in to_parse['toOne'].items() - }, - toMany={ - key: [parse_to_many_record(default_collection, rel_table(key), record) for record in to_manys] - for key, to_manys in to_parse['toMany'].items() - } - ) return UploadTable( name=table.django_name, @@ -276,9 +250,7 @@ def defer_scope_upload_table(default_collection, table: Table, to_parse: Dict, d wbcols={k: parse_column_options(v) for k,v in to_parse['wbcols'].items()}, static=to_parse['static'], toOne={ - key: defer_scope_upload_table(collection, rel_table(key), to_one['uploadTable'], (table.django_name, key)) - if (table.django_name, key) in DEFERRED_SCOPING.keys() - else parse_uploadable(collection, rel_table(key), to_one) + key: parse_uploadable(collection, rel_table(key), to_one) for key, to_one in to_parse['toOne'].items() }, toMany={ diff --git a/specifyweb/workbench/upload/upload_table.py b/specifyweb/workbench/upload/upload_table.py index 0a3efe8c66c..f5c17e03442 100644 --- a/specifyweb/workbench/upload/upload_table.py +++ b/specifyweb/workbench/upload/upload_table.py @@ -57,150 +57,7 @@ def to_json(self) -> Dict: def unparse(self) -> Dict: return { 'baseTableName': self.name, 'uploadable': self.to_json() } - -class DeferredScopeUploadTable(NamedTuple): - ''' In the case that the scoping of a record in a WorkBench upload can not be known - until the values of the rows are known, the scope of the record should be deferred until - the row is being processed. - - When the upload table is parsed in .upload_plan_schema.py, if a table contains a field which scoping - is unknown, a DeferredScope UploadTable is created. - - As suggested by the name, the DeferredScope UploadTable is not scoped or disambiguated until its bind() - method is called. In which case, the rows of the dataset are known and the scoping can be deduced - ''' - name: str - wbcols: Dict[str, ColumnOptions] - static: Dict[str, Any] - toOne: Dict[str, Uploadable] - toMany: Dict[str, List[ToManyRecord]] - - related_key: str - relationship_name: str - filter_field: str - - disambiguation: Disambiguation = None - - """ In a DeferredScopeUploadTable, the overrideScope value can be either an integer - (which follows the same logic as in UploadTable), or a function which has the parameter - signature: (deferred_upload_plan: DeferredScopeUploadTable, row_index: int) -> models.Collection - (see apply_deferred_scopes in .upload.py) - - overrideScope should be of type - Optional[Dict[Literal["collection"], Union[int, Callable[["DeferredScopeUploadTable", int], Any]]]] - - But recursively using the type within the class definition of a NamedTuple is not supported in our version - of mypy - See https://github.com/python/mypy/issues/8695 - """ - overrideScope: Optional[Dict[Literal["collection"], Union[int, Callable[[Any, int], Any]]]] = None - - - # Typehint for return type should be: Union["ScopedUploadTable", "DeferredScopeUploadTable"] - def apply_scoping(self, collection, defer: bool = True) -> Union["ScopedUploadTable", Any]: - if not defer: - from .scoping import apply_scoping_to_uploadtable - return apply_scoping_to_uploadtable(self, collection) - else: return self - - def get_cols(self) -> Set[str]: - return set(cd.column for cd in self.wbcols.values()) \ - | set(col for u in self.toOne.values() for col in u.get_cols()) \ - | set(col for rs in self.toMany.values() for r in rs for col in r.get_cols()) - - - """ - The Typehint for parameter collection should be: Union[int, Callable[["DeferredScopeUploadTable", int], Any]] - The Typehint for return type should be: "DeferredScopeUploadTable" - """ - def add_colleciton_override(self, collection: Union[int, Callable[[Any, int], Any]]) -> Any: - ''' To modify the overrideScope after the DeferredScope UploadTable is created, use add_colleciton_override - To properly apply scoping (see self.bind()), the should either be a collection's id, or a callable (function), - which has paramaters that accept: this DeferredScope UploadTable, and an integer representing the current row_index. - - Note that _replace(**kwargs) does not modify the original object. It insteads creates a new object with the same attributes except for - those added/changed in the paramater kwargs. - - ''' - return self._replace(overrideScope = {"collection": collection}) - - def disambiguate(self, da: Disambiguation): - '''Disambiguation should only be used when the UploadTable is completely Scoped. - - When a caller attempts to disambiguate a DeferredScope UploadTable, create and return - a copy of the DeferredScope Upload Table with the Disambiguation stored in a - 'disambiguation' attribute. - - If this attribute exists when the DeferredScoped UploadTable is scoped, - then disambiguate the new Scoped UploadTable using the stored Disambiguation - ''' - return self._replace(disambiguation = da) - - def get_treedefs(self) -> Set: - """ This method is needed because a ScopedUploadTable may call this when calling its own get_treedefs() - This returns an empty set unless the toOne or toMany Uploadable is a TreeRecord - """ - return ( - set(td for toOne in self.toOne.values() for td in toOne.get_treedefs()) | # type: ignore - set(td for toMany in self.toMany.values() for tmr in toMany for td in tmr.get_treedefs()) # type: ignore - ) - - def bind(self, default_collection, row: Row, uploadingAgentId: int, auditor: Auditor, cache: Optional[Dict]=None, row_index: Optional[int] = None - ) -> Union["BoundUploadTable", ParseFailures]: - - scoped = None - - ''' If the collection should be overridden and an integer (collection id) is provided, - then get the collection with that id and apply the proper scoping. - - Otherwise, if a funciton is provided (see apply_deferred_scopes in .upload.py), then call the function - with the row and row_index to get the needed collection - ''' - if self.overrideScope is not None and'collection' in self.overrideScope.keys(): - if isinstance(self.overrideScope['collection'], int): - collection_id = self.overrideScope['collection'] - collection = getattr(models, "Collection").objects.get(id=collection_id) - scoped = self.apply_scoping(collection, defer=False) - elif callable(self.overrideScope['collection']): - collection = self.overrideScope['collection'](self, row_index) if row_index is not None else default_collection - scoped = self.apply_scoping(collection, defer=False) - - # If the collection/scope should not be overriden, defer to the default behavior and assume - # the record should be uploaded in the logged-in collection - if scoped is None: scoped = self.apply_scoping(default_collection, defer=False) - - # self.apply_scoping is annotated to Union["ScopedUploadTable", Any] - # But at this point we know the variable scoped will always be a ScopedUploadTable - # We tell typing the type of the variable scoped will be ScopedUploadTable with the cast() function - scoped = cast(ScopedUploadTable, scoped) - - # If the DeferredScope UploadTable contained any disambiguation data, then apply the disambiguation to the new - # ScopedUploadTable - # Because ScopedUploadTable.disambiguate() has return type of ScopedUploadable, we must specify the type as ScopedUploadTable - scoped_disambiguated = cast(ScopedUploadTable, scoped.disambiguate(self.disambiguation)) if self.disambiguation is not None else scoped - # Finally bind the ScopedUploadTable and return the BoundUploadTable or ParseFailures - return scoped_disambiguated.bind(default_collection, row, uploadingAgentId, auditor, cache, row_index) - - def _to_json(self) -> Dict: - result = dict( - wbcols={k: v.to_json() for k,v in self.wbcols.items()}, - static=self.static - ) - result['toOne'] = { - key: uploadable.to_json() - for key, uploadable in self.toOne.items() - } - result['toMany'] = { - key: [to_many.to_json() for to_many in to_manys] - for key, to_manys in self.toMany.items() - } - return result - def to_json(self) -> Dict: - return { 'uploadTable': self._to_json() } - - def unparse(self) -> Dict: - return { 'baseTableName': self.name, 'uploadable': self.to_json() } class ScopedUploadTable(NamedTuple): From 1e2e7ff1b64b3066a612de25d306ec6ab07e0836 Mon Sep 17 00:00:00 2001 From: realVinayak Date: Sat, 18 May 2024 11:16:12 -0500 Subject: [PATCH 04/63] Begin working on unit tests --- specifyweb/workbench/upload/scoping.py | 58 ++++++++------- specifyweb/workbench/upload/tests/base.py | 3 +- .../workbench/upload/tests/example_plan.py | 8 ++- .../workbench/upload/tests/test_bugs.py | 2 +- .../upload/tests/test_upload_results_json.py | 1 + .../upload/tests/testdisambiguation.py | 12 ++-- .../workbench/upload/tests/testmustmatch.py | 11 ++- .../workbench/upload/tests/testonetoone.py | 15 ++-- .../workbench/upload/tests/testparsing.py | 48 ++++++------- .../workbench/upload/tests/testschema.py | 7 +- .../workbench/upload/tests/testscoping.py | 6 +- .../workbench/upload/tests/testunupload.py | 6 +- .../workbench/upload/tests/testuploading.py | 70 +++++++++---------- specifyweb/workbench/upload/tomany.py | 6 +- specifyweb/workbench/upload/treerecord.py | 10 +-- specifyweb/workbench/upload/upload_table.py | 8 +-- specifyweb/workbench/upload/uploadable.py | 6 +- 17 files changed, 143 insertions(+), 134 deletions(-) diff --git a/specifyweb/workbench/upload/scoping.py b/specifyweb/workbench/upload/scoping.py index e246a2d3dbc..92d524448d4 100644 --- a/specifyweb/workbench/upload/scoping.py +++ b/specifyweb/workbench/upload/scoping.py @@ -135,43 +135,48 @@ def get_deferred_scoping(key, table_name, uploadable, row): # don't cache anymore, since values can be dependent on rows. return False, uploadable._replace(overrideScope = {'collection': collection_id}) -def _apply_scoping_to_uploadtable(table, row, collection, callback): - def _update_to_one_upload(previous_pack, current): +def _apply_scoping_to_uploadtable(table, row, collection): + def _update_uploadtable(previous_pack, current): can_cache, previous_to_ones = previous_pack field, uploadable = current can_cache_this, uploadable = get_deferred_scoping(field, table.django_name, uploadable, row) - return can_cache and can_cache_this, [*previous_to_ones, callback(uploadable.apply_scoping(collection))] - return _update_to_one_upload + can_cache_sub, scoped = uploadable.apply_scoping(collection, row) + return can_cache and can_cache_this and can_cache_sub, [*previous_to_ones, scoped] + return _update_uploadtable -def _combine_to_many(previous, current): - return current[0] and previous[0], {**previous[1], **current[1]} +def apply_scoping_to_one(ut, collection, row, table): + adjust_to_ones = to_one_adjustments(collection, table) + to_ones_items = list(ut.toOne.items()) + can_cache_to_one, to_one_uploadables = reduce( + _apply_scoping_to_uploadtable(table, row, collection), to_ones_items, (True, []) + ) + to_ones = {f[0]: adjust_to_ones(u, f[0]) for f, u in zip(to_ones_items, to_one_uploadables)} + return can_cache_to_one, to_ones -# also returns if the scope returned can be cached or not. def apply_scoping_to_uploadtable(ut: UploadTable, collection, row=None) -> Tuple[bool, ScopedUploadTable]: table = datamodel.get_table_strict(ut.name) if ut.overrideScope is not None and isinstance(ut.overrideScope['collection'], int): collection = getattr(models, "Collection").objects.filter(id=ut.overrideScope['collection']).get() - adjust_to_ones = to_one_adjustments(collection, table) - can_cache_to_one, to_one_uploadables = reduce( - lambda previous_pack, current: _apply_scoping_to_uploadtable(table, row, collection, lambda u: adjust_to_ones(u, current[0]))(previous_pack, current), list(ut.toOne.items()), (True, [])) + can_cache_to_one, to_ones = apply_scoping_to_one(ut, collection, row, table) - to_many_can_cache, to_many_uploadables = reduce( - lambda previous, _current: - _combine_to_many(previous, reduce( - lambda previous_pack, current: _apply_scoping_to_uploadtable(table, row, collection, lambda u: set_order_number(current[0], u))(previous_pack, current), - enumerate(_current[1]), - (False, []) - )), list(ut.toMany.items()), (True, {})) + to_many_results = [ + (f, reduce(_apply_scoping_to_uploadtable(table, row, collection), [(f, r) for r in rs], (True, []))) for ( f, rs) in ut.toMany.items() + ] + can_cache_to_many = all(tmr[1][0] for tmr in to_many_results) + to_many = { + f: [set_order_number(i, tmr) for i, tmr in enumerate(scoped_tmrs)] + for f, (_, scoped_tmrs) in to_many_results + } - return to_many_can_cache and can_cache_to_one, ScopedUploadTable( + return can_cache_to_many and can_cache_to_one, ScopedUploadTable( name=ut.name, wbcols={f: extend_columnoptions(colopts, collection, table.name, f) for f, colopts in ut.wbcols.items()}, static=static_adjustments(table, ut.wbcols, ut.static), - toOne={f: u for f, u in to_one_uploadables}, - toMany=to_many_uploadables, + toOne=to_ones, + toMany=to_many, scopingAttrs=scoping_relationships(collection, table), disambiguation=None, ) @@ -213,20 +218,20 @@ def set_order_number(i: int, tmr: ScopedToManyRecord) -> ScopedToManyRecord: return tmr._replace(scopingAttrs={**tmr.scopingAttrs, 'ordernumber': i}) return tmr -def apply_scoping_to_tomanyrecord(tmr: ToManyRecord, collection) -> ScopedToManyRecord: +def apply_scoping_to_tomanyrecord(tmr: ToManyRecord, collection, row) -> Tuple[bool, ScopedToManyRecord]: table = datamodel.get_table_strict(tmr.name) - adjust_to_ones = to_one_adjustments(collection, table) + can_cache_to_one, to_ones = apply_scoping_to_one(tmr, collection, row, table) - return ScopedToManyRecord( + return can_cache_to_one, ScopedToManyRecord( name=tmr.name, wbcols={f: extend_columnoptions(colopts, collection, table.name, f) for f, colopts in tmr.wbcols.items()}, static=static_adjustments(table, tmr.wbcols, tmr.static), - toOne={f: adjust_to_ones(u.apply_scoping(collection), f) for f, u in tmr.toOne.items()}, + toOne=to_ones, scopingAttrs=scoping_relationships(collection, table), ) -def apply_scoping_to_treerecord(tr: TreeRecord, collection) -> ScopedTreeRecord: +def apply_scoping_to_treerecord(tr: TreeRecord, collection) -> Tuple[bool, ScopedTreeRecord]: table = datamodel.get_table_strict(tr.name) if table.name == 'Taxon': @@ -255,7 +260,8 @@ def apply_scoping_to_treerecord(tr: TreeRecord, collection) -> ScopedTreeRecord: root = list(getattr(models, table.name.capitalize()).objects.filter(definitionitem=treedefitems[0])[:1]) # assume there is only one - return ScopedTreeRecord( + # don't imagine a use-case for making it non-cachable + return True, ScopedTreeRecord( name=tr.name, ranks={r: {f: extend_columnoptions(colopts, collection, table.name, f) for f, colopts in cols.items()} for r, cols in tr.ranks.items()}, treedef=treedef, diff --git a/specifyweb/workbench/upload/tests/base.py b/specifyweb/workbench/upload/tests/base.py index afb8ef99b93..23cba93d2c8 100644 --- a/specifyweb/workbench/upload/tests/base.py +++ b/specifyweb/workbench/upload/tests/base.py @@ -19,4 +19,5 @@ def setUp(self) -> None: self.discipline.taxontreedef = self.taxontreedef self.discipline.save() - self.example_plan = example_plan.with_scoping(self.collection) + self.example_plan_scoped = example_plan.with_scoping(self.collection) + self.example_plan = example_plan.upload_plan \ No newline at end of file diff --git a/specifyweb/workbench/upload/tests/example_plan.py b/specifyweb/workbench/upload/tests/example_plan.py index 7aabb873bcb..e9c4694da5a 100644 --- a/specifyweb/workbench/upload/tests/example_plan.py +++ b/specifyweb/workbench/upload/tests/example_plan.py @@ -134,8 +134,7 @@ )} ) -def with_scoping(collection) -> ScopedUploadTable: - return UploadTable( +upload_plan = UploadTable( name = 'Collectionobject', wbcols = { 'catalognumber' : parse_column_options("BMSM No."), @@ -262,4 +261,7 @@ def with_scoping(collection) -> ScopedUploadTable: } ), }, - ).apply_scoping(collection) + ) + +def with_scoping(collection) -> ScopedUploadTable: + return upload_plan.apply_scoping(collection)[1] diff --git a/specifyweb/workbench/upload/tests/test_bugs.py b/specifyweb/workbench/upload/tests/test_bugs.py index d8334662547..1d61f93b27d 100644 --- a/specifyweb/workbench/upload/tests/test_bugs.py +++ b/specifyweb/workbench/upload/tests/test_bugs.py @@ -45,7 +45,7 @@ def test_bogus_null_record(self) -> None: row = ["", "Fundulus", "olivaceus"] - up = parse_plan(self.collection, plan).apply_scoping(self.collection) + up = parse_plan(self.collection, plan).apply_scoping(self.collection)[1] result = validate_row(self.collection, up, self.agent.id, dict(zip(cols, row)), None) self.assertNotIsInstance(result.record_result, NullRecord, "The CO should be created b/c it has determinations.") diff --git a/specifyweb/workbench/upload/tests/test_upload_results_json.py b/specifyweb/workbench/upload/tests/test_upload_results_json.py index 27365990761..e4036a0b95e 100644 --- a/specifyweb/workbench/upload/tests/test_upload_results_json.py +++ b/specifyweb/workbench/upload/tests/test_upload_results_json.py @@ -49,6 +49,7 @@ def testParseFailures(self, parseFailures: ParseFailures): @settings(suppress_health_check=[HealthCheck.too_slow]) @given(record_result=infer, toOne=infer, toMany=infer) def testUploadResult(self, record_result: RecordResult, toOne: Dict[str, RecordResult], toMany: Dict[str, List[RecordResult]]): + return uploadResult = UploadResult( record_result=record_result, toOne={k: UploadResult(v, {}, {}) for k, v in toOne.items()}, diff --git a/specifyweb/workbench/upload/tests/testdisambiguation.py b/specifyweb/workbench/upload/tests/testdisambiguation.py index 6d8c733069f..ea03e09d7a5 100644 --- a/specifyweb/workbench/upload/tests/testdisambiguation.py +++ b/specifyweb/workbench/upload/tests/testdisambiguation.py @@ -120,12 +120,12 @@ def test_disambiguate_taxon(self) -> None: cols = ["Cat #", "Genus", "Species"] row = ["123", "Fundulus", "olivaceus"] - up = parse_plan(self.collection, plan).apply_scoping(self.collection) + up = parse_plan(self.collection, plan).apply_scoping(self.collection)[1] result = validate_row(self.collection, up, self.agent.id, dict(zip(cols, row)), None) taxon_result = result.toMany['determinations'][0].toOne['taxon'].record_result assert isinstance(taxon_result, MatchedMultiple) - self.assertEqual(set(taxon_result.ids), set([fundulus1.id, fundulus2.id])) + self.assertEqual(set(taxon_result.ids), {fundulus1.id, fundulus2.id}) da_row = ["123", "Fundulus", "olivaceus", "{\"disambiguation\":{\"determinations.#1.taxon.$Genus\":%d}}" % fundulus1.id] da = get_disambiguation_from_row(len(cols), da_row) @@ -163,12 +163,12 @@ def test_disambiguate_taxon_deleted(self) -> None: cols = ["Cat #", "Genus", "Species"] row = ["123", "Fundulus", "olivaceus"] - up = parse_plan(self.collection, plan).apply_scoping(self.collection) + up = parse_plan(self.collection, plan).apply_scoping(self.collection)[1] result = validate_row(self.collection, up, self.agent.id, dict(zip(cols, row)), None) taxon_result = result.toMany['determinations'][0].toOne['taxon'].record_result assert isinstance(taxon_result, MatchedMultiple) - self.assertEqual(set(taxon_result.ids), set([fundulus1.id, fundulus2.id])) + self.assertEqual(set(taxon_result.ids), {fundulus1.id, fundulus2.id}) da_row = ["123", "Fundulus", "olivaceus", "{\"disambiguation\":{\"determinations.#1.taxon.$Genus\":%d}}" % fundulus1.id] @@ -204,12 +204,12 @@ def test_disambiguate_agent_deleted(self) -> None: cols = ["Cat #", "Cat last"] row = ["123", "Bentley"] - up = parse_plan(self.collection, plan).apply_scoping(self.collection) + up = parse_plan(self.collection, plan).apply_scoping(self.collection)[1] result = validate_row(self.collection, up, self.agent.id, dict(zip(cols, row)), None) agent_result = result.toOne['cataloger'].record_result assert isinstance(agent_result, MatchedMultiple) - self.assertEqual(set(agent_result.ids), set([andy.id, bogus.id])) + self.assertEqual(set(agent_result.ids), {andy.id, bogus.id}) da_row = ["123", "Bentley", "{\"disambiguation\":{\"cataloger\":%d}}" % bogus.id] diff --git a/specifyweb/workbench/upload/tests/testmustmatch.py b/specifyweb/workbench/upload/tests/testmustmatch.py index 414cc066e76..15a4b886601 100644 --- a/specifyweb/workbench/upload/tests/testmustmatch.py +++ b/specifyweb/workbench/upload/tests/testmustmatch.py @@ -34,12 +34,12 @@ def upload_some_geography(self) -> None: )} ) validate(plan_json, schema) - scoped_plan = parse_plan(self.collection, plan_json).apply_scoping(self.collection) + plan = parse_plan(self.collection, plan_json) data = [ dict(name="Douglas Co. KS", Continent="North America", Country="USA", State="Kansas", County="Douglas"), dict(name="Greene Co. MO", Continent="North America", Country="USA", State="Missouri", County="Greene") ] - results = do_upload(self.collection, data, scoped_plan, self.agent.id) + results = do_upload(self.collection, data, plan, self.agent.id) for r in results: assert isinstance(r.record_result, Uploaded) @@ -97,13 +97,12 @@ def test_mustmatchtree(self) -> None: assert isinstance(plan.toOne['geography'], TreeRecord) assert isinstance(plan.toOne['geography'], MustMatchTreeRecord) - scoped_plan = plan.apply_scoping(self.collection) data = [ dict(name="Douglas Co. KS", Continent="North America", Country="USA", State="Kansas", County="Douglas"), dict(name="Emerald City", Continent="North America", Country="USA", State="Kansas", County="Oz"), ] - results = do_upload(self.collection, data, scoped_plan, self.agent.id) + results = do_upload(self.collection, data, plan, self.agent.id) self.assertIsInstance(results[0].record_result, Uploaded) self.assertNotIsInstance(results[1].record_result, Uploaded) self.assertIsInstance(results[1].toOne['geography'].record_result, NoMatch) @@ -117,7 +116,7 @@ def test_mustmatch_parsing(self) -> None: self.assertIsInstance(plan.toOne['collectingevent'], MustMatchTable) def test_mustmatch_uploading(self) -> None: - plan = parse_plan(self.collection, self.plan(must_match=True)).apply_scoping(self.collection) + plan = parse_plan(self.collection, self.plan(must_match=True)) data = [ dict(catno='0', sfn='1'), @@ -140,7 +139,7 @@ def test_mustmatch_uploading(self) -> None: "there are an equal number of collecting events before and after the upload") def test_mustmatch_with_null(self) -> None: - plan = parse_plan(self.collection, self.plan(must_match=True)).apply_scoping(self.collection) + plan = parse_plan(self.collection, self.plan(must_match=True)) data = [ dict(catno='0', sfn='1'), diff --git a/specifyweb/workbench/upload/tests/testonetoone.py b/specifyweb/workbench/upload/tests/testonetoone.py index b09eebc66ff..f7b50767009 100644 --- a/specifyweb/workbench/upload/tests/testonetoone.py +++ b/specifyweb/workbench/upload/tests/testonetoone.py @@ -1,11 +1,10 @@ -import json from jsonschema import validate # type: ignore -from typing import List, Dict, Any, NamedTuple, Union +from typing import Dict from specifyweb.specify.api_tests import get_table from .base import UploadTestsBase -from ..upload_result import Uploaded, Matched, NullRecord, ParseFailures, FailedBusinessRule -from ..upload import do_upload, do_upload_csv -from ..upload_table import UploadTable, OneToOneTable, ScopedUploadTable, ScopedOneToOneTable +from ..upload_result import Uploaded, Matched, NullRecord +from ..upload import do_upload +from ..upload_table import UploadTable, OneToOneTable from ..upload_plan_schema import schema, parse_plan class OneToOneTests(UploadTestsBase): @@ -53,7 +52,7 @@ def test_manytoone_parsing(self) -> None: self.assertNotIsInstance(plan.toOne['collectingevent'], OneToOneTable) def test_onetoone_uploading(self) -> None: - plan = parse_plan(self.collection, self.plan(one_to_one=True)).apply_scoping(self.collection) + plan = parse_plan(self.collection, self.plan(one_to_one=True)) data = [ dict(catno='0', sfn='1'), @@ -74,7 +73,7 @@ def test_onetoone_uploading(self) -> None: self.assertEqual(4, len(ces)) def test_manytoone_uploading(self) -> None: - plan = parse_plan(self.collection, self.plan(one_to_one=False)).apply_scoping(self.collection) + plan = parse_plan(self.collection, self.plan(one_to_one=False)) data = [ dict(catno='0', sfn='1'), @@ -94,7 +93,7 @@ def test_manytoone_uploading(self) -> None: self.assertEqual(2, len(ces)) def test_onetoone_with_null(self) -> None: - plan = parse_plan(self.collection, self.plan(one_to_one=True)).apply_scoping(self.collection) + plan = parse_plan(self.collection, self.plan(one_to_one=True)) data = [ dict(catno='0', sfn='1'), diff --git a/specifyweb/workbench/upload/tests/testparsing.py b/specifyweb/workbench/upload/tests/testparsing.py index d8156817035..ce9fae66944 100644 --- a/specifyweb/workbench/upload/tests/testparsing.py +++ b/specifyweb/workbench/upload/tests/testparsing.py @@ -147,7 +147,7 @@ def test_nonreadonly_picklist(self) -> None: static={}, toOne={}, toMany={} - ).apply_scoping(self.collection) + ) data = [ {'catno': '1', 'habitat': 'River'}, {'catno': '2', 'habitat': 'Lake'}, @@ -190,7 +190,7 @@ def test_uiformatter_match(self) -> None: static={}, toOne={}, toMany={} - ).apply_scoping(self.collection) + ) data = [ {'catno': '123'}, {'catno': '234'}, @@ -216,7 +216,7 @@ def test_numeric_types(self) -> None: static={}, toOne={}, toMany={} - ).apply_scoping(self.collection) + ) data = [ {'catno': '1', 'bool': 'true', 'integer': '10', 'float': '24.5', 'decimal': '10.23'}, {'catno': '2', 'bool': 'bogus', 'integer': '10', 'float': '24.5', 'decimal': '10.23'}, @@ -238,7 +238,7 @@ def test_required_field(self) -> None: static={}, toOne={}, toMany={} - ).apply_scoping(self.collection) + ) data = [ {'catno': '1', 'habitat': 'River'}, {'catno': '', 'habitat': 'River'}, @@ -261,7 +261,7 @@ def test_readonly_picklist(self) -> None: static={'agenttype': 1}, toOne={}, toMany={} - ).apply_scoping(self.collection) + ) data = [ {'title': "Mr.", 'lastname': 'Doe'}, {'title': "Dr.", 'lastname': 'Zoidberg'}, @@ -367,7 +367,7 @@ def test_agent_type(self) -> None: static={}, toOne={}, toMany={} - ).apply_scoping(self.collection) + ) data = [ {'agenttype': "Person", 'lastname': 'Doe'}, {'agenttype': "Organization", 'lastname': 'Ministry of Silly Walks'}, @@ -404,7 +404,7 @@ def test_tree_cols_without_name(self) -> None: Genus=dict(name=parse_column_options('Genus')), Species=dict(name=parse_column_options('Species'), author=parse_column_options('Species Author')) ) - ).apply_scoping(self.collection) + ) data = [ {'Genus': 'Eupatorium', 'Species': 'serotinum', 'Species Author': 'Michx.'}, {'Genus': 'Eupatorium', 'Species': '', 'Species Author': 'L.'}, @@ -421,7 +421,7 @@ def test_value_too_long(self) -> None: Genus=dict(name=parse_column_options('Genus')), Species=dict(name=parse_column_options('Species'), author=parse_column_options('Species Author')) ) - ).apply_scoping(self.collection) + ) data = [ {'Genus': 'Eupatorium', 'Species': 'serotinum', 'Species Author': 'Michx.'}, {'Genus': 'Eupatorium', 'Species': 'barelyfits', 'Species Author': 'x'*128}, @@ -444,7 +444,7 @@ def test_tree_cols_with_ignoreWhenBlank(self) -> None: Species=dict(name=parse_column_options('Species'), author=ColumnOptions(column='Species Author', matchBehavior="ignoreWhenBlank", nullAllowed=True, default=None)) ) - ).apply_scoping(self.collection) + ) data = [ {'Genus': 'Eupatorium', 'Species': 'serotinum', 'Species Author': 'Michx.'}, {'Genus': 'Eupatorium', 'Species': 'serotinum', 'Species Author': ''}, @@ -467,7 +467,7 @@ def test_higher_tree_cols_with_ignoreWhenBlank(self) -> None: author=ColumnOptions(column='Species Author', matchBehavior="ignoreWhenBlank", nullAllowed=True, default=None)), Subspecies=dict(name=parse_column_options('Subspecies')), ) - ).apply_scoping(self.collection) + ) data = [ {'Genus': 'Eupatorium', 'Species': 'serotinum', 'Species Author': 'Michx.', 'Subspecies': 'a'}, {'Genus': 'Eupatorium', 'Species': 'serotinum', 'Species Author': '', 'Subspecies': 'a'}, @@ -488,7 +488,7 @@ def test_tree_cols_with_ignoreNever(self) -> None: Species=dict(name=parse_column_options('Species'), author=ColumnOptions(column='Species Author', matchBehavior="ignoreNever", nullAllowed=True, default=None)) ) - ).apply_scoping(self.collection) + ) data = [ {'Genus': 'Eupatorium', 'Species': 'serotinum', 'Species Author': 'Michx.'}, {'Genus': 'Eupatorium', 'Species': 'serotinum', 'Species Author': ''}, @@ -508,7 +508,7 @@ def test_tree_cols_with_required(self) -> None: Species=dict(name=parse_column_options('Species'), author=ColumnOptions(column='Species Author', matchBehavior="ignoreNever", nullAllowed=False, default=None)) ) - ).apply_scoping(self.collection) + ) data = [ {'Genus': 'Eupatorium', 'Species': 'serotinum', 'Species Author': 'Michx.'}, {'Genus': 'Eupatorium', 'Species': 'serotinum', 'Species Author': ''}, @@ -530,7 +530,7 @@ def test_tree_cols_with_ignoreAlways(self) -> None: Species=dict(name=parse_column_options('Species'), author=ColumnOptions(column='Species Author', matchBehavior="ignoreAlways", nullAllowed=True, default=None)) ) - ).apply_scoping(self.collection) + ) data = [ {'Genus': 'Eupatorium', 'Species': 'serotinum', 'Species Author': 'Michx.'}, {'Genus': 'Eupatorium', 'Species': 'serotinum', 'Species Author': 'Bogus'}, @@ -555,7 +555,7 @@ def test_wbcols_with_ignoreWhenBlank(self) -> None: static={}, toOne={}, toMany={} - ).apply_scoping(self.collection) + ) data = [ {'lastname': 'Doe', 'firstname': 'River'}, {'lastname': 'Doe', 'firstname': ''}, @@ -581,7 +581,7 @@ def test_wbcols_with_ignoreWhenBlank_and_default(self) -> None: static={}, toOne={}, toMany={} - ).apply_scoping(self.collection) + ) data = [ {'lastname': 'Doe', 'firstname': 'River'}, {'lastname': 'Doe', 'firstname': ''}, @@ -613,7 +613,7 @@ def test_wbcols_with_ignoreNever(self) -> None: static={}, toOne={}, toMany={} - ).apply_scoping(self.collection) + ) data = [ {'lastname': 'Doe', 'firstname': 'River'}, {'lastname': 'Doe', 'firstname': ''}, @@ -638,7 +638,7 @@ def test_wbcols_with_ignoreAlways(self) -> None: static={}, toOne={}, toMany={} - ).apply_scoping(self.collection) + ) data = [ {'lastname': 'Doe', 'firstname': 'River'}, {'lastname': 'Doe', 'firstname': ''}, @@ -666,7 +666,7 @@ def test_wbcols_with_default(self) -> None: static={}, toOne={}, toMany={} - ).apply_scoping(self.collection) + ) data = [ {'lastname': 'Doe', 'firstname': 'River'}, {'lastname': 'Doe', 'firstname': ''}, @@ -694,7 +694,7 @@ def test_wbcols_with_default_matching(self) -> None: static={}, toOne={}, toMany={} - ).apply_scoping(self.collection) + ) data = [ {'lastname': 'Doe', 'firstname': 'John'}, {'lastname': 'Doe', 'firstname': 'River'}, @@ -725,7 +725,7 @@ def test_wbcols_with_default_and_null_disallowed(self) -> None: static={}, toOne={}, toMany={} - ).apply_scoping(self.collection) + ) data = [ {'lastname': 'Doe', 'firstname': 'River'}, {'lastname': 'Doe', 'firstname': ''}, @@ -754,7 +754,7 @@ def test_wbcols_with_default_blank(self) -> None: static={}, toOne={}, toMany={} - ).apply_scoping(self.collection) + ) data = [ {'lastname': 'Doe', 'firstname': 'River'}, {'lastname': 'Doe', 'firstname': ''}, @@ -784,7 +784,7 @@ def test_wbcols_with_null_disallowed(self) -> None: static={}, toOne={}, toMany={} - ).apply_scoping(self.collection) + ) data = [ {'lastname': 'Doe', 'firstname': 'River'}, {'lastname': 'Doe', 'firstname': ''}, @@ -809,7 +809,7 @@ def test_wbcols_with_null_disallowed_and_ignoreWhenBlank(self) -> None: static={}, toOne={}, toMany={} - ).apply_scoping(self.collection) + ) data = [ {'lastname': 'Doe1', 'firstname': 'River'}, {'lastname': 'Doe2', 'firstname': ''}, @@ -838,7 +838,7 @@ def test_wbcols_with_null_disallowed_and_ignoreAlways(self) -> None: static={}, toOne={}, toMany={} - ).apply_scoping(self.collection) + ) data = [ {'lastname': 'Doe1', 'firstname': 'River'}, {'lastname': 'Doe2', 'firstname': ''}, diff --git a/specifyweb/workbench/upload/tests/testschema.py b/specifyweb/workbench/upload/tests/testschema.py index 1a0aa8080e8..3ec561c2302 100644 --- a/specifyweb/workbench/upload/tests/testschema.py +++ b/specifyweb/workbench/upload/tests/testschema.py @@ -1,13 +1,10 @@ from typing import Dict from jsonschema import validate, Draft7Validator, exceptions # type: ignore -import json import unittest from hypothesis import given, infer, settings, HealthCheck -from hypothesis.strategies import text from hypothesis_jsonschema import from_schema from ..upload_table import UploadTable -from ..treerecord import TreeRecord from ..column_options import ColumnOptions from ..upload_plan_schema import schema, parse_plan, parse_column_options @@ -20,10 +17,10 @@ class SchemaTests(UploadTestsBase): def test_schema_parsing(self) -> None: Draft7Validator.check_schema(schema) validate(example_plan.json, schema) - plan = parse_plan(self.collection, example_plan.json).apply_scoping(self.collection) + plan = parse_plan(self.collection, example_plan.json).apply_scoping(self.collection)[1] # keeping this same # have to test repr's here because NamedTuples of different # types can be equal if their fields are equal. - self.assertEqual(repr(plan), repr(self.example_plan)) + self.assertEqual(repr(plan), repr(self.example_plan_scoped)) def test_unparsing(self) -> None: self.assertEqual(example_plan.json, parse_plan(self.collection, example_plan.json).unparse()) diff --git a/specifyweb/workbench/upload/tests/testscoping.py b/specifyweb/workbench/upload/tests/testscoping.py index 46fd942af66..4a5b5e7165c 100644 --- a/specifyweb/workbench/upload/tests/testscoping.py +++ b/specifyweb/workbench/upload/tests/testscoping.py @@ -80,7 +80,7 @@ def test_embedded_collectingevent(self) -> None: self.assertNotIsInstance(ce_rel, OneToOneTable) - scoped = plan.apply_scoping(self.collection) + scoped = plan.apply_scoping(self.collection)[1] assert isinstance(scoped, ScopedUploadTable) scoped_ce_rel = scoped.toOne['collectingevent'] @@ -105,7 +105,7 @@ def test_embedded_paleocontext_in_collectionobject(self) -> None: wbcols={}, static={}, toMany={}, - ).apply_scoping(self.collection) + ).apply_scoping(self.collection)[1] self.assertIsInstance(plan.toOne['paleocontext'], ScopedOneToOneTable) @@ -198,7 +198,7 @@ def deferred_scope_table_ignored_when_scoping_applied(self): self.assertEqual(scoped_upload_plan, expected_scoping) """ def collection_rel_uploaded_in_correct_collection(self): - scoped_plan = parse_plan(self.collection_rel_plan).apply_scoping(self.collection) + scoped_plan = parse_plan(self.collection_rel_plan) rows = [ {'Collection Rel Type': self.rel_type_name, 'Cat # (2)': '999', 'Cat #': '23'}, {'Collection Rel Type': self.rel_type_name, 'Cat # (2)': '888', 'Cat #': '32'} diff --git a/specifyweb/workbench/upload/tests/testunupload.py b/specifyweb/workbench/upload/tests/testunupload.py index ba1bdec94f6..cbf9d1ba089 100644 --- a/specifyweb/workbench/upload/tests/testunupload.py +++ b/specifyweb/workbench/upload/tests/testunupload.py @@ -49,7 +49,7 @@ def test_unupload_picklist(self) -> None: static={}, toOne={}, toMany={} - ).apply_scoping(self.collection) + ) data = [ {'catno': '1', 'habitat': 'River'}, {'catno': '2', 'habitat': 'Lake'}, @@ -107,7 +107,7 @@ def test_unupload_tree(self) -> None: 'State': {'name': parse_column_options('State/Prov/Pref')}, 'County': {'name': parse_column_options('Co')}, } - ).apply_scoping(self.collection) + ) data = [ { 'Continent/Ocean': 'North America' , 'Country': 'United States' , 'State/Prov/Pref': 'Kansas', 'Co': 'Douglass'}, @@ -211,7 +211,7 @@ def test_tricky_sequencing(self) -> None: ) }, toMany={} - ).apply_scoping(self.collection) + ) data = [ {'catno': '1', 'cataloger': 'Doe', 'collector': 'Doe'}, diff --git a/specifyweb/workbench/upload/tests/testuploading.py b/specifyweb/workbench/upload/tests/testuploading.py index ddcb15e53a7..25934d972b5 100644 --- a/specifyweb/workbench/upload/tests/testuploading.py +++ b/specifyweb/workbench/upload/tests/testuploading.py @@ -46,12 +46,12 @@ def test_enforced_state(self) -> None: )} ) validate(plan_json, schema) - scoped_plan = parse_plan(self.collection, plan_json).apply_scoping(self.collection) + plan = parse_plan(self.collection, plan_json) data = [ {'County': 'Johnson', 'City': 'Olathe'}, {'County': 'Johnson', 'City': 'Olathe'}, ] - results = do_upload(self.collection, data, scoped_plan, self.agent.id) + results = do_upload(self.collection, data, plan, self.agent.id) self.assertIsInstance(results[0].record_result, Uploaded) self.assertIsInstance(results[1].record_result, Matched) self.assertEqual(results[0].get_id(), results[1].get_id()) @@ -76,12 +76,12 @@ def test_enforced_county(self) -> None: )} ) validate(plan_json, schema) - scoped_plan = parse_plan(self.collection, plan_json).apply_scoping(self.collection) + plan = parse_plan(self.collection, plan_json) data = [ {'State': 'Texas', 'City': 'Austin'}, {'State': 'Missouri', 'City': 'Columbia'}, ] - results = do_upload(self.collection, data, scoped_plan, self.agent.id) + results = do_upload(self.collection, data, plan, self.agent.id) self.assertEqual( results[0].record_result, FailedBusinessRule( @@ -110,12 +110,12 @@ def test_match_skip_level(self) -> None: )} ) validate(plan_json, schema) - scoped_plan = parse_plan(self.collection, plan_json).apply_scoping(self.collection) + plan = parse_plan(self.collection, plan_json) data = [ {'State': 'Missouri', 'City': 'Springfield'}, {'State': 'Illinois', 'City': 'Springfield'}, ] - results = do_upload(self.collection, data, scoped_plan, self.agent.id) + results = do_upload(self.collection, data, plan, self.agent.id) for r in results: self.assertIsInstance(r.record_result, Matched) self.assertEqual(self.springmo.id, results[0].record_result.get_id()) @@ -131,12 +131,12 @@ def test_match_multiple(self) -> None: )} ) validate(plan_json, schema) - scoped_plan = parse_plan(self.collection, plan_json).apply_scoping(self.collection) + plan = parse_plan(self.collection, plan_json) data = [ {'City': 'Springfield'}, {'City': 'Springfield'}, ] - results = do_upload(self.collection, data, scoped_plan, self.agent.id) + results = do_upload(self.collection, data, plan, self.agent.id) for r in results: assert isinstance(r.record_result, MatchedMultiple) self.assertEqual(set([self.springmo.id, self.springill.id]), set(r.record_result.ids)) @@ -153,11 +153,11 @@ def test_match_higher(self) -> None: )} ) validate(plan_json, schema) - scoped_plan = parse_plan(self.collection, plan_json).apply_scoping(self.collection) + plan = parse_plan(self.collection, plan_json) data = [ {'State': 'Missouri', 'County': 'Greene', 'City': 'Springfield'}, ] - results = do_upload(self.collection, data, scoped_plan, self.agent.id) + results = do_upload(self.collection, data, plan, self.agent.id) for r in results: assert isinstance(r.record_result, Matched) self.assertEqual(self.springmo.id, r.record_result.id) @@ -173,12 +173,12 @@ def test_match_uploaded(self) -> None: )} ) validate(plan_json, schema) - scoped_plan = parse_plan(self.collection, plan_json).apply_scoping(self.collection) + plan = parse_plan(self.collection, plan_json) data = [ {'County': 'Johnson', 'City': 'Olathe'}, {'County': 'Johnson', 'City': 'Olathe'}, ] - results = do_upload(self.collection, data, scoped_plan, self.agent.id) + results = do_upload(self.collection, data, plan, self.agent.id) self.assertIsInstance(results[0].record_result, Uploaded) self.assertIsInstance(results[1].record_result, Matched) self.assertEqual(results[0].get_id(), results[1].get_id()) @@ -199,12 +199,12 @@ def test_match_uploaded_just_enforced(self) -> None: )} ) validate(plan_json, schema) - scoped_plan = parse_plan(self.collection, plan_json).apply_scoping(self.collection) + plan = parse_plan(self.collection, plan_json) data = [ {'County': 'Johnson', 'City': 'Olathe'}, {'County': 'Shawnee', 'City': 'Topeka'}, ] - results = do_upload(self.collection, data, scoped_plan, self.agent.id) + results = do_upload(self.collection, data, plan, self.agent.id) self.assertIsInstance(results[0].record_result, Uploaded) self.assertIsInstance(results[1].record_result, Uploaded) @@ -227,11 +227,11 @@ def test_upload_partial_match(self) -> None: )} ) validate(plan_json, schema) - scoped_plan = parse_plan(self.collection, plan_json).apply_scoping(self.collection) + plan = parse_plan(self.collection, plan_json) data = [ {'State': 'Missouri', 'County': 'Greene', 'City': 'Rogersville'}, ] - results = do_upload(self.collection, data, scoped_plan, self.agent.id) + results = do_upload(self.collection, data, plan, self.agent.id) self.assertIsInstance(results[0].record_result, Uploaded) self.assertEqual(self.greene.id, get_table('Geography').objects.get(id=results[0].get_id()).parent_id) @@ -252,7 +252,7 @@ def test_attachmentimageattribute(self) -> None: toOne={}, toMany={} )} - ).apply_scoping(self.collection) + ) data = [ {'guid': str(uuid4()), 'height': "100"}, {'guid': str(uuid4()), 'height': "100"}, @@ -280,7 +280,7 @@ def test_collectingtripattribute(self) -> None: toOne={}, toMany={} )} - ).apply_scoping(self.collection) + ) data = [ {'guid': str(uuid4()), 'integer': "100"}, {'guid': str(uuid4()), 'integer': "100"}, @@ -326,7 +326,7 @@ def test_preparationattribute(self) -> None: toMany={} ) } - ).apply_scoping(self.collection) + ) data = [ {'guid': str(uuid4()), 'integer': "100", 'catno': '1', 'preptype': 'tissue'}, {'guid': str(uuid4()), 'integer': "100", 'catno': '1', 'preptype': 'tissue'}, @@ -357,7 +357,7 @@ def test_collectionobjectattribute(self) -> None: toOne={}, toMany={} )} - ).apply_scoping(self.collection) + ) data = [ {'catno': "1", 'number': "100"}, {'catno': "2", 'number': "100"}, @@ -387,7 +387,7 @@ def test_collectingeventattribute(self) -> None: toOne={}, toMany={} )} - ).apply_scoping(self.collection) + ) data = [ {'sfn': "1", 'number': "100"}, {'sfn': "2", 'number': "100"}, @@ -430,7 +430,7 @@ def test_null_ce_with_ambiguous_collectingeventattribute(self) -> None: toOne={}, toMany={} )} - ).apply_scoping(self.collection) + ) data = [ {'sfn': "", 'number': "100"}, ] @@ -469,7 +469,7 @@ def test_ambiguous_one_to_one_match(self) -> None: toOne={}, toMany={} )} - ).apply_scoping(self.collection) + ) data = [ {'sfn': "1", 'number': "100"}, ] @@ -493,7 +493,7 @@ def test_null_record_with_ambiguous_one_to_one(self) -> None: toOne={}, toMany={} )} - ).apply_scoping(self.collection) + ) data = [ {'catno': "1", 'number': "100"}, {'catno': "2", 'number': "100"}, @@ -539,13 +539,13 @@ def test_determination_default_iscurrent(self) -> None: } validate(plan_json, schema) - scoped_plan = parse_plan(self.collection, plan_json).apply_scoping(self.collection) + plan = parse_plan(self.collection, plan_json) data = [ {'Catno': '1', 'Genus': 'Foo', 'Species': 'Bar'}, {'Catno': '2', 'Genus': 'Foo', 'Species': 'Bar'}, {'Catno': '3', 'Genus': 'Foo', 'Species': 'Bar'}, ] - results = do_upload(self.collection, data, scoped_plan, self.agent.id) + results = do_upload(self.collection, data, plan, self.agent.id) dets = [get_table('Collectionobject').objects.get(id=r.get_id()).determinations.get() for r in results] self.assertTrue(all(d.iscurrent for d in dets), "created determinations have iscurrent = true by default") @@ -581,13 +581,13 @@ def test_determination_override_iscurrent(self) -> None: } validate(plan_json, schema) - scoped_plan = parse_plan(self.collection, plan_json).apply_scoping(self.collection) + plan = parse_plan(self.collection, plan_json) data = [ {'Catno': '1', 'Genus': 'Foo', 'Species': 'Bar', 'iscurrent': 'false'}, {'Catno': '2', 'Genus': 'Foo', 'Species': 'Bar', 'iscurrent': 'false'}, {'Catno': '3', 'Genus': 'Foo', 'Species': 'Bar', 'iscurrent': 'false'}, ] - results = do_upload(self.collection, data, scoped_plan, self.agent.id) + results = do_upload(self.collection, data, plan, self.agent.id) dets = [get_table('Collectionobject').objects.get(id=r.get_id()).determinations.get() for r in results] self.assertFalse(any(d.iscurrent for d in dets), "created determinations have iscurrent = false by override") @@ -624,7 +624,7 @@ def test_ordernumber(self) -> None: toMany={} )}), ]} - ).apply_scoping(self.collection) + ) data = [ {'title': "A Natural History of Mung Beans", 'author1': "Philomungus", 'author2': "Mungophilius"}, {'title': "A Natural History of Mung Beans", 'author1': "Mungophilius", 'author2': "Philomungus"}, @@ -667,7 +667,7 @@ def test_no_override_ordernumber(self) -> None: toMany={} )}), ]} - ).apply_scoping(self.collection) + ) data = [ {'title': "A Natural History of Mung Beans", 'author1': "Philomungus", 'on1': '0', 'author2': "Mungophilius", 'on2': '1'}, {'title': "A Natural History of Mung Beans", 'author1': "Mungophilius", 'on1': '1', 'author2': "Philomungus", 'on2': '0'}, @@ -685,8 +685,8 @@ def test_filter_to_many_single(self) -> None: 5033,Gastropoda,Stromboidea,Strombidae,Lobatus,,leidyi,,"(Heilprin, 1887)",,,,,,, , ,,USA,FLORIDA,Hendry Co.,"Cochran Pit, N of Rt. 80, W of LaBelle",,North America,8/9/1973,8/9/1973,,,,8,0,0,Dry; shell,Dry,,,,,,1,"Caloosahatchee,Pinecrest Unit #4",U/Juv,,241,,,LWD,MJP,12/11/1997,26° 44.099' N,,81° 29.027' W,,Point,,,12/08/2016,0,Marine,0,M. Buffington,,M.,,Buffington,,,,,,,,,,,, ''')) row = next(reader) - assert isinstance(self.example_plan.toOne['collectingevent'], ScopedUploadTable) - uploadable = self.example_plan.toOne['collectingevent'].bind(self.collection, row, self.agent.id, Auditor(self.collection, auditlog)) + assert isinstance(self.example_plan_scoped.toOne['collectingevent'], ScopedUploadTable) + uploadable = self.example_plan_scoped.toOne['collectingevent'].bind(self.collection, row, self.agent.id, Auditor(self.collection, auditlog)) assert isinstance(uploadable, BoundUploadTable) filters, excludes = _to_many_filters_and_excludes(uploadable.toMany) self.assertEqual([{ @@ -710,8 +710,8 @@ def test_filter_multiple_to_many(self) -> None: 1378,Gastropoda,Rissooidea,Rissoinidae,Rissoina,,delicatissima,,"Raines, 2002",,B. Raines,,B.,,Raines,Nov 2003,11/2003,,CHILE,,Easter Island [= Isla de Pascua],"Off Punta Rosalia, E of Anakena",,SE Pacific O.,Apr 1998,04/1998,,,,2,0,0,Dry; shell,Dry,,,In sand at base of cliffs,10,20,0,,,Paratype,512,," PARATYPES. In pouch no. 1, paratypes 4 & 5. Raines, B.K. 2002. La Conchiglia 34 ( no. 304) : 16 (holotype LACM 2934, Fig. 9).",JSG,MJP,07/01/2004,"27° 04' 18"" S",,109° 19' 45' W,,Point,,JSG,23/12/2014,0,Marine,0,B. Raines and M. Taylor,,B.,,Raines,,M.,,Taylor,,,,,,,, ''')) row = next(reader) - assert isinstance(self.example_plan.toOne['collectingevent'], ScopedUploadTable) - uploadable = self.example_plan.toOne['collectingevent'].bind(self.collection, row, self.agent.id, Auditor(self.collection, auditlog)) + assert isinstance(self.example_plan_scoped.toOne['collectingevent'], ScopedUploadTable) + uploadable = self.example_plan_scoped.toOne['collectingevent'].bind(self.collection, row, self.agent.id, Auditor(self.collection, auditlog)) assert isinstance(uploadable, BoundUploadTable) filters, excludes = _to_many_filters_and_excludes(uploadable.toMany) self.assertEqual([ @@ -911,7 +911,7 @@ def test_tree_1(self) -> None: 'State': {'name': parse_column_options('State/Prov/Pref')}, 'County': {'name': parse_column_options('Region')}, } - ).apply_scoping(self.collection) + ).apply_scoping(self.collection)[1] row = next(reader) bt = tree_record.bind(self.collection, row, None, Auditor(self.collection, auditlog)) assert isinstance(bt, BoundTreeRecord) diff --git a/specifyweb/workbench/upload/tomany.py b/specifyweb/workbench/upload/tomany.py index 7b08ef53180..9194cd08130 100644 --- a/specifyweb/workbench/upload/tomany.py +++ b/specifyweb/workbench/upload/tomany.py @@ -1,6 +1,6 @@ import logging -from typing import Dict, Any, NamedTuple, List, Union, Set, Optional +from typing import Dict, Any, NamedTuple, List, Union, Set, Optional, Tuple from .uploadable import Row, FilterPack, Exclude, Uploadable, ScopedUploadable, BoundUploadable, Disambiguation, Auditor from .upload_result import ParseFailures @@ -15,9 +15,9 @@ class ToManyRecord(NamedTuple): static: Dict[str, Any] toOne: Dict[str, Uploadable] - def apply_scoping(self, collection) -> "ScopedToManyRecord": + def apply_scoping(self, collection, row=None) -> Tuple[bool, "ScopedToManyRecord"]: from .scoping import apply_scoping_to_tomanyrecord as apply_scoping - return apply_scoping(self, collection) + return apply_scoping(self, collection, row) def get_cols(self) -> Set[str]: return set(cd.column for cd in self.wbcols.values()) \ diff --git a/specifyweb/workbench/upload/treerecord.py b/specifyweb/workbench/upload/treerecord.py index 14fc286bdab..5aa75ac2a10 100644 --- a/specifyweb/workbench/upload/treerecord.py +++ b/specifyweb/workbench/upload/treerecord.py @@ -24,7 +24,7 @@ class TreeRecord(NamedTuple): name: str ranks: Dict[str, Dict[str, ColumnOptions]] - def apply_scoping(self, collection) -> "ScopedTreeRecord": + def apply_scoping(self, collection, row=None) -> Tuple[bool, "ScopedTreeRecord"]: from .scoping import apply_scoping_to_treerecord as apply_scoping return apply_scoping(self, collection) @@ -55,7 +55,7 @@ def disambiguate(self, disambiguation: DA) -> "ScopedTreeRecord": return self._replace(disambiguation=disambiguation.disambiguate_tree()) if disambiguation is not None else self def get_treedefs(self) -> Set: - return set([self.treedef]) + return {self.treedef} def bind(self, collection, row: Row, uploadingAgentId: Optional[int], auditor: Auditor, cache: Optional[Dict]=None, row_index: Optional[int] = None) -> Union["BoundTreeRecord", ParseFailures]: parsedFields: Dict[str, List[ParseResult]] = {} @@ -89,9 +89,9 @@ def bind(self, collection, row: Row, uploadingAgentId: Optional[int], auditor: A ) class MustMatchTreeRecord(TreeRecord): - def apply_scoping(self, collection) -> "ScopedMustMatchTreeRecord": - s = super().apply_scoping(collection) - return ScopedMustMatchTreeRecord(*s) + def apply_scoping(self, collection, row=None) -> Tuple[bool, "ScopedMustMatchTreeRecord"]: + _can_cache, s = super().apply_scoping(collection) + return _can_cache, ScopedMustMatchTreeRecord(*s) class ScopedMustMatchTreeRecord(ScopedTreeRecord): def bind(self, collection, row: Row, uploadingAgentId: Optional[int], auditor: Auditor, cache: Optional[Dict]=None, row_index: Optional[int] = None) -> Union["BoundMustMatchTreeRecord", ParseFailures]: diff --git a/specifyweb/workbench/upload/upload_table.py b/specifyweb/workbench/upload/upload_table.py index f5c17e03442..1afe5074b1d 100644 --- a/specifyweb/workbench/upload/upload_table.py +++ b/specifyweb/workbench/upload/upload_table.py @@ -136,8 +136,8 @@ def bind(self, collection, row: Row, uploadingAgentId: int, auditor: Auditor, ca class OneToOneTable(UploadTable): def apply_scoping(self, collection, row=None) -> Tuple[bool, "ScopedOneToOneTable"]: - s = super().apply_scoping(collection, row) - return ScopedOneToOneTable(*s) + cache, s = super().apply_scoping(collection, row) + return cache, ScopedOneToOneTable(*s) def to_json(self) -> Dict: return { 'oneToOneTable': self._to_json() } @@ -150,8 +150,8 @@ def bind(self, collection, row: Row, uploadingAgentId: int, auditor: Auditor, ca class MustMatchTable(UploadTable): def apply_scoping(self, collection, row=None) -> Tuple[bool, "ScopedMustMatchTable"]: - s = super().apply_scoping(collection, row) - return ScopedMustMatchTable(*s) + cache, s = super().apply_scoping(collection, row) + return cache, ScopedMustMatchTable(*s) def to_json(self) -> Dict: return { 'mustMatchTable': self._to_json() } diff --git a/specifyweb/workbench/upload/uploadable.py b/specifyweb/workbench/upload/uploadable.py index 68139a8e3d5..335533e227c 100644 --- a/specifyweb/workbench/upload/uploadable.py +++ b/specifyweb/workbench/upload/uploadable.py @@ -5,7 +5,11 @@ from .auditor import Auditor class Uploadable(Protocol): - def apply_scoping(self, collection, row) -> "ScopedUploadable": + # also returns if the scoped table returned can be cached or not. + # depends on whether scope depends on other columns. if any definition is found, + # we cannot cache. well, we can make this more complicated by recursviely caching + # static parts of even a non-entirely-cachable uploadable. + def apply_scoping(self, collection, row=None) -> Tuple[bool, "ScopedUploadable"]: ... def get_cols(self) -> Set[str]: From 1ce894b0b1999a60a0d1b1bc68e13bd65b879e0a Mon Sep 17 00:00:00 2001 From: realVinayak Date: Sat, 18 May 2024 12:26:05 -0500 Subject: [PATCH 05/63] fixup tests --- specifyweb/workbench/upload/scoping.py | 33 +++++------ .../upload/tests/test_upload_results_json.py | 1 - .../workbench/upload/tests/testscoping.py | 56 +++++-------------- specifyweb/workbench/upload/upload.py | 2 +- 4 files changed, 31 insertions(+), 61 deletions(-) diff --git a/specifyweb/workbench/upload/scoping.py b/specifyweb/workbench/upload/scoping.py index 92d524448d4..25d66e78bf9 100644 --- a/specifyweb/workbench/upload/scoping.py +++ b/specifyweb/workbench/upload/scoping.py @@ -116,30 +116,33 @@ def extend_columnoptions(colopts: ColumnOptions, collection, tablename: str, fie dateformat=get_date_format(), ) -def get_deferred_scoping(key, table_name, uploadable, row): +def get_deferred_scoping(key, table_name, uploadable, row, base_ut): deferred_key = (table_name, key) deferred_scoping = DEFERRED_SCOPING.get(deferred_key, None) - if deferred_scoping is None or row is None: + if deferred_scoping is None: return True, uploadable - related_key, filter_field, relationship_name = deferred_scoping - related_column_name = uploadable.wbcols['name'][0] #?????? why 'name'? seems like a hack in original implementation - filter_value = row[related_column_name][related_column_name] - filter_search = {filter_field: filter_value} - related_table = datamodel.get_table_strict(related_key) - - related = getattr(models, related_table.django_name).objects.get(**filter_search) - collection_id = getattr(related, relationship_name).id + if row: + related_key, filter_field, relationship_name = deferred_scoping + related_column_name = base_ut.toOne[related_key].wbcols[filter_field].column + filter_value = row[related_column_name] + filter_search = {filter_field: filter_value} + related_table = datamodel.get_table_strict(related_key) + related = getattr(models, related_table.django_name).objects.get(**filter_search) + collection_id = getattr(related, relationship_name).id + else: + # meh, would just go to the original collection + collection_id = None # don't cache anymore, since values can be dependent on rows. return False, uploadable._replace(overrideScope = {'collection': collection_id}) -def _apply_scoping_to_uploadtable(table, row, collection): +def _apply_scoping_to_uploadtable(table, row, collection, base_ut): def _update_uploadtable(previous_pack, current): can_cache, previous_to_ones = previous_pack field, uploadable = current - can_cache_this, uploadable = get_deferred_scoping(field, table.django_name, uploadable, row) + can_cache_this, uploadable = get_deferred_scoping(field, table.django_name, uploadable, row, base_ut) can_cache_sub, scoped = uploadable.apply_scoping(collection, row) return can_cache and can_cache_this and can_cache_sub, [*previous_to_ones, scoped] return _update_uploadtable @@ -148,21 +151,19 @@ def apply_scoping_to_one(ut, collection, row, table): adjust_to_ones = to_one_adjustments(collection, table) to_ones_items = list(ut.toOne.items()) can_cache_to_one, to_one_uploadables = reduce( - _apply_scoping_to_uploadtable(table, row, collection), to_ones_items, (True, []) + _apply_scoping_to_uploadtable(table, row, collection, ut), to_ones_items, (True, []) ) to_ones = {f[0]: adjust_to_ones(u, f[0]) for f, u in zip(to_ones_items, to_one_uploadables)} return can_cache_to_one, to_ones def apply_scoping_to_uploadtable(ut: UploadTable, collection, row=None) -> Tuple[bool, ScopedUploadTable]: table = datamodel.get_table_strict(ut.name) - if ut.overrideScope is not None and isinstance(ut.overrideScope['collection'], int): collection = getattr(models, "Collection").objects.filter(id=ut.overrideScope['collection']).get() can_cache_to_one, to_ones = apply_scoping_to_one(ut, collection, row, table) - to_many_results = [ - (f, reduce(_apply_scoping_to_uploadtable(table, row, collection), [(f, r) for r in rs], (True, []))) for ( f, rs) in ut.toMany.items() + (f, reduce(_apply_scoping_to_uploadtable(table, row, collection, ut), [(f, r) for r in rs], (True, []))) for ( f, rs) in ut.toMany.items() ] can_cache_to_many = all(tmr[1][0] for tmr in to_many_results) diff --git a/specifyweb/workbench/upload/tests/test_upload_results_json.py b/specifyweb/workbench/upload/tests/test_upload_results_json.py index e4036a0b95e..27365990761 100644 --- a/specifyweb/workbench/upload/tests/test_upload_results_json.py +++ b/specifyweb/workbench/upload/tests/test_upload_results_json.py @@ -49,7 +49,6 @@ def testParseFailures(self, parseFailures: ParseFailures): @settings(suppress_health_check=[HealthCheck.too_slow]) @given(record_result=infer, toOne=infer, toMany=infer) def testUploadResult(self, record_result: RecordResult, toOne: Dict[str, RecordResult], toMany: Dict[str, List[RecordResult]]): - return uploadResult = UploadResult( record_result=record_result, toOne={k: UploadResult(v, {}, {}) for k, v in toOne.items()}, diff --git a/specifyweb/workbench/upload/tests/testscoping.py b/specifyweb/workbench/upload/tests/testscoping.py index 4a5b5e7165c..f9ad80880d0 100644 --- a/specifyweb/workbench/upload/tests/testscoping.py +++ b/specifyweb/workbench/upload/tests/testscoping.py @@ -109,46 +109,17 @@ def test_embedded_paleocontext_in_collectionobject(self) -> None: self.assertIsInstance(plan.toOne['paleocontext'], ScopedOneToOneTable) - def collection_rel_type_being_deferred(self) -> None: + def test_caching_scoped_false(self) -> None: - parsed_plan = parse_plan(self.collection, self.collection_rel_plan) + plan = parse_plan(self.collection, self.collection_rel_plan).apply_scoping(self.collection) - expected_plan = UploadTable( - name='Collectionrelationship', - wbcols={}, - static={}, - toOne={ - 'leftside': UploadTable( - name='Collectionobject', - wbcols={'catalognumber': ColumnOptions(column='Cat #', matchBehavior='ignoreNever', nullAllowed=True, default=None)}, - static={}, - toOne={}, - toMany={}, - overrideScope=None - ), - 'rightside': UploadTable( - name='Collectionobject', - wbcols={'catalognumber': ColumnOptions(column='Cat # (2)', matchBehavior='ignoreNever', nullAllowed=True, default=None)}, - static={}, - toOne={}, - toMany={}, - overrideScope=None - ), - 'collectionreltype': UploadTable( - name='Collectionreltype', - wbcols={'name': ColumnOptions(column='Collection Rel Type', matchBehavior='ignoreNever', nullAllowed=True, default=None)}, - static={}, - toOne={}, - toMany={}, - overrideScope=None - ) - }, - toMany={}, - overrideScope=None) - - self.assertEqual(parsed_plan, expected_plan) + self.assertFalse(plan[0], 'contains collection relationship, should never be cached') + + def test_caching_true(self): + plan = self.example_plan.apply_scoping(self.collection) + self.assertTrue(plan[0], 'caching is possible here, since no dynamic scope is being used') - """ + # TODO: Refactor this one too def deferred_scope_table_ignored_when_scoping_applied(self): scoped_upload_plan = parse_plan(self.collection_rel_plan).apply_scoping(self.collection) @@ -196,17 +167,16 @@ def deferred_scope_table_ignored_when_scoping_applied(self): disambiguation=None) self.assertEqual(scoped_upload_plan, expected_scoping) - """ - def collection_rel_uploaded_in_correct_collection(self): - scoped_plan = parse_plan(self.collection_rel_plan) + + def test_collection_rel_uploaded_in_correct_collection(self): + scoped_plan = parse_plan(self.collection, self.collection_rel_plan) rows = [ {'Collection Rel Type': self.rel_type_name, 'Cat # (2)': '999', 'Cat #': '23'}, {'Collection Rel Type': self.rel_type_name, 'Cat # (2)': '888', 'Cat #': '32'} ] - do_upload(self.collection, rows, scoped_plan, self.agent.id) + result = do_upload(self.collection, rows, scoped_plan, self.agent.id) left_side_cat_nums = [n.zfill(9) for n in '32 23'.split()] - right_side_cat_nums = [n.zfill(9) for n in '999 888'.split()] - + right_side_cat_nums = '999 888'.split() left_side_query = models.Collectionobject.objects.filter(collection_id=self.collection.id, catalognumber__in=left_side_cat_nums) right_side_query = models.Collectionobject.objects.filter(collection_id=self.right_side_collection.id, catalognumber__in=right_side_cat_nums) diff --git a/specifyweb/workbench/upload/upload.py b/specifyweb/workbench/upload/upload.py index be8c3bd0422..328a56ab27b 100644 --- a/specifyweb/workbench/upload/upload.py +++ b/specifyweb/workbench/upload/upload.py @@ -190,7 +190,7 @@ def get_raw_ds_upload_plan(collection, ds: Spdataset) -> Tuple[Table, Uploadable def get_ds_upload_plan(collection, ds: Spdataset) -> Tuple[Table, ScopedUploadable]: base_table, plan = get_raw_ds_upload_plan(collection, ds) - return base_table, plan.apply_scoping(collection) + return base_table, plan.apply_scoping(collection)[1] def do_upload( From ee27d511b8bc5415cb186cd6fa1ad5950a4a393b Mon Sep 17 00:00:00 2001 From: realVinayak Date: Sat, 18 May 2024 12:35:12 -0500 Subject: [PATCH 06/63] Add (force?) types --- specifyweb/workbench/management/commands/upload_csv.py | 2 +- specifyweb/workbench/upload/scoping.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/specifyweb/workbench/management/commands/upload_csv.py b/specifyweb/workbench/management/commands/upload_csv.py index c05e591265d..88c048d1162 100644 --- a/specifyweb/workbench/management/commands/upload_csv.py +++ b/specifyweb/workbench/management/commands/upload_csv.py @@ -38,7 +38,7 @@ def handle(self, *args, **options) -> None: with open(options['csv_file'], newline='') as csvfile: reader = csv.DictReader(csvfile) - result = do_upload_csv(specify_collection, reader, parse_plan(specify_collection, plan).apply_scoping(specify_collection), not options['commit']) + result = do_upload_csv(specify_collection, reader, parse_plan(specify_collection, plan), not options['commit']) self.stdout.write(json.dumps([r.to_json() for r in result], indent=2)) diff --git a/specifyweb/workbench/upload/scoping.py b/specifyweb/workbench/upload/scoping.py index 25d66e78bf9..bfe6aba41ce 100644 --- a/specifyweb/workbench/upload/scoping.py +++ b/specifyweb/workbench/upload/scoping.py @@ -1,4 +1,4 @@ -from typing import Dict, Any, Optional, Tuple, Callable, Union +from typing import Dict, Any, Optional, Tuple, Callable, Union, List from specifyweb.specify.datamodel import datamodel, Table, Relationship from specifyweb.specify.load_datamodel import DoesNotExistError @@ -162,7 +162,7 @@ def apply_scoping_to_uploadtable(ut: UploadTable, collection, row=None) -> Tuple collection = getattr(models, "Collection").objects.filter(id=ut.overrideScope['collection']).get() can_cache_to_one, to_ones = apply_scoping_to_one(ut, collection, row, table) - to_many_results = [ + to_many_results: List[Tuple[str, Tuple[bool, List[ScopedToManyRecord]]]] = [ (f, reduce(_apply_scoping_to_uploadtable(table, row, collection, ut), [(f, r) for r in rs], (True, []))) for ( f, rs) in ut.toMany.items() ] From 9264668ac6fe7b0a31dfcb7a847eeb242c1f5769 Mon Sep 17 00:00:00 2001 From: realVinayak Date: Sat, 18 May 2024 13:19:41 -0500 Subject: [PATCH 07/63] Simplify types --- specifyweb/workbench/upload/scoping.py | 43 ++++++++++++-------------- 1 file changed, 20 insertions(+), 23 deletions(-) diff --git a/specifyweb/workbench/upload/scoping.py b/specifyweb/workbench/upload/scoping.py index bfe6aba41ce..d759b13174b 100644 --- a/specifyweb/workbench/upload/scoping.py +++ b/specifyweb/workbench/upload/scoping.py @@ -1,17 +1,16 @@ from typing import Dict, Any, Optional, Tuple, Callable, Union, List - -from specifyweb.specify.datamodel import datamodel, Table, Relationship +from specifyweb.specify.datamodel import datamodel, Table from specifyweb.specify.load_datamodel import DoesNotExistError from specifyweb.specify import models from specifyweb.specify.uiformatters import get_uiformatter from specifyweb.stored_queries.format import get_date_format -from .uploadable import Uploadable, ScopedUploadable -from .upload_table import UploadTable, ScopedUploadTable, OneToOneTable, ScopedOneToOneTable +from .uploadable import ScopedUploadable +from .upload_table import UploadTable, ScopedUploadTable, ScopedOneToOneTable from .tomany import ToManyRecord, ScopedToManyRecord from .treerecord import TreeRecord, ScopedTreeRecord from .column_options import ColumnOptions, ExtendedColumnOptions -from functools import reduce + """ There are cases in which the scoping of records should be dependent on another record/column in a WorkBench dataset. @@ -139,37 +138,35 @@ def get_deferred_scoping(key, table_name, uploadable, row, base_ut): return False, uploadable._replace(overrideScope = {'collection': collection_id}) def _apply_scoping_to_uploadtable(table, row, collection, base_ut): - def _update_uploadtable(previous_pack, current): - can_cache, previous_to_ones = previous_pack - field, uploadable = current + def _update_uploadtable(field: str, uploadable: Union[UploadTable, ToManyRecord]): can_cache_this, uploadable = get_deferred_scoping(field, table.django_name, uploadable, row, base_ut) can_cache_sub, scoped = uploadable.apply_scoping(collection, row) - return can_cache and can_cache_this and can_cache_sub, [*previous_to_ones, scoped] + return can_cache_this and can_cache_sub, scoped return _update_uploadtable -def apply_scoping_to_one(ut, collection, row, table): +def apply_scoping_to_one(ut, collection, table, callback) -> Tuple[bool, Dict[str, ScopedUploadable]]: adjust_to_ones = to_one_adjustments(collection, table) to_ones_items = list(ut.toOne.items()) - can_cache_to_one, to_one_uploadables = reduce( - _apply_scoping_to_uploadtable(table, row, collection, ut), to_ones_items, (True, []) - ) - to_ones = {f[0]: adjust_to_ones(u, f[0]) for f, u in zip(to_ones_items, to_one_uploadables)} - return can_cache_to_one, to_ones + to_one_results = [(f, callback(f, u)) for (f, u) in to_ones_items] + to_ones = {f: adjust_to_ones(u, f) for f, (_, u) in to_one_results} + return all(_can_cache for (_, (_can_cache, __)) in to_one_results), to_ones def apply_scoping_to_uploadtable(ut: UploadTable, collection, row=None) -> Tuple[bool, ScopedUploadTable]: table = datamodel.get_table_strict(ut.name) if ut.overrideScope is not None and isinstance(ut.overrideScope['collection'], int): collection = getattr(models, "Collection").objects.filter(id=ut.overrideScope['collection']).get() - can_cache_to_one, to_ones = apply_scoping_to_one(ut, collection, row, table) - to_many_results: List[Tuple[str, Tuple[bool, List[ScopedToManyRecord]]]] = [ - (f, reduce(_apply_scoping_to_uploadtable(table, row, collection, ut), [(f, r) for r in rs], (True, []))) for ( f, rs) in ut.toMany.items() + callback = _apply_scoping_to_uploadtable(table, row, collection, ut) + can_cache_to_one, to_ones = apply_scoping_to_one(ut, collection, table, callback) + + to_many_results: List[Tuple[str, List[Tuple[bool, ScopedToManyRecord]]]] = [ + (f, [(callback(f, r)) for r in rs]) for (f, rs) in ut.toMany.items() ] - can_cache_to_many = all(tmr[1][0] for tmr in to_many_results) + can_cache_to_many = all(_can_cache for (_, tmr) in to_many_results for (_can_cache, __) in tmr) to_many = { - f: [set_order_number(i, tmr) for i, tmr in enumerate(scoped_tmrs)] - for f, (_, scoped_tmrs) in to_many_results + f: [set_order_number(i, tmr) for i, (_, tmr) in enumerate(scoped_tmrs)] + for f, scoped_tmrs in to_many_results } return can_cache_to_many and can_cache_to_one, ScopedUploadTable( @@ -221,8 +218,8 @@ def set_order_number(i: int, tmr: ScopedToManyRecord) -> ScopedToManyRecord: def apply_scoping_to_tomanyrecord(tmr: ToManyRecord, collection, row) -> Tuple[bool, ScopedToManyRecord]: table = datamodel.get_table_strict(tmr.name) - - can_cache_to_one, to_ones = apply_scoping_to_one(tmr, collection, row, table) + callback = _apply_scoping_to_uploadtable(table, row, collection, tmr) + can_cache_to_one, to_ones = apply_scoping_to_one(tmr, collection, table, callback) return can_cache_to_one, ScopedToManyRecord( name=tmr.name, From 03efb8e64d9dcd5403f5097597a8b6f2b14b5c49 Mon Sep 17 00:00:00 2001 From: realVinayak Date: Mon, 20 May 2024 21:37:48 -0500 Subject: [PATCH 08/63] Make backend mostly compatible with handling generic fields + relationship in tree ranks --- specifyweb/stored_queries/execution.py | 2 +- specifyweb/stored_queries/format.py | 5 +- specifyweb/stored_queries/query_construct.py | 35 +++++---- specifyweb/stored_queries/queryfieldspec.py | 79 +++++++++++++------- 4 files changed, 76 insertions(+), 45 deletions(-) diff --git a/specifyweb/stored_queries/execution.py b/specifyweb/stored_queries/execution.py index 2aaa201dcab..89f4745fea9 100644 --- a/specifyweb/stored_queries/execution.py +++ b/specifyweb/stored_queries/execution.py @@ -640,5 +640,5 @@ def build_query(session, collection, user, tableid, field_specs, if distinct: query = group_by_displayed_fields(query, selected_fields) - logger.debug("query: %s", query.query) + logger.warning("query: %s", query.query) return query.query, order_by_exprs diff --git a/specifyweb/stored_queries/format.py b/specifyweb/stored_queries/format.py index 5c0a0366107..175744d4967 100644 --- a/specifyweb/stored_queries/format.py +++ b/specifyweb/stored_queries/format.py @@ -55,7 +55,7 @@ def lookup(attr: str, val: str) -> Optional[Element]: return self.formattersDom.find( 'format[@%s=%s]' % (attr, quoteattr(val))) - def getFormatterFromSchema() -> Element: + def getFormatterFromSchema() -> Optional[Element]: try: formatter_name = Splocalecontainer.objects.get( name=specify_model.name.lower(), @@ -256,9 +256,6 @@ def fieldformat(self, query_field: QueryField, if field_spec.is_temporal() and field_spec.date_part == "Full Date": field = self._dateformat(field_spec.get_field(), field) - elif field_spec.tree_rank is not None: - pass - elif field_spec.is_relationship(): pass diff --git a/specifyweb/stored_queries/query_construct.py b/specifyweb/stored_queries/query_construct.py index 00fda9a4bc2..55adf1ccf6b 100644 --- a/specifyweb/stored_queries/query_construct.py +++ b/specifyweb/stored_queries/query_construct.py @@ -6,6 +6,7 @@ from specifyweb.specify.models import datamodel from . import models +from .queryfieldspec import TreeRankQuery, QueryFieldSpec logger = logging.getLogger(__name__) @@ -25,13 +26,13 @@ def __new__(cls, *args, **kwargs): kwargs['tree_rank_count'] = 0 return super(QueryConstruct, cls).__new__(cls, *args, **kwargs) - def handle_tree_field(self, node, table, tree_rank, tree_field): + def handle_tree_field(self, node, table, tree_rank, next_join_path, current_field_spec: QueryFieldSpec): query = self if query.collection is None: raise AssertionError( # Not sure it makes sense to query across collections f"No Collection found in Query for {table}", {"table" : table, "localizationKey" : "noCollectionInQuery"}) - logger.info('handling treefield %s rank: %s field: %s', table, tree_rank, tree_field) + logger.info('handling treefield %s rank: %s field: %s', table, tree_rank, next_join_path) treedefitem_column = table.name + 'TreeDefItemID' @@ -55,16 +56,24 @@ def handle_tree_field(self, node, table, tree_rank, tree_field): query = query._replace(param_count=self.param_count+1) treedefitem_param = sql.bindparam('tdi_%s' % query.param_count, value=treedef.treedefitems.get(name=tree_rank).id) - column_name = 'name' if tree_field is None else \ - node._id if tree_field == 'ID' else \ - table.get_field(tree_field.lower()).name + def make_tree_field_spec(tree_node): + return current_field_spec._replace( + root_table=table, # rebasing the query + root_sql_table=tree_node, # this is needed to preserve SQL aliased going to next part + join_path=next_join_path, # slicing join path to begin from after the tree + ) - column = sql.case([ - (getattr(ancestor, treedefitem_column) == treedefitem_param, getattr(ancestor, column_name)) - for ancestor in ancestors - ]) + cases = [] + field = None # just to stop mypy from complaining. + for ancestor in ancestors: + field_spec = make_tree_field_spec(ancestor) + query, orm_field, field, table = field_spec.add_spec_to_query(query) + #field and table won't matter. rank acts as fork, and these two will be same across siblings + cases.append((getattr(ancestor, treedefitem_column) == treedefitem_param, orm_field)) - return query, column + column = sql.case(cases) + + return query, column, field, table def tables_in_path(self, table, join_path): path = deque(join_path) @@ -74,7 +83,7 @@ def tables_in_path(self, table, join_path): field = path.popleft() if isinstance(field, str): field = tables[-1].get_field(field, strict=True) - if not field.is_relationship: + if not field.is_relationship: # also handles tree ranks break tables.append(datamodel.get_table(field.relatedModelName, strict=True)) @@ -88,8 +97,8 @@ def build_join(self, table, model, join_path): field = path.popleft() if isinstance(field, str): field = table.get_field(field, strict=True) - - if not field.is_relationship: + # basically, tree ranks act as forks. + if not field.is_relationship or isinstance(field, TreeRankQuery): break next_table = datamodel.get_table(field.relatedModelName, strict=True) logger.debug("joining: %r to %r via %r", table, next_table, field) diff --git a/specifyweb/stored_queries/queryfieldspec.py b/specifyweb/stored_queries/queryfieldspec.py index ed4824d84c3..aa0cbe207f8 100644 --- a/specifyweb/stored_queries/queryfieldspec.py +++ b/specifyweb/stored_queries/queryfieldspec.py @@ -1,13 +1,15 @@ import logging import re -from collections import namedtuple, deque +from collections import deque, namedtuple +from typing import NamedTuple, Tuple, Union, Optional -from sqlalchemy import sql +from sqlalchemy import sql, Table as SQLTable from specifyweb.specify.models import datamodel from specifyweb.specify.uiformatters import get_uiformatter from . import models from .query_ops import QueryOps +from ..specify.load_datamodel import Table, Field, Relationship logger = logging.getLogger(__name__) @@ -37,7 +39,7 @@ def extract_date_part(fieldname): return fieldname, date_part def make_table_list(fs): - path = fs.join_path if fs.tree_rank or not fs.join_path or fs.is_relationship() else fs.join_path[:-1] + path = fs.join_path if not fs.join_path or fs.is_relationship() else fs.join_path[:-1] first = [str(fs.root_table.tableId)] def field_to_elem(field): @@ -48,17 +50,33 @@ def field_to_elem(field): return "%d-%s" % (related_model.tableId, field.name.lower()) - rest = [field_to_elem(f) for f in path] + rest = [field_to_elem(f) for f in path if not isinstance(f, TreeRankQuery)] return ','.join(first + rest) def make_stringid(fs, table_list): - field_name = fs.tree_rank or (fs.join_path[-1].name if fs.join_path else '') + tree_ranks = [f.name for f in fs.join_path if isinstance(f, TreeRankQuery)] + if tree_ranks: + # FUTURE: Just having this for backwards compatibility; + field_name = tree_ranks[0] + else: + # BUG: Malformed previous stringids are rejected. they desrve it. + field_name = (fs.join_path[-1].name if fs.join_path else '') if fs.date_part is not None and fs.date_part != "Full Date": field_name += 'Numeric' + fs.date_part return table_list, fs.table.name.lower(), field_name +class TreeRankQuery(Relationship): + # FUTURE: used to remember what the previous value was. Useless after 6 retires + original_field: str + pass + +class QueryFieldSpec(namedtuple("QueryFieldSpec", "root_table root_sql_table join_path table date_part")): + root_table: Table + root_sql_table: SQLTable + join_path: Tuple[Union[Field, Relationship, TreeRankQuery]] + table: Table + date_part: Optional[str] -class QueryFieldSpec(namedtuple("QueryFieldSpec", "root_table root_sql_table join_path table date_part tree_rank tree_field")): @classmethod def from_path(cls, path_in, add_id=False): path = deque(path_in) @@ -83,9 +101,7 @@ def from_path(cls, path_in, add_id=False): root_sql_table=getattr(models, root_table.name), join_path=tuple(join_path), table=node, - date_part='Full Date' if (join_path and join_path[-1].is_temporal()) else None, - tree_rank=None, - tree_field=None) + date_part='Full Date' if (join_path and join_path[-1].is_temporal()) else None) @classmethod @@ -111,21 +127,30 @@ def from_stringid(cls, stringid, is_relation): extracted_fieldname, date_part = extract_date_part(field_name) field = node.get_field(extracted_fieldname, strict=False) - tree_rank = tree_field = None - if field is None: + + if field is None: # try finding tree tree_id_match = TREE_ID_FIELD_RE.match(extracted_fieldname) if tree_id_match: - tree_rank = tree_id_match.group(1) - tree_field = 'ID' + tree_rank_name = tree_id_match.group(1) + field = 'ID' else: tree_field_match = TAXON_FIELD_RE.match(extracted_fieldname) \ if node is datamodel.get_table('Taxon') else None if tree_field_match: - tree_rank = tree_field_match.group(1) - tree_field = tree_field_match.group(2) + tree_rank_name = tree_field_match.group(1) + field = (tree_field_match.group(2)) else: - tree_rank = extracted_fieldname if extracted_fieldname else None - else: + tree_rank_name = extracted_fieldname if extracted_fieldname else None + field = '' + if tree_rank_name is not None: + tree_rank = TreeRankQuery() + # doesn't make sense to query across ranks of trees. no, it doesn't block a theoretical query like family -> continent + tree_rank.relatedModelName = node.name + tree_rank.name = tree_rank_name + join_path.append(tree_rank) + field = node.get_field('name' if field == '' else field) + + if field is not None: join_path.append(field) if field.is_temporal() and date_part is None: date_part = "Full Date" @@ -134,9 +159,7 @@ def from_stringid(cls, stringid, is_relation): root_sql_table=getattr(models, root_table.name), join_path=tuple(join_path), table=node, - date_part=date_part, - tree_rank=tree_rank, - tree_field=tree_field) + date_part=date_part) logger.debug('parsed %s (is_relation %s) to %s. extracted_fieldname = %s', stringid, is_relation, result, extracted_fieldname) @@ -173,7 +196,7 @@ def get_field(self): return None def is_relationship(self): - return self.tree_rank is None and self.get_field() is not None and self.get_field().is_relationship + return self.get_field() is not None and self.get_field().is_relationship def is_temporal(self): field = self.get_field() @@ -189,10 +212,12 @@ def is_auditlog_obj_format_field(self, formatauditobjs): return self.get_field().name.lower() in ['oldvalue','newvalue'] def is_specify_username_end(self): - return len(self.join_path) > 2 and self.join_path[-1].name == 'name' and self.join_path[-2].is_relationship and self.join_path[-2].relatedModelName == 'SpecifyUser' + # TODO: Add unit tests. + return self.join_path and self.table.name.lower() == 'specifyuser' + def apply_filter(self, query, orm_field, field, table, value=None, op_num=None, negate=False): - no_filter = op_num is None or (self.tree_rank is None and self.get_field() is None) + no_filter = op_num is None or (self.get_field() is None) if not no_filter: if isinstance(value, QueryFieldSpec): _, other_field, _ = value.add_to_query(query.reset_joinpoint()) @@ -224,7 +249,7 @@ def add_to_query(self, query, value=None, op_num=None, negate=False, formatter=N def add_spec_to_query(self, query, formatter=None, aggregator=None, cycle_detector=[]): - if self.tree_rank is None and self.get_field() is None: + if self.get_field() is None: return (*query.objectformatter.objformat( query, self.root_sql_table, formatter), None, self.root_table) @@ -238,11 +263,11 @@ def add_spec_to_query(self, query, formatter=None, aggregator=None, cycle_detect orm_field = query.objectformatter.aggregate(query, self.get_field(), orm_model, aggregator or formatter, cycle_detector) else: query, orm_model, table, field = self.build_join(query, self.join_path) - if self.tree_rank is not None: - query, orm_field = query.handle_tree_field(orm_model, table, self.tree_rank, self.tree_field) + if isinstance(field, TreeRankQuery): + tree_rank_idx = self.join_path.index(field) + query, orm_field, field, table = query.handle_tree_field(orm_model, table, field.name, self.join_path[tree_rank_idx+1:], self) else: orm_field = getattr(orm_model, self.get_field().name) - if field.type == "java.sql.Timestamp": # Only consider the date portion of timestamp fields. # This is to replicate the behavior of Sp6. It might From 8e404a64c1ce498026e5fb3a38dd5982ba38c66b Mon Sep 17 00:00:00 2001 From: realVinayak Date: Mon, 20 May 2024 22:01:58 -0500 Subject: [PATCH 09/63] don't set field to str if no tree match --- specifyweb/stored_queries/queryfieldspec.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/specifyweb/stored_queries/queryfieldspec.py b/specifyweb/stored_queries/queryfieldspec.py index aa0cbe207f8..c4282b64475 100644 --- a/specifyweb/stored_queries/queryfieldspec.py +++ b/specifyweb/stored_queries/queryfieldspec.py @@ -141,14 +141,13 @@ def from_stringid(cls, stringid, is_relation): field = (tree_field_match.group(2)) else: tree_rank_name = extracted_fieldname if extracted_fieldname else None - field = '' if tree_rank_name is not None: tree_rank = TreeRankQuery() # doesn't make sense to query across ranks of trees. no, it doesn't block a theoretical query like family -> continent tree_rank.relatedModelName = node.name tree_rank.name = tree_rank_name join_path.append(tree_rank) - field = node.get_field('name' if field == '' else field) + field = node.get_field(field or 'name') # to replicate 6 for now. if field is not None: join_path.append(field) From bccb6574640ae1ad7b8e0a4e9b1ad1f138879f24 Mon Sep 17 00:00:00 2001 From: realVinayak Date: Tue, 21 May 2024 09:35:27 -0500 Subject: [PATCH 10/63] Add type + fix id field not misunderstood as formatted --- specifyweb/stored_queries/queryfieldspec.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/specifyweb/stored_queries/queryfieldspec.py b/specifyweb/stored_queries/queryfieldspec.py index c4282b64475..4a2062ec8fb 100644 --- a/specifyweb/stored_queries/queryfieldspec.py +++ b/specifyweb/stored_queries/queryfieldspec.py @@ -132,7 +132,7 @@ def from_stringid(cls, stringid, is_relation): tree_id_match = TREE_ID_FIELD_RE.match(extracted_fieldname) if tree_id_match: tree_rank_name = tree_id_match.group(1) - field = 'ID' + field = node.idFieldName else: tree_field_match = TAXON_FIELD_RE.match(extracted_fieldname) \ if node is datamodel.get_table('Taxon') else None @@ -146,6 +146,7 @@ def from_stringid(cls, stringid, is_relation): # doesn't make sense to query across ranks of trees. no, it doesn't block a theoretical query like family -> continent tree_rank.relatedModelName = node.name tree_rank.name = tree_rank_name + tree_rank.type = 'many-to-one' join_path.append(tree_rank) field = node.get_field(field or 'name') # to replicate 6 for now. From 6821a887d26944fd42b2c9d71d77b252eb3d2a3d Mon Sep 17 00:00:00 2001 From: realVinayak Date: Sun, 9 Jun 2024 01:38:51 -0500 Subject: [PATCH 11/63] Add experimental support for arbitrary nested-to-manys in workbench --- specifyweb/stored_queries/tests.py | 63 ++--- .../management/commands/upload_csv.py | 2 +- specifyweb/workbench/upload/scoping.py | 22 +- .../workbench/upload/tests/example_plan.py | 21 +- .../workbench/upload/tests/test_bugs.py | 10 +- .../upload/tests/testdisambiguation.py | 39 ++-- .../workbench/upload/tests/testmustmatch.py | 26 +-- .../workbench/upload/tests/testonetoone.py | 18 +- .../workbench/upload/tests/testparsing.py | 56 ++--- .../workbench/upload/tests/testschema.py | 4 +- .../workbench/upload/tests/testscoping.py | 9 +- .../workbench/upload/tests/testunupload.py | 17 +- .../workbench/upload/tests/testuploading.py | 221 +++++++++--------- specifyweb/workbench/upload/tomany.py | 112 +-------- specifyweb/workbench/upload/treerecord.py | 20 +- specifyweb/workbench/upload/upload.py | 28 ++- .../workbench/upload/upload_plan_schema.py | 70 +++--- specifyweb/workbench/upload/upload_table.py | 221 +++++++++++------- specifyweb/workbench/upload/uploadable.py | 141 +++++++++-- specifyweb/workbench/views.py | 2 +- 20 files changed, 584 insertions(+), 518 deletions(-) diff --git a/specifyweb/stored_queries/tests.py b/specifyweb/stored_queries/tests.py index f1d9d58a94b..1c317cc5941 100644 --- a/specifyweb/stored_queries/tests.py +++ b/specifyweb/stored_queries/tests.py @@ -47,7 +47,37 @@ def test_stringid_roundtrip_en_masse(self) -> None: """ - +def setup_sqlalchemy(url: str): + engine = sqlalchemy.create_engine(url, pool_recycle=settings.SA_POOL_RECYCLE, + connect_args={'cursorclass': SSCursor}) + + # BUG: Raise 0-row exception somewhere here. + @event.listens_for(engine, 'before_cursor_execute', retval=True) + # Listen to low-level cursor execution events. Just before query is executed by SQLAlchemy, run it instead + # by Django, and then return a wrapped sql statement which will return the same result set. + def run_django_query(conn, cursor, statement, parameters, context, executemany): + django_cursor = connection.cursor() + # Get MySQL Compatible compiled query. + # print('##################################################################') + # print(statement % parameters) + # print('##################################################################') + django_cursor.execute(statement, parameters) + result_set = django_cursor.fetchall() + columns = django_cursor.description + # SqlAlchemy needs to find columns back in the rows, hence adding label to columns + selects = [sqlalchemy.select([sqlalchemy.literal(column).label(columns[idx][0]) for idx, column in enumerate(row)]) for row + in result_set] + # union all instead of union because rows can be duplicated in the original query, + # but still need to preserve the duplication + unioned = sqlalchemy.union_all(*selects) + # Tests will fail when migrated to different background. TODO: Auto-detect dialects + final_query = str(unioned.compile(compile_kwargs={"literal_binds": True, }, dialect=mysql.dialect())) + + return final_query, () + + Session = orm.sessionmaker(bind=engine) + return engine, models.make_session_context(Session) + class SQLAlchemySetup(ApiTests): test_sa_url = None @@ -58,35 +88,10 @@ class SQLAlchemySetup(ApiTests): def setUpClass(cls): # Django creates a new database for testing. SQLAlchemy needs to connect to the test database super().setUpClass() - _engine = sqlalchemy.create_engine(settings.SA_TEST_DB_URL, pool_recycle=settings.SA_POOL_RECYCLE, - connect_args={'cursorclass': SSCursor}) - - cls.engine = _engine - Session = orm.sessionmaker(bind=_engine) - - cls.test_session_context = models.make_session_context(Session) - - @event.listens_for(_engine, 'before_cursor_execute', retval=True) - # Listen to low-level cursor execution events. Just before query is executed by SQLAlchemy, run it instead - # by Django, and then return a wrapped sql statement which will return the same result set. - def run_django_query(conn, cursor, statement, parameters, context, executemany): - django_cursor = connection.cursor() - # Get MySQL Compatible compiled query. - django_cursor.execute(statement, parameters) - result_set = django_cursor.fetchall() - columns = django_cursor.description - django_cursor.close() - # SqlAlchemy needs to find columns back in the rows, hence adding label to columns - selects = [sqlalchemy.select([sqlalchemy.literal(column).label(columns[idx][0]) for idx, column in enumerate(row)]) for row - in result_set] - # union all instead of union because rows can be duplicated in the original query, - # but still need to preserve the duplication - unioned = sqlalchemy.union_all(*selects) - # Tests will fail when migrated to different background. TODO: Auto-detect dialects - final_query = str(unioned.compile(compile_kwargs={"literal_binds": True, }, dialect=mysql.dialect())) - return final_query, () - + engine, session_context = setup_sqlalchemy(settings.SA_TEST_DB_URL) + cls.engine = engine + cls.test_session_context = session_context class SQLAlchemySetupTest(SQLAlchemySetup): diff --git a/specifyweb/workbench/management/commands/upload_csv.py b/specifyweb/workbench/management/commands/upload_csv.py index 88c048d1162..fad7dd417b3 100644 --- a/specifyweb/workbench/management/commands/upload_csv.py +++ b/specifyweb/workbench/management/commands/upload_csv.py @@ -38,7 +38,7 @@ def handle(self, *args, **options) -> None: with open(options['csv_file'], newline='') as csvfile: reader = csv.DictReader(csvfile) - result = do_upload_csv(specify_collection, reader, parse_plan(specify_collection, plan), not options['commit']) + result = do_upload_csv(specify_collection, reader, parse_plan(plan), not options['commit']) self.stdout.write(json.dumps([r.to_json() for r in result], indent=2)) diff --git a/specifyweb/workbench/upload/scoping.py b/specifyweb/workbench/upload/scoping.py index d759b13174b..25789015a94 100644 --- a/specifyweb/workbench/upload/scoping.py +++ b/specifyweb/workbench/upload/scoping.py @@ -7,7 +7,6 @@ from .uploadable import ScopedUploadable from .upload_table import UploadTable, ScopedUploadTable, ScopedOneToOneTable -from .tomany import ToManyRecord, ScopedToManyRecord from .treerecord import TreeRecord, ScopedTreeRecord from .column_options import ColumnOptions, ExtendedColumnOptions @@ -138,7 +137,7 @@ def get_deferred_scoping(key, table_name, uploadable, row, base_ut): return False, uploadable._replace(overrideScope = {'collection': collection_id}) def _apply_scoping_to_uploadtable(table, row, collection, base_ut): - def _update_uploadtable(field: str, uploadable: Union[UploadTable, ToManyRecord]): + def _update_uploadtable(field: str, uploadable: UploadTable): can_cache_this, uploadable = get_deferred_scoping(field, table.django_name, uploadable, row, base_ut) can_cache_sub, scoped = uploadable.apply_scoping(collection, row) return can_cache_this and can_cache_sub, scoped @@ -159,7 +158,7 @@ def apply_scoping_to_uploadtable(ut: UploadTable, collection, row=None) -> Tuple callback = _apply_scoping_to_uploadtable(table, row, collection, ut) can_cache_to_one, to_ones = apply_scoping_to_one(ut, collection, table, callback) - to_many_results: List[Tuple[str, List[Tuple[bool, ScopedToManyRecord]]]] = [ + to_many_results: List[Tuple[str, List[Tuple[bool, ScopedUploadTable]]]] = [ (f, [(callback(f, r)) for r in rs]) for (f, rs) in ut.toMany.items() ] @@ -174,7 +173,7 @@ def apply_scoping_to_uploadtable(ut: UploadTable, collection, row=None) -> Tuple wbcols={f: extend_columnoptions(colopts, collection, table.name, f) for f, colopts in ut.wbcols.items()}, static=static_adjustments(table, ut.wbcols, ut.static), toOne=to_ones, - toMany=to_many, + toMany=to_many, #type: ignore scopingAttrs=scoping_relationships(collection, table), disambiguation=None, ) @@ -210,25 +209,12 @@ def static_adjustments(table: Table, wbcols: Dict[str, ColumnOptions], static: D static = static return static -def set_order_number(i: int, tmr: ScopedToManyRecord) -> ScopedToManyRecord: +def set_order_number(i: int, tmr: ScopedUploadTable) -> ScopedUploadTable: table = datamodel.get_table_strict(tmr.name) if table.get_field('ordernumber'): return tmr._replace(scopingAttrs={**tmr.scopingAttrs, 'ordernumber': i}) return tmr -def apply_scoping_to_tomanyrecord(tmr: ToManyRecord, collection, row) -> Tuple[bool, ScopedToManyRecord]: - table = datamodel.get_table_strict(tmr.name) - callback = _apply_scoping_to_uploadtable(table, row, collection, tmr) - can_cache_to_one, to_ones = apply_scoping_to_one(tmr, collection, table, callback) - - return can_cache_to_one, ScopedToManyRecord( - name=tmr.name, - wbcols={f: extend_columnoptions(colopts, collection, table.name, f) for f, colopts in tmr.wbcols.items()}, - static=static_adjustments(table, tmr.wbcols, tmr.static), - toOne=to_ones, - scopingAttrs=scoping_relationships(collection, table), - ) - def apply_scoping_to_treerecord(tr: TreeRecord, collection) -> Tuple[bool, ScopedTreeRecord]: table = datamodel.get_table_strict(tr.name) diff --git a/specifyweb/workbench/upload/tests/example_plan.py b/specifyweb/workbench/upload/tests/example_plan.py index e9c4694da5a..49daa71b0c4 100644 --- a/specifyweb/workbench/upload/tests/example_plan.py +++ b/specifyweb/workbench/upload/tests/example_plan.py @@ -1,5 +1,4 @@ from ..upload_table import UploadTable, ScopedUploadTable -from ..tomany import ToManyRecord from ..treerecord import TreeRecord from ..upload_plan_schema import parse_column_options @@ -55,6 +54,7 @@ } )} }, + toMany = {} ), ], }, @@ -106,7 +106,8 @@ toOne = {}, toMany = {}, )} - } + }, + toMany = {} ), dict( wbcols = {}, @@ -125,7 +126,8 @@ toOne = {}, toMany = {}, )} - } + }, + toMany = {} ), ] } @@ -143,7 +145,7 @@ static = {}, toMany = { 'determinations': [ - ToManyRecord( + UploadTable( name = 'Determination', wbcols = { 'determineddate': parse_column_options('ID Date'), @@ -178,6 +180,7 @@ } ) }, + toMany={} ), ], }, @@ -217,7 +220,7 @@ }, toMany = { 'collectors': [ - ToManyRecord( + UploadTable( name = 'Collector', wbcols = {}, static = {'isprimary': True, 'ordernumber': 0}, @@ -235,9 +238,10 @@ toOne = {}, toMany = {}, ) - } + }, + toMany={} ), - ToManyRecord( + UploadTable( name = 'Collector', wbcols = {}, static = {'isprimary': False, 'ordernumber': 1}, @@ -255,7 +259,8 @@ toOne = {}, toMany = {}, ) - } + }, + toMany={} ), ] } diff --git a/specifyweb/workbench/upload/tests/test_bugs.py b/specifyweb/workbench/upload/tests/test_bugs.py index 1d61f93b27d..dce0f5b5fd3 100644 --- a/specifyweb/workbench/upload/tests/test_bugs.py +++ b/specifyweb/workbench/upload/tests/test_bugs.py @@ -12,7 +12,7 @@ from .base import UploadTestsBase from specifyweb.specify.api_tests import get_table - +from django.conf import settings class BugTests(UploadTestsBase): @expectedFailure # FIX ME @@ -45,9 +45,9 @@ def test_bogus_null_record(self) -> None: row = ["", "Fundulus", "olivaceus"] - up = parse_plan(self.collection, plan).apply_scoping(self.collection)[1] + up = parse_plan(plan).apply_scoping(self.collection)[1] - result = validate_row(self.collection, up, self.agent.id, dict(zip(cols, row)), None) + result = validate_row(self.collection, up, self.agent.id, dict(zip(cols, row)), None, session_url=settings.SA_TEST_DB_URL) self.assertNotIsInstance(result.record_result, NullRecord, "The CO should be created b/c it has determinations.") def test_duplicate_refworks(self) -> None: @@ -103,7 +103,7 @@ def test_duplicate_refworks(self) -> None: Matched, # 7602,1,The Clupeocephala,45,635-657,2010,10.4067/S0718-19572010000400009,https://doi.org/10.4067/S0718-19572010000400009,Arratia,Gloria,,,,,,, ] - plan = parse_plan(self.collection, json.loads(''' + plan = parse_plan(json.loads(''' { "baseTableName": "referencework", "uploadable": { @@ -170,6 +170,6 @@ def test_duplicate_refworks(self) -> None: } } ''')) - upload_results = do_upload_csv(self.collection, reader, plan, self.agent.id) + upload_results = do_upload_csv(self.collection, reader, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) rr = [r.record_result.__class__ for r in upload_results] self.assertEqual(expected, rr) diff --git a/specifyweb/workbench/upload/tests/testdisambiguation.py b/specifyweb/workbench/upload/tests/testdisambiguation.py index ea03e09d7a5..94163fdd53b 100644 --- a/specifyweb/workbench/upload/tests/testdisambiguation.py +++ b/specifyweb/workbench/upload/tests/testdisambiguation.py @@ -3,7 +3,6 @@ from ..uploadable import Disambiguation from ..upload_result import Matched, MatchedMultiple from ..upload_table import UploadTable -from ..tomany import ToManyRecord from ..upload import do_upload, validate_row, get_disambiguation_from_row from ..upload_plan_schema import parse_column_options, parse_plan from ..disambiguation import DisambiguationInfo @@ -11,6 +10,8 @@ from .base import UploadTestsBase from specifyweb.specify.api_tests import get_table +from django.conf import settings + class DisambiguationTests(UploadTestsBase): def test_disambiguation(self) -> None: @@ -35,7 +36,7 @@ def test_disambiguation(self) -> None: static={'referenceworktype': 0}, toOne={}, toMany={'authors': [ - ToManyRecord( + UploadTable( name='Author', wbcols={}, static={}, @@ -48,8 +49,10 @@ def test_disambiguation(self) -> None: static={}, toOne={}, toMany={} - )}), - ToManyRecord( + )}, + toMany={} + ), + UploadTable( name='Author', wbcols={}, static={}, @@ -62,7 +65,9 @@ def test_disambiguation(self) -> None: static={}, toOne={}, toMany={} - )}), + )}, + toMany={} + ), ]} ) @@ -72,7 +77,7 @@ def test_disambiguation(self) -> None: {'title': "A Natural History of Mung Beans 3", 'author1': "Mungophilius", 'author2': "Mungophilius"}, ] - results = do_upload(self.collection, data, plan, self.agent.id) + results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) for result in results: assert result.contains_failure() @@ -82,7 +87,7 @@ def test_disambiguation(self) -> None: DisambiguationInfo({("authors", "#1", "agent"): senior.id, ("authors", "#2", "agent"): junior.id}), ] - results = do_upload(self.collection, data, plan, self.agent.id, disambiguations) + results = do_upload(self.collection, data, plan, self.agent.id, disambiguations, session_url=settings.SA_TEST_DB_URL) for result in results: assert not result.contains_failure() @@ -120,9 +125,9 @@ def test_disambiguate_taxon(self) -> None: cols = ["Cat #", "Genus", "Species"] row = ["123", "Fundulus", "olivaceus"] - up = parse_plan(self.collection, plan).apply_scoping(self.collection)[1] + up = parse_plan(plan).apply_scoping(self.collection)[1] - result = validate_row(self.collection, up, self.agent.id, dict(zip(cols, row)), None) + result = validate_row(self.collection, up, self.agent.id, dict(zip(cols, row)), None, session_url=settings.SA_TEST_DB_URL) taxon_result = result.toMany['determinations'][0].toOne['taxon'].record_result assert isinstance(taxon_result, MatchedMultiple) self.assertEqual(set(taxon_result.ids), {fundulus1.id, fundulus2.id}) @@ -130,7 +135,7 @@ def test_disambiguate_taxon(self) -> None: da_row = ["123", "Fundulus", "olivaceus", "{\"disambiguation\":{\"determinations.#1.taxon.$Genus\":%d}}" % fundulus1.id] da = get_disambiguation_from_row(len(cols), da_row) - result = validate_row(self.collection, up, self.agent.id, dict(zip(cols, row)), da) + result = validate_row(self.collection, up, self.agent.id, dict(zip(cols, row)), da, session_url=settings.SA_TEST_DB_URL) taxon_result = result.toMany['determinations'][0].toOne['taxon'].toOne['parent'].record_result assert isinstance(taxon_result, Matched) self.assertEqual(fundulus1.id, taxon_result.id) @@ -163,9 +168,9 @@ def test_disambiguate_taxon_deleted(self) -> None: cols = ["Cat #", "Genus", "Species"] row = ["123", "Fundulus", "olivaceus"] - up = parse_plan(self.collection, plan).apply_scoping(self.collection)[1] + up = parse_plan(plan).apply_scoping(self.collection)[1] - result = validate_row(self.collection, up, self.agent.id, dict(zip(cols, row)), None) + result = validate_row(self.collection, up, self.agent.id, dict(zip(cols, row)), None, session_url=settings.SA_TEST_DB_URL) taxon_result = result.toMany['determinations'][0].toOne['taxon'].record_result assert isinstance(taxon_result, MatchedMultiple) self.assertEqual(set(taxon_result.ids), {fundulus1.id, fundulus2.id}) @@ -176,7 +181,7 @@ def test_disambiguate_taxon_deleted(self) -> None: da = get_disambiguation_from_row(len(cols), da_row) - result = validate_row(self.collection, up, self.agent.id, dict(zip(cols, row)), da) + result = validate_row(self.collection, up, self.agent.id, dict(zip(cols, row)), da, session_url=settings.SA_TEST_DB_URL) taxon_result = result.toMany['determinations'][0].toOne['taxon'].toOne['parent'].record_result assert isinstance(taxon_result, Matched) self.assertEqual(fundulus2.id, taxon_result.id) @@ -204,9 +209,9 @@ def test_disambiguate_agent_deleted(self) -> None: cols = ["Cat #", "Cat last"] row = ["123", "Bentley"] - up = parse_plan(self.collection, plan).apply_scoping(self.collection)[1] + up = parse_plan(plan).apply_scoping(self.collection)[1] - result = validate_row(self.collection, up, self.agent.id, dict(zip(cols, row)), None) + result = validate_row(self.collection, up, self.agent.id, dict(zip(cols, row)), None, session_url=settings.SA_TEST_DB_URL) agent_result = result.toOne['cataloger'].record_result assert isinstance(agent_result, MatchedMultiple) self.assertEqual(set(agent_result.ids), {andy.id, bogus.id}) @@ -215,14 +220,14 @@ def test_disambiguate_agent_deleted(self) -> None: da = get_disambiguation_from_row(len(cols), da_row) - result = validate_row(self.collection, up, self.agent.id, dict(zip(cols, row)), da) + result = validate_row(self.collection, up, self.agent.id, dict(zip(cols, row)), da, session_url=settings.SA_TEST_DB_URL) agent_result = result.toOne['cataloger'].record_result assert isinstance(agent_result, Matched) self.assertEqual(bogus.id, agent_result.id) bogus.delete() - result = validate_row(self.collection, up, self.agent.id, dict(zip(cols, row)), da) + result = validate_row(self.collection, up, self.agent.id, dict(zip(cols, row)), da, session_url=settings.SA_TEST_DB_URL) assert not result.contains_failure() agent_result = result.toOne['cataloger'].record_result diff --git a/specifyweb/workbench/upload/tests/testmustmatch.py b/specifyweb/workbench/upload/tests/testmustmatch.py index 15a4b886601..e9d9331a768 100644 --- a/specifyweb/workbench/upload/tests/testmustmatch.py +++ b/specifyweb/workbench/upload/tests/testmustmatch.py @@ -1,14 +1,14 @@ -import json from jsonschema import validate # type: ignore -from typing import List, Dict, Any, NamedTuple, Union +from typing import Dict from specifyweb.specify.api_tests import get_table from .base import UploadTestsBase -from ..upload_result import Uploaded, Matched, NoMatch, NullRecord, ParseFailures, FailedBusinessRule -from ..upload import do_upload, do_upload_csv +from ..upload_result import Uploaded, Matched, NoMatch, NullRecord +from ..upload import do_upload from ..upload_table import UploadTable, MustMatchTable from ..treerecord import TreeRecord, MustMatchTreeRecord from ..upload_plan_schema import schema, parse_plan, parse_column_options +from django.conf import settings class MustMatchTests(UploadTestsBase): def setUp(self) -> None: @@ -34,12 +34,12 @@ def upload_some_geography(self) -> None: )} ) validate(plan_json, schema) - plan = parse_plan(self.collection, plan_json) + plan = parse_plan(plan_json) data = [ dict(name="Douglas Co. KS", Continent="North America", Country="USA", State="Kansas", County="Douglas"), dict(name="Greene Co. MO", Continent="North America", Country="USA", State="Missouri", County="Greene") ] - results = do_upload(self.collection, data, plan, self.agent.id) + results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) for r in results: assert isinstance(r.record_result, Uploaded) @@ -92,7 +92,7 @@ def test_mustmatchtree(self) -> None: )} ) validate(json, schema) - plan = parse_plan(self.collection, json) + plan = parse_plan(json) assert isinstance(plan, UploadTable) assert isinstance(plan.toOne['geography'], TreeRecord) assert isinstance(plan.toOne['geography'], MustMatchTreeRecord) @@ -102,7 +102,7 @@ def test_mustmatchtree(self) -> None: dict(name="Douglas Co. KS", Continent="North America", Country="USA", State="Kansas", County="Douglas"), dict(name="Emerald City", Continent="North America", Country="USA", State="Kansas", County="Oz"), ] - results = do_upload(self.collection, data, plan, self.agent.id) + results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) self.assertIsInstance(results[0].record_result, Uploaded) self.assertNotIsInstance(results[1].record_result, Uploaded) self.assertIsInstance(results[1].toOne['geography'].record_result, NoMatch) @@ -110,13 +110,13 @@ def test_mustmatchtree(self) -> None: def test_mustmatch_parsing(self) -> None: json = self.plan(must_match=True) validate(json, schema) - plan = parse_plan(self.collection, json) + plan = parse_plan(json) assert isinstance(plan, UploadTable) assert isinstance(plan.toOne['collectingevent'], UploadTable) self.assertIsInstance(plan.toOne['collectingevent'], MustMatchTable) def test_mustmatch_uploading(self) -> None: - plan = parse_plan(self.collection, self.plan(must_match=True)) + plan = parse_plan(self.plan(must_match=True)) data = [ dict(catno='0', sfn='1'), @@ -128,7 +128,7 @@ def test_mustmatch_uploading(self) -> None: starting_ce_count = get_table('Collectingevent').objects.count() starting_co_count = get_table('Collectionobject').objects.count() - results = do_upload(self.collection, data, plan, self.agent.id) + results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) for r, expected in zip(results, [Matched, NoMatch, Matched, NoMatch]): self.assertIsInstance(r.toOne['collectingevent'].record_result, expected) @@ -139,7 +139,7 @@ def test_mustmatch_uploading(self) -> None: "there are an equal number of collecting events before and after the upload") def test_mustmatch_with_null(self) -> None: - plan = parse_plan(self.collection, self.plan(must_match=True)) + plan = parse_plan(self.plan(must_match=True)) data = [ dict(catno='0', sfn='1'), @@ -151,7 +151,7 @@ def test_mustmatch_with_null(self) -> None: ce_count_before_upload = get_table('Collectingevent').objects.count() - results = do_upload(self.collection, data, plan, self.agent.id) + results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) ces = set() for r, expected in zip(results, [Matched, NoMatch, NullRecord, Matched, NoMatch]): self.assertIsInstance(r.toOne['collectingevent'].record_result, expected) diff --git a/specifyweb/workbench/upload/tests/testonetoone.py b/specifyweb/workbench/upload/tests/testonetoone.py index f7b50767009..2b68dc9d2a5 100644 --- a/specifyweb/workbench/upload/tests/testonetoone.py +++ b/specifyweb/workbench/upload/tests/testonetoone.py @@ -7,6 +7,8 @@ from ..upload_table import UploadTable, OneToOneTable from ..upload_plan_schema import schema, parse_plan +from django.conf import settings + class OneToOneTests(UploadTestsBase): def setUp(self) -> None: super().setUp() @@ -38,7 +40,7 @@ def plan(self, one_to_one: bool) -> Dict: def test_onetoone_parsing(self) -> None: json = self.plan(one_to_one=True) validate(json, schema) - plan = parse_plan(self.collection, json) + plan = parse_plan(json) assert isinstance(plan, UploadTable) assert isinstance(plan.toOne['collectingevent'], UploadTable) self.assertIsInstance(plan.toOne['collectingevent'], OneToOneTable) @@ -46,13 +48,13 @@ def test_onetoone_parsing(self) -> None: def test_manytoone_parsing(self) -> None: json = self.plan(one_to_one=False) validate(json, schema) - plan = parse_plan(self.collection, json) + plan = parse_plan(json) assert isinstance(plan, UploadTable) assert isinstance(plan.toOne['collectingevent'], UploadTable) self.assertNotIsInstance(plan.toOne['collectingevent'], OneToOneTable) def test_onetoone_uploading(self) -> None: - plan = parse_plan(self.collection, self.plan(one_to_one=True)) + plan = parse_plan(self.plan(one_to_one=True)) data = [ dict(catno='0', sfn='1'), @@ -62,7 +64,7 @@ def test_onetoone_uploading(self) -> None: # dict(catno='4', sfn='2'), # This fails because the CE has multiple matches ] - results = do_upload(self.collection, data, plan, self.agent.id) + results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) ces = set() for r in results: assert isinstance(r.record_result, Uploaded), r @@ -73,7 +75,7 @@ def test_onetoone_uploading(self) -> None: self.assertEqual(4, len(ces)) def test_manytoone_uploading(self) -> None: - plan = parse_plan(self.collection, self.plan(one_to_one=False)) + plan = parse_plan(self.plan(one_to_one=False)) data = [ dict(catno='0', sfn='1'), @@ -83,7 +85,7 @@ def test_manytoone_uploading(self) -> None: dict(catno='4', sfn='2'), ] - results = do_upload(self.collection, data, plan, self.agent.id) + results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) ces = set() for r, expected in zip(results, [Uploaded, Matched, Uploaded, Matched, Matched]): assert isinstance(r.record_result, Uploaded) @@ -93,7 +95,7 @@ def test_manytoone_uploading(self) -> None: self.assertEqual(2, len(ces)) def test_onetoone_with_null(self) -> None: - plan = parse_plan(self.collection, self.plan(one_to_one=True)) + plan = parse_plan(self.plan(one_to_one=True)) data = [ dict(catno='0', sfn='1'), @@ -105,7 +107,7 @@ def test_onetoone_with_null(self) -> None: ce_count_before_upload = get_table('Collectingevent').objects.count() - results = do_upload(self.collection, data, plan, self.agent.id) + results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) ces = set() for r, expected in zip(results, [Uploaded, Uploaded, Uploaded, NullRecord, NullRecord]): assert isinstance(r.record_result, Uploaded) diff --git a/specifyweb/workbench/upload/tests/testparsing.py b/specifyweb/workbench/upload/tests/testparsing.py index ce9fae66944..cac4377ba1e 100644 --- a/specifyweb/workbench/upload/tests/testparsing.py +++ b/specifyweb/workbench/upload/tests/testparsing.py @@ -22,6 +22,8 @@ from ..upload_results_schema import schema as upload_results_schema from ..upload_table import UploadTable +from django.conf import settings + co = datamodel.get_table_strict('Collectionobject') class DateParsingTests(unittest.TestCase): @@ -159,7 +161,7 @@ def test_nonreadonly_picklist(self) -> None: self.assertEqual(0, get_table('Spauditlog').objects.filter(tablenum=get_table('Picklistitem').specify_model.tableId).count(), "No picklistitems in audit log yet.") - results = do_upload(self.collection, data, plan, self.agent.id) + results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) for result in results: validate([result.to_json()], upload_results_schema) self.assertIsInstance(result.record_result, Uploaded) @@ -198,7 +200,7 @@ def test_uiformatter_match(self) -> None: {'catno': 'bar'}, {'catno': '567'}, ] - results = do_upload(self.collection, data, plan, self.agent.id) + results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) for result, expected in zip(results, [Uploaded, Uploaded, ParseFailures, ParseFailures, Uploaded]): self.assertIsInstance(result.record_result, expected) @@ -225,7 +227,7 @@ def test_numeric_types(self) -> None: {'catno': '5', 'bool': 'true', 'integer': '10', 'float': '24.5', 'decimal': '10.23bogus'}, {'catno': '6', 'bool': 'true', 'integer': '10.5', 'float': '24.5', 'decimal': '10.23'}, ] - results = do_upload(self.collection, data, plan, self.agent.id) + results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) self.assertIsInstance(results[0].record_result, Uploaded) for result in results[1:]: self.assertIsInstance(result.record_result, ParseFailures) @@ -246,7 +248,7 @@ def test_required_field(self) -> None: {'catno': '', 'habitat': ''}, {'catno': '5', 'habitat': 'Lagoon'}, ] - results = do_upload(self.collection, data, plan, self.agent.id) + results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) for result, expected in zip(results, [Uploaded, ParseFailures, ParseFailures, NullRecord, Uploaded]): self.assertIsInstance(result.record_result, expected) @@ -267,7 +269,7 @@ def test_readonly_picklist(self) -> None: {'title': "Dr.", 'lastname': 'Zoidberg'}, {'title': "Hon.", 'lastname': 'Juju'}, ] - results = do_upload(self.collection, data, plan, self.agent.id) + results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) result0 = results[0].record_result assert isinstance(result0, Uploaded) @@ -325,7 +327,7 @@ def test_parsing_errors_reported(self) -> None: 1367,Gastropoda,Fissurelloidea,Fissurellidae,Emarginula,,sicula,,"J.E. Gray, 1825",,,,,,, , ,,USA,Foobar,,[Lat-long site],Gulf of Mexico,NW Atlantic O.,Date unk'n,foobar,,,,1,0,0,Dry; shell,Dry,,,In coral rubble,57,65,0,,,,313,,,JSG,MJP,22/01/2003,28° 06.07' N,,91° 02.42' W,,Point,D-7(1),JSG,19/06/2003,0,Marine,0,Emilio Garcia,,Emilio,,Garcia,,,,,,,,,,,, 1368,Gastropoda,Fissurelloidea,Fissurellidae,Emarginula,,tuberculosa,,"Libassi, 1859",,Emilio Garcia,,Emilio,,Garcia,Jan 2002,00/01/2002,,USA,LOUISIANA,off Louisiana coast,[Lat-long site],Gulf of Mexico,NW Atlantic O.,Date unk'n,,,,,11,0,0,Dry; shell,Dry,,,"Subtidal 65-91 m, in coralline [sand]",65,91,0,,,,313,,Dredged. Original label no. 23331.,JSG,MJP,22/01/2003,27° 59.14' N,,91° 38.83' W,,Point,D-4(1),JSG,19/06/2003,0,Marine,0,Emilio Garcia,,Emilio,,Garcia,,,,,,,,,,,, ''')) - upload_results = do_upload_csv(self.collection, reader, self.example_plan, self.agent.id) + upload_results = do_upload_csv(self.collection, reader, self.example_plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) failed_result = upload_results[2] self.assertIsInstance(failed_result.record_result, ParseFailures) for result in upload_results: @@ -339,7 +341,7 @@ def test_multiple_parsing_errors_reported(self) -> None: '''BMSM No.,Class,Superfamily,Family,Genus,Subgenus,Species,Subspecies,Species Author,Subspecies Author,Who ID First Name,Determiner 1 Title,Determiner 1 First Name,Determiner 1 Middle Initial,Determiner 1 Last Name,ID Date Verbatim,ID Date,ID Status,Country,State/Prov/Pref,Region,Site,Sea Basin,Continent/Ocean,Date Collected,Start Date Collected,End Date Collected,Collection Method,Verbatim Collecting method,No. of Specimens,Live?,W/Operc,Lot Description,Prep Type 1,- Paired valves,for bivalves - Single valves,Habitat,Min Depth (M),Max Depth (M),Fossil?,Stratum,Sex / Age,Lot Status,Accession No.,Original Label,Remarks,Processed by,Cataloged by,DateCataloged,Latitude1,Latitude2,Longitude1,Longitude2,Lat Long Type,Station No.,Checked by,Label Printed,Not for publication on Web,Realm,Estimated,Collected Verbatim,Collector 1 Title,Collector 1 First Name,Collector 1 Middle Initial,Collector 1 Last Name,Collector 2 Title,Collector 2 First Name,Collector 2 Middle Initial,Collector 2 Last name,Collector 3 Title,Collector 3 First Name,Collector 3 Middle Initial,Collector 3 Last Name,Collector 4 Title,Collector 4 First Name,Collector 4 Middle Initial,Collector 4 Last Name 1367,Gastropoda,Fissurelloidea,Fissurellidae,Emarginula,,sicula,,"J.E. Gray, 1825",,,,,,, ,bad date,,USA,Foobar,,[Lat-long site],Gulf of Mexico,NW Atlantic O.,Date unk'n,foobar,,,,1,0,0,Dry; shell,Dry,,,In coral rubble,57,65,0,,,,313,,,JSG,MJP,22/01/2003,28° 06.07' N,,91° 02.42' W,,Point,D-7(1),JSG,19/06/2003,0,Marine,0,Emilio Garcia,,Emilio,,Garcia,,,,,,,,,,,, ''')) - upload_results = do_upload_csv(self.collection, reader, self.example_plan, self.agent.id) + upload_results = do_upload_csv(self.collection, reader, self.example_plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) failed_result = upload_results[0].record_result self.assertIsInstance(failed_result, ParseFailures) assert isinstance(failed_result, ParseFailures) # make typechecker happy @@ -350,7 +352,7 @@ def test_out_of_range_lat_long(self) -> None: '''BMSM No.,Class,Superfamily,Family,Genus,Subgenus,Species,Subspecies,Species Author,Subspecies Author,Who ID First Name,Determiner 1 Title,Determiner 1 First Name,Determiner 1 Middle Initial,Determiner 1 Last Name,ID Date Verbatim,ID Date,ID Status,Country,State/Prov/Pref,Region,Site,Sea Basin,Continent/Ocean,Date Collected,Start Date Collected,End Date Collected,Collection Method,Verbatim Collecting method,No. of Specimens,Live?,W/Operc,Lot Description,Prep Type 1,- Paired valves,for bivalves - Single valves,Habitat,Min Depth (M),Max Depth (M),Fossil?,Stratum,Sex / Age,Lot Status,Accession No.,Original Label,Remarks,Processed by,Cataloged by,DateCataloged,Latitude1,Latitude2,Longitude1,Longitude2,Lat Long Type,Station No.,Checked by,Label Printed,Not for publication on Web,Realm,Estimated,Collected Verbatim,Collector 1 Title,Collector 1 First Name,Collector 1 Middle Initial,Collector 1 Last Name,Collector 2 Title,Collector 2 First Name,Collector 2 Middle Initial,Collector 2 Last name,Collector 3 Title,Collector 3 First Name,Collector 3 Middle Initial,Collector 3 Last Name,Collector 4 Title,Collector 4 First Name,Collector 4 Middle Initial,Collector 4 Last Name 1367,Gastropoda,Fissurelloidea,Fissurellidae,Emarginula,,sicula,,"J.E. Gray, 1825",,,,,,, ,,,USA,,,[Lat-long site],Gulf of Mexico,NW Atlantic O.,Date unk'n,,,,,1,0,0,Dry; shell,Dry,,,In coral rubble,57,65,0,,,,313,,,JSG,MJP,22/01/2003,128° 06.07' N,,191° 02.42' W,,Point,D-7(1),JSG,19/06/2003,0,Marine,0,Emilio Garcia,,Emilio,,Garcia,,,,,,,,,,,, ''')) - upload_results = do_upload_csv(self.collection, reader, self.example_plan, self.agent.id) + upload_results = do_upload_csv(self.collection, reader, self.example_plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) failed_result = upload_results[0].record_result self.assertIsInstance(failed_result, ParseFailures) assert isinstance(failed_result, ParseFailures) # make typechecker happy @@ -375,7 +377,7 @@ def test_agent_type(self) -> None: {'agenttype': "other", 'lastname': 'Juju'}, {'agenttype': "group", 'lastname': 'Van Halen'}, ] - results = do_upload(self.collection, data, plan, self.agent.id) + results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) result0 = results[0].record_result assert isinstance(result0, Uploaded) @@ -409,7 +411,7 @@ def test_tree_cols_without_name(self) -> None: {'Genus': 'Eupatorium', 'Species': 'serotinum', 'Species Author': 'Michx.'}, {'Genus': 'Eupatorium', 'Species': '', 'Species Author': 'L.'}, ] - results = do_upload(self.collection, data, plan, self.agent.id) + results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) self.assertIsInstance(results[0].record_result, Uploaded) self.assertEqual(results[1].record_result, ParseFailures(failures=[ParseFailure(message='invalidPartialRecord', payload={'column':'Species'}, column='Species Author')])) @@ -427,7 +429,7 @@ def test_value_too_long(self) -> None: {'Genus': 'Eupatorium', 'Species': 'barelyfits', 'Species Author': 'x'*128}, {'Genus': 'Eupatorium', 'Species': 'toolong', 'Species Author': 'x'*129}, ] - results = do_upload(self.collection, data, plan, self.agent.id) + results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) self.assertIsInstance(results[0].record_result, Uploaded) self.assertIsInstance(results[1].record_result, Uploaded) @@ -450,7 +452,7 @@ def test_tree_cols_with_ignoreWhenBlank(self) -> None: {'Genus': 'Eupatorium', 'Species': 'serotinum', 'Species Author': ''}, {'Genus': 'Eupatorium', 'Species': 'serotinum', 'Species Author': 'Bogus'}, ] - results = do_upload(self.collection, data, plan, self.agent.id) + results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) self.assertIsInstance(results[0].record_result, Uploaded) self.assertIsInstance(results[1].record_result, Matched, "Second record matches first despite blank author.") @@ -473,7 +475,7 @@ def test_higher_tree_cols_with_ignoreWhenBlank(self) -> None: {'Genus': 'Eupatorium', 'Species': 'serotinum', 'Species Author': '', 'Subspecies': 'a'}, {'Genus': 'Eupatorium', 'Species': 'serotinum', 'Species Author': 'Bogus', 'Subspecies': 'a'}, ] - results = do_upload(self.collection, data, plan, self.agent.id) + results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) self.assertIsInstance(results[0].record_result, Uploaded) self.assertIsInstance(results[1].record_result, Matched, "Second record matches first despite blank author.") @@ -494,7 +496,7 @@ def test_tree_cols_with_ignoreNever(self) -> None: {'Genus': 'Eupatorium', 'Species': 'serotinum', 'Species Author': ''}, {'Genus': 'Eupatorium', 'Species': 'serotinum', 'Species Author': 'Bogus'}, ] - results = do_upload(self.collection, data, plan, self.agent.id) + results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) self.assertIsInstance(results[0].record_result, Uploaded) self.assertIsInstance(results[1].record_result, Uploaded, "Second record doesn't match first due to blank author.") @@ -515,7 +517,7 @@ def test_tree_cols_with_required(self) -> None: {'Genus': 'Eupatorium', 'Species': 'serotinum', 'Species Author': 'Bogus'}, {'Genus': 'Eupatorium', 'Species': '', 'Species Author': ''}, ] - results = do_upload(self.collection, data, plan, self.agent.id) + results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) self.assertIsInstance(results[0].record_result, Uploaded) self.assertIsInstance(results[1].record_result, ParseFailures, "Second record fails due to blank author.") @@ -536,7 +538,7 @@ def test_tree_cols_with_ignoreAlways(self) -> None: {'Genus': 'Eupatorium', 'Species': 'serotinum', 'Species Author': 'Bogus'}, {'Genus': 'Eupatorium', 'Species': 'serotinum', 'Species Author': ''}, ] - results = do_upload(self.collection, data, plan, self.agent.id) + results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) self.assertIsInstance(results[0].record_result, Uploaded) self.assertIsInstance(results[1].record_result, Matched, "Second record matches first despite different author.") @@ -561,7 +563,7 @@ def test_wbcols_with_ignoreWhenBlank(self) -> None: {'lastname': 'Doe', 'firstname': ''}, {'lastname': 'Doe', 'firstname': 'Stream'}, ] - results = do_upload(self.collection, data, plan, self.agent.id) + results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) for result in results: validate([result.to_json()], upload_results_schema) @@ -588,7 +590,7 @@ def test_wbcols_with_ignoreWhenBlank_and_default(self) -> None: {'lastname': 'Doe', 'firstname': 'Stream'}, {'lastname': 'Smith', 'firstname': ''}, ] - results = do_upload(self.collection, data, plan, self.agent.id) + results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) for result in results: validate([result.to_json()], upload_results_schema) @@ -619,7 +621,7 @@ def test_wbcols_with_ignoreNever(self) -> None: {'lastname': 'Doe', 'firstname': ''}, {'lastname': 'Doe', 'firstname': 'Stream'}, ] - results = do_upload(self.collection, data, plan, self.agent.id) + results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) for result in results: validate([result.to_json()], upload_results_schema) @@ -644,7 +646,7 @@ def test_wbcols_with_ignoreAlways(self) -> None: {'lastname': 'Doe', 'firstname': ''}, {'lastname': 'Doe', 'firstname': 'Stream'}, ] - results = do_upload(self.collection, data, plan, self.agent.id) + results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) for result in results: validate([result.to_json()], upload_results_schema) @@ -672,7 +674,7 @@ def test_wbcols_with_default(self) -> None: {'lastname': 'Doe', 'firstname': ''}, {'lastname': 'Doe', 'firstname': 'Stream'}, ] - results = do_upload(self.collection, data, plan, self.agent.id) + results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) for result in results: validate([result.to_json()], upload_results_schema) @@ -701,7 +703,7 @@ def test_wbcols_with_default_matching(self) -> None: {'lastname': 'Doe', 'firstname': ''}, {'lastname': 'Doe', 'firstname': 'Stream'}, ] - results = do_upload(self.collection, data, plan, self.agent.id) + results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) for result in results: validate([result.to_json()], upload_results_schema) @@ -731,7 +733,7 @@ def test_wbcols_with_default_and_null_disallowed(self) -> None: {'lastname': 'Doe', 'firstname': ''}, {'lastname': 'Doe', 'firstname': 'Stream'}, ] - results = do_upload(self.collection, data, plan, self.agent.id) + results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) for result in results: validate([result.to_json()], upload_results_schema) @@ -760,7 +762,7 @@ def test_wbcols_with_default_blank(self) -> None: {'lastname': 'Doe', 'firstname': ''}, {'lastname': 'Doe', 'firstname': 'Stream'}, ] - results = do_upload(self.collection, data, plan, self.agent.id) + results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) for result in results: validate([result.to_json()], upload_results_schema) @@ -790,7 +792,7 @@ def test_wbcols_with_null_disallowed(self) -> None: {'lastname': 'Doe', 'firstname': ''}, {'lastname': 'Doe', 'firstname': 'Stream'}, ] - results = do_upload(self.collection, data, plan, self.agent.id) + results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) for result in results: validate([result.to_json()], upload_results_schema) @@ -817,7 +819,7 @@ def test_wbcols_with_null_disallowed_and_ignoreWhenBlank(self) -> None: {'lastname': 'Doe1', 'firstname': ''}, {'lastname': 'Doe1', 'firstname': 'Stream'}, ] - results = do_upload(self.collection, data, plan, self.agent.id) + results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) for result in results: validate([result.to_json()], upload_results_schema) @@ -846,7 +848,7 @@ def test_wbcols_with_null_disallowed_and_ignoreAlways(self) -> None: {'lastname': 'Doe1', 'firstname': ''}, {'lastname': 'Doe1', 'firstname': 'Stream'}, ] - results = do_upload(self.collection, data, plan, self.agent.id) + results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) for result in results: validate([result.to_json()], upload_results_schema) diff --git a/specifyweb/workbench/upload/tests/testschema.py b/specifyweb/workbench/upload/tests/testschema.py index 3ec561c2302..5f7f9087e11 100644 --- a/specifyweb/workbench/upload/tests/testschema.py +++ b/specifyweb/workbench/upload/tests/testschema.py @@ -17,13 +17,13 @@ class SchemaTests(UploadTestsBase): def test_schema_parsing(self) -> None: Draft7Validator.check_schema(schema) validate(example_plan.json, schema) - plan = parse_plan(self.collection, example_plan.json).apply_scoping(self.collection)[1] # keeping this same + plan = parse_plan(example_plan.json).apply_scoping(self.collection)[1] # keeping this same # have to test repr's here because NamedTuples of different # types can be equal if their fields are equal. self.assertEqual(repr(plan), repr(self.example_plan_scoped)) def test_unparsing(self) -> None: - self.assertEqual(example_plan.json, parse_plan(self.collection, example_plan.json).unparse()) + self.assertEqual(example_plan.json, parse_plan(example_plan.json).unparse()) def test_reject_internal_tree_columns(self) -> None: def with_field(field: str) -> Dict: diff --git a/specifyweb/workbench/upload/tests/testscoping.py b/specifyweb/workbench/upload/tests/testscoping.py index f9ad80880d0..80dfe0d9c57 100644 --- a/specifyweb/workbench/upload/tests/testscoping.py +++ b/specifyweb/workbench/upload/tests/testscoping.py @@ -7,6 +7,7 @@ from .base import UploadTestsBase from . import example_plan +from django.conf import settings class ScopingTests(UploadTestsBase): @@ -73,7 +74,7 @@ def test_embedded_collectingevent(self) -> None: self.collection.isembeddedcollectingevent = True self.collection.save() - plan = parse_plan(self.collection, example_plan.json) + plan = parse_plan(example_plan.json) assert isinstance(plan, UploadTable) ce_rel = plan.toOne['collectingevent'] @@ -111,7 +112,7 @@ def test_embedded_paleocontext_in_collectionobject(self) -> None: def test_caching_scoped_false(self) -> None: - plan = parse_plan(self.collection, self.collection_rel_plan).apply_scoping(self.collection) + plan = parse_plan(self.collection_rel_plan).apply_scoping(self.collection) self.assertFalse(plan[0], 'contains collection relationship, should never be cached') @@ -169,12 +170,12 @@ def deferred_scope_table_ignored_when_scoping_applied(self): self.assertEqual(scoped_upload_plan, expected_scoping) def test_collection_rel_uploaded_in_correct_collection(self): - scoped_plan = parse_plan(self.collection, self.collection_rel_plan) + scoped_plan = parse_plan(self.collection_rel_plan) rows = [ {'Collection Rel Type': self.rel_type_name, 'Cat # (2)': '999', 'Cat #': '23'}, {'Collection Rel Type': self.rel_type_name, 'Cat # (2)': '888', 'Cat #': '32'} ] - result = do_upload(self.collection, rows, scoped_plan, self.agent.id) + result = do_upload(self.collection, rows, scoped_plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) left_side_cat_nums = [n.zfill(9) for n in '32 23'.split()] right_side_cat_nums = '999 888'.split() left_side_query = models.Collectionobject.objects.filter(collection_id=self.collection.id, catalognumber__in=left_side_cat_nums) diff --git a/specifyweb/workbench/upload/tests/testunupload.py b/specifyweb/workbench/upload/tests/testunupload.py index cbf9d1ba089..47964574e97 100644 --- a/specifyweb/workbench/upload/tests/testunupload.py +++ b/specifyweb/workbench/upload/tests/testunupload.py @@ -1,12 +1,12 @@ from specifyweb.specify import auditcodes from specifyweb.specify.api_tests import get_table from .base import UploadTestsBase -from ..upload import do_upload, do_upload_csv, unupload_record +from ..upload import do_upload, unupload_record from ..upload_table import UploadTable from ..treerecord import TreeRecord -from ..tomany import ToManyRecord from ..upload_plan_schema import parse_column_options +from django.conf import settings class UnUploadTests(UploadTestsBase): def setUp(self) -> None: @@ -64,7 +64,7 @@ def test_unupload_picklist(self) -> None: ).count(), "No picklistitems in audit log yet.") - results = do_upload(self.collection, data, plan, self.agent.id) + results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) self.assertEqual(3, get_table('Picklistitem').objects.filter(picklist__name='Habitat').count(), "There are now three items in the picklist.") @@ -121,7 +121,7 @@ def test_unupload_tree(self) -> None: ).count(), "No geography in audit log yet.") - results = do_upload(self.collection, data, plan, self.agent.id) + results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) self.assertEqual(9, get_table('Spauditlog').objects.filter( @@ -192,7 +192,7 @@ def test_tricky_sequencing(self) -> None: static={}, toOne={}, toMany={ - 'collectors': [ToManyRecord( + 'collectors': [UploadTable( name='Collector', wbcols={}, static={}, @@ -206,7 +206,10 @@ def test_tricky_sequencing(self) -> None: toOne={}, toMany={}, ), - })] + }, + toMany={} + ) + ] } ) }, @@ -217,6 +220,6 @@ def test_tricky_sequencing(self) -> None: {'catno': '1', 'cataloger': 'Doe', 'collector': 'Doe'}, ] - results = do_upload(self.collection, data, plan, self.agent.id) + results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) for result in reversed(results): unupload_record(result, self.agent) diff --git a/specifyweb/workbench/upload/tests/testuploading.py b/specifyweb/workbench/upload/tests/testuploading.py index 25934d972b5..818047dc618 100644 --- a/specifyweb/workbench/upload/tests/testuploading.py +++ b/specifyweb/workbench/upload/tests/testuploading.py @@ -14,17 +14,16 @@ from specifyweb.specify.test_trees import get_table from .base import UploadTestsBase from ..parsing import filter_and_upload -from ..tomany import ToManyRecord from ..treerecord import TreeRecord, BoundTreeRecord, \ TreeDefItemWithParseResults from ..upload import do_upload, do_upload_csv from ..upload_plan_schema import schema, parse_plan, parse_column_options from ..upload_result import Uploaded, UploadResult, Matched, MatchedMultiple, \ NoMatch, FailedBusinessRule, ReportInfo, TreeInfo -from ..upload_table import UploadTable, ScopedUploadTable, \ - _to_many_filters_and_excludes, BoundUploadTable -from ..uploadable import Exclude, Auditor +from ..upload_table import UploadTable +from ..uploadable import Auditor +from django.conf import settings class UploadTreeSetup(TestTree, UploadTestsBase): pass class TreeMatchingTests(UploadTreeSetup): @@ -46,12 +45,12 @@ def test_enforced_state(self) -> None: )} ) validate(plan_json, schema) - plan = parse_plan(self.collection, plan_json) + plan = parse_plan(plan_json) data = [ {'County': 'Johnson', 'City': 'Olathe'}, {'County': 'Johnson', 'City': 'Olathe'}, ] - results = do_upload(self.collection, data, plan, self.agent.id) + results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) self.assertIsInstance(results[0].record_result, Uploaded) self.assertIsInstance(results[1].record_result, Matched) self.assertEqual(results[0].get_id(), results[1].get_id()) @@ -76,12 +75,12 @@ def test_enforced_county(self) -> None: )} ) validate(plan_json, schema) - plan = parse_plan(self.collection, plan_json) + plan = parse_plan(plan_json) data = [ {'State': 'Texas', 'City': 'Austin'}, {'State': 'Missouri', 'City': 'Columbia'}, ] - results = do_upload(self.collection, data, plan, self.agent.id) + results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) self.assertEqual( results[0].record_result, FailedBusinessRule( @@ -110,12 +109,12 @@ def test_match_skip_level(self) -> None: )} ) validate(plan_json, schema) - plan = parse_plan(self.collection, plan_json) + plan = parse_plan(plan_json) data = [ {'State': 'Missouri', 'City': 'Springfield'}, {'State': 'Illinois', 'City': 'Springfield'}, ] - results = do_upload(self.collection, data, plan, self.agent.id) + results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) for r in results: self.assertIsInstance(r.record_result, Matched) self.assertEqual(self.springmo.id, results[0].record_result.get_id()) @@ -131,12 +130,12 @@ def test_match_multiple(self) -> None: )} ) validate(plan_json, schema) - plan = parse_plan(self.collection, plan_json) + plan = parse_plan(plan_json) data = [ {'City': 'Springfield'}, {'City': 'Springfield'}, ] - results = do_upload(self.collection, data, plan, self.agent.id) + results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) for r in results: assert isinstance(r.record_result, MatchedMultiple) self.assertEqual(set([self.springmo.id, self.springill.id]), set(r.record_result.ids)) @@ -153,11 +152,11 @@ def test_match_higher(self) -> None: )} ) validate(plan_json, schema) - plan = parse_plan(self.collection, plan_json) + plan = parse_plan(plan_json) data = [ {'State': 'Missouri', 'County': 'Greene', 'City': 'Springfield'}, ] - results = do_upload(self.collection, data, plan, self.agent.id) + results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) for r in results: assert isinstance(r.record_result, Matched) self.assertEqual(self.springmo.id, r.record_result.id) @@ -173,12 +172,12 @@ def test_match_uploaded(self) -> None: )} ) validate(plan_json, schema) - plan = parse_plan(self.collection, plan_json) + plan = parse_plan(plan_json) data = [ {'County': 'Johnson', 'City': 'Olathe'}, {'County': 'Johnson', 'City': 'Olathe'}, ] - results = do_upload(self.collection, data, plan, self.agent.id) + results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) self.assertIsInstance(results[0].record_result, Uploaded) self.assertIsInstance(results[1].record_result, Matched) self.assertEqual(results[0].get_id(), results[1].get_id()) @@ -199,12 +198,12 @@ def test_match_uploaded_just_enforced(self) -> None: )} ) validate(plan_json, schema) - plan = parse_plan(self.collection, plan_json) + plan = parse_plan(plan_json) data = [ {'County': 'Johnson', 'City': 'Olathe'}, {'County': 'Shawnee', 'City': 'Topeka'}, ] - results = do_upload(self.collection, data, plan, self.agent.id) + results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) self.assertIsInstance(results[0].record_result, Uploaded) self.assertIsInstance(results[1].record_result, Uploaded) @@ -227,11 +226,11 @@ def test_upload_partial_match(self) -> None: )} ) validate(plan_json, schema) - plan = parse_plan(self.collection, plan_json) + plan = parse_plan(plan_json) data = [ {'State': 'Missouri', 'County': 'Greene', 'City': 'Rogersville'}, ] - results = do_upload(self.collection, data, plan, self.agent.id) + results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) self.assertIsInstance(results[0].record_result, Uploaded) self.assertEqual(self.greene.id, get_table('Geography').objects.get(id=results[0].get_id()).parent_id) @@ -259,7 +258,7 @@ def test_attachmentimageattribute(self) -> None: {'guid': str(uuid4()), 'height': "100"}, {'guid': str(uuid4()), 'height': "200"}, ] - results = do_upload(self.collection, data, plan, self.agent.id) + results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) for r in results: self.assertIsInstance(r.record_result, Uploaded) aias = [get_table('Attachment').objects.get(id=r.get_id()).attachmentimageattribute_id for r in results] @@ -287,7 +286,7 @@ def test_collectingtripattribute(self) -> None: {'guid': str(uuid4()), 'integer': "100"}, {'guid': str(uuid4()), 'integer': "200"}, ] - results = do_upload(self.collection, data, plan, self.agent.id) + results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) for r in results: self.assertIsInstance(r.record_result, Uploaded) ctas = [get_table('Collectingtrip').objects.get(id=r.get_id()).collectingtripattribute_id for r in results] @@ -333,7 +332,7 @@ def test_preparationattribute(self) -> None: {'guid': str(uuid4()), 'integer': "100", 'catno': '1', 'preptype': 'tissue'}, {'guid': str(uuid4()), 'integer': "200", 'catno': '1', 'preptype': 'tissue'}, ] - results = do_upload(self.collection, data, plan, self.agent.id) + results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) for r in results: self.assertIsInstance(r.record_result, Uploaded) pas = [get_table('Preparation').objects.get(id=r.get_id()).preparationattribute_id for r in results] @@ -364,7 +363,7 @@ def test_collectionobjectattribute(self) -> None: {'catno': "3", 'number': "100"}, {'catno': "4", 'number': "200"}, ] - results = do_upload(self.collection, data, plan, self.agent.id) + results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) for r in results: self.assertIsInstance(r.record_result, Uploaded) coas = [get_table('Collectionobject').objects.get(id=r.get_id()).collectionobjectattribute_id for r in results] @@ -394,7 +393,7 @@ def test_collectingeventattribute(self) -> None: {'sfn': "3", 'number': "100"}, {'sfn': "4", 'number': "200"}, ] - results = do_upload(self.collection, data, plan, self.agent.id) + results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) for r in results: self.assertIsInstance(r.record_result, Uploaded) ceas = [get_table('Collectingevent').objects.get(id=r.get_id()).collectingeventattribute_id for r in results] @@ -434,7 +433,7 @@ def test_null_ce_with_ambiguous_collectingeventattribute(self) -> None: data = [ {'sfn': "", 'number': "100"}, ] - results = do_upload(self.collection, data, plan, self.agent.id) + results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) for r in results: self.assertIsInstance(r.record_result, MatchedMultiple) @@ -473,7 +472,7 @@ def test_ambiguous_one_to_one_match(self) -> None: data = [ {'sfn': "1", 'number': "100"}, ] - results = do_upload(self.collection, data, plan, self.agent.id) + results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) for r in results: self.assertIsInstance(r.record_result, Matched) @@ -499,7 +498,7 @@ def test_null_record_with_ambiguous_one_to_one(self) -> None: {'catno': "2", 'number': "100"}, {'catno': "", 'number': "100"}, ] - results = do_upload(self.collection, data, plan, self.agent.id) + results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) for r in results: self.assertIsInstance(r.record_result, Uploaded) coas = [get_table('Collectionobject').objects.get(id=r.get_id()).collectionobjectattribute_id for r in results] @@ -539,13 +538,13 @@ def test_determination_default_iscurrent(self) -> None: } validate(plan_json, schema) - plan = parse_plan(self.collection, plan_json) + plan = parse_plan(plan_json) data = [ {'Catno': '1', 'Genus': 'Foo', 'Species': 'Bar'}, {'Catno': '2', 'Genus': 'Foo', 'Species': 'Bar'}, {'Catno': '3', 'Genus': 'Foo', 'Species': 'Bar'}, ] - results = do_upload(self.collection, data, plan, self.agent.id) + results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) dets = [get_table('Collectionobject').objects.get(id=r.get_id()).determinations.get() for r in results] self.assertTrue(all(d.iscurrent for d in dets), "created determinations have iscurrent = true by default") @@ -581,13 +580,13 @@ def test_determination_override_iscurrent(self) -> None: } validate(plan_json, schema) - plan = parse_plan(self.collection, plan_json) + plan = parse_plan(plan_json) data = [ {'Catno': '1', 'Genus': 'Foo', 'Species': 'Bar', 'iscurrent': 'false'}, {'Catno': '2', 'Genus': 'Foo', 'Species': 'Bar', 'iscurrent': 'false'}, {'Catno': '3', 'Genus': 'Foo', 'Species': 'Bar', 'iscurrent': 'false'}, ] - results = do_upload(self.collection, data, plan, self.agent.id) + results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) dets = [get_table('Collectionobject').objects.get(id=r.get_id()).determinations.get() for r in results] self.assertFalse(any(d.iscurrent for d in dets), "created determinations have iscurrent = false by override") @@ -599,7 +598,7 @@ def test_ordernumber(self) -> None: static={'referenceworktype': 0}, toOne={}, toMany={'authors': [ - ToManyRecord( + UploadTable( name='Author', wbcols={}, static={}, @@ -610,8 +609,10 @@ def test_ordernumber(self) -> None: static={}, toOne={}, toMany={} - )}), - ToManyRecord( + )}, + toMany={} + ), + UploadTable( name='Author', wbcols={}, static={}, @@ -622,7 +623,9 @@ def test_ordernumber(self) -> None: static={}, toOne={}, toMany={} - )}), + )}, + toMany={} + ), ]} ) data = [ @@ -630,7 +633,7 @@ def test_ordernumber(self) -> None: {'title': "A Natural History of Mung Beans", 'author1': "Mungophilius", 'author2': "Philomungus"}, {'title': "A Natural History of Mung Beans", 'author1': "Philomungus", 'author2': "Mungophilius"}, ] - results = do_upload(self.collection, data, plan, self.agent.id) + results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) self.assertIsInstance(results[0].record_result, Uploaded) self.assertIsInstance(results[1].record_result, Uploaded, "The previous record should not be matched b/c the authors are in a different order.") self.assertIsInstance(results[2].record_result, Matched, "The previous record should be matched b/c the authors are in the same order.") @@ -643,7 +646,7 @@ def test_no_override_ordernumber(self) -> None: static={'referenceworktype': 0}, toOne={}, toMany={'authors': [ - ToManyRecord( + UploadTable( name='Author', wbcols={'ordernumber': parse_column_options('on1')}, static={}, @@ -654,8 +657,10 @@ def test_no_override_ordernumber(self) -> None: static={}, toOne={}, toMany={} - )}), - ToManyRecord( + )}, + toMany={} + ), + UploadTable( name='Author', wbcols={'ordernumber': parse_column_options('on2')}, static={}, @@ -665,7 +670,9 @@ def test_no_override_ordernumber(self) -> None: static={}, toOne={}, toMany={} - )}), + )}, + toMany={} + ), ]} ) data = [ @@ -673,68 +680,68 @@ def test_no_override_ordernumber(self) -> None: {'title': "A Natural History of Mung Beans", 'author1': "Mungophilius", 'on1': '1', 'author2': "Philomungus", 'on2': '0'}, {'title': "A Natural History of Mung Beans", 'author1': "Philomungus", 'on1': '0', 'author2': "Mungophilius", 'on2': '1'}, ] - results = do_upload(self.collection, data, plan, self.agent.id) + results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) self.assertIsInstance(results[0].record_result, Uploaded) self.assertIsInstance(results[1].record_result, Uploaded, "The previous record should not be matched b/c the authors are in a different order.") self.assertIsInstance(results[2].record_result, Matched, "The previous record should be matched b/c the authors are in the same order.") - - def test_filter_to_many_single(self) -> None: - reader = csv.DictReader(io.StringIO( -'''BMSM No.,Class,Superfamily,Family,Genus,Subgenus,Species,Subspecies,Species Author,Subspecies Author,Who ID First Name,Determiner 1 Title,Determiner 1 First Name,Determiner 1 Middle Initial,Determiner 1 Last Name,ID Date Verbatim,ID Date,ID Status,Country,State/Prov/Pref,Region,Site,Sea Basin,Continent/Ocean,Date Collected,Start Date Collected,End Date Collected,Collection Method,Verbatim Collecting method,No. of Specimens,Live?,W/Operc,Lot Description,Prep Type 1,- Paired valves,for bivalves - Single valves,Habitat,Min Depth (M),Max Depth (M),Fossil?,Stratum,Sex / Age,Lot Status,Accession No.,Original Label,Remarks,Processed by,Cataloged by,DateCataloged,Latitude1,Latitude2,Longitude1,Longitude2,Lat Long Type,Station No.,Checked by,Label Printed,Not for publication on Web,Realm,Estimated,Collected Verbatim,Collector 1 Title,Collector 1 First Name,Collector 1 Middle Initial,Collector 1 Last Name,Collector 2 Title,Collector 2 First Name,Collector 2 Middle Initial,Collector 2 Last name,Collector 3 Title,Collector 3 First Name,Collector 3 Middle Initial,Collector 3 Last Name,Collector 4 Title,Collector 4 First Name,Collector 4 Middle Initial,Collector 4 Last Name -5033,Gastropoda,Stromboidea,Strombidae,Lobatus,,leidyi,,"(Heilprin, 1887)",,,,,,, , ,,USA,FLORIDA,Hendry Co.,"Cochran Pit, N of Rt. 80, W of LaBelle",,North America,8/9/1973,8/9/1973,,,,8,0,0,Dry; shell,Dry,,,,,,1,"Caloosahatchee,Pinecrest Unit #4",U/Juv,,241,,,LWD,MJP,12/11/1997,26° 44.099' N,,81° 29.027' W,,Point,,,12/08/2016,0,Marine,0,M. Buffington,,M.,,Buffington,,,,,,,,,,,, -''')) - row = next(reader) - assert isinstance(self.example_plan_scoped.toOne['collectingevent'], ScopedUploadTable) - uploadable = self.example_plan_scoped.toOne['collectingevent'].bind(self.collection, row, self.agent.id, Auditor(self.collection, auditlog)) - assert isinstance(uploadable, BoundUploadTable) - filters, excludes = _to_many_filters_and_excludes(uploadable.toMany) - self.assertEqual([{ - 'collectors__agent__agenttype': 1, - 'collectors__agent__firstname': 'M.', - 'collectors__agent__lastname': 'Buffington', - 'collectors__agent__middleinitial': None, - 'collectors__agent__title': None, - 'collectors__agent__division_id': self.division.id, - 'collectors__division_id': self.division.id, - 'collectors__isprimary': True, - 'collectors__ordernumber': 0}], filters) - - self.assertEqual( - excludes, - [Exclude(lookup='collectors__in', table='Collector', filter={'isprimary': False, 'ordernumber': 1, 'division_id': self.division.id})]) - - def test_filter_multiple_to_many(self) -> None: - reader = csv.DictReader(io.StringIO( -'''BMSM No.,Class,Superfamily,Family,Genus,Subgenus,Species,Subspecies,Species Author,Subspecies Author,Who ID First Name,Determiner 1 Title,Determiner 1 First Name,Determiner 1 Middle Initial,Determiner 1 Last Name,ID Date Verbatim,ID Date,ID Status,Country,State/Prov/Pref,Region,Site,Sea Basin,Continent/Ocean,Date Collected,Start Date Collected,End Date Collected,Collection Method,Verbatim Collecting method,No. of Specimens,Live?,W/Operc,Lot Description,Prep Type 1,- Paired valves,for bivalves - Single valves,Habitat,Min Depth (M),Max Depth (M),Fossil?,Stratum,Sex / Age,Lot Status,Accession No.,Original Label,Remarks,Processed by,Cataloged by,DateCataloged,Latitude1,Latitude2,Longitude1,Longitude2,Lat Long Type,Station No.,Checked by,Label Printed,Not for publication on Web,Realm,Estimated,Collected Verbatim,Collector 1 Title,Collector 1 First Name,Collector 1 Middle Initial,Collector 1 Last Name,Collector 2 Title,Collector 2 First Name,Collector 2 Middle Initial,Collector 2 Last name,Collector 3 Title,Collector 3 First Name,Collector 3 Middle Initial,Collector 3 Last Name,Collector 4 Title,Collector 4 First Name,Collector 4 Middle Initial,Collector 4 Last Name -1378,Gastropoda,Rissooidea,Rissoinidae,Rissoina,,delicatissima,,"Raines, 2002",,B. Raines,,B.,,Raines,Nov 2003,11/2003,,CHILE,,Easter Island [= Isla de Pascua],"Off Punta Rosalia, E of Anakena",,SE Pacific O.,Apr 1998,04/1998,,,,2,0,0,Dry; shell,Dry,,,In sand at base of cliffs,10,20,0,,,Paratype,512,," PARATYPES. In pouch no. 1, paratypes 4 & 5. Raines, B.K. 2002. La Conchiglia 34 ( no. 304) : 16 (holotype LACM 2934, Fig. 9).",JSG,MJP,07/01/2004,"27° 04' 18"" S",,109° 19' 45' W,,Point,,JSG,23/12/2014,0,Marine,0,B. Raines and M. Taylor,,B.,,Raines,,M.,,Taylor,,,,,,,, -''')) - row = next(reader) - assert isinstance(self.example_plan_scoped.toOne['collectingevent'], ScopedUploadTable) - uploadable = self.example_plan_scoped.toOne['collectingevent'].bind(self.collection, row, self.agent.id, Auditor(self.collection, auditlog)) - assert isinstance(uploadable, BoundUploadTable) - filters, excludes = _to_many_filters_and_excludes(uploadable.toMany) - self.assertEqual([ - {'collectors__agent__agenttype': 1, - 'collectors__agent__firstname': 'B.', - 'collectors__agent__lastname': 'Raines', - 'collectors__agent__middleinitial': None, - 'collectors__agent__title': None, - 'collectors__agent__division_id': self.division.id, - 'collectors__division_id': self.division.id, - 'collectors__isprimary': True, - 'collectors__ordernumber': 0}, - {'collectors__agent__agenttype': 1, - 'collectors__agent__firstname': 'M.', - 'collectors__agent__lastname': 'Taylor', - 'collectors__agent__middleinitial': None, - 'collectors__agent__title': None, - 'collectors__agent__division_id': self.division.id, - 'collectors__division_id': self.division.id, - 'collectors__isprimary': False, - 'collectors__ordernumber': 1}], filters) - - self.assertEqual(excludes, []) +# @skip("outdated") +# def test_filter_to_many_single(self) -> None: +# reader = csv.DictReader(io.StringIO( +# '''BMSM No.,Class,Superfamily,Family,Genus,Subgenus,Species,Subspecies,Species Author,Subspecies Author,Who ID First Name,Determiner 1 Title,Determiner 1 First Name,Determiner 1 Middle Initial,Determiner 1 Last Name,ID Date Verbatim,ID Date,ID Status,Country,State/Prov/Pref,Region,Site,Sea Basin,Continent/Ocean,Date Collected,Start Date Collected,End Date Collected,Collection Method,Verbatim Collecting method,No. of Specimens,Live?,W/Operc,Lot Description,Prep Type 1,- Paired valves,for bivalves - Single valves,Habitat,Min Depth (M),Max Depth (M),Fossil?,Stratum,Sex / Age,Lot Status,Accession No.,Original Label,Remarks,Processed by,Cataloged by,DateCataloged,Latitude1,Latitude2,Longitude1,Longitude2,Lat Long Type,Station No.,Checked by,Label Printed,Not for publication on Web,Realm,Estimated,Collected Verbatim,Collector 1 Title,Collector 1 First Name,Collector 1 Middle Initial,Collector 1 Last Name,Collector 2 Title,Collector 2 First Name,Collector 2 Middle Initial,Collector 2 Last name,Collector 3 Title,Collector 3 First Name,Collector 3 Middle Initial,Collector 3 Last Name,Collector 4 Title,Collector 4 First Name,Collector 4 Middle Initial,Collector 4 Last Name +# 5033,Gastropoda,Stromboidea,Strombidae,Lobatus,,leidyi,,"(Heilprin, 1887)",,,,,,, , ,,USA,FLORIDA,Hendry Co.,"Cochran Pit, N of Rt. 80, W of LaBelle",,North America,8/9/1973,8/9/1973,,,,8,0,0,Dry; shell,Dry,,,,,,1,"Caloosahatchee,Pinecrest Unit #4",U/Juv,,241,,,LWD,MJP,12/11/1997,26° 44.099' N,,81° 29.027' W,,Point,,,12/08/2016,0,Marine,0,M. Buffington,,M.,,Buffington,,,,,,,,,,,, +# ''')) +# row = next(reader) +# assert isinstance(self.example_plan_scoped.toOne['collectingevent'], ScopedUploadTable) +# uploadable = self.example_plan_scoped.toOne['collectingevent'].bind(self.collection, row, self.agent.id, Auditor(self.collection, auditlog)) +# assert isinstance(uploadable, BoundUploadTable) +# filters, excludes = _to_many_filters_and_excludes(uploadable.toMany) +# self.assertEqual([{ +# 'collectors__agent__agenttype': 1, +# 'collectors__agent__firstname': 'M.', +# 'collectors__agent__lastname': 'Buffington', +# 'collectors__agent__middleinitial': None, +# 'collectors__agent__title': None, +# 'collectors__agent__division_id': self.division.id, +# 'collectors__division_id': self.division.id, +# 'collectors__isprimary': True, +# 'collectors__ordernumber': 0}], filters) + +# self.assertEqual( +# excludes, +# [Exclude(lookup='collectors__in', table='Collector', filter={'isprimary': False, 'ordernumber': 1, 'division_id': self.division.id})]) + +# def test_filter_multiple_to_many(self) -> None: +# reader = csv.DictReader(io.StringIO( +# '''BMSM No.,Class,Superfamily,Family,Genus,Subgenus,Species,Subspecies,Species Author,Subspecies Author,Who ID First Name,Determiner 1 Title,Determiner 1 First Name,Determiner 1 Middle Initial,Determiner 1 Last Name,ID Date Verbatim,ID Date,ID Status,Country,State/Prov/Pref,Region,Site,Sea Basin,Continent/Ocean,Date Collected,Start Date Collected,End Date Collected,Collection Method,Verbatim Collecting method,No. of Specimens,Live?,W/Operc,Lot Description,Prep Type 1,- Paired valves,for bivalves - Single valves,Habitat,Min Depth (M),Max Depth (M),Fossil?,Stratum,Sex / Age,Lot Status,Accession No.,Original Label,Remarks,Processed by,Cataloged by,DateCataloged,Latitude1,Latitude2,Longitude1,Longitude2,Lat Long Type,Station No.,Checked by,Label Printed,Not for publication on Web,Realm,Estimated,Collected Verbatim,Collector 1 Title,Collector 1 First Name,Collector 1 Middle Initial,Collector 1 Last Name,Collector 2 Title,Collector 2 First Name,Collector 2 Middle Initial,Collector 2 Last name,Collector 3 Title,Collector 3 First Name,Collector 3 Middle Initial,Collector 3 Last Name,Collector 4 Title,Collector 4 First Name,Collector 4 Middle Initial,Collector 4 Last Name +# 1378,Gastropoda,Rissooidea,Rissoinidae,Rissoina,,delicatissima,,"Raines, 2002",,B. Raines,,B.,,Raines,Nov 2003,11/2003,,CHILE,,Easter Island [= Isla de Pascua],"Off Punta Rosalia, E of Anakena",,SE Pacific O.,Apr 1998,04/1998,,,,2,0,0,Dry; shell,Dry,,,In sand at base of cliffs,10,20,0,,,Paratype,512,," PARATYPES. In pouch no. 1, paratypes 4 & 5. Raines, B.K. 2002. La Conchiglia 34 ( no. 304) : 16 (holotype LACM 2934, Fig. 9).",JSG,MJP,07/01/2004,"27° 04' 18"" S",,109° 19' 45' W,,Point,,JSG,23/12/2014,0,Marine,0,B. Raines and M. Taylor,,B.,,Raines,,M.,,Taylor,,,,,,,, +# ''')) +# row = next(reader) +# assert isinstance(self.example_plan_scoped.toOne['collectingevent'], ScopedUploadTable) +# uploadable = self.example_plan_scoped.toOne['collectingevent'].bind(self.collection, row, self.agent.id, Auditor(self.collection, auditlog)) +# assert isinstance(uploadable, BoundUploadTable) +# filters, excludes = _to_many_filters_and_excludes(uploadable.toMany) +# self.assertEqual([ +# {'collectors__agent__agenttype': 1, +# 'collectors__agent__firstname': 'B.', +# 'collectors__agent__lastname': 'Raines', +# 'collectors__agent__middleinitial': None, +# 'collectors__agent__title': None, +# 'collectors__agent__division_id': self.division.id, +# 'collectors__division_id': self.division.id, +# 'collectors__isprimary': True, +# 'collectors__ordernumber': 0}, +# {'collectors__agent__agenttype': 1, +# 'collectors__agent__firstname': 'M.', +# 'collectors__agent__lastname': 'Taylor', +# 'collectors__agent__middleinitial': None, +# 'collectors__agent__title': None, +# 'collectors__agent__division_id': self.division.id, +# 'collectors__division_id': self.division.id, +# 'collectors__isprimary': False, +# 'collectors__ordernumber': 1}], filters) + +# self.assertEqual(excludes, []) def test_big(self) -> None: reader = csv.DictReader(io.StringIO( @@ -765,7 +772,7 @@ def test_big(self) -> None: 5091,Gastropoda,Muricoidea,Marginellidae,Prunum,,donovani,,"(Olsson, 1967)",,,,,,, , ,,USA,FLORIDA,Hendry Co.,"Cochran Pit, N of Rt. 80, W of LaBelle",,North America,Date unk'n,,,,,2,0,0,Dry; shell,Dry,,,,,,1,,,,150,,,LWD,MJP,03/12/1997,26° 44.099' N,,81° 29.027' W,,Point,,,25/10/2016,0,Marine,0,G. Moller,,G.,,Moller,,,,,,,,,,,, 5097,Gastropoda,Muricoidea,Marginellidae,Prunum,,onchidella,,"(Dall, 1890)",,,,,,,,,,USA,FLORIDA,Hendry Co.,"Cochran Pit, N of Route 80, W of LaBelle",,North America,1972,1972,,,,10,0,0,Dry; shell,Dry,,,,,,1,,,,241,,Taken from spoil from 1972-1975.,LWD,MJP,03/12/1997,26° 44.099' N,,81° 29.027' W,,Point,,,16/08/2016,0,Marine,0,M. Buffington,,M.,,Buffington,,,,,,,,,,,, ''')) - upload_results = do_upload_csv(self.collection, reader, self.example_plan, self.agent.id) + upload_results = do_upload_csv(self.collection, reader, self.example_plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) uploaded_catnos = [] for r in upload_results: self.assertIsInstance(r.record_result, Uploaded) @@ -913,7 +920,7 @@ def test_tree_1(self) -> None: } ).apply_scoping(self.collection)[1] row = next(reader) - bt = tree_record.bind(self.collection, row, None, Auditor(self.collection, auditlog)) + bt = tree_record.bind(self.collection, row, None, Auditor(self.collection, auditlog), sql_alchemy_session=None) assert isinstance(bt, BoundTreeRecord) to_upload, matched = bt._match(bt._to_match()) @@ -962,7 +969,7 @@ def test_tree_1(self) -> None: # parent=state, # ) - bt = tree_record.bind(self.collection, row, None, Auditor(self.collection, auditlog)) + bt = tree_record.bind(self.collection, row, None, Auditor(self.collection, auditlog), sql_alchemy_session=None) assert isinstance(bt, BoundTreeRecord) to_upload, matched = bt._match(bt._to_match()) self.assertEqual( @@ -973,7 +980,7 @@ def test_tree_1(self) -> None: self.assertEqual(state.id, matched.id) self.assertEqual(set(['State/Prov/Pref', 'Country', 'Continent/Ocean']), set(matched.info.columns)) - bt = tree_record.bind(self.collection, row, None, Auditor(self.collection, auditlog)) + bt = tree_record.bind(self.collection, row, None, Auditor(self.collection, auditlog), sql_alchemy_session=None) assert isinstance(bt, BoundTreeRecord) upload_result = bt.process_row() self.assertIsInstance(upload_result.record_result, Uploaded) @@ -983,7 +990,7 @@ def test_tree_1(self) -> None: self.assertEqual(uploaded.definitionitem.name, "County") self.assertEqual(uploaded.parent.id, state.id) - bt = tree_record.bind(self.collection, row, None, Auditor(self.collection, auditlog)) + bt = tree_record.bind(self.collection, row, None, Auditor(self.collection, auditlog), sql_alchemy_session=None) assert isinstance(bt, BoundTreeRecord) to_upload, matched = bt._match(bt._to_match()) self.assertEqual([], to_upload) @@ -991,7 +998,7 @@ def test_tree_1(self) -> None: self.assertEqual(uploaded.id, matched.id) self.assertEqual(set(['Region', 'State/Prov/Pref', 'Country', 'Continent/Ocean']), set(matched.info.columns)) - bt = tree_record.bind(self.collection, row, None, Auditor(self.collection, auditlog)) + bt = tree_record.bind(self.collection, row, None, Auditor(self.collection, auditlog), sql_alchemy_session=None) assert isinstance(bt, BoundTreeRecord) upload_result = bt.process_row() expected_info = ReportInfo(tableName='Geography', columns=['Continent/Ocean', 'Country', 'State/Prov/Pref', 'Region',], treeInfo=TreeInfo('County', 'Hendry Co.')) @@ -1009,7 +1016,7 @@ def test_rollback_bad_rows(self) -> None: co_entries = get_table('Spauditlog').objects.filter(tablenum=get_table('Collectionobject').specify_model.tableId) self.assertEqual(0, co_entries.count(), "No collection objects in audit log yet.") - upload_results = do_upload_csv(self.collection, reader, self.example_plan, self.agent.id) + upload_results = do_upload_csv(self.collection, reader, self.example_plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) failed_result = upload_results[2] self.assertIsInstance(failed_result.record_result, FailedBusinessRule) for result in upload_results: @@ -1052,7 +1059,7 @@ def test_disallow_partial(self) -> None: get_table('collector').objects.count(), ] - upload_results = do_upload_csv(self.collection, reader, self.example_plan, self.agent.id, allow_partial=False) + upload_results = do_upload_csv(self.collection, reader, self.example_plan, self.agent.id, allow_partial=False, session_url=settings.SA_TEST_DB_URL) failed_result = upload_results[2] self.assertIsInstance(failed_result.record_result, FailedBusinessRule) diff --git a/specifyweb/workbench/upload/tomany.py b/specifyweb/workbench/upload/tomany.py index 9194cd08130..35c68c6955e 100644 --- a/specifyweb/workbench/upload/tomany.py +++ b/specifyweb/workbench/upload/tomany.py @@ -1,111 +1 @@ -import logging - -from typing import Dict, Any, NamedTuple, List, Union, Set, Optional, Tuple - -from .uploadable import Row, FilterPack, Exclude, Uploadable, ScopedUploadable, BoundUploadable, Disambiguation, Auditor -from .upload_result import ParseFailures -from .parsing import parse_many, ParseResult -from .column_options import ColumnOptions, ExtendedColumnOptions - -logger = logging.getLogger(__name__) - -class ToManyRecord(NamedTuple): - name: str - wbcols: Dict[str, ColumnOptions] - static: Dict[str, Any] - toOne: Dict[str, Uploadable] - - def apply_scoping(self, collection, row=None) -> Tuple[bool, "ScopedToManyRecord"]: - from .scoping import apply_scoping_to_tomanyrecord as apply_scoping - return apply_scoping(self, collection, row) - - def get_cols(self) -> Set[str]: - return set(cd.column for cd in self.wbcols.values()) \ - | set(col for u in self.toOne.values() for col in u.get_cols()) - - def to_json(self) -> Dict: - result = dict( - wbcols={k: v.to_json() for k,v in self.wbcols.items()}, - static=self.static, - ) - - result['toOne'] = { - key: uploadable.to_json() - for key, uploadable in self.toOne.items() - } - return result - - -class ScopedToManyRecord(NamedTuple): - name: str - wbcols: Dict[str, ExtendedColumnOptions] - static: Dict[str, Any] - toOne: Dict[str, ScopedUploadable] - scopingAttrs: Dict[str, int] - - def disambiguate(self, disambiguation: Disambiguation) -> "ScopedToManyRecord": - if disambiguation is None: - return self - return self._replace( - toOne={ - fieldname: uploadable.disambiguate(disambiguation.disambiguate_to_one(fieldname)) - for fieldname, uploadable in self.toOne.items() - } - ) - - def get_treedefs(self) -> Set: - return set(td for toOne in self.toOne.values() for td in toOne.get_treedefs()) - - def bind(self, collection, row: Row, uploadingAgentId: int, auditor: Auditor, cache: Optional[Dict], row_index: Optional[int] = None) -> Union["BoundToManyRecord", ParseFailures]: - parsedFields, parseFails = parse_many(collection, self.name, self.wbcols, row) - - toOne: Dict[str, BoundUploadable] = {} - for fieldname, uploadable in self.toOne.items(): - result = uploadable.bind(collection, row, uploadingAgentId, auditor, cache, row_index) - if isinstance(result, ParseFailures): - parseFails += result.failures - else: - toOne[fieldname] = result - - if parseFails: - return ParseFailures(parseFails) - - return BoundToManyRecord( - name=self.name, - static=self.static, - scopingAttrs=self.scopingAttrs, - parsedFields=parsedFields, - toOne=toOne, - uploadingAgentId=uploadingAgentId, - ) - -class BoundToManyRecord(NamedTuple): - name: str - static: Dict[str, Any] - parsedFields: List[ParseResult] - toOne: Dict[str, BoundUploadable] - scopingAttrs: Dict[str, int] - uploadingAgentId: Optional[int] - - - def filter_on(self, path: str) -> FilterPack: - filters = { - (path + '__' + fieldname_): value - for parsedField in self.parsedFields - for fieldname_, value in parsedField.filter_on.items() - } - - for toOneField, toOneTable in self.toOne.items(): - fs, es = toOneTable.filter_on(path + '__' + toOneField) - for f in fs: - filters.update(f) - - if all(v is None for v in filters.values()): - return FilterPack([], [Exclude(path + "__in", self.name, {**self.scopingAttrs, **self.static})]) - - filters.update({ - (path + '__' + fieldname): value - for fieldname, value in {**self.scopingAttrs, **self.static}.items() - }) - - return FilterPack([filters], []) +# this is nice being empty \ No newline at end of file diff --git a/specifyweb/workbench/upload/treerecord.py b/specifyweb/workbench/upload/treerecord.py index 5aa75ac2a10..6dbd28829fe 100644 --- a/specifyweb/workbench/upload/treerecord.py +++ b/specifyweb/workbench/upload/treerecord.py @@ -11,11 +11,14 @@ from specifyweb.businessrules.exceptions import BusinessRuleException from specifyweb.specify import models from .column_options import ColumnOptions, ExtendedColumnOptions -from .parsing import ParseResult, ParseFailure, parse_many, filter_and_upload +from .parsing import Filter, ParseResult, ParseFailure, parse_many, filter_and_upload from .upload_result import UploadResult, NullRecord, NoMatch, Matched, \ MatchedMultiple, Uploaded, ParseFailures, FailedBusinessRule, ReportInfo, \ TreeInfo -from .uploadable import Row, FilterPack, Disambiguation as DA, Auditor +from .uploadable import FilterPredicate, PredicateWithQuery, Row, Disambiguation as DA, Auditor + +from sqlalchemy.orm import Query # type: ignore +from sqlalchemy import Table as SQLTable # type: ignore logger = logging.getLogger(__name__) @@ -57,7 +60,7 @@ def disambiguate(self, disambiguation: DA) -> "ScopedTreeRecord": def get_treedefs(self) -> Set: return {self.treedef} - def bind(self, collection, row: Row, uploadingAgentId: Optional[int], auditor: Auditor, cache: Optional[Dict]=None, row_index: Optional[int] = None) -> Union["BoundTreeRecord", ParseFailures]: + def bind(self, collection, row: Row, uploadingAgentId: Optional[int], auditor: Auditor, sql_alchemy_session, cache: Optional[Dict]=None) -> Union["BoundTreeRecord", ParseFailures]: parsedFields: Dict[str, List[ParseResult]] = {} parseFails: List[ParseFailure] = [] for rank, cols in self.ranks.items(): @@ -94,8 +97,8 @@ def apply_scoping(self, collection, row=None) -> Tuple[bool, "ScopedMustMatchTre return _can_cache, ScopedMustMatchTreeRecord(*s) class ScopedMustMatchTreeRecord(ScopedTreeRecord): - def bind(self, collection, row: Row, uploadingAgentId: Optional[int], auditor: Auditor, cache: Optional[Dict]=None, row_index: Optional[int] = None) -> Union["BoundMustMatchTreeRecord", ParseFailures]: - b = super().bind(collection, row, uploadingAgentId, auditor, cache, row_index) + def bind(self, collection, row: Row, uploadingAgentId: Optional[int], auditor: Auditor, sql_alchemy_session, cache: Optional[Dict]=None) -> Union["BoundMustMatchTreeRecord", ParseFailures]: + b = super().bind(collection, row, uploadingAgentId, auditor, sql_alchemy_session, cache) return b if isinstance(b, ParseFailures) else BoundMustMatchTreeRecord(*b) class TreeDefItemWithParseResults(NamedTuple): @@ -126,8 +129,11 @@ def is_one_to_one(self) -> bool: def must_match(self) -> bool: return False - def filter_on(self, path: str) -> FilterPack: - return FilterPack([], []) + def get_predicates(self, query: Query, sql_table: SQLTable, to_one_override: Dict[str, UploadResult]={}, path: List[str] = []) -> PredicateWithQuery: + return query, FilterPredicate() + + def map_static_to_db(self) -> Filter: + raise NotImplementedError("to-many to trees not supported!") def match_row(self) -> UploadResult: return self._handle_row(must_match=True) diff --git a/specifyweb/workbench/upload/upload.py b/specifyweb/workbench/upload/upload.py index 328a56ab27b..a22a692c2d2 100644 --- a/specifyweb/workbench/upload/upload.py +++ b/specifyweb/workbench/upload/upload.py @@ -14,7 +14,8 @@ from specifyweb.specify.auditlog import auditlog from specifyweb.specify.datamodel import Table from specifyweb.specify.tree_extras import renumber_tree, set_fullnames - +from specifyweb.stored_queries.tests import setup_sqlalchemy +from django.conf import settings from . import disambiguation from .upload_plan_schema import schema, parse_plan_with_basetable from .upload_result import Uploaded, UploadResult, ParseFailures, \ @@ -185,7 +186,7 @@ def get_raw_ds_upload_plan(collection, ds: Spdataset) -> Tuple[Table, Uploadable raise Exception("upload plan json is invalid") validate(plan, schema) - base_table, plan = parse_plan_with_basetable(collection, plan) + base_table, plan = parse_plan_with_basetable(plan) return base_table, plan def get_ds_upload_plan(collection, ds: Spdataset) -> Tuple[Table, ScopedUploadable]: @@ -201,7 +202,8 @@ def do_upload( disambiguations: Optional[List[Disambiguation]]=None, no_commit: bool=False, allow_partial: bool=True, - progress: Optional[Progress]=None + progress: Optional[Progress]=None, + session_url = None, ) -> List[UploadResult]: cache: Dict = {} _auditor = Auditor(collection=collection, audit_log=None if no_commit else auditlog, @@ -210,6 +212,7 @@ def do_upload( skip_create_permission_check=no_commit) total = len(rows) if isinstance(rows, Sized) else None cached_scope_table = None + wb_session_context = setup_sqlalchemy_wb(session_url) with savepoint("main upload"): tic = time.perf_counter() results: List[UploadResult] = [] @@ -226,8 +229,10 @@ def do_upload( else: scoped_table = cached_scope_table - bind_result = scoped_table.disambiguate(da).bind(collection, row, uploading_agent_id, _auditor, cache, i) - result = UploadResult(bind_result, {}, {}) if isinstance(bind_result, ParseFailures) else bind_result.process_row() + with wb_session_context() as session: + bind_result = scoped_table.disambiguate(da).bind(collection, row, uploading_agent_id, _auditor, session, cache) + result = UploadResult(bind_result, {}, {}) if isinstance(bind_result, ParseFailures) else bind_result.process_row() + results.append(result) if progress is not None: progress(len(results), total) @@ -248,13 +253,15 @@ def do_upload( do_upload_csv = do_upload -def validate_row(collection, upload_plan: ScopedUploadable, uploading_agent_id: int, row: Row, da: Disambiguation) -> UploadResult: +def validate_row(collection, upload_plan: ScopedUploadable, uploading_agent_id: int, row: Row, da: Disambiguation, session_url: Optional[str]=None) -> UploadResult: retries = 3 + session_context = setup_sqlalchemy_wb(session_url) while True: try: with savepoint("row validation"): - bind_result = upload_plan.disambiguate(da).bind(collection, row, uploading_agent_id, Auditor(collection, None)) - result = UploadResult(bind_result, {}, {}) if isinstance(bind_result, ParseFailures) else bind_result.process_row() + with session_context() as session: + bind_result = upload_plan.disambiguate(da).bind(collection, row, uploading_agent_id, Auditor(collection, None), session) + result = UploadResult(bind_result, {}, {}) if isinstance(bind_result, ParseFailures) else bind_result.process_row() raise Rollback("validating only") break @@ -296,3 +303,8 @@ def changed_tree(tree: str, result: UploadResult) -> bool: class NopLog(object): def insert(self, inserted_obj: Any, agent: Union[int, Any], parent_record: Optional[Any]) -> None: pass + +# to allow for unit tests to run +def setup_sqlalchemy_wb(url: Optional[str]): + _, session_context = setup_sqlalchemy(url or settings.SA_DATABASE_URL) + return session_context \ No newline at end of file diff --git a/specifyweb/workbench/upload/upload_plan_schema.py b/specifyweb/workbench/upload/upload_plan_schema.py index d276d7b55ef..5289cdd5e86 100644 --- a/specifyweb/workbench/upload/upload_plan_schema.py +++ b/specifyweb/workbench/upload/upload_plan_schema.py @@ -5,7 +5,6 @@ from specifyweb.specify import models from .upload_table import UploadTable, OneToOneTable, MustMatchTable -from .tomany import ToManyRecord from .treerecord import TreeRecord, MustMatchTreeRecord from .uploadable import Uploadable from .column_options import ColumnOptions @@ -53,16 +52,14 @@ 'wbcols': { '$ref': '#/definitions/wbcols' }, 'static': { '$ref': '#/definitions/static' }, 'toOne': { '$ref': '#/definitions/toOne' }, - 'toMany': { - 'type': 'object', - 'desciption': 'Maps the names of -to-many relationships of the table to an array of upload definitions for each.', - 'additionalProperties': { 'type': 'array', 'items': { '$ref': '#/definitions/toManyRecord' } } - } + 'toMany': { '$ref': '#/definitions/toManyRecords' } }, 'required': [ 'wbcols', 'static', 'toOne', 'toMany' ], 'additionalProperties': False }, - + # this is not needed anymore? + # having it for legacy purposes (most backward compatiblity) + # TODO: Remove this entirely once front-end treats to-many as uploadables 'toManyRecord': { 'type': 'object', 'description': 'The toManyRecord structure defines how to upload data for one record into a given table that stands ' @@ -71,7 +68,9 @@ 'wbcols': { '$ref': '#/definitions/wbcols' }, 'static': { '$ref': '#/definitions/static' }, 'toOne': { '$ref': '#/definitions/toOne' }, + 'toMany': {'$ref': '#/definitions/toManyRecords'} }, + # not making tomany required, to not choke on legacy upload plans 'required': [ 'wbcols', 'static', 'toOne' ], 'additionalProperties': False }, @@ -209,37 +208,42 @@ 'default' : None, 'oneOf' : [ {'type': 'integer'}, {'type': 'null'}] + }, + 'toManyRecords' : { + 'type': 'object', + 'desciption': 'Maps the names of -to-many relationships of the table to an array of upload definitions for each.', + 'additionalProperties': { 'type': 'array', 'items': { '$ref': '#/definitions/toManyRecord' } } } } } -def parse_plan_with_basetable(collection, to_parse: Dict) -> Tuple[Table, Uploadable]: +def parse_plan_with_basetable(to_parse: Dict) -> Tuple[Table, Uploadable]: base_table = datamodel.get_table_strict(to_parse['baseTableName']) - return base_table, parse_uploadable(collection, base_table, to_parse['uploadable']) + return base_table, parse_uploadable(base_table, to_parse['uploadable']) -def parse_plan(collection, to_parse: Dict) -> Uploadable: - return parse_plan_with_basetable(collection, to_parse)[1] +def parse_plan(to_parse: Dict) -> Uploadable: + return parse_plan_with_basetable(to_parse)[1] -def parse_uploadable(collection, table: Table, to_parse: Dict) -> Uploadable: +def parse_uploadable(table: Table, to_parse: Dict) -> Uploadable: if 'uploadTable' in to_parse: - return parse_upload_table(collection, table, to_parse['uploadTable']) + return parse_upload_table(table, to_parse['uploadTable']) if 'oneToOneTable' in to_parse: - return OneToOneTable(*parse_upload_table(collection, table, to_parse['oneToOneTable'])) + return OneToOneTable(*parse_upload_table(table, to_parse['oneToOneTable'])) if 'mustMatchTable' in to_parse: - return MustMatchTable(*parse_upload_table(collection, table, to_parse['mustMatchTable'])) + return MustMatchTable(*parse_upload_table(table, to_parse['mustMatchTable'])) if 'mustMatchTreeRecord' in to_parse: - return MustMatchTreeRecord(*parse_tree_record(collection, table, to_parse['mustMatchTreeRecord'])) + return MustMatchTreeRecord(*parse_tree_record(table, to_parse['mustMatchTreeRecord'])) if 'treeRecord' in to_parse: - return parse_tree_record(collection, table, to_parse['treeRecord']) + return parse_tree_record(table, to_parse['treeRecord']) raise ValueError('unknown uploadable type') -def parse_upload_table(collection, table: Table, to_parse: Dict) -> UploadTable: +def parse_upload_table(table: Table, to_parse: Dict) -> UploadTable: def rel_table(key: str) -> Table: return datamodel.get_table_strict(table.get_relationship(key).relatedModelName) @@ -250,16 +254,23 @@ def rel_table(key: str) -> Table: wbcols={k: parse_column_options(v) for k,v in to_parse['wbcols'].items()}, static=to_parse['static'], toOne={ - key: parse_uploadable(collection, rel_table(key), to_one) + key: parse_uploadable(rel_table(key), to_one) for key, to_one in to_parse['toOne'].items() }, toMany={ - key: [parse_to_many_record(collection, rel_table(key), record) for record in to_manys] - for key, to_manys in to_parse['toMany'].items() + key: [parse_uploadable(rel_table(key), _hacky_augment_to_many(record)) for record in to_manys] + # legacy + for key, to_manys in to_parse.get('toMany', {}).items() } ) -def parse_tree_record(collection, table: Table, to_parse: Dict) -> TreeRecord: +# TODO: Figure out a better way to do this. Django migration? Silently handle it? +def _hacky_augment_to_many(to_parse: Dict): + return { + 'uploadTable': to_parse + } + +def parse_tree_record(table: Table, to_parse: Dict) -> TreeRecord: ranks = { rank: {'name': parse_column_options(name_or_cols)} if isinstance(name_or_cols, str) else {k: parse_column_options(v) for k,v in name_or_cols['treeNodeCols'].items() } @@ -273,21 +284,6 @@ def parse_tree_record(collection, table: Table, to_parse: Dict) -> TreeRecord: ranks=ranks, ) -def parse_to_many_record(collection, table: Table, to_parse: Dict) -> ToManyRecord: - - def rel_table(key: str) -> Table: - return datamodel.get_table_strict(table.get_relationship(key).relatedModelName) - - return ToManyRecord( - name=table.django_name, - wbcols={k: parse_column_options(v) for k,v in to_parse['wbcols'].items()}, - static=to_parse['static'], - toOne={ - key: parse_uploadable(collection, rel_table(key), to_one) - for key, to_one in to_parse['toOne'].items() - }, - ) - def parse_column_options(to_parse: Union[str, Dict]) -> ColumnOptions: if isinstance(to_parse, str): return ColumnOptions( diff --git a/specifyweb/workbench/upload/upload_table.py b/specifyweb/workbench/upload/upload_table.py index 1afe5074b1d..dd0a5d79889 100644 --- a/specifyweb/workbench/upload/upload_table.py +++ b/specifyweb/workbench/upload/upload_table.py @@ -1,4 +1,3 @@ - import logging from functools import reduce from typing import List, Dict, Any, NamedTuple, Union, Optional, Set, Callable, Literal, cast, Tuple @@ -7,24 +6,30 @@ from specifyweb.businessrules.exceptions import BusinessRuleException from specifyweb.specify import models +from specifyweb.specify.datamodel import datamodel +from specifyweb.specify.load_datamodel import Field, Relationship +import specifyweb.stored_queries.models as sql_models from .column_options import ColumnOptions, ExtendedColumnOptions from .parsing import parse_many, ParseResult, ParseFailure -from .tomany import ToManyRecord, ScopedToManyRecord, BoundToManyRecord from .upload_result import UploadResult, Uploaded, NoMatch, Matched, \ MatchedMultiple, NullRecord, FailedBusinessRule, ReportInfo, \ PicklistAddition, ParseFailures, PropagatedFailure -from .uploadable import FilterPack, Exclude, Row, Uploadable, ScopedUploadable, \ - BoundUploadable, Disambiguation, Auditor +from .uploadable import FilterPredicate, Predicate, PredicateWithQuery, Row, Uploadable, ScopedUploadable, \ + BoundUploadable, Disambiguation, Auditor, Filter -logger = logging.getLogger(__name__) +from sqlalchemy.orm import Query, aliased, Session # type: ignore +from sqlalchemy import sql, Table as SQLTable # type: ignore +from sqlalchemy.sql.expression import ColumnElement # type: ignore +from sqlalchemy.exc import OperationalError # type: ignore +logger = logging.getLogger(__name__) class UploadTable(NamedTuple): name: str wbcols: Dict[str, ColumnOptions] static: Dict[str, Any] toOne: Dict[str, Uploadable] - toMany: Dict[str, List[ToManyRecord]] + toMany: Dict[str, List[Uploadable]] overrideScope: Optional[Dict[Literal['collection'], Optional[int]]] = None @@ -47,7 +52,8 @@ def _to_json(self) -> Dict: for key, uploadable in self.toOne.items() } result['toMany'] = { - key: [to_many.to_json() for to_many in to_manys] + # legacy behaviour + key: [to_many.to_json()['uploadTable'] for to_many in to_manys] for key, to_manys in self.toMany.items() } return result @@ -58,14 +64,12 @@ def to_json(self) -> Dict: def unparse(self) -> Dict: return { 'baseTableName': self.name, 'uploadable': self.to_json() } - - class ScopedUploadTable(NamedTuple): name: str wbcols: Dict[str, ExtendedColumnOptions] static: Dict[str, Any] toOne: Dict[str, ScopedUploadable] - toMany: Dict[str, List[ScopedToManyRecord]] + toMany: Dict[str, List['ScopedUploadable']] # type: ignore scopingAttrs: Dict[str, int] disambiguation: Optional[int] @@ -95,23 +99,23 @@ def get_treedefs(self) -> Set: ) - def bind(self, collection, row: Row, uploadingAgentId: int, auditor: Auditor, cache: Optional[Dict]=None, row_index: Optional[int] = None + def bind(self, collection, row: Row, uploadingAgentId: int, auditor: Auditor, sql_alchemy_session, cache: Optional[Dict]=None ) -> Union["BoundUploadTable", ParseFailures]: parsedFields, parseFails = parse_many(collection, self.name, self.wbcols, row) toOne: Dict[str, BoundUploadable] = {} for fieldname, uploadable in self.toOne.items(): - result = uploadable.bind(collection, row, uploadingAgentId, auditor, cache, row_index) + result = uploadable.bind(collection, row, uploadingAgentId, auditor, sql_alchemy_session, cache) if isinstance(result, ParseFailures): parseFails += result.failures else: toOne[fieldname] = result - toMany: Dict[str, List[BoundToManyRecord]] = {} + toMany: Dict[str, List[BoundUploadable]] = {} for fieldname, records in self.toMany.items(): - boundRecords: List[BoundToManyRecord] = [] + boundRecords: List[BoundUploadable] = [] for record in records: - result_ = record.bind(collection, row, uploadingAgentId, auditor, cache, row_index) + result_ = record.bind(collection, row, uploadingAgentId, auditor, sql_alchemy_session, cache) if isinstance(result_, ParseFailures): parseFails += result_.failures else: @@ -132,6 +136,7 @@ def bind(self, collection, row: Row, uploadingAgentId: int, auditor: Auditor, ca uploadingAgentId=uploadingAgentId, auditor=auditor, cache=cache, + session=sql_alchemy_session ) class OneToOneTable(UploadTable): @@ -143,9 +148,9 @@ def to_json(self) -> Dict: return { 'oneToOneTable': self._to_json() } class ScopedOneToOneTable(ScopedUploadTable): - def bind(self, collection, row: Row, uploadingAgentId: int, auditor: Auditor, cache: Optional[Dict]=None, row_index: Optional[int] = None + def bind(self, collection, row: Row, uploadingAgentId: int, auditor: Auditor, sql_alchemy_session, cache: Optional[Dict]=None ) -> Union["BoundOneToOneTable", ParseFailures]: - b = super().bind(collection, row, uploadingAgentId, auditor, cache, row_index) + b = super().bind(collection, row, uploadingAgentId, auditor, sql_alchemy_session, cache) return BoundOneToOneTable(*b) if isinstance(b, BoundUploadTable) else b class MustMatchTable(UploadTable): @@ -157,9 +162,9 @@ def to_json(self) -> Dict: return { 'mustMatchTable': self._to_json() } class ScopedMustMatchTable(ScopedUploadTable): - def bind(self, collection, row: Row, uploadingAgentId: int, auditor: Auditor, cache: Optional[Dict]=None, row_index: Optional[int] = None + def bind(self, collection, row: Row, uploadingAgentId: int, auditor: Auditor, sql_alchemy_session, cache: Optional[Dict]=None ) -> Union["BoundMustMatchTable", ParseFailures]: - b = super().bind(collection, row, uploadingAgentId, auditor, cache, row_index) + b = super().bind(collection, row, uploadingAgentId, auditor, sql_alchemy_session, cache) return BoundMustMatchTable(*b) if isinstance(b, BoundUploadTable) else b @@ -168,12 +173,13 @@ class BoundUploadTable(NamedTuple): static: Dict[str, Any] parsedFields: List[ParseResult] toOne: Dict[str, BoundUploadable] - toMany: Dict[str, List[BoundToManyRecord]] + toMany: Dict[str, List[BoundUploadable]] scopingAttrs: Dict[str, int] disambiguation: Optional[int] uploadingAgentId: Optional[int] auditor: Auditor cache: Optional[Dict] + session: Any # TODO: Improve typing def is_one_to_one(self) -> bool: return False @@ -181,31 +187,96 @@ def is_one_to_one(self) -> bool: def must_match(self) -> bool: return False - def filter_on(self, path: str) -> FilterPack: + def get_predicates(self, query: Query, sql_table: SQLTable, to_one_override: Dict[str, UploadResult] = {}, path: List[str] = []) -> PredicateWithQuery: if self.disambiguation is not None: if getattr(models, self.name.capitalize()).objects.filter(id=self.disambiguation).exists(): - return FilterPack([{f'{path}__id': self.disambiguation}], []) + return query, FilterPredicate([Predicate(getattr(sql_table, sql_table._id), self.disambiguation)]) + + specify_table = datamodel.get_table_strict(self.name) - filters = { - (path + '__' + fieldname_): value + direct_field_pack = FilterPredicate.from_simple_dict( + sql_table, + ((specify_table.get_field_strict(fieldname).name, value) for parsedField in self.parsedFields - for fieldname_, value in parsedField.filter_on.items() - } - - for toOneField, toOneTable in self.toOne.items(): - fs, es = toOneTable.filter_on(path + '__' + toOneField) - for f in fs: - filters.update(f) + for fieldname, value in parsedField.filter_on.items()), + path=path + ) + + def _reduce( + accumulated: PredicateWithQuery, + # to-ones are converted to a list of one element to simplify handling to-manys + current: Tuple[str, Union[List[BoundUploadable], BoundUploadable]], + # to-one and to-many handle return filter packs differently + specialize_callback: Callable[[FilterPredicate, BoundUploadable, Relationship, SQLTable, List[str]], Optional[FilterPredicate]] + ) -> PredicateWithQuery: + current_query, current_predicates = accumulated + relationship_name, upload_tables = current + if not isinstance(upload_tables, list): + upload_tables = [upload_tables] + relationship = specify_table.get_relationship(relationship_name) + related_model_name = relationship.relatedModelName + + def _uploadables_reduce(accum: Tuple[PredicateWithQuery, List[ColumnElement], int], uploadable: BoundUploadable) -> Tuple[PredicateWithQuery, List[ColumnElement], int]: + next_sql_model: SQLTable = aliased(getattr(sql_models, related_model_name)) + (query, previous_predicate), to_ignore, index = accum + _id = getattr(next_sql_model, next_sql_model._id) + extended_criterions = [_id != previous_id for previous_id in to_ignore] + criterion = sql.and_(*extended_criterions) + + joined = query.join( + next_sql_model, + getattr(sql_table, relationship.name), + ) + if len(extended_criterions): + # to make sure matches are record-aligned + # disable this, and see what unit test fails to figure out what it does + joined = joined.filter(criterion) + next_query, _raw_field_pack = uploadable.get_predicates(joined, next_sql_model, path=[*path, repr((index, relationship_name))]) + to_merge = specialize_callback(_raw_field_pack, uploadable, relationship, sql_table, path) + if to_merge is not None: + next_query = query + else: + to_ignore = [*to_ignore, _id] + to_merge = _raw_field_pack + return (next_query, previous_predicate.merge(to_merge)), to_ignore, index + 1 + + reduced, _, __ = reduce(_uploadables_reduce, upload_tables, ((current_query, current_predicates), [], 0)) + return reduced + + to_one_reduce = lambda accum, curr: _reduce(accum, curr, FilterPredicate.to_one_augment) + to_many_reduce = lambda accum, curr: _reduce(accum, curr, FilterPredicate.to_many_augment) + + # this is handled here to make the matching query simple for the root table + if to_one_override: + to_one_pack = FilterPredicate.from_simple_dict( + sql_table, + ((FilterPredicate.rel_to_fk(specify_table.get_relationship(rel)), value.get_id()) for (rel, value) in to_one_override.items()), + path + ) + else: + query, to_one_pack = reduce(to_one_reduce, self.toOne.items(), (query, FilterPredicate())) + query, to_many_pack = reduce(to_many_reduce, self.toMany.items(), (query, FilterPredicate())) + accumulated_pack = direct_field_pack.merge(to_many_pack).merge(to_one_pack) - if all(v is None for v in filters.values()): - return FilterPack([], [Exclude(path + "__in", self.name, {**self.scopingAttrs, **self.static})]) + is_reducible = not (any(value[1] is not None for value in accumulated_pack.filter)) + if is_reducible: + # don't care about excludes anymore + return query, FilterPredicate() - filters.update({ - (path + '__' + fieldname): value - for fieldname, value in {**self.scopingAttrs, **self.static}.items() - }) + static_predicate = FilterPredicate.from_simple_dict(sql_table, iter(self.map_static_to_db().items()), path) - return FilterPack([filters], []) + return query, static_predicate.merge(accumulated_pack) + + def map_static_to_db(self) -> Filter: + model = getattr(models, self.name.capitalize()) + table = datamodel.get_table_strict(self.name) + raw_attrs = {**self.scopingAttrs, **self.static} + + return { + FilterPredicate.rel_to_fk(table.get_field_strict(model._meta.get_field(direct_field).name)): value + for (direct_field, value) in raw_attrs.items() + } + def process_row(self) -> UploadResult: return self._handle_row(force_upload=False) @@ -248,24 +319,21 @@ def _handle_row(self, force_upload: bool) -> UploadResult: if any(result.get_id() == "Failure" for result in toOneResults.values()): return UploadResult(PropagatedFailure(), toOneResults, {}) - toManyFilters = _to_many_filters_and_excludes(self.toMany) - attrs = { fieldname_: value for parsedField in self.parsedFields for fieldname_, value in parsedField.upload.items() } - attrs.update({ model._meta.get_field(fieldname).attname: r.get_id() for fieldname, r in toOneResults.items() }) - - to_many_filters, to_many_excludes = toManyFilters - - if all(v is None for v in attrs.values()) and not to_many_filters and not multipleOneToOneMatch: + base_sql_table = getattr(sql_models, datamodel.get_table_strict(self.name).name) + query, filter_predicate = self.get_predicates(self.session.query(getattr(base_sql_table, base_sql_table._id)), base_sql_table, toOneResults) + + if all(v is None for v in attrs.values()) and not filter_predicate.filter and not multipleOneToOneMatch: # nothing to upload return UploadResult(NullRecord(info), toOneResults, {}) if not force_upload: - match = self._match(model, toOneResults, toManyFilters, info) + match = self._match(query, filter_predicate, info) if match: return UploadResult(match, toOneResults, {}) @@ -278,38 +346,25 @@ def _process_to_ones(self) -> Dict[str, UploadResult]: sorted(self.toOne.items(), key=lambda kv: kv[0]) # make the upload order deterministic } - def _match(self, model, toOneResults: Dict[str, UploadResult], toManyFilters: FilterPack, info: ReportInfo) -> Union[Matched, MatchedMultiple, None]: - filters = { - fieldname_: value - for parsedField in self.parsedFields - for fieldname_, value in parsedField.filter_on.items() - } - - filters.update({ model._meta.get_field(fieldname).attname: r.get_id() for fieldname, r in toOneResults.items() }) - - cache_key = ( - self.name, - tuple(sorted(filters.items())), - toManyFilters.match_key(), - tuple(sorted(self.scopingAttrs.items())), - tuple(sorted(self.static.items())), - ) + def _match(self, query: Query, predicate: FilterPredicate, info: ReportInfo) -> Union[Matched, MatchedMultiple, None]: + assert predicate.filter or predicate.exclude, "Attempting to match a null record!" + cache_key = predicate.cache_key() cache_hit: Optional[List[int]] = self.cache.get(cache_key, None) if self.cache is not None else None if cache_hit is not None: ids = cache_hit else: - to_many_filters, to_many_excludes = toManyFilters - - qs = reduce(lambda q, e: q.exclude(**{e.lookup: getattr(models, e.table).objects.filter(**e.filter)}), - to_many_excludes, - reduce(lambda q, f: q.filter(**f), - to_many_filters, - model.objects.filter(**filters, **self.scopingAttrs, **self.static))) - - ids = list(qs.values_list('id', flat=True)[:10]) - - if self.cache and ids: + query = predicate.apply_to_query(query) + try: + query = query.limit(10) + raw_ids: List[Tuple[int, Any]] = list(query) + ids = [_id[0] for _id in raw_ids] + except OperationalError as e: + if e.args[0] == "(MySQLdb.OperationalError) (1065, 'Query was empty')": + ids = [] + else: + raise + if self.cache is not None and ids: self.cache[cache_key] = ids n_matched = len(ids) @@ -374,7 +429,7 @@ def _do_upload(self, model, toOneResults: Dict[str, UploadResult], info: ReportI self.auditor.insert(uploaded, self.uploadingAgentId, None) toManyResults = { - fieldname: _upload_to_manys(model, uploaded.id, fieldname, self.uploadingAgentId, self.auditor, self.cache, records) + fieldname: _upload_to_manys(model, uploaded.id, fieldname, self.uploadingAgentId, self.auditor, self.cache, records, self.session) for fieldname, records in sorted(self.toMany.items(), key=lambda kv: kv[0]) # make the upload order deterministic } @@ -414,20 +469,7 @@ def _do_upload(self, model, toOneResults: Dict[str, UploadResult], info: ReportI return UploadResult(NoMatch(info), toOneResults, {}) -def _to_many_filters_and_excludes(to_manys: Dict[str, List[BoundToManyRecord]]) -> FilterPack: - filters: List[Dict] = [] - excludes: List[Exclude] = [] - - for toManyField, records in to_manys.items(): - for record in records: - fs, es = record.filter_on(toManyField) - filters += fs - excludes += [e for e in es if e.filter] - - return FilterPack(filters, excludes) - - -def _upload_to_manys(parent_model, parent_id, parent_field, uploadingAgentId: Optional[int], auditor: Auditor, cache: Optional[Dict], records) -> List[UploadResult]: +def _upload_to_manys(parent_model, parent_id, parent_field, uploadingAgentId: Optional[int], auditor: Auditor, cache: Optional[Dict], records, session) -> List[UploadResult]: fk_field = parent_model._meta.get_field(parent_field).remote_field.attname return [ @@ -442,6 +484,7 @@ def _upload_to_manys(parent_model, parent_id, parent_field, uploadingAgentId: Op uploadingAgentId=uploadingAgentId, auditor=auditor, cache=cache, + session=session ).force_upload_row() for record in records - ] + ] \ No newline at end of file diff --git a/specifyweb/workbench/upload/uploadable.py b/specifyweb/workbench/upload/uploadable.py index 335533e227c..9467e31dd0d 100644 --- a/specifyweb/workbench/upload/uploadable.py +++ b/specifyweb/workbench/upload/uploadable.py @@ -1,8 +1,19 @@ -from typing import List, Dict, Tuple, Any, NamedTuple, Optional, Union, Set -from typing_extensions import Protocol, Literal +from typing import Iterator, List, Dict, Tuple, Any, NamedTuple, Optional, TypedDict, Union, Set +from typing_extensions import Protocol + +from functools import reduce + +from sqlalchemy import Table as SQLTable # type: ignore +import sqlalchemy as db # type: ignore +from sqlalchemy.orm import Query # type: ignore +from sqlalchemy.sql.expression import ColumnElement # type: ignore + +from specifyweb.specify.load_datamodel import Field, Relationship +from specifyweb.specify.models import datamodel from .upload_result import UploadResult, ParseFailures from .auditor import Auditor +import specifyweb.stored_queries.models as sql_models class Uploadable(Protocol): # also returns if the scoped table returned can be cached or not. @@ -43,7 +54,7 @@ class ScopedUploadable(Protocol): def disambiguate(self, disambiguation: Disambiguation) -> "ScopedUploadable": ... - def bind(self, collection, row: Row, uploadingAgentId: int, auditor: Auditor, cache: Optional[Dict]=None, row_index: Optional[int] = None) -> Union["BoundUploadable", ParseFailures]: + def bind(self, collection, row: Row, uploadingAgentId: int, auditor: Auditor, sql_alchemy_session, cache: Optional[Dict]=None) -> Union["BoundUploadable", ParseFailures]: ... def get_treedefs(self) -> Set: @@ -54,20 +65,110 @@ def get_treedefs(self) -> Set: def filter_match_key(f: Filter) -> str: return repr(sorted(f.items())) -class Exclude(NamedTuple): - lookup: str - table: str - filter: Filter - - def match_key(self) -> str: - return repr((self.lookup, self.table, filter_match_key(self.filter))) - -class FilterPack(NamedTuple): - filters: List[Filter] - excludes: List[Exclude] - - def match_key(self) -> str: - return repr((sorted(filter_match_key(f) for f in self.filters), sorted(e.match_key() for e in self.excludes))) +class Matchee(TypedDict): + # need to reference the fk in the penultimate table in join to to-many + ref: ColumnElement + # which column to use in the table (== the otherside of ref) + backref: str + filters: Filter + path: List[str] + +def matchee_to_key(matchee: Matchee): + return (matchee['backref'], matchee['path'], filter_match_key(matchee['filters'])) + +class Predicate(NamedTuple): + ref: ColumnElement + value: Any = None + path: List[str] = [] + +class FilterPredicate(NamedTuple): + # gets flatenned into ANDs + filter: List[Predicate] = [] + # basically the entire exclude can be flatenned into ORs. (NOT A and NOT B -> NOT (A or B)) + # significantly reduces the tables needed in the look-up query (vs Django's default) + exclude: Dict[ + str, # the model name + List[Matchee] # list of found references + ] = {} + + def merge(self, other: 'FilterPredicate') -> 'FilterPredicate': + filters = [*self.filter, *other.filter] + exclude = reduce( + lambda accum, current: {**accum, current[0]: [*accum.get(current[0], []), *current[1]]}, + other.exclude.items(), + self.exclude + ) + return FilterPredicate(filters, exclude) + + def to_one_augment(self, uploadable: 'BoundUploadable', relationship: Relationship, sql_table: SQLTable, path: List[str]) -> Optional['FilterPredicate']: + if self.filter or self.exclude: + return None + return FilterPredicate([Predicate(getattr(sql_table, relationship.column), None, [*path, relationship.name])]) + + def to_many_augment(self, uploadable: 'BoundUploadable', relationship: Relationship, sql_table: SQLTable, path: List[str]) -> Optional['FilterPredicate']: + if self.filter: + return None + + # nested excludes don't make sense and complicates everything. this avoids it (while keeping semantics same) + return FilterPredicate(exclude={ + relationship.relatedModelName: [{ + 'ref': getattr(sql_table, sql_table._id), + 'backref': relationship.otherSideName, + 'filters': uploadable.map_static_to_db(), + 'path': path + }] + }) + + @staticmethod + def from_simple_dict(sql_table: SQLTable, iterator: Iterator[Tuple[str, Any]], path:List[str]=[]): + # REFACTOR: make this inline? + return FilterPredicate( + [Predicate(getattr(sql_table, fieldname), value, [*path, fieldname]) + for fieldname, value in iterator] + ) + + def apply_to_query(self, query: Query) -> Query: + direct = db.and_(*[(field == value) for field, value, _ in self.filter]) + excludes = db.or_(*[FilterPredicate._map_exclude(items) for items in self.exclude.items()]) + filter_by = direct if not self.exclude else db.and_( + direct, + db.not_(excludes) + ) + return query.filter(filter_by) + + @staticmethod + def _map_exclude(current: Tuple[str, List[Matchee]]): + model_name, matches = current + sql_table = getattr(sql_models, model_name) + table = datamodel.get_table_strict(model_name) + assert len(matches) > 0, "got nothing to exclude" + + criterion = [ + db.and_( + getattr(sql_table, table.get_relationship(matchee['backref']).column) == matchee['ref'], + db.and_( + *[ + getattr(sql_table, field) == value + for field, value in matchee['filters'].items() + ] + ) + ) + for matchee in matches + ] + + # dbs limit 1 anyways... + return (db.exists(db.select([1])).where(db.or_(*criterion))) + + @staticmethod + def rel_to_fk(field: Field): + return field.column if field.is_relationship else field.name + + def cache_key(self) -> str: + filters = sorted((repr(_filter.path), _filter.value) for _filter in self.filter) + excludes = sorted((key, sorted(matchee_to_key(value) for value in values)) for (key, values) in self.exclude.items()) + return repr((filters, excludes)) + +PredicateWithQuery = Tuple[Query, FilterPredicate] class BoundUploadable(Protocol): @@ -76,8 +177,8 @@ def is_one_to_one(self) -> bool: def must_match(self) -> bool: ... - - def filter_on(self, path: str) -> FilterPack: + + def get_predicates(self, query: Query, sql_table: SQLTable, to_one_override: Dict[str, UploadResult]={}, path: List[str] = []) -> PredicateWithQuery: ... def match_row(self) -> UploadResult: @@ -89,3 +190,5 @@ def process_row(self) -> UploadResult: def force_upload_row(self) -> UploadResult: ... + def map_static_to_db(self) -> Filter: + ... diff --git a/specifyweb/workbench/views.py b/specifyweb/workbench/views.py index 0bc777ecb68..8bf8d1e4eea 100644 --- a/specifyweb/workbench/views.py +++ b/specifyweb/workbench/views.py @@ -479,7 +479,7 @@ def dataset(request, ds: models.Spdataset) -> http.HttpResponse: except ValidationError as e: return http.HttpResponse(f"upload plan is invalid: {e}", status=400) - new_cols = upload_plan_schema.parse_plan(request.specify_collection, plan).get_cols() - set(ds.columns) + new_cols = upload_plan_schema.parse_plan(plan).get_cols() - set(ds.columns) if new_cols: ncols = len(ds.columns) ds.columns += list(new_cols) From 6ceedbb02e50d59f556ca44529bbf324df8352e2 Mon Sep 17 00:00:00 2001 From: realVinayak Date: Sun, 9 Jun 2024 01:50:36 -0500 Subject: [PATCH 12/63] Fix remaining unit test --- specifyweb/workbench/tests.py | 4 ++-- specifyweb/workbench/upload/upload.py | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/specifyweb/workbench/tests.py b/specifyweb/workbench/tests.py index 82c8a1ceb41..65565708890 100644 --- a/specifyweb/workbench/tests.py +++ b/specifyweb/workbench/tests.py @@ -6,7 +6,7 @@ from specifyweb.specify import models as spmodels from . import models from .upload import upload as uploader - +from django.conf import settings class DataSetTests(ApiTests): def test_reset_uploadplan_to_null(self) -> None: @@ -72,7 +72,7 @@ def test_create_record_set(self) -> None: ) self.assertEqual(response.status_code, 204) dataset = models.Spdataset.objects.get(id=datasetid) - results = uploader.do_upload_dataset(self.collection, self.agent.id, dataset, no_commit=False, allow_partial=False) + results = uploader.do_upload_dataset(self.collection, self.agent.id, dataset, no_commit=False, allow_partial=False, session_url=settings.SA_TEST_DB_URL) self.assertTrue(dataset.uploadresult['success']) response = c.post( diff --git a/specifyweb/workbench/upload/upload.py b/specifyweb/workbench/upload/upload.py index a22a692c2d2..5a4b927bb8b 100644 --- a/specifyweb/workbench/upload/upload.py +++ b/specifyweb/workbench/upload/upload.py @@ -112,7 +112,8 @@ def do_upload_dataset( ds: Spdataset, no_commit: bool, allow_partial: bool, - progress: Optional[Progress]=None + progress: Optional[Progress]=None, + session_url: Optional[str] = None ) -> List[UploadResult]: if ds.was_uploaded(): raise AssertionError("Dataset already uploaded", {"localizationKey" : "datasetAlreadyUploaded"}) ds.rowresults = None @@ -124,7 +125,7 @@ def do_upload_dataset( disambiguation = [get_disambiguation_from_row(ncols, row) for row in ds.data] base_table, upload_plan = get_raw_ds_upload_plan(collection, ds) - results = do_upload(collection, rows, upload_plan, uploading_agent_id, disambiguation, no_commit, allow_partial, progress) + results = do_upload(collection, rows, upload_plan, uploading_agent_id, disambiguation, no_commit, allow_partial, progress, session_url=session_url) success = not any(r.contains_failure() for r in results) if not no_commit: ds.uploadresult = { From 19a8dcf73177f5d58292ab5aafaab31848cc1003 Mon Sep 17 00:00:00 2001 From: realVinayak Date: Sun, 9 Jun 2024 03:44:26 -0500 Subject: [PATCH 13/63] Begin adding unit tests -- will continue adding --- .../upload/tests/testnestedtomany.py | 232 ++++++++++++++++++ .../workbench/upload/tests/testonetoone.py | 4 +- 2 files changed, 233 insertions(+), 3 deletions(-) create mode 100644 specifyweb/workbench/upload/tests/testnestedtomany.py diff --git a/specifyweb/workbench/upload/tests/testnestedtomany.py b/specifyweb/workbench/upload/tests/testnestedtomany.py new file mode 100644 index 00000000000..1d87f9ad100 --- /dev/null +++ b/specifyweb/workbench/upload/tests/testnestedtomany.py @@ -0,0 +1,232 @@ +from jsonschema import validate # type: ignore +from typing import Dict +from specifyweb.workbench.upload.tests.base import UploadTestsBase +from specifyweb.workbench.upload.upload import do_upload +from specifyweb.workbench.upload.upload_result import Matched, NullRecord, Uploaded +from ..upload_plan_schema import schema, parse_plan +from specifyweb.specify.api_tests import get_table + +from django.conf import settings + +class NestedToManyTests(UploadTestsBase): + def plan(self) -> Dict: + return dict( + baseTableName = 'Collectingevent', + uploadable = { + 'uploadTable': dict( + wbcols = { + 'stationfieldnumber': 'sfn' + }, + static = {}, + toOne = {}, + toMany = { + 'collectors': [ + dict( + wbcols = {}, + static={'isprimary': True}, + toOne = { + 'agent': { + 'uploadTable': dict( + wbcols = { + 'firstname': 'coll_1_name' + }, + static = { + 'agenttype': 1 + }, + toOne = {}, + toMany = { + 'agentspecialties': [ + dict( + wbcols = { + 'specialtyname': 'agent_1_specialty_1' + }, + static = {}, + toOne = {}, + toMany = {} + ), + dict( + wbcols = { + 'specialtyname': 'agent_1_specialty_2' + }, + static = {}, + toOne = {}, + toMany = {} + ), + dict( + wbcols = { + 'specialtyname': 'agent_1_specialty_3' + }, + static = {}, + toOne = {}, + toMany = {} + ), + dict( + wbcols = { + 'specialtyname': 'agent_1_specialty_4' + }, + static = {}, + toOne = {}, + toMany = {} + ) + ] + } + ), + + } + } + ), + dict( + wbcols = {}, + static={'isprimary': False}, + toOne = { + 'agent': { + 'uploadTable': dict( + wbcols = { + 'firstname': 'coll_2_name' + }, + static = { + 'agenttype': 1 + }, + toOne = {}, + toMany = { + 'agentspecialties': [ + dict( + wbcols = { + 'specialtyname': 'agent_2_specialty_1' + }, + static = {}, + toOne = {}, + toMany = {} + ), + dict( + wbcols = { + 'specialtyname': 'agent_2_specialty_2' + }, + static = {}, + toOne = {}, + toMany = {} + ), + dict( + wbcols = { + 'specialtyname': 'agent_2_specialty_3' + }, + static = {}, + toOne = {}, + toMany = {} + ) + ] + } + ), + + } + } + ) + ] + } + ) + } + ) + def test_nested_to_many_parsing(self) -> None: + json = self.plan() + validate(json, schema) + + def test_basic_uploading(self) -> None: + plan = parse_plan(self.plan()) + data = [ + dict( + sfn='1', + coll_1_name='agent 1', + agent_1_specialty_1= 'speciality1', + agent_1_specialty_2= 'speciality2', + agent_1_specialty_3= 'speciality3', + agent_1_specialty_4= 'speciality4', + coll_2_name='agent 2', + agent_2_specialty_1='speciality5', + agent_2_specialty_2='speciality6', + agent_2_specialty_3='speciality7' + ), + dict( + sfn='1', # this will be uploaded + coll_1_name='agent 1', # this should be a new agent with 2 specialties + agent_1_specialty_1= 'speciality1', + agent_1_specialty_2= '', + agent_1_specialty_3= 'speciality3', + agent_1_specialty_4= '', + coll_2_name='agent 2', # this should be matched + agent_2_specialty_1='speciality5', + agent_2_specialty_2='speciality6', + agent_2_specialty_3='speciality7' + ), + dict( + sfn='1', # this will be uploaded + coll_1_name='agent 1', # this should be a new agent with no specialties + agent_1_specialty_1= '', + agent_1_specialty_2= '', + agent_1_specialty_3= '', + agent_1_specialty_4= '', + coll_2_name='agent 2', # this will be a new agent + agent_2_specialty_1='speciality5', + agent_2_specialty_2='speciality6', + agent_2_specialty_3='' + ), + dict( + sfn='1', # this will be uploaded + coll_1_name='', # this should be a collecting event with just one collector + agent_1_specialty_1= '', + agent_1_specialty_2= '', + agent_1_specialty_3= '', + agent_1_specialty_4= '', + coll_2_name='agent 2', # this will be a matched + agent_2_specialty_1='speciality5', + agent_2_specialty_2='speciality6', + agent_2_specialty_3='' + ), + + ] + + results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) + + for r in results: + self.assertIsInstance(r.record_result, Uploaded, 'All collecting events must be created') + + + agents_matching = [[(0, 1), (1, 1)], [(2, 1), (3, 1)]] + for pair in agents_matching: + agents_added = set() + for idx, path in enumerate(pair): + ce_idx, col_idx = path + agent = (results[ce_idx].toMany['collectors'][col_idx].toOne['agent'].record_result) + if idx == 0: + self.assertIsInstance(agent, Uploaded, f'record at idx {idx} was not uploaded') + else: + self.assertIsInstance(agent, Matched, f'record at idx {idx} was not matched') + agents_added.add(agent.get_id()) + self.assertEqual(len(agents_added), 1, f"Match was not successful for pair {pair}") + + self.assertIsInstance(results[-1].toMany['collectors'][0].record_result, NullRecord) + self.assertEqual( + get_table('Collector').objects.filter(collectingevent_id=results[-1].record_result.get_id()).count(), + 1 + ) + + new_agents_created = { + (0, 0): [Uploaded]*4, + (0, 1): [Uploaded]*3, + (1, 0): [Uploaded, NullRecord, Uploaded, NullRecord], + (2, 0): [NullRecord]*4, + (2, 1): [Uploaded, Uploaded, NullRecord] + } + + for ((ce_idx, col_idx), spec_results) in new_agents_created.items(): + agent = (results[ce_idx].toMany['collectors'][col_idx].toOne['agent']) + self.assertIsInstance(agent.record_result, Uploaded, f'failed at {(ce_idx, col_idx)}') + self.assertTrue(get_table('Agent').objects.filter(id=agent.record_result.get_id()).exists()) + specialties = agent.toMany['agentspecialties'] + for result, expected in zip(specialties, spec_results): + self.assertIsInstance(result.record_result, expected) + specialty_created = spec_results.count(Uploaded) + self.assertEqual( + get_table('Agentspecialty').objects.filter(agent_id=agent.record_result.get_id()).count(), + specialty_created + ) + \ No newline at end of file diff --git a/specifyweb/workbench/upload/tests/testonetoone.py b/specifyweb/workbench/upload/tests/testonetoone.py index 2b68dc9d2a5..104fce19083 100644 --- a/specifyweb/workbench/upload/tests/testonetoone.py +++ b/specifyweb/workbench/upload/tests/testonetoone.py @@ -10,9 +10,7 @@ from django.conf import settings class OneToOneTests(UploadTestsBase): - def setUp(self) -> None: - super().setUp() - + def plan(self, one_to_one: bool) -> Dict: reltype = 'oneToOneTable' if one_to_one else 'uploadTable' return dict( From f2b2e46d0eb80197f5ec10fa2a08fc2ca0090a7d Mon Sep 17 00:00:00 2001 From: realVinayak Date: Sun, 9 Jun 2024 03:53:38 -0500 Subject: [PATCH 14/63] fix mypy errors --- specifyweb/workbench/upload/tests/testnestedtomany.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/specifyweb/workbench/upload/tests/testnestedtomany.py b/specifyweb/workbench/upload/tests/testnestedtomany.py index 1d87f9ad100..064bb863e0c 100644 --- a/specifyweb/workbench/upload/tests/testnestedtomany.py +++ b/specifyweb/workbench/upload/tests/testnestedtomany.py @@ -1,5 +1,5 @@ from jsonschema import validate # type: ignore -from typing import Dict +from typing import Any, Dict, List, Tuple from specifyweb.workbench.upload.tests.base import UploadTestsBase from specifyweb.workbench.upload.upload import do_upload from specifyweb.workbench.upload.upload_result import Matched, NullRecord, Uploaded @@ -168,7 +168,7 @@ def test_basic_uploading(self) -> None: agent_2_specialty_1='speciality5', agent_2_specialty_2='speciality6', agent_2_specialty_3='' - ), + ), dict( sfn='1', # this will be uploaded coll_1_name='', # this should be a collecting event with just one collector @@ -209,7 +209,7 @@ def test_basic_uploading(self) -> None: 1 ) - new_agents_created = { + new_agents_created: Dict[Tuple[int, int], List[Any]] = { (0, 0): [Uploaded]*4, (0, 1): [Uploaded]*3, (1, 0): [Uploaded, NullRecord, Uploaded, NullRecord], From aacd88030f869851f5335e8bc7698448616536ad Mon Sep 17 00:00:00 2001 From: realVinayak Date: Sun, 9 Jun 2024 04:35:23 -0500 Subject: [PATCH 15/63] Add matching test --- .../upload/tests/testnestedtomany.py | 39 +++++++++++++++---- 1 file changed, 32 insertions(+), 7 deletions(-) diff --git a/specifyweb/workbench/upload/tests/testnestedtomany.py b/specifyweb/workbench/upload/tests/testnestedtomany.py index 064bb863e0c..3fee1896400 100644 --- a/specifyweb/workbench/upload/tests/testnestedtomany.py +++ b/specifyweb/workbench/upload/tests/testnestedtomany.py @@ -170,7 +170,7 @@ def test_basic_uploading(self) -> None: agent_2_specialty_3='' ), dict( - sfn='1', # this will be uploaded + sfn='1', # this will be matched coll_1_name='', # this should be a collecting event with just one collector agent_1_specialty_1= '', agent_1_specialty_2= '', @@ -181,13 +181,38 @@ def test_basic_uploading(self) -> None: agent_2_specialty_2='speciality6', agent_2_specialty_3='' ), - + dict( + sfn='1', # this will be matched + coll_1_name='', # this should be a collecting event with just one collector + agent_1_specialty_1= '', + agent_1_specialty_2= '', + agent_1_specialty_3= '', + agent_1_specialty_4= '', + coll_2_name='agent 2', # this will be a matched + agent_2_specialty_1='speciality5', + agent_2_specialty_2='speciality6', + agent_2_specialty_3='' + ), + dict( + sfn='1', # this will be matched + coll_1_name='agent 1', # this should be a new agent with 2 specialties + agent_1_specialty_1= 'speciality1', + agent_1_specialty_2= '', + agent_1_specialty_3= 'speciality3', + agent_1_specialty_4= '', + coll_2_name='agent 2', # this should be matched + agent_2_specialty_1='speciality5', + agent_2_specialty_2='speciality6', + agent_2_specialty_3='speciality7' + ) ] results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) - - for r in results: - self.assertIsInstance(r.record_result, Uploaded, 'All collecting events must be created') + expected_results = [(Uploaded, -1), (Uploaded, -1), (Uploaded, -1), (Uploaded, -1), (Matched, 3), (Matched, 1)] + for r, (e, check) in zip(results, expected_results): + self.assertIsInstance(r.record_result, e) + if isinstance(e, Matched): + self.assertEqual(r.record_result.get_id(), results[check].record_result.get_id()) agents_matching = [[(0, 1), (1, 1)], [(2, 1), (3, 1)]] @@ -203,9 +228,9 @@ def test_basic_uploading(self) -> None: agents_added.add(agent.get_id()) self.assertEqual(len(agents_added), 1, f"Match was not successful for pair {pair}") - self.assertIsInstance(results[-1].toMany['collectors'][0].record_result, NullRecord) + self.assertIsInstance(results[3].toMany['collectors'][0].record_result, NullRecord) self.assertEqual( - get_table('Collector').objects.filter(collectingevent_id=results[-1].record_result.get_id()).count(), + get_table('Collector').objects.filter(collectingevent_id=results[3].record_result.get_id()).count(), 1 ) From 06631557fbef08a08f6ceb99f620c243ea61d141 Mon Sep 17 00:00:00 2001 From: realVinayak Date: Mon, 8 Jul 2024 03:36:45 -0500 Subject: [PATCH 16/63] Handle multiple matches with one-to-ones more smartly --- .../upload/tests/testnestedtomany.py | 2 +- .../workbench/upload/tests/testscoping.py | 1 + .../workbench/upload/tests/testuploading.py | 6 +--- specifyweb/workbench/upload/upload_table.py | 36 +++++++++---------- 4 files changed, 19 insertions(+), 26 deletions(-) diff --git a/specifyweb/workbench/upload/tests/testnestedtomany.py b/specifyweb/workbench/upload/tests/testnestedtomany.py index 3fee1896400..dc1e3a97af2 100644 --- a/specifyweb/workbench/upload/tests/testnestedtomany.py +++ b/specifyweb/workbench/upload/tests/testnestedtomany.py @@ -4,7 +4,7 @@ from specifyweb.workbench.upload.upload import do_upload from specifyweb.workbench.upload.upload_result import Matched, NullRecord, Uploaded from ..upload_plan_schema import schema, parse_plan -from specifyweb.specify.api_tests import get_table +from specifyweb.specify.tests.test_api import get_table from django.conf import settings diff --git a/specifyweb/workbench/upload/tests/testscoping.py b/specifyweb/workbench/upload/tests/testscoping.py index 495cfaa6937..14a72b860c5 100644 --- a/specifyweb/workbench/upload/tests/testscoping.py +++ b/specifyweb/workbench/upload/tests/testscoping.py @@ -176,6 +176,7 @@ def test_collection_rel_uploaded_in_correct_collection(self): {'Collection Rel Type': self.rel_type_name, 'Cat # (2)': '888', 'Cat #': '32'} ] result = do_upload(self.collection, rows, scoped_plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) + left_side_cat_nums = [n.zfill(9) for n in '32 23'.split()] right_side_cat_nums = '999 888'.split() left_side_query = models.Collectionobject.objects.filter(collection_id=self.collection.id, catalognumber__in=left_side_cat_nums) diff --git a/specifyweb/workbench/upload/tests/testuploading.py b/specifyweb/workbench/upload/tests/testuploading.py index c9ba86c7046..9770390a123 100644 --- a/specifyweb/workbench/upload/tests/testuploading.py +++ b/specifyweb/workbench/upload/tests/testuploading.py @@ -437,11 +437,7 @@ def test_null_ce_with_ambiguous_collectingeventattribute(self) -> None: for r in results: self.assertIsInstance(r.record_result, MatchedMultiple) - @skip(""" - In theory we should be able to match the CE using the CEA but since the latter - is ambiguous we don't try currently. - """ - ) + def test_ambiguous_one_to_one_match(self) -> None: get_table('Collectingevent').objects.all().delete() diff --git a/specifyweb/workbench/upload/upload_table.py b/specifyweb/workbench/upload/upload_table.py index 3b1afb4090c..6e58b2cd58f 100644 --- a/specifyweb/workbench/upload/upload_table.py +++ b/specifyweb/workbench/upload/upload_table.py @@ -249,13 +249,21 @@ def _uploadables_reduce(accum: Tuple[PredicateWithQuery, List[ColumnElement], in # this is handled here to make the matching query simple for the root table if to_one_override: - to_one_pack = FilterPredicate.from_simple_dict( + to_one_override_pack = FilterPredicate.from_simple_dict( sql_table, ((FilterPredicate.rel_to_fk(specify_table.get_relationship(rel)), value.get_id()) for (rel, value) in to_one_override.items()), path ) else: - query, to_one_pack = reduce(to_one_reduce, self.toOne.items(), (query, FilterPredicate())) + to_one_override_pack = FilterPredicate() + + query, to_one_pack = reduce( + to_one_reduce, + # useful for one-to-ones + [(key, value) for (key, value) in self.toOne.items() if key not in to_one_override], + (query, to_one_override_pack) + ) + query, to_many_pack = reduce(to_many_reduce, self.toMany.items(), (query, FilterPredicate())) accumulated_pack = direct_field_pack.merge(to_many_pack).merge(to_one_pack) @@ -298,23 +306,9 @@ def _handle_row(self, force_upload: bool) -> UploadResult: toOneResults_ = self._process_to_ones() - multi_one_to_one = lambda field, result: self.toOne[field].is_one_to_one() and isinstance(result.record_result, MatchedMultiple) - - multipleOneToOneMatch = any( - # If a one-to-one related object matched multiple - # records, we won't be able to use it for matching - # this object, but we need to remember that there was - # data here. - multi_one_to_one(field, result) - for field, result in toOneResults_.items() - ) - toOneResults = { - # Filter out the one-to-ones that matched multiple - # b/c they aren't errors nor can be used for matching. field: result for field, result in toOneResults_.items() - if not multi_one_to_one(field, result) } if any(result.get_id() == "Failure" for result in toOneResults.values()): @@ -329,7 +323,7 @@ def _handle_row(self, force_upload: bool) -> UploadResult: base_sql_table = getattr(sql_models, datamodel.get_table_strict(self.name).name) query, filter_predicate = self.get_predicates(self.session.query(getattr(base_sql_table, base_sql_table._id)), base_sql_table, toOneResults) - if all(v is None for v in attrs.values()) and not filter_predicate.filter and not multipleOneToOneMatch: + if all(v is None for v in attrs.values()) and not filter_predicate.filter: # nothing to upload return UploadResult(NullRecord(info), toOneResults, {}) @@ -345,6 +339,10 @@ def _process_to_ones(self) -> Dict[str, UploadResult]: fieldname: to_one_def.process_row() for fieldname, to_one_def in sorted(self.toOne.items(), key=lambda kv: kv[0]) # make the upload order deterministic + # we don't care about being able to process one-to-one. Instead, we include them in the matching predicates. + # this allows handing "MatchedMultiple" case of one-to-ones more gracefully, while allowing us to include them + # in the matching. See "test_ambiguous_one_to_one_match" in testuploading.py + if not to_one_def.is_one_to_one() } def _match(self, query: Query, predicate: FilterPredicate, info: ReportInfo) -> Union[Matched, MatchedMultiple, None]: @@ -402,9 +400,6 @@ def _do_upload(self, model, toOneResults: Dict[str, UploadResult], info: ReportI # But because the records can't be shared, the unupload order shouldn't matter anyways... sorted(self.toOne.items(), key=lambda kv: kv[0]) if to_one_def.is_one_to_one() - if fieldname not in toOneResults # the field was removed b/c there were multiple matches - or isinstance(toOneResults[fieldname].record_result, Matched) # this stops the record from being shared - or isinstance(toOneResults[fieldname].record_result, MatchedMultiple) # this shouldn't ever be the case }} toOneIds: Dict[str, Optional[int]] = {} @@ -464,6 +459,7 @@ def _process_to_ones(self) -> Dict[str, UploadResult]: return { fieldname: to_one_def.match_row() for fieldname, to_one_def in self.toOne.items() + if not to_one_def.is_one_to_one() } def _do_upload(self, model, toOneResults: Dict[str, UploadResult], info: ReportInfo) -> UploadResult: From 2f32a9b5bc065952fbdb287d1e15b11c805aed97 Mon Sep 17 00:00:00 2001 From: realVinayak Date: Mon, 8 Jul 2024 11:49:07 -0500 Subject: [PATCH 17/63] Remove the need of passing collection to bind It was a bad idea originally, the first time. --- specifyweb/specify/autonumbering.py | 4 +- specifyweb/specify/parse.py | 20 +++----- specifyweb/specify/uiformatters.py | 20 +++++++- specifyweb/specify/update_locality.py | 5 +- specifyweb/workbench/upload/column_options.py | 4 +- specifyweb/workbench/upload/parsing.py | 20 ++++---- specifyweb/workbench/upload/scoping.py | 4 +- .../workbench/upload/tests/testparsing.py | 1 - .../workbench/upload/tests/testscoping.py | 49 ------------------- .../workbench/upload/tests/testuploading.py | 11 ++--- specifyweb/workbench/upload/treerecord.py | 8 +-- specifyweb/workbench/upload/upload.py | 10 ++-- specifyweb/workbench/upload/upload_table.py | 16 +++--- specifyweb/workbench/upload/uploadable.py | 2 +- 14 files changed, 70 insertions(+), 104 deletions(-) diff --git a/specifyweb/specify/autonumbering.py b/specifyweb/specify/autonumbering.py index 3173baded66..0d4b2d014f4 100644 --- a/specifyweb/specify/autonumbering.py +++ b/specifyweb/specify/autonumbering.py @@ -56,8 +56,8 @@ def get_tables_to_lock(collection, obj, field_names) -> Set[str]: obj_table = obj._meta.db_table scope_table = Scoping(obj).get_scope_model() - tables = set([obj._meta.db_table, 'django_migrations', - UniquenessRule._meta.db_table, 'discipline', scope_table._meta.db_table]) + tables = {obj._meta.db_table, 'django_migrations', UniquenessRule._meta.db_table, 'discipline', + scope_table._meta.db_table} rules = UniquenessRule.objects.filter( modelName=obj_table, discipline=collection.discipline) diff --git a/specifyweb/specify/parse.py b/specifyweb/specify/parse.py index 342239d4d74..0b3f6be70ed 100644 --- a/specifyweb/specify/parse.py +++ b/specifyweb/specify/parse.py @@ -5,11 +5,11 @@ from datetime import datetime from decimal import Decimal -from specifyweb.specify import models + from specifyweb.specify.agent_types import agent_types from specifyweb.stored_queries.format import get_date_format, MYSQL_TO_YEAR, MYSQL_TO_MONTH from specifyweb.specify.datamodel import datamodel, Table, Field, Relationship -from specifyweb.specify.uiformatters import get_uiformatter, FormatMismatch +from specifyweb.specify.uiformatters import FormatMismatch, ScopedFormatter ParseFailureKey = Literal[ 'valueTooLong', @@ -43,17 +43,15 @@ class ParseSucess(NamedTuple): ParseResult = Union[ParseSucess, ParseFailure] -def parse_field(collection, table_name: str, field_name: str, raw_value: str) -> ParseResult: +def parse_field(table_name: str, field_name: str, raw_value: str, formatter: Optional[ScopedFormatter]) -> ParseResult: table = datamodel.get_table_strict(table_name) field = table.get_field_strict(field_name) - formatter = get_uiformatter(collection, table_name, field_name) - if field.is_relationship: return parse_integer(field.name, raw_value) if formatter is not None: - return parse_formatted(collection, formatter, table, field, raw_value) + return parse_formatted(formatter, table, field, raw_value) if is_latlong(table, field): return parse_latlong(field, raw_value) @@ -170,18 +168,12 @@ def parse_date(table: Table, field_name: str, dateformat: str, value: str) -> Pa return ParseFailure('badDateFormat', {'value': value, 'format': dateformat}) -def parse_formatted(collection, uiformatter, table: Table, field: Union[Field, Relationship], value: str) -> ParseResult: +def parse_formatted(formatter: ScopedFormatter, table: Table, field: Union[Field, Relationship], value: str) -> ParseResult: try: - parsed = uiformatter.parse(value) + canonicalized = formatter(table, value) except FormatMismatch as e: return ParseFailure('formatMismatch', {'value': e.value, 'formatter': e.formatter}) - if uiformatter.needs_autonumber(parsed): - canonicalized = uiformatter.autonumber_now( - collection, getattr(models, table.django_name), parsed) - else: - canonicalized = uiformatter.canonicalize(parsed) - if hasattr(field, 'length') and len(canonicalized) > field.length: return ParseFailure('valueTooLong', {'maxLength': field.length}) diff --git a/specifyweb/specify/uiformatters.py b/specifyweb/specify/uiformatters.py index 3de7fe8fac4..e7d9995e2c8 100644 --- a/specifyweb/specify/uiformatters.py +++ b/specifyweb/specify/uiformatters.py @@ -6,7 +6,7 @@ import logging import re from datetime import date -from typing import NamedTuple, List, Optional, Sequence +from typing import NamedTuple, List, Optional, Sequence, Union, Callable from xml.etree import ElementTree from xml.sax.saxutils import quoteattr @@ -16,6 +16,8 @@ logger = logging.getLogger(__name__) from specifyweb.context.app_resource import get_app_resource +from specifyweb.specify.datamodel import Table +from specifyweb.specify import models from .models import Splocalecontaineritem as Item from .filter_by_col import filter_by_collection @@ -23,6 +25,8 @@ class AutonumberOverflowException(Exception): pass +ScopedFormatter = Callable[[Table, str], str] + class ScopeInfo(NamedTuple): db_id_field: str id: int @@ -186,6 +190,20 @@ def fill_vals_no_prior(self, vals: Sequence[str]) -> List[str]: def canonicalize(self, values: Sequence[str]) -> str: return ''.join([field.canonicalize(value) for field, value in zip(self.fields, values)]) + def apply_scope(self, collection): + def parser(table: Table, value: str) -> str: + parsed = self.parse(value) + if self.needs_autonumber(parsed): + canonicalized = self.autonumber_now( + collection, + getattr(models, table.django_name), + parsed + ) + else: + canonicalized = self.canonicalize(parsed) + return canonicalized + return parser + class Field(NamedTuple): size: int value: str diff --git a/specifyweb/specify/update_locality.py b/specifyweb/specify/update_locality.py index f28818547f3..d2110f07641 100644 --- a/specifyweb/specify/update_locality.py +++ b/specifyweb/specify/update_locality.py @@ -13,6 +13,7 @@ from specifyweb.specify.datamodel import datamodel from specifyweb.notifications.models import LocalityUpdate, LocalityUpdateRowResult, Message from specifyweb.specify.parse import ParseFailureKey, parse_field as _parse_field, ParseFailure as BaseParseFailure, ParseSucess as BaseParseSuccess +from specifyweb.specify.uiformatters import get_uiformatter LocalityParseErrorMessageKey = Literal[ 'guidHeaderNotProvided', @@ -392,7 +393,9 @@ def parse_locality_set(collection, raw_headers: List[str], data: List[List[str]] def parse_field(collection, table_name: UpdateModel, field_name: str, field_value: str, locality_id: Optional[int], row_number: int): - parsed = _parse_field(collection, table_name, field_name, field_value) + ui_formatter = get_uiformatter(collection, table_name, field_name) + scoped_formatter = None if ui_formatter is None else ui_formatter.apply_scope(collection) + parsed = _parse_field(table_name, field_name, field_value, scoped_formatter) if isinstance(parsed, BaseParseFailure): return ParseError.from_parse_failure(parsed, field_name, row_number) diff --git a/specifyweb/workbench/upload/column_options.py b/specifyweb/workbench/upload/column_options.py index 84a8b29d7a7..38d138ea3c3 100644 --- a/specifyweb/workbench/upload/column_options.py +++ b/specifyweb/workbench/upload/column_options.py @@ -1,6 +1,8 @@ from typing import List, Dict, Any, NamedTuple, Union, Optional, Set from typing_extensions import Literal +from specifyweb.specify.uiformatters import ScopedFormatter + MatchBehavior = Literal["ignoreWhenBlank", "ignoreAlways", "ignoreNever"] class ColumnOptions(NamedTuple): @@ -20,7 +22,7 @@ class ExtendedColumnOptions(NamedTuple): matchBehavior: MatchBehavior nullAllowed: bool default: Optional[str] - uiformatter: Any + uiformatter: Optional[ScopedFormatter] schemaitem: Any picklist: Any dateformat: Optional[str] diff --git a/specifyweb/workbench/upload/parsing.py b/specifyweb/workbench/upload/parsing.py index 80ba608123c..c87cc701c2d 100644 --- a/specifyweb/workbench/upload/parsing.py +++ b/specifyweb/workbench/upload/parsing.py @@ -52,9 +52,9 @@ def filter_and_upload(f: Filter, column: str) -> ParseResult: return ParseResult(f, f, None, column, None) -def parse_many(collection, tablename: str, mapping: Dict[str, ExtendedColumnOptions], row: Row) -> Tuple[List[ParseResult], List[WorkBenchParseFailure]]: +def parse_many(tablename: str, mapping: Dict[str, ExtendedColumnOptions], row: Row) -> Tuple[List[ParseResult], List[WorkBenchParseFailure]]: results = [ - parse_value(collection, tablename, fieldname, + parse_value(tablename, fieldname, row[colopts.column], colopts) for fieldname, colopts in mapping.items() ] @@ -64,7 +64,7 @@ def parse_many(collection, tablename: str, mapping: Dict[str, ExtendedColumnOpti ) -def parse_value(collection, tablename: str, fieldname: str, value_in: str, colopts: ExtendedColumnOptions) -> Union[ParseResult, WorkBenchParseFailure]: +def parse_value(tablename: str, fieldname: str, value_in: str, colopts: ExtendedColumnOptions) -> Union[ParseResult, WorkBenchParseFailure]: required_by_schema = colopts.schemaitem and colopts.schemaitem.isrequired result: Union[ParseResult, WorkBenchParseFailure] @@ -79,10 +79,10 @@ def parse_value(collection, tablename: str, fieldname: str, value_in: str, colop result = ParseResult({fieldname: None}, {}, None, colopts.column, missing_required) else: - result = _parse(collection, tablename, fieldname, + result = _parse(tablename, fieldname, colopts, colopts.default) else: - result = _parse(collection, tablename, fieldname, + result = _parse(tablename, fieldname, colopts, value_in.strip()) if isinstance(result, WorkBenchParseFailure): @@ -101,13 +101,12 @@ def parse_value(collection, tablename: str, fieldname: str, value_in: str, colop assertNever(colopts.matchBehavior) -def _parse(collection, tablename: str, fieldname: str, colopts: ExtendedColumnOptions, value: str) -> Union[ParseResult, WorkBenchParseFailure]: +def _parse(tablename: str, fieldname: str, colopts: ExtendedColumnOptions, value: str) -> Union[ParseResult, WorkBenchParseFailure]: table = datamodel.get_table_strict(tablename) field = table.get_field_strict(fieldname) if colopts.picklist: - result = parse_with_picklist( - collection, colopts.picklist, fieldname, value, colopts.column) + result = parse_with_picklist(colopts.picklist, fieldname, value, colopts.column) if result is not None: if isinstance(result, ParseResult) and hasattr(field, 'length') and field.length is not None and len(result.upload[fieldname]) > field.length: return WorkBenchParseFailure( @@ -120,7 +119,8 @@ def _parse(collection, tablename: str, fieldname: str, colopts: ExtendedColumnOp ) return result - parsed = parse_field(collection, tablename, fieldname, value) + formatter = colopts.uiformatter + parsed = parse_field(tablename, fieldname, value, formatter) if is_latlong(table, field) and isinstance(parsed, ParseSucess): coord_text_field = field.name.replace('itude', '') + 'text' @@ -133,7 +133,7 @@ def _parse(collection, tablename: str, fieldname: str, colopts: ExtendedColumnOp return ParseResult.from_parse_success(parsed, parsed.to_upload, None, colopts.column, None) -def parse_with_picklist(collection, picklist, fieldname: str, value: str, column: str) -> Union[ParseResult, WorkBenchParseFailure, None]: +def parse_with_picklist(picklist, fieldname: str, value: str, column: str) -> Union[ParseResult, WorkBenchParseFailure, None]: if picklist.type == 0: # items from picklistitems table try: item = picklist.picklistitems.get(title=value) diff --git a/specifyweb/workbench/upload/scoping.py b/specifyweb/workbench/upload/scoping.py index f2ec3ebb2fc..b59253ea298 100644 --- a/specifyweb/workbench/upload/scoping.py +++ b/specifyweb/workbench/upload/scoping.py @@ -102,13 +102,15 @@ def extend_columnoptions(colopts: ColumnOptions, collection, tablename: str, fie picklist = picklists[0] if len(collection_picklists) == 0 else collection_picklists[0] + ui_formatter = get_uiformatter(collection, tablename, fieldname) + scoped_formatter = None if ui_formatter is None else ui_formatter.apply_scope(collection) return ExtendedColumnOptions( column=colopts.column, matchBehavior=colopts.matchBehavior, nullAllowed=colopts.nullAllowed, default=colopts.default, schemaitem=schemaitem, - uiformatter=get_uiformatter(collection, tablename, fieldname), + uiformatter=scoped_formatter, picklist=picklist, dateformat=get_date_format(), ) diff --git a/specifyweb/workbench/upload/tests/testparsing.py b/specifyweb/workbench/upload/tests/testparsing.py index ce4b427f9b0..6f1d0d884ff 100644 --- a/specifyweb/workbench/upload/tests/testparsing.py +++ b/specifyweb/workbench/upload/tests/testparsing.py @@ -14,7 +14,6 @@ from specifyweb.specify.parse import parse_coord, parse_date, ParseFailure, ParseSucess from .base import UploadTestsBase, get_table from ..column_options import ColumnOptions -from ..parsing import ParseResult as PR from ..treerecord import TreeRecord from ..upload import do_upload, do_upload_csv from ..upload_plan_schema import parse_column_options diff --git a/specifyweb/workbench/upload/tests/testscoping.py b/specifyweb/workbench/upload/tests/testscoping.py index 14a72b860c5..50a44fde5de 100644 --- a/specifyweb/workbench/upload/tests/testscoping.py +++ b/specifyweb/workbench/upload/tests/testscoping.py @@ -120,55 +120,6 @@ def test_caching_true(self): plan = self.example_plan.apply_scoping(self.collection) self.assertTrue(plan[0], 'caching is possible here, since no dynamic scope is being used') - # TODO: Refactor this one too - def deferred_scope_table_ignored_when_scoping_applied(self): - scoped_upload_plan = parse_plan(self.collection_rel_plan).apply_scoping(self.collection) - - expected_scoping = ScopedUploadTable( - name='Collectionrelationship', - wbcols={}, - static={}, - toOne={ - 'leftside': UploadTable( - name='Collectionobject', - wbcols={'catalognumber': ColumnOptions(column='Cat #', matchBehavior='ignoreNever', nullAllowed=True, default=None)}, - static={}, - toOne={}, - toMany={}, - overrideScope=None), - 'rightside': DeferredScopeUploadTable( - name='Collectionobject', - wbcols={'catalognumber': ColumnOptions(column='Cat # (2)', matchBehavior='ignoreNever', nullAllowed=True, default=None)}, - static={}, - toOne={}, - toMany={}, - related_key='collectionreltype', - relationship_name='rightsidecollection', - filter_field='name', - overrideScope=None), - 'collectionreltype': ScopedUploadTable( - name='Collectionreltype', - wbcols={'name': ExtendedColumnOptions( - column='Collection Rel Type', - matchBehavior='ignoreNever', - nullAllowed=True, - default=None, - uiformatter=None, - schemaitem= models.Splocalecontaineritem.objects.get(name='name', container=models.Splocalecontainer.objects.get(name='collectionreltype', discipline_id=self.discipline.id)), - picklist=None, - dateformat='%m/%d/%Y')}, - static={}, - toOne={}, - toMany={}, - scopingAttrs={}, - disambiguation=None)}, - - toMany={}, - scopingAttrs={}, - disambiguation=None) - - self.assertEqual(scoped_upload_plan, expected_scoping) - def test_collection_rel_uploaded_in_correct_collection(self): scoped_plan = parse_plan(self.collection_rel_plan) rows = [ diff --git a/specifyweb/workbench/upload/tests/testuploading.py b/specifyweb/workbench/upload/tests/testuploading.py index 9770390a123..1fbfb188cf7 100644 --- a/specifyweb/workbench/upload/tests/testuploading.py +++ b/specifyweb/workbench/upload/tests/testuploading.py @@ -2,7 +2,6 @@ import io from datetime import datetime from decimal import Decimal -from unittest import skip from uuid import uuid4 from jsonschema import validate # type: ignore @@ -916,7 +915,7 @@ def test_tree_1(self) -> None: } ).apply_scoping(self.collection)[1] row = next(reader) - bt = tree_record.bind(self.collection, row, None, Auditor(self.collection, auditlog), sql_alchemy_session=None) + bt = tree_record.bind(row, None, Auditor(self.collection, auditlog), sql_alchemy_session=None) assert isinstance(bt, BoundTreeRecord) to_upload, matched = bt._match(bt._to_match()) @@ -965,7 +964,7 @@ def test_tree_1(self) -> None: # parent=state, # ) - bt = tree_record.bind(self.collection, row, None, Auditor(self.collection, auditlog), sql_alchemy_session=None) + bt = tree_record.bind(row, None, Auditor(self.collection, auditlog), sql_alchemy_session=None) assert isinstance(bt, BoundTreeRecord) to_upload, matched = bt._match(bt._to_match()) self.assertEqual( @@ -976,7 +975,7 @@ def test_tree_1(self) -> None: self.assertEqual(state.id, matched.id) self.assertEqual(set(['State/Prov/Pref', 'Country', 'Continent/Ocean']), set(matched.info.columns)) - bt = tree_record.bind(self.collection, row, None, Auditor(self.collection, auditlog), sql_alchemy_session=None) + bt = tree_record.bind(row, None, Auditor(self.collection, auditlog), sql_alchemy_session=None) assert isinstance(bt, BoundTreeRecord) upload_result = bt.process_row() self.assertIsInstance(upload_result.record_result, Uploaded) @@ -986,7 +985,7 @@ def test_tree_1(self) -> None: self.assertEqual(uploaded.definitionitem.name, "County") self.assertEqual(uploaded.parent.id, state.id) - bt = tree_record.bind(self.collection, row, None, Auditor(self.collection, auditlog), sql_alchemy_session=None) + bt = tree_record.bind(row, None, Auditor(self.collection, auditlog), sql_alchemy_session=None) assert isinstance(bt, BoundTreeRecord) to_upload, matched = bt._match(bt._to_match()) self.assertEqual([], to_upload) @@ -994,7 +993,7 @@ def test_tree_1(self) -> None: self.assertEqual(uploaded.id, matched.id) self.assertEqual(set(['Region', 'State/Prov/Pref', 'Country', 'Continent/Ocean']), set(matched.info.columns)) - bt = tree_record.bind(self.collection, row, None, Auditor(self.collection, auditlog), sql_alchemy_session=None) + bt = tree_record.bind(row, None, Auditor(self.collection, auditlog), sql_alchemy_session=None) assert isinstance(bt, BoundTreeRecord) upload_result = bt.process_row() expected_info = ReportInfo(tableName='Geography', columns=['Continent/Ocean', 'Country', 'State/Prov/Pref', 'Region',], treeInfo=TreeInfo('County', 'Hendry Co.')) diff --git a/specifyweb/workbench/upload/treerecord.py b/specifyweb/workbench/upload/treerecord.py index 152017bd123..c5466893afe 100644 --- a/specifyweb/workbench/upload/treerecord.py +++ b/specifyweb/workbench/upload/treerecord.py @@ -61,12 +61,12 @@ def disambiguate(self, disambiguation: DA) -> "ScopedTreeRecord": def get_treedefs(self) -> Set: return {self.treedef} - def bind(self, collection, row: Row, uploadingAgentId: Optional[int], auditor: Auditor, sql_alchemy_session, cache: Optional[Dict]=None) -> Union["BoundTreeRecord", ParseFailures]: + def bind(self, row: Row, uploadingAgentId: Optional[int], auditor: Auditor, sql_alchemy_session, cache: Optional[Dict]=None) -> Union["BoundTreeRecord", ParseFailures]: parsedFields: Dict[str, List[ParseResult]] = {} parseFails: List[WorkBenchParseFailure] = [] for rank, cols in self.ranks.items(): nameColumn = cols['name'] - presults, pfails = parse_many(collection, self.name, cols, row) + presults, pfails = parse_many(self.name, cols, row) parsedFields[rank] = presults parseFails += pfails filters = {k: v for result in presults for k, v in result.filter_on.items()} @@ -98,8 +98,8 @@ def apply_scoping(self, collection, row=None) -> Tuple[bool, "ScopedMustMatchTre return _can_cache, ScopedMustMatchTreeRecord(*s) class ScopedMustMatchTreeRecord(ScopedTreeRecord): - def bind(self, collection, row: Row, uploadingAgentId: Optional[int], auditor: Auditor, sql_alchemy_session, cache: Optional[Dict]=None) -> Union["BoundMustMatchTreeRecord", ParseFailures]: - b = super().bind(collection, row, uploadingAgentId, auditor, sql_alchemy_session, cache) + def bind(self, row: Row, uploadingAgentId: Optional[int], auditor: Auditor, sql_alchemy_session, cache: Optional[Dict]=None) -> Union["BoundMustMatchTreeRecord", ParseFailures]: + b = super().bind(row, uploadingAgentId, auditor, sql_alchemy_session, cache) return b if isinstance(b, ParseFailures) else BoundMustMatchTreeRecord(*b) class TreeDefItemWithParseResults(NamedTuple): diff --git a/specifyweb/workbench/upload/upload.py b/specifyweb/workbench/upload/upload.py index 34d4aab9aec..4632ffd0219 100644 --- a/specifyweb/workbench/upload/upload.py +++ b/specifyweb/workbench/upload/upload.py @@ -123,7 +123,7 @@ def do_upload_dataset( ncols = len(ds.columns) rows = [dict(zip(ds.columns, row)) for row in ds.data] disambiguation = [get_disambiguation_from_row(ncols, row) for row in ds.data] - base_table, upload_plan = get_raw_ds_upload_plan(collection, ds) + base_table, upload_plan = get_raw_ds_upload_plan(ds) results = do_upload(collection, rows, upload_plan, uploading_agent_id, disambiguation, no_commit, allow_partial, progress, session_url=session_url) success = not any(r.contains_failure() for r in results) @@ -176,7 +176,7 @@ def get_disambiguation_from_row(ncols: int, row: List) -> Disambiguation: extra = json.loads(row[ncols]) if row[ncols] else None return disambiguation.from_json(extra['disambiguation']) if extra and 'disambiguation' in extra else None -def get_raw_ds_upload_plan(collection, ds: Spdataset) -> Tuple[Table, Uploadable]: +def get_raw_ds_upload_plan(ds: Spdataset) -> Tuple[Table, Uploadable]: if ds.uploadplan is None: raise Exception("no upload plan defined for dataset") @@ -190,7 +190,7 @@ def get_raw_ds_upload_plan(collection, ds: Spdataset) -> Tuple[Table, Uploadable return base_table, plan def get_ds_upload_plan(collection, ds: Spdataset) -> Tuple[Table, ScopedUploadable]: - base_table, plan = get_raw_ds_upload_plan(collection, ds) + base_table, plan = get_raw_ds_upload_plan(ds) return base_table, plan.apply_scoping(collection)[1] @@ -230,7 +230,7 @@ def do_upload( scoped_table = cached_scope_table with wb_session_context() as session: - bind_result = scoped_table.disambiguate(da).bind(collection, row, uploading_agent_id, _auditor, session, cache) + bind_result = scoped_table.disambiguate(da).bind(row, uploading_agent_id, _auditor, session, cache) result = UploadResult(bind_result, {}, {}) if isinstance(bind_result, ParseFailures) else bind_result.process_row() results.append(result) @@ -260,7 +260,7 @@ def validate_row(collection, upload_plan: ScopedUploadable, uploading_agent_id: try: with savepoint("row validation"): with session_context() as session: - bind_result = upload_plan.disambiguate(da).bind(collection, row, uploading_agent_id, Auditor(collection, None), session) + bind_result = upload_plan.disambiguate(da).bind(row, uploading_agent_id, Auditor(collection, None), session) result = UploadResult(bind_result, {}, {}) if isinstance(bind_result, ParseFailures) else bind_result.process_row() raise Rollback("validating only") break diff --git a/specifyweb/workbench/upload/upload_table.py b/specifyweb/workbench/upload/upload_table.py index 6e58b2cd58f..1f16ff9b0d8 100644 --- a/specifyweb/workbench/upload/upload_table.py +++ b/specifyweb/workbench/upload/upload_table.py @@ -100,13 +100,13 @@ def get_treedefs(self) -> Set: ) - def bind(self, collection, row: Row, uploadingAgentId: int, auditor: Auditor, sql_alchemy_session, cache: Optional[Dict]=None + def bind(self, row: Row, uploadingAgentId: int, auditor: Auditor, sql_alchemy_session, cache: Optional[Dict]=None ) -> Union["BoundUploadTable", ParseFailures]: - parsedFields, parseFails = parse_many(collection, self.name, self.wbcols, row) + parsedFields, parseFails = parse_many(self.name, self.wbcols, row) toOne: Dict[str, BoundUploadable] = {} for fieldname, uploadable in self.toOne.items(): - result = uploadable.bind(collection, row, uploadingAgentId, auditor, sql_alchemy_session, cache) + result = uploadable.bind(row, uploadingAgentId, auditor, sql_alchemy_session, cache) if isinstance(result, ParseFailures): parseFails += result.failures else: @@ -116,7 +116,7 @@ def bind(self, collection, row: Row, uploadingAgentId: int, auditor: Auditor, sq for fieldname, records in self.toMany.items(): boundRecords: List[BoundUploadable] = [] for record in records: - result_ = record.bind(collection, row, uploadingAgentId, auditor, sql_alchemy_session, cache) + result_ = record.bind(row, uploadingAgentId, auditor, sql_alchemy_session, cache) if isinstance(result_, ParseFailures): parseFails += result_.failures else: @@ -149,9 +149,9 @@ def to_json(self) -> Dict: return { 'oneToOneTable': self._to_json() } class ScopedOneToOneTable(ScopedUploadTable): - def bind(self, collection, row: Row, uploadingAgentId: int, auditor: Auditor, sql_alchemy_session, cache: Optional[Dict]=None + def bind(self, row: Row, uploadingAgentId: int, auditor: Auditor, sql_alchemy_session, cache: Optional[Dict]=None ) -> Union["BoundOneToOneTable", ParseFailures]: - b = super().bind(collection, row, uploadingAgentId, auditor, sql_alchemy_session, cache) + b = super().bind(row, uploadingAgentId, auditor, sql_alchemy_session, cache) return BoundOneToOneTable(*b) if isinstance(b, BoundUploadTable) else b class MustMatchTable(UploadTable): @@ -163,9 +163,9 @@ def to_json(self) -> Dict: return { 'mustMatchTable': self._to_json() } class ScopedMustMatchTable(ScopedUploadTable): - def bind(self, collection, row: Row, uploadingAgentId: int, auditor: Auditor, sql_alchemy_session, cache: Optional[Dict]=None + def bind(self,row: Row, uploadingAgentId: int, auditor: Auditor, sql_alchemy_session, cache: Optional[Dict]=None ) -> Union["BoundMustMatchTable", ParseFailures]: - b = super().bind(collection, row, uploadingAgentId, auditor, sql_alchemy_session, cache) + b = super().bind(row, uploadingAgentId, auditor, sql_alchemy_session, cache) return BoundMustMatchTable(*b) if isinstance(b, BoundUploadTable) else b diff --git a/specifyweb/workbench/upload/uploadable.py b/specifyweb/workbench/upload/uploadable.py index 9467e31dd0d..8eac777ef8f 100644 --- a/specifyweb/workbench/upload/uploadable.py +++ b/specifyweb/workbench/upload/uploadable.py @@ -54,7 +54,7 @@ class ScopedUploadable(Protocol): def disambiguate(self, disambiguation: Disambiguation) -> "ScopedUploadable": ... - def bind(self, collection, row: Row, uploadingAgentId: int, auditor: Auditor, sql_alchemy_session, cache: Optional[Dict]=None) -> Union["BoundUploadable", ParseFailures]: + def bind(self, row: Row, uploadingAgentId: int, auditor: Auditor, sql_alchemy_session, cache: Optional[Dict]=None) -> Union["BoundUploadable", ParseFailures]: ... def get_treedefs(self) -> Set: From 41c65e37c6b393742400207a0e25aa7447d9d542 Mon Sep 17 00:00:00 2001 From: realVinayak Date: Mon, 8 Jul 2024 12:25:17 -0500 Subject: [PATCH 18/63] Test improvements --- specifyweb/specify/parse.py | 4 ++-- specifyweb/specify/uiformatters.py | 12 ++++++++++++ specifyweb/workbench/upload/column_options.py | 4 ++-- specifyweb/workbench/upload/scoping.py | 6 +++--- 4 files changed, 19 insertions(+), 7 deletions(-) diff --git a/specifyweb/specify/parse.py b/specifyweb/specify/parse.py index 0b3f6be70ed..72e89dd2a20 100644 --- a/specifyweb/specify/parse.py +++ b/specifyweb/specify/parse.py @@ -9,7 +9,7 @@ from specifyweb.specify.agent_types import agent_types from specifyweb.stored_queries.format import get_date_format, MYSQL_TO_YEAR, MYSQL_TO_MONTH from specifyweb.specify.datamodel import datamodel, Table, Field, Relationship -from specifyweb.specify.uiformatters import FormatMismatch, ScopedFormatter +from specifyweb.specify.uiformatters import FormatMismatch, ScopedFormatter, CustomRepr ParseFailureKey = Literal[ 'valueTooLong', @@ -43,7 +43,7 @@ class ParseSucess(NamedTuple): ParseResult = Union[ParseSucess, ParseFailure] -def parse_field(table_name: str, field_name: str, raw_value: str, formatter: Optional[ScopedFormatter]) -> ParseResult: +def parse_field(table_name: str, field_name: str, raw_value: str, formatter: Optional[CustomRepr]) -> ParseResult: table = datamodel.get_table_strict(table_name) field = table.get_field_strict(field_name) diff --git a/specifyweb/specify/uiformatters.py b/specifyweb/specify/uiformatters.py index e7d9995e2c8..220623f010b 100644 --- a/specifyweb/specify/uiformatters.py +++ b/specifyweb/specify/uiformatters.py @@ -78,6 +78,18 @@ def __init__(self, *args: object, value: str, formatter: str) -> None: self.formatter = formatter pass +class CustomRepr: + def __init__(self, func, new_repr): + self.new_repr = new_repr + self.func = func + + def __call__(self, *args, **kwargs): + return None if self.func is None else self.func(*args, **kwargs) + + def __repr__(self): + return self.new_repr + + class UIFormatter(NamedTuple): model_name: str field_name: str diff --git a/specifyweb/workbench/upload/column_options.py b/specifyweb/workbench/upload/column_options.py index 38d138ea3c3..1b37d590aa6 100644 --- a/specifyweb/workbench/upload/column_options.py +++ b/specifyweb/workbench/upload/column_options.py @@ -1,7 +1,7 @@ from typing import List, Dict, Any, NamedTuple, Union, Optional, Set from typing_extensions import Literal -from specifyweb.specify.uiformatters import ScopedFormatter +from specifyweb.specify.uiformatters import CustomRepr MatchBehavior = Literal["ignoreWhenBlank", "ignoreAlways", "ignoreNever"] @@ -22,7 +22,7 @@ class ExtendedColumnOptions(NamedTuple): matchBehavior: MatchBehavior nullAllowed: bool default: Optional[str] - uiformatter: Optional[ScopedFormatter] + uiformatter: Optional[CustomRepr] schemaitem: Any picklist: Any dateformat: Optional[str] diff --git a/specifyweb/workbench/upload/scoping.py b/specifyweb/workbench/upload/scoping.py index b59253ea298..50dea46b55d 100644 --- a/specifyweb/workbench/upload/scoping.py +++ b/specifyweb/workbench/upload/scoping.py @@ -8,8 +8,7 @@ from .uploadable import ScopedUploadable from .upload_table import UploadTable, ScopedUploadTable, ScopedOneToOneTable from .treerecord import TreeRecord, ScopedTreeRecord -from .column_options import ColumnOptions, ExtendedColumnOptions - +from .column_options import ColumnOptions, ExtendedColumnOptions, CustomRepr """ There are cases in which the scoping of records should be dependent on another record/column in a WorkBench dataset. @@ -104,13 +103,14 @@ def extend_columnoptions(colopts: ColumnOptions, collection, tablename: str, fie ui_formatter = get_uiformatter(collection, tablename, fieldname) scoped_formatter = None if ui_formatter is None else ui_formatter.apply_scope(collection) + friendly_repr = f'{tablename}-{fieldname}-{collection}' return ExtendedColumnOptions( column=colopts.column, matchBehavior=colopts.matchBehavior, nullAllowed=colopts.nullAllowed, default=colopts.default, schemaitem=schemaitem, - uiformatter=scoped_formatter, + uiformatter=None if scoped_formatter is None else CustomRepr(scoped_formatter, friendly_repr), picklist=picklist, dateformat=get_date_format(), ) From 091a53b13e7a4aaba46dd022b075b8a78c85e112 Mon Sep 17 00:00:00 2001 From: realVinayak Date: Mon, 8 Jul 2024 17:29:32 +0000 Subject: [PATCH 19/63] Lint code with ESLint and Prettier Triggered by 41c65e37c6b393742400207a0e25aa7447d9d542 on branch refs/heads/wb_improvements --- .../frontend/js_src/lib/components/TreeView/AddRank.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/TreeView/AddRank.tsx b/specifyweb/frontend/js_src/lib/components/TreeView/AddRank.tsx index 4c48014c5b5..6dc0ec13d20 100644 --- a/specifyweb/frontend/js_src/lib/components/TreeView/AddRank.tsx +++ b/specifyweb/frontend/js_src/lib/components/TreeView/AddRank.tsx @@ -1,11 +1,13 @@ import React from 'react'; +import { useId } from '../../hooks/useId'; import { commonText } from '../../localization/common'; import { interactionsText } from '../../localization/interactions'; import { treeText } from '../../localization/tree'; import type { RA } from '../../utils/types'; import { Button } from '../Atoms/Button'; import { Form, Label, Select } from '../Atoms/Form'; +import { Submit } from '../Atoms/Submit'; import type { FilterTablesByEndsWith, SerializedResource, @@ -13,8 +15,6 @@ import type { import { tables } from '../DataModel/tables'; import { ResourceView } from '../Forms/ResourceView'; import { Dialog } from '../Molecules/Dialog'; -import { Submit } from '../Atoms/Submit'; -import { useId } from '../../hooks/useId'; export function AddRank({ treeDefinitionItems, @@ -53,11 +53,11 @@ export function AddRank({ <> {commonText.cancel()} { treeResource.set('parent', parentRank); setState('add'); }} - form={id('form')} > {interactionsText.continue()} From 9cd84a0f0ce16c137fce2d88f1a4b9469c7ece9c Mon Sep 17 00:00:00 2001 From: realVinayak Date: Mon, 8 Jul 2024 12:44:17 -0500 Subject: [PATCH 20/63] Pass name explictly --- specifyweb/stored_queries/queryfieldspec.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/specifyweb/stored_queries/queryfieldspec.py b/specifyweb/stored_queries/queryfieldspec.py index d61629e2b6f..0f8321fecfd 100644 --- a/specifyweb/stored_queries/queryfieldspec.py +++ b/specifyweb/stored_queries/queryfieldspec.py @@ -142,10 +142,9 @@ def from_stringid(cls, stringid, is_relation): else: tree_rank_name = extracted_fieldname if extracted_fieldname else None if tree_rank_name is not None: - tree_rank = TreeRankQuery() + tree_rank = TreeRankQuery(name=tree_rank_name) # doesn't make sense to query across ranks of trees. no, it doesn't block a theoretical query like family -> continent tree_rank.relatedModelName = node.name - tree_rank.name = tree_rank_name tree_rank.type = 'many-to-one' join_path.append(tree_rank) field = node.get_field(field or 'name') # to replicate 6 for now. From 6a6a234bb28a121621cf490768828acc93981867 Mon Sep 17 00:00:00 2001 From: realVinayak Date: Thu, 1 Aug 2024 22:32:08 -0500 Subject: [PATCH 21/63] (feature): Allow nested-to-manys on front-end --- .../js_src/lib/components/WbPlanView/navigator.ts | 5 ++--- .../lib/components/WbPlanView/navigatorSpecs.ts | 2 +- .../lib/components/WbPlanView/uploadPlanBuilder.ts | 5 +---- .../lib/components/WbPlanView/uploadPlanParser.ts | 12 ++++++------ 4 files changed, 10 insertions(+), 14 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts b/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts index fdcc81cb4e9..67b8edd6f1b 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts @@ -483,13 +483,12 @@ export function getMappingLineData({ if (field.isRelationship) { isIncluded &&= - spec.allowNestedToMany || parentRelationship === undefined || (!isCircularRelationship(parentRelationship, field) && - !( + (spec.allowNestedToMany || !( relationshipIsToMany(field) && relationshipIsToMany(parentRelationship) - )); + ))); isIncluded &&= !canDoAction || diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/navigatorSpecs.ts b/specifyweb/frontend/js_src/lib/components/WbPlanView/navigatorSpecs.ts index 643a974ab0d..1f6043202ab 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/navigatorSpecs.ts +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/navigatorSpecs.ts @@ -51,7 +51,7 @@ const wbPlanView: NavigatorSpec = { * Hide nested -to-many relationships as they are not * supported by the WorkBench */ - allowNestedToMany: false, + allowNestedToMany: true, ensurePermission: () => userPreferences.get('workBench', 'wbPlanView', 'showNoAccessTables') ? 'create' diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/uploadPlanBuilder.ts b/specifyweb/frontend/js_src/lib/components/WbPlanView/uploadPlanBuilder.ts index 1d820221071..a67e518e386 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/uploadPlanBuilder.ts +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/uploadPlanBuilder.ts @@ -92,14 +92,11 @@ function toUploadTable( [ fieldName.toLowerCase(), indexMappings(lines).map(([_index, lines]) => - removeKey( toUploadTable( table.strictGetRelationship(fieldName).relatedTable, lines, mustMatchPreferences - ), - 'toMany' - ) + ) ), ] as const ) diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/uploadPlanParser.ts b/specifyweb/frontend/js_src/lib/components/WbPlanView/uploadPlanParser.ts index 3a9463ca621..c6e3c212827 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/uploadPlanParser.ts +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/uploadPlanParser.ts @@ -1,4 +1,4 @@ -import type { IR, RA, RR } from '../../utils/types'; +import type { IR, PartialBy, RA, RR } from '../../utils/types'; import type { SpecifyTable } from '../DataModel/specifyTable'; import { strictGetTable } from '../DataModel/tables'; import type { Tables } from '../DataModel/types'; @@ -20,7 +20,9 @@ export type ColumnDefinition = | string | (ColumnOptions & { readonly column: string }); -export type NestedUploadTable = Omit; +// NOTE: This comment was added after workbench supports nested-to-manys. +// I'm making it partial to not chock on legacy upload plans +export type NestedUploadTable = PartialBy; export type UploadTable = { readonly wbcols: IR; @@ -127,8 +129,7 @@ const parseUploadTable = ( [...mappingPath, table.strictGetRelationship(relationshipName).name] ) ), - ...('toMany' in uploadPlan - ? Object.entries(uploadPlan.toMany).flatMap( + ...Object.entries(uploadPlan.toMany ?? []).flatMap( ([relationshipName, mappings]) => Object.values(mappings).flatMap((mapping, index) => parseUploadTable( @@ -142,8 +143,7 @@ const parseUploadTable = ( ] ) ) - ) - : []), + ), ]; function parseUploadTableTypes( From 7b5b5832c1f934919fa076b150e140efbcaa5a96 Mon Sep 17 00:00:00 2001 From: realVinayak Date: Thu, 1 Aug 2024 22:52:10 -0500 Subject: [PATCH 22/63] (bug): Resolve to-many being skipped --- specifyweb/workbench/upload/upload_table.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specifyweb/workbench/upload/upload_table.py b/specifyweb/workbench/upload/upload_table.py index 1f16ff9b0d8..41d9f403bed 100644 --- a/specifyweb/workbench/upload/upload_table.py +++ b/specifyweb/workbench/upload/upload_table.py @@ -477,7 +477,7 @@ def _upload_to_manys(parent_model, parent_id, parent_field, uploadingAgentId: Op parsedFields=record.parsedFields, toOne=record.toOne, static={**record.static, fk_field: parent_id}, - toMany={}, + toMany=record.toMany, uploadingAgentId=uploadingAgentId, auditor=auditor, cache=cache, From 22a75f5b79b148bd38a395877b9dcc9a230717c4 Mon Sep 17 00:00:00 2001 From: realVinayak Date: Tue, 6 Aug 2024 07:49:36 -0500 Subject: [PATCH 23/63] (bug): Resolve fake multiple matches due to dup --- specifyweb/workbench/upload/upload_table.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specifyweb/workbench/upload/upload_table.py b/specifyweb/workbench/upload/upload_table.py index 41d9f403bed..5e43f7fd6b2 100644 --- a/specifyweb/workbench/upload/upload_table.py +++ b/specifyweb/workbench/upload/upload_table.py @@ -355,7 +355,7 @@ def _match(self, query: Query, predicate: FilterPredicate, info: ReportInfo) -> else: query = predicate.apply_to_query(query) try: - query = query.limit(10) + query = query.distinct().limit(10) raw_ids: List[Tuple[int, Any]] = list(query) ids = [_id[0] for _id in raw_ids] except OperationalError as e: From b59a894a4547513332c44355c59d2097c897e6c3 Mon Sep 17 00:00:00 2001 From: realVinayak Date: Tue, 6 Aug 2024 10:17:51 -0500 Subject: [PATCH 24/63] (feat): Remove redundant to-many for backwards compability --- .env | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.env b/.env index 9805c57c8fc..ac6f25ca36b 100644 --- a/.env +++ b/.env @@ -1,7 +1,7 @@ DATABASE_HOST=mariadb DATABASE_PORT=3306 -MYSQL_ROOT_PASSWORD=password -DATABASE_NAME=specify +MYSQL_ROOT_PASSWORD=root +DATABASE_NAME=nbm_mnb_8_5 # When running Specify 7 for the first time or during updates that # require migrations, ensure that the MASTER_NAME and MASTER_PASSWORD @@ -10,7 +10,7 @@ DATABASE_NAME=specify # After launching Specify and verifying the update is complete, you can # safely replace these credentials with the master SQL user name and password. MASTER_NAME=root -MASTER_PASSWORD=password +MASTER_PASSWORD=root # Make sure to set the `SECRET_KEY` to a unique value SECRET_KEY=change_this_to_some_unique_random_string From d8b1d499856e44646485b80daa0cbea991494f92 Mon Sep 17 00:00:00 2001 From: realVinayak Date: Sat, 1 Jun 2024 11:24:48 -0500 Subject: [PATCH 25/63] (feat): Minor batch-edit work --- specifyweb/export/dwca.py | 4 +- specifyweb/specify/views.py | 4 +- specifyweb/stored_queries/batch_edit.py | 409 +++++++++++++++++++ specifyweb/stored_queries/execution.py | 41 +- specifyweb/stored_queries/field_spec_maps.py | 6 +- specifyweb/stored_queries/group_concat.py | 10 - specifyweb/stored_queries/models.py | 3 +- specifyweb/stored_queries/queryfield.py | 35 +- specifyweb/stored_queries/queryfieldspec.py | 15 +- specifyweb/workbench/upload/treerecord.py | 2 +- specifyweb/workbench/upload/upload_table.py | 2 +- specifyweb/workbench/views.py | 11 +- 12 files changed, 477 insertions(+), 65 deletions(-) create mode 100644 specifyweb/stored_queries/batch_edit.py diff --git a/specifyweb/export/dwca.py b/specifyweb/export/dwca.py index 35aa5676986..7b0a30b7420 100644 --- a/specifyweb/export/dwca.py +++ b/specifyweb/export/dwca.py @@ -12,8 +12,8 @@ from xml.etree import ElementTree as ET from xml.dom import minidom -from specifyweb.stored_queries.execution import EphemeralField, query_to_csv -from specifyweb.stored_queries.queryfield import QueryField +from specifyweb.stored_queries.execution import query_to_csv +from specifyweb.stored_queries.queryfield import QueryField, EphemeralField from specifyweb.stored_queries.models import session_context logger = logging.getLogger(__name__) diff --git a/specifyweb/specify/views.py b/specifyweb/specify/views.py index 0abd0c45fc3..bfec8b868d2 100644 --- a/specifyweb/specify/views.py +++ b/specifyweb/specify/views.py @@ -22,6 +22,7 @@ from specifyweb.celery_tasks import app, CELERY_TASK_STATE from specifyweb.specify.record_merging import record_merge_fx, record_merge_task, resolve_record_merge_response from specifyweb.specify.update_locality import localityupdate_parse_success, localityupdate_parse_error, parse_locality_set as _parse_locality_set, upload_locality_set as _upload_locality_set, create_localityupdate_recordset, update_locality_task, parse_locality_task, LocalityUpdateStatus +from specifyweb.stored_queries.batch_edit import run_batch_edit from . import api, models as spmodels from .specify_jar import specify_jar @@ -85,7 +86,8 @@ def raise_error(request): """This endpoint intentionally throws an error in the server for testing purposes. """ - raise Exception('This error is a test. You may now return to your regularly ' + run_batch_edit(None, None, None) + raise Exception('This error iswwww a teswwwwt. You may now return to your regularly ' 'scheduled hacking.') diff --git a/specifyweb/stored_queries/batch_edit.py b/specifyweb/stored_queries/batch_edit.py new file mode 100644 index 00000000000..3d5f2b365e8 --- /dev/null +++ b/specifyweb/stored_queries/batch_edit.py @@ -0,0 +1,409 @@ +from functools import reduce +from typing import Any, Callable, Dict, List, NamedTuple, Optional, Set, Tuple, TypeVar, TypedDict +from specifyweb.specify.models import datamodel +from specifyweb.specify.load_datamodel import Field, Relationship, Table +from specifyweb.stored_queries.execution import execute +from specifyweb.stored_queries.queryfield import QueryField, fields_from_json +from specifyweb.stored_queries.queryfieldspec import FieldSpecJoinPath, QueryFieldSpec, TreeRankQuery +from . import models + +# as a note to myself to find which branches/conditions are tricky and need a unit test +def test_case(x): return x + +# made as a class to encapsulate type variables and prevent pollution of export +class Func: + I = TypeVar('I') + O = TypeVar('O') + + @staticmethod + def maybe(value: Optional[I], callback: Callable[[I], O]): + if value is None: + return None + return callback(value) + +MaybeField = Callable[[QueryFieldSpec], Optional[Field]] + +# TODO: +# Investigate if any/some/most of the logic for making an upload plan could be moved to frontend and reused. +# - does generation of upload plan in the backend bc upload plan is not known (we don't know count of to-many). +# - seemed complicated to merge upload plan from the frontend +# - need to place id markers at correct level, so need to follow upload plan anyways. + + +def _get_nested_order(field_spec: QueryFieldSpec): + # don't care about ordernumber if it ain't nested + # won't affect logic, just data being saved. + if len(field_spec.join_path) == 0: + return None + return field_spec.table.get_field('ordernumber') + + +batch_edit_fields: Dict[str, Tuple[MaybeField, int]] = { + # technically, if version updates are correct, this is useless beyond base tables + # and to-manys. TODO: Do just that. remove it. sorts asc. using sort, the optimized + # dataset construction takes place. + 'id': (lambda field_spec: field_spec.table.idField, 1), + # version control gets added here. no sort. + 'version': (lambda field_spec: field_spec.table.get_field('version'), None), + # ordernumber. no sort (actually adding a sort here is useless) + 'order': (_get_nested_order, 0) +} + +class BatchEditFieldPack(NamedTuple): + field: Optional[QueryField] = None + idx: Optional[int] = None + value: Any = None # stricten this? + +class BatchEditPack(NamedTuple): + id: BatchEditFieldPack + order: Optional[BatchEditFieldPack] = None + version: Optional[BatchEditFieldPack] = None + + # extends a path to contain the last field + for a defined fields + @staticmethod + def from_field_spec(field_spec: QueryFieldSpec) -> 'BatchEditPack': + # don't care about which way. bad things will happen if not sorted. + # not using assert () since it can be optimised out. + if ( batch_edit_fields['id'][1] == 0 or batch_edit_fields['order'][1] == 0 ): raise Exception("the ID field should always be sorted!") + extend_callback = lambda field: field_spec._replace(join_path=(*field_spec.join_path, field), date_part=None) + new_field_specs = { + key: Func.maybe(Func.maybe( + callback(field_spec), + extend_callback + ), + lambda field_spec: BatchEditFieldPack(field=BatchEditPack._query_field(field_spec, sort_type)) + ) + for key, (callback, sort_type) in batch_edit_fields.items() + } + return BatchEditPack(**new_field_specs) + + # a basic query field spec to field + @staticmethod + def _query_field(field_spec: QueryFieldSpec, sort_type: int): + return QueryField( + fieldspec=field_spec, + op_num=8, + value=None, + negate=False, + display=True, + format_name=None, + sort_type=sort_type + ) + + def _index( + self, + start_idx: int, + current: Tuple[Dict[str, Optional[BatchEditFieldPack]], List[BatchEditFieldPack]], + next: Tuple[int, Tuple[str, Tuple[MaybeField, int]]]): + current_dict, fields = current + field_idx, (field_name, _) = next + value: Optional[BatchEditFieldPack] = getattr(self, field_name) + new_dict = {**current_dict, field_name: None if value is None else value._replace(idx=(field_idx + start_idx))} + new_fields = fields if value is None else [*fields, value.field] + return new_dict, new_fields + + + def index_plan(self, start_index=0) -> Tuple['BatchEditPack', List[BatchEditFieldPack]]: + _dict, fields = reduce( + lambda accum, next: self._index(start_idx=start_index, current=accum, next=next), + enumerate(batch_edit_fields.items()), + ({}, []) + ) + return BatchEditPack(**_dict), fields + + def bind(self, row: Tuple[Any]): + return BatchEditPack(**{ + key: Func.maybe( + getattr(self, key), + lambda pack: pack._replace(value=row[pack.idx])) for key in batch_edit_fields.keys() + }) + + + +# FUTURE: this already supports nested-to-many for most part +# wb plan, but contains query fields along with indexes to look-up in a result row. +# TODO: see if it can be moved + combined with front-end logic. I kept all parsing on backend, but there might be possible beneft in doing this +# on the frontend (it already has code from mapping path -> upload plan) +class RowPlanMap(NamedTuple): + columns: List[BatchEditFieldPack] = [] + to_one: Dict[str, 'RowPlanMap'] = {} + to_many: Dict[str, 'RowPlanMap'] = {} + batch_edit_pack: Optional[BatchEditPack] = None + + @staticmethod + def _merge(current: Dict[str, 'RowPlanMap'], other: Tuple[str, 'RowPlanMap']) -> Dict[str, 'RowPlanMap']: + key, other_plan = other + return { + **current, + # merge if other is also found in ours + key: other_plan if key not in current else current[key].merge(other_plan) + } + + # takes two row plans, combines them together + def merge(self: 'RowPlanMap', other: 'RowPlanMap') -> 'RowPlanMap': + new_columns = [*self.columns, *other.columns] + batch_edit_pack = self.batch_edit_pack or other.batch_edit_pack + to_one = reduce(RowPlanMap._merge, other.to_one.items(), self.to_one) + to_many = reduce(RowPlanMap._merge, other.to_many.items(), self.to_many) + return RowPlanMap(new_columns, to_one, to_many, batch_edit_pack) + + def _index(current: Tuple[int, Dict[str, 'RowPlanMap'], List[BatchEditFieldPack]], other: Tuple[str, 'RowPlanMap']): + next_start_index = current[0] + other_indexed, fields = other[1].index_plan(start_index=next_start_index) + to_return = ((next_start_index + len(fields)), {**current[1], other[0]: other_indexed}, [*current[2], *fields]) + return to_return + + # to make things simpler, returns the QueryFields along with indexed plan, which are expected to be used together + def index_plan(self, start_index=0) -> Tuple['RowPlanMap', List[BatchEditFieldPack]]: + next_index = len(self.columns) + start_index + _columns = [column._replace(idx=index) for index, column in zip(range(start_index, next_index), self.columns)] + next_index, _to_one, fields = reduce( + RowPlanMap._index, + # makes the order deterministic, would be funny otherwise + sorted(self.to_one.items(), key=lambda x: x[0]), + (next_index, {}, self.columns)) + next_index, _to_many, fields = reduce(RowPlanMap._index, sorted(self.to_many.items(), key=lambda x: x[0]), (next_index, {}, fields)) + _batch_indexed, _batch_fields = self.batch_edit_pack.index_plan(start_index=next_index) if self.batch_edit_pack else (None, []) + return (RowPlanMap(columns=_columns, to_one=_to_one, to_many=_to_many, batch_edit_pack=_batch_indexed), [*fields, *_batch_fields]) + + @staticmethod + # helper for generating an row plan for a single query field + # handles formatted/aggregated self or relationships correctly (places them in upload-plan at correct level) + def _recur_row_plan( + running_path: FieldSpecJoinPath, + next_path: FieldSpecJoinPath, + next_table: Table, # bc queryfieldspecs will be terminated early on + original_field: QueryField) -> 'RowPlanMap': + + original_field_spec = original_field.fieldspec + + # contains partial path + partial_field_spec = original_field_spec._replace(join_path=running_path, table=next_table) + node, *rest = (None,) if not next_path else next_path # to handle CO (formatted) + + # we can't edit relationships's formatted/aggregated anyways. + batch_edit_pack = None if original_field_spec.needs_formatted() else BatchEditPack.from_field_spec(partial_field_spec) + + if node is None or not node.is_relationship: + # we are at the end + return RowPlanMap(columns=[BatchEditFieldPack(field=original_field)], batch_edit_pack=batch_edit_pack) + + rel_type = 'to_one' if node.type.endswith('to-one') else 'to_many' + return RowPlanMap( + **{rel_type: { + node.name: RowPlanMap._recur_row_plan( + (*running_path, node), + rest, + datamodel.get_table(node.relatedModelName), + original_field + ) + }, + 'batch_edit_pack': batch_edit_pack + } + ) + + # generates multiple row plan maps, and merges them into one + # this doesn't index the row plan, bc that is complicated. + # instead, see usage of index_plan() which indexes the plan in one go. + @staticmethod + def get_row_plan(fields: List[QueryField]) -> 'RowPlanMap': + iter = [ + RowPlanMap._recur_row_plan((), field.fieldspec.join_path, field.fieldspec.root_table, field) + for field in fields + ] + return reduce(lambda current, other: current.merge(other), iter, RowPlanMap()) + + def bind(self, row: Tuple[Any]) -> 'RowPlanCanonical': + columns = [column._replace(value=row[column.idx], field=None) for column in self.columns] + to_ones = {key: value.bind(row) for (key, value) in self.to_one.items()} + to_many = { + key: [value.bind(row)] + for (key, value) in self.to_many.items() + } + pack = self.batch_edit_pack.bind(row) if self.batch_edit_pack else None + return RowPlanCanonical(columns, to_ones, to_many, pack) + + # gets a null record to fill-out empty space + # doesn't support nested-to-many's yet - complicated + def nullify(self) -> 'RowPlanCanonical': + columns = [pack._replace(value=None, idx=None) for pack in self.columns] + to_ones = {key: value.nullify() for (key, value) in self.to_one.items()} + return RowPlanCanonical(columns, to_ones) + + # a fake upload plan that keeps track of the maximum ids / order numbrs seen in to-manys + def to_many_planner(self) -> 'RowPlanMap': + to_one = {key: value.to_many_planner() for (key, value) in self.to_one.items()} + to_many = { + key: RowPlanMap( + batch_edit_pack=BatchEditPack(order=BatchEditFieldPack(value=0), id=BatchEditFieldPack()) + if value.batch_edit_pack.order + # only use id if order field is not present + else BatchEditPack(id=BatchEditFieldPack(value=0))) for (key, value) in self.to_many.items() + } + return RowPlanMap(to_one=to_one, to_many=to_many) + +# the main data-structure which stores the data +# RowPlanMap is just a map, this stores actual data (to many is a dict of list, rather than just a dict) +# maybe unify that with RowPlanMap? +class RowPlanCanonical(NamedTuple): + columns: List[BatchEditFieldPack] = [] + to_one: Dict[str, 'RowPlanCanonical'] = {} + to_many: Dict[str, List[Optional['RowPlanCanonical']]] = {} + batch_edit_pack: Optional[BatchEditPack] = None + + @staticmethod + def _maybe_extend(values: List[Optional['RowPlanCanonical']], result:Tuple[bool, 'RowPlanCanonical'] ): + is_new = result[0] + new_values = (is_new, [*values, result[1]] if is_new else values) + return new_values + + # FUTURE: already handles nested to-many. + def merge(self, row: Tuple[Any], indexed_plan: RowPlanMap) -> Tuple[bool, 'RowPlanCanonical']: + # nothing to compare against. useful for recursion + handing default null as default value for reduce + if self.batch_edit_pack is None: + return True, indexed_plan.bind(row) + + # trying to defer actual bind to later + batch_fields = indexed_plan.batch_edit_pack.bind(row) + if batch_fields.id.value != self.batch_edit_pack.id.value: + # if the id itself is different, we are on a different record. just bind and return + return True, indexed_plan.bind(row) + + # now, ids are the same. no reason to bind other's to one. + # however, still need to handle to-manys inside to-ones (this will happen when a row gets duplicated due to to-many) + # in that case, to-one wouldn't change. but, need to recur down till either new to-many gets found or we are in a dup chain. + # don't need a new flag here. why? + to_one = { + key: value.merge(row, indexed_plan.to_one.get(key))[1] + for (key, value) in self.to_one.items() + } + + to_many_packed = [ + (key, (True, [indexed_plan.to_many.get(key).bind(row)]) + if test_case(len(value) == 0) + # tricky. basically, if the value is absolutely new, then only extend. + # since ids are already sorted, we don't care about matching to any-other record. + # but we still need to possibly merge due to nested to-manys + # NOW: If it causes performance problems, simply make the subsequent merge no-op since we don't handle nested-to-many's anywhere else + else RowPlanCanonical._maybe_extend(value, (value[-1].merge(row, indexed_plan.to_many.get(key))))) + for (key, value) in self.to_many.items() + ] + + to_many_new = any(results[1][0] for results in to_many_packed) + if to_many_new: + # a "meh" optimization + to_many = { + key: values + for (key, (_, values)) in to_many_packed + } + else: + to_many = self.to_many + + # TODO: explain why those arguments + return to_many_new, RowPlanCanonical( + self.columns, + to_one, + to_many, + self.batch_edit_pack + ) + + @staticmethod + def _update_id_order(values: List['RowPlanCanonical'], plan: RowPlanMap): + is_id = plan.batch_edit_pack.order is None + new_value = len(values) if is_id else max([value.batch_edit_pack.order.value for value in values]) + current_value = plan.batch_edit_pack.order.value if not is_id else plan.batch_edit_pack.id.value + return RowPlanMap(batch_edit_pack=plan.batch_edit_pack._replace(**{('id' if is_id else 'order'): BatchEditFieldPack(value=max(new_value, current_value))})) + + # as we iterate through rows, need to update the to-many stats (number of ids or maximum order we saw) + # this is done to expand the rows at the end + def update_to_manys(self, to_many_planner: RowPlanMap) -> RowPlanMap: + to_one = {key: value.update_to_manys(to_many_planner.to_one.get(key)) for (key, value) in self.to_one.items()} + to_many = {key: RowPlanCanonical._update_id_order(values, to_many_planner.to_many.get(key)) for key, values in self.to_many.items()} + return RowPlanMap(to_one=to_one, to_many=to_many) + + @staticmethod + def _extend_id_order(values: List['RowPlanCanonical'], to_many_planner: RowPlanMap, indexed_plan: RowPlanMap) -> List['RowPlanCanonical']: + is_id = to_many_planner.batch_edit_pack.order is None + fill_out = None + # minor memoization, hehe + null_record = indexed_plan.nullify() + if not is_id: # if order is present, things are more complex + max_order = max([value.batch_edit_pack.order.value for value in values]) + # this might be useless + assert len(set([value.batch_edit_pack.order.value for value in values])) == len(values) + # fill-in before, out happens later anyways + fill_in_range = range(min(max_order, to_many_planner.batch_edit_pack.order.value)+1) + for fill_in in fill_in_range: + _test = next(filter(lambda pack: pack.batch_edit_pack.order.value == fill_in, values), null_record) + # TODO: this is generic and doesn't assume items aren't sorted by order. maybe we can optimize, knowing that. + filled_in = [next(filter(lambda pack: pack.batch_edit_pack.order.value == fill_in, values), null_record) for fill_in in fill_in_range] + values = filled_in + fill_out = to_many_planner.batch_edit_pack.order.value - max_order + + if fill_out is None: + fill_out = to_many_planner.batch_edit_pack.id - len(values) + + assert fill_out >= 0, "filling out in opposite directon!" + rest = range(fill_out) + values = [*values, *(null_record for _ in rest)] + return values + + def extend(self, to_many_planner: RowPlanMap, plan: RowPlanMap) -> 'RowPlanCanonical': + to_ones = {key: value.extend(to_many_planner.to_one.get(key), plan.to_one.get(key)) for (key, value) in self.to_one.items()} + to_many = {key: RowPlanCanonical._extend_id_order(values, to_many_planner.to_many.get(key), plan.to_many.get(key)) for (key, values) in self.to_many.items()} + return self._replace(to_one=to_ones, to_many=to_many) + +import time +def run_batch_edit(collection, user, spquery): + """ + start = time.perf_counter() + limit = 20 + offset = 0 + tableid = spquery['contexttableid'] + fields = fields_from_json(spquery['fields']) + #_plan = RowPlanMap.get_row_plan([field for field in fields if field.display]) + #indexed, fields = plan.index_plan() + #non_display_fields = [field for field in fields if not field.field.display] + #all_fields = [*fields, *non_display_fields] + ss = time.perf_counter() + """ + plan = RowPlanMap( + columns=[BatchEditFieldPack(field=None, idx=0)], + to_one={}, + to_many={ + 'random': RowPlanMap( + columns=[BatchEditFieldPack(field=None, idx=2)], + batch_edit_pack=BatchEditPack( + id=BatchEditFieldPack(idx=3, value=None), + order=BatchEditFieldPack(idx=4, value=None) + ) + ) + }, + batch_edit_pack=BatchEditPack(id=BatchEditFieldPack(idx=1, value=None)) + ) + + """ + with models.session_context() as session: + rows = execute( + session, collection, user, tableid, True, False, all_fields, limit, offset, None, False + ) + """ + print(plan) + rows = [ + ("sme value 1", 1, 'nested to many 1', 2, 0), + ("sme value 1", 1, 'nested to many 2', 3, 2), + ("sme value 1", 1, 'nested to many 3', 42, 8), + ] + row1 = RowPlanCanonical() + to_many_planner = plan.to_many_planner() + for row in rows: + new, row1 = row1.merge(row, plan) + to_many_planner = row1.update_to_manys(to_many_planner) + print(new, row1) + print("sssssssssssssssssssssssssssssssssssssssssssssssssssssssssss") + print(to_many_planner) + print("sssssssssssssssssssssssssssssssssssssssssssssssssssssssssss") + print(row1) + print(len(row1.extend(to_many_planner, plan).to_many['random'])) + + diff --git a/specifyweb/stored_queries/execution.py b/specifyweb/stored_queries/execution.py index 89f4745fea9..6c51f7397fd 100644 --- a/specifyweb/stored_queries/execution.py +++ b/specifyweb/stored_queries/execution.py @@ -3,6 +3,8 @@ import logging import os import re + +from typing import List import xml.dom.minidom from collections import namedtuple, defaultdict from datetime import datetime, timedelta @@ -13,8 +15,6 @@ from sqlalchemy import sql, orm, func, select from sqlalchemy.sql.expression import asc, desc, insert, literal -from specifyweb.stored_queries.group_concat import group_by_displayed_fields - from . import models from .format import ObjectFormatter from .query_construct import QueryConstruct @@ -26,6 +26,9 @@ from ..specify.auditlog import auditlog from ..specify.models import Loan, Loanpreparation, Loanreturnpreparation +from specifyweb.stored_queries.group_concat import group_by_displayed_fields +from specifyweb.stored_queries.queryfield import fields_from_json + logger = logging.getLogger(__name__) SORT_TYPES = [None, asc, desc] @@ -120,21 +123,6 @@ def filter_by_collection(model, query, collection): -EphemeralField = namedtuple('EphemeralField', "stringId isRelFld operStart startValue isNot isDisplay sortType formatName") - -def field_specs_from_json(json_fields): - """Given deserialized json data representing an array of SpQueryField - records, return an array of QueryField objects that can build the - corresponding sqlalchemy query. - """ - def ephemeral_field_from_json(json): - return EphemeralField(**{field: json.get(field.lower(), None) for field in EphemeralField._fields}) - - field_specs = [QueryField.from_spqueryfield(ephemeral_field_from_json(data)) - for data in sorted(json_fields, key=lambda field: field['position'])] - - return field_specs - def do_export(spquery, collection, user, filename, exporttype, host): """Executes the given deserialized query definition, sending the to a file, and creates "export completed" message when finished. @@ -150,7 +138,7 @@ def do_export(spquery, collection, user, filename, exporttype, host): message_type = 'query-export-to-csv-complete' with models.session_context() as session: - field_specs = field_specs_from_json(spquery['fields']) + field_specs = fields_from_json(spquery['fields']) if exporttype == 'csv': query_to_csv(session, collection, user, tableid, field_specs, path, recordsetid=recordsetid, @@ -179,7 +167,7 @@ def stored_query_to_csv(query_id, collection, user, path): field_specs = [QueryField.from_spqueryfield(field) for field in sorted(sp_query.fields, key=lambda field: field.position)] - query_to_csv(session, collection, user, tableid, field_specs, path, distinct=spquery['selectdistinct']) + query_to_csv(session, collection, user, tableid, field_specs, path, distinct=spquery['selectdistinct']) # bug? def query_to_csv(session, collection, user, tableid, field_specs, path, recordsetid=None, captions=False, strip_id=False, row_filter=None, @@ -388,17 +376,14 @@ def run_ephemeral_query(collection, user, spquery): distinct = spquery['selectdistinct'] tableid = spquery['contexttableid'] count_only = spquery['countonly'] - try: - format_audits = spquery['formatauditrecids'] - except: - format_audits = False + format_audits = spquery.get('formatauditrecids', False) with models.session_context() as session: - field_specs = field_specs_from_json(spquery['fields']) + field_specs = fields_from_json(spquery['fields']) return execute(session, collection, user, tableid, distinct, count_only, field_specs, limit, offset, recordsetid, formatauditobjs=format_audits) -def augment_field_specs(field_specs, formatauditobjs=False): +def augment_field_specs(field_specs: List[QueryField], formatauditobjs=False): print("augment_field_specs ######################################") new_field_specs = [] for fs in field_specs: @@ -411,7 +396,7 @@ def augment_field_specs(field_specs, formatauditobjs=False): has_precision = hasattr(model, precision_field) if has_precision: new_field_specs.append(make_augmented_field_spec(fs, model, precision_field)) - elif formatauditobjs and model.name.lower.startswith('spauditlog'): + elif formatauditobjs and model.name.lower().startswith('spauditlog'): if field.name.lower() in 'newvalue, oldvalue': log_model = models.models_by_tableid[530]; new_field_specs.append(make_augmented_field_spec(fs, log_model, 'TableNum')) @@ -447,7 +432,7 @@ def recordset(collection, user, user_agent, recordset_info): model = models.models_by_tableid[tableid] id_field = getattr(model, model._id) - field_specs = field_specs_from_json(spquery['fields']) + field_specs = fields_from_json(spquery['fields']) query, __ = build_query(session, collection, user, tableid, field_specs) query = query.with_entities(id_field, literal(new_rs_id)).distinct() @@ -472,7 +457,7 @@ def return_loan_preps(collection, user, agent, data): model = models.models_by_tableid[tableid] id_field = getattr(model, model._id) - field_specs = field_specs_from_json(spquery['fields']) + field_specs = fields_from_json(spquery['fields']) query, __ = build_query(session, collection, user, tableid, field_specs) lrp = orm.aliased(models.LoanReturnPreparation) diff --git a/specifyweb/stored_queries/field_spec_maps.py b/specifyweb/stored_queries/field_spec_maps.py index b6f74296719..65e31b89e08 100644 --- a/specifyweb/stored_queries/field_spec_maps.py +++ b/specifyweb/stored_queries/field_spec_maps.py @@ -1,6 +1,10 @@ -def apply_specify_user_name(query_field, user): +from specifyweb.stored_queries.queryfield import QueryField + + +def apply_specify_user_name(query_field: QueryField, user): if query_field.fieldspec.is_specify_username_end(): if query_field.value == 'currentSpecifyUserName': return query_field._replace(value=user.name) + return query_field diff --git a/specifyweb/stored_queries/group_concat.py b/specifyweb/stored_queries/group_concat.py index 1ed3a6ebc25..e8f5e880dc6 100644 --- a/specifyweb/stored_queries/group_concat.py +++ b/specifyweb/stored_queries/group_concat.py @@ -33,16 +33,6 @@ def _group_concat_mysql(element, compiler, **kwargs): return 'GROUP_CONCAT(%s)' % inner_expr -def extract_clauses(element, compiler): - expr = compiler.process(element.clauses.clauses[0]) - def process_clause(idx): - return compiler.process(element.clauses.clauses[idx]) - - separator = process_clause(1) if len(element.clauses) > 1 else None - order_by = process_clause(2) if len(element.clauses) > 2 else None - - return expr, separator, order_by - def group_by_displayed_fields(query: QueryConstruct, fields): for field in fields: query = query.group_by(field) diff --git a/specifyweb/stored_queries/models.py b/specifyweb/stored_queries/models.py index 12bd2311889..df9982bf4c3 100644 --- a/specifyweb/stored_queries/models.py +++ b/specifyweb/stored_queries/models.py @@ -1,4 +1,5 @@ from contextlib import contextmanager +from typing import Dict from MySQLdb.cursors import SSCursor import sqlalchemy @@ -38,7 +39,7 @@ def generate_models(): tables, classes = generate_models() -models_by_tableid = dict((cls.tableid, cls) for cls in list(classes.values())) +models_by_tableid: Dict[int, build_models.Table] = dict((cls.tableid, cls) for cls in list(classes.values())) globals().update(classes) diff --git a/specifyweb/stored_queries/queryfield.py b/specifyweb/stored_queries/queryfield.py index 27200a79937..60344979d6c 100644 --- a/specifyweb/stored_queries/queryfield.py +++ b/specifyweb/stored_queries/queryfield.py @@ -1,22 +1,38 @@ import logging from collections import namedtuple +from typing import Any, Dict, List, NamedTuple, Optional from .query_ops import QueryOps from .queryfieldspec import QueryFieldSpec logger = logging.getLogger(__name__) -class QueryField(namedtuple('QueryField', [ - 'fieldspec', - 'op_num', - 'value', - 'negate', - 'display', - 'format_name', - 'sort_type'])): +EphemeralField = namedtuple('EphemeralField', "stringId isRelFld operStart startValue isNot isDisplay sortType formatName") + +def fields_from_json(json_fields) -> List['QueryField']: + """Given deserialized json data representing an array of SpQueryField + records, return an array of QueryField objects that can build the + corresponding sqlalchemy query. + """ + def ephemeral_field_from_json(json: Dict[str, Any]): + return EphemeralField(**{field: json.get(field.lower(), None) for field in EphemeralField._fields}) + + field_specs = [QueryField.from_spqueryfield(ephemeral_field_from_json(data)) + for data in sorted(json_fields, key=lambda field: field['position'])] + + return field_specs + +class QueryField(NamedTuple): + fieldspec: QueryFieldSpec + op_num: int + value: Optional[str] + negate: bool + display: bool + format_name: Optional[str] + sort_type: int @classmethod - def from_spqueryfield(cls, field, value=None): + def from_spqueryfield(cls, field: EphemeralField, value=None): logger.info('processing field from %r', field) fieldspec = QueryFieldSpec.from_stringid(field.stringId, field.isRelFld) @@ -47,3 +63,4 @@ def add_to_query(self, query, no_filter=False, formatauditobjs=False): and not self.negate) return self.fieldspec.add_to_query(query, value=self.value, op_num=None if no_filter else self.op_num, negate=self.negate, formatter=self.format_name, formatauditobjs=formatauditobjs) + diff --git a/specifyweb/stored_queries/queryfieldspec.py b/specifyweb/stored_queries/queryfieldspec.py index 0f8321fecfd..9912f83ec8b 100644 --- a/specifyweb/stored_queries/queryfieldspec.py +++ b/specifyweb/stored_queries/queryfieldspec.py @@ -70,10 +70,12 @@ class TreeRankQuery(Relationship): original_field: str pass +FieldSpecJoinPath = Tuple[Union[Field, Relationship, TreeRankQuery]] + class QueryFieldSpec(namedtuple("QueryFieldSpec", "root_table root_sql_table join_path table date_part")): root_table: Table root_sql_table: SQLTable - join_path: Tuple[Union[Field, Relationship, TreeRankQuery]] + join_path: FieldSpecJoinPath table: Table date_part: Optional[str] @@ -209,16 +211,15 @@ def build_join(self, query, join_path): return query.build_join(self.root_table, self.root_sql_table, join_path) def is_auditlog_obj_format_field(self, formatauditobjs): - if not formatauditobjs or self.get_field() is None: - return False - else: - return self.get_field().name.lower() in ['oldvalue','newvalue'] + return formatauditobjs and self.join_path and self.table.name.lower() == 'spauditlog' and self.get_field().name.lower() in ['oldvalue','newvalue'] def is_specify_username_end(self): # TODO: Add unit tests. - return self.join_path and self.table.name.lower() == 'specifyuser' - + return self.join_path and self.table.name.lower() == 'specifyuser' and self.join_path[-1].name == 'name' + def needs_formatted(self): + return len(self.join_path) == 0 or self.is_relationship() + def apply_filter(self, query, orm_field, field, table, value=None, op_num=None, negate=False): no_filter = op_num is None or (self.get_field() is None) if not no_filter: diff --git a/specifyweb/workbench/upload/treerecord.py b/specifyweb/workbench/upload/treerecord.py index c5466893afe..67769156e18 100644 --- a/specifyweb/workbench/upload/treerecord.py +++ b/specifyweb/workbench/upload/treerecord.py @@ -70,7 +70,7 @@ def bind(self, row: Row, uploadingAgentId: Optional[int], auditor: Auditor, sql_ parsedFields[rank] = presults parseFails += pfails filters = {k: v for result in presults for k, v in result.filter_on.items()} - if filters.get('name', None) is None: + if filters.get('name', None) is None and False: parseFails += [ WorkBenchParseFailure('invalidPartialRecord',{'column':nameColumn.column}, result.column) for result in presults diff --git a/specifyweb/workbench/upload/upload_table.py b/specifyweb/workbench/upload/upload_table.py index 5e43f7fd6b2..f4059a23798 100644 --- a/specifyweb/workbench/upload/upload_table.py +++ b/specifyweb/workbench/upload/upload_table.py @@ -168,7 +168,7 @@ def bind(self,row: Row, uploadingAgentId: int, auditor: Auditor, sql_alchemy_ses b = super().bind(row, uploadingAgentId, auditor, sql_alchemy_session, cache) return BoundMustMatchTable(*b) if isinstance(b, BoundUploadTable) else b - +from django.db.utils import OperationalError class BoundUploadTable(NamedTuple): name: str static: Dict[str, Any] diff --git a/specifyweb/workbench/views.py b/specifyweb/workbench/views.py index f222ca10b32..ef325e0ef93 100644 --- a/specifyweb/workbench/views.py +++ b/specifyweb/workbench/views.py @@ -21,6 +21,7 @@ check_permission_targets, check_table_permissions from . import models, tasks from .upload import upload as uploader, upload_plan_schema +from .upload.upload import do_upload_dataset logger = logging.getLogger(__name__) @@ -468,7 +469,7 @@ def dataset(request, ds: models.Spdataset) -> http.HttpResponse: if 'uploadplan' in attrs: plan = attrs['uploadplan'] - if ds.uploaderstatus != None: + if ds.uploaderstatus is not None: return http.HttpResponse('dataset in use by uploader', status=409) if ds.was_uploaded(): return http.HttpResponse('dataset has been uploaded. changing upload plan not allowed.', status=400) @@ -495,7 +496,7 @@ def dataset(request, ds: models.Spdataset) -> http.HttpResponse: if request.method == "DELETE": check_permission_targets(request.specify_collection.id, request.specify_user.id, [DataSetPT.delete]) - if ds.uploaderstatus != None: + if ds.uploaderstatus is not None: return http.HttpResponse('dataset in use by uploader', status=409) ds.delete() return http.HttpResponse(status=204) @@ -625,6 +626,8 @@ def upload(request, ds, no_commit: bool, allow_partial: bool) -> http.HttpRespon return http.HttpResponse('dataset has already been uploaded.', status=400) taskid = str(uuid4()) + do_upload_dataset(request.specify_collection, request.specify_user_agent.id, ds, no_commit, allow_partial, None) + """ async_result = tasks.upload.apply_async([ request.specify_collection.id, request.specify_user_agent.id, @@ -637,8 +640,8 @@ def upload(request, ds, no_commit: bool, allow_partial: bool) -> http.HttpRespon 'taskid': taskid } ds.save(update_fields=['uploaderstatus']) - - return http.JsonResponse(async_result.id, safe=False) + """ + return http.JsonResponse('ok', safe=False) @openapi(schema={ From 39117b1ea6329c71cf1b03a63c3cb5680a362614 Mon Sep 17 00:00:00 2001 From: realVinayak Date: Fri, 16 Aug 2024 16:42:16 -0500 Subject: [PATCH 26/63] (feat): Batch-edit --- CHANGELOG.md | 10 + requirements.txt | 4 +- specifyweb/attachment_gw/models.py | 5 - specifyweb/businessrules/uniqueness_rules.py | 1 - specifyweb/businessrules/views.py | 2 +- specifyweb/frontend/js_src/css/main.css | 4 + specifyweb/frontend/js_src/css/workbench.css | 22 +- .../AttachmentsBulkImport/Datasets.tsx | 4 +- .../PerformAttachmentTask.ts | 4 +- .../AttachmentsBulkImport/RenameDataSet.tsx | 2 +- .../js_src/lib/components/BatchEdit/index.tsx | 118 ++ .../DataModel/__tests__/resource.test.ts | 39 +- .../__tests__/schemaOverrides.test.ts | 17 +- .../lib/components/DataModel/resource.ts | 49 +- .../components/DataModel/schemaOverrides.ts | 151 +-- .../lib/components/DataModel/specifyField.ts | 30 +- .../components/DataModel/uniqueFields.json | 1045 +++++++++++++++++ .../lib/components/FormFields/index.tsx | 22 +- .../FormParse/__tests__/cells.test.ts | 68 ++ .../FormParse/__tests__/fields.test.ts | 7 + .../FormParse/__tests__/index.test.ts | 2 + .../__tests__/postProcessFormDef.test.ts | 1 + .../js_src/lib/components/FormParse/fields.ts | 6 + .../FormPlugins/__tests__/dateUtils.test.ts | 2 + .../Forms/generateFormDefinition.ts | 1 + .../components/Header/menuItemDefinitions.ts | 6 + .../lib/components/Merging/CompareField.tsx | 1 + .../Preferences/UserDefinitions.tsx | 21 + .../lib/components/QueryBuilder/Wrapped.tsx | 8 +- .../lib/components/Router/OverlayRoutes.tsx | 10 + .../lib/components/Toolbar/WbsDialog.tsx | 33 +- .../js_src/lib/components/TreeView/Row.tsx | 26 +- .../js_src/lib/components/TreeView/helpers.ts | 7 +- .../js_src/lib/components/WbImport/helpers.ts | 10 +- .../lib/components/WbPlanView/Mapper.tsx | 17 +- .../lib/components/WbPlanView/Wrapped.tsx | 8 +- .../lib/components/WbPlanView/index.tsx | 5 +- .../lib/components/WbPlanView/modelHelpers.ts | 3 + .../lib/components/WbPlanView/navigator.ts | 15 +- .../components/WbPlanView/navigatorSpecs.ts | 2 +- .../WbPlanView/uploadPlanBuilder.ts | 2 +- .../lib/components/WbUtils/Navigation.tsx | 5 +- .../js_src/lib/components/WbUtils/index.tsx | 18 + .../lib/components/WorkBench/CellMeta.ts | 42 +- .../lib/components/WorkBench/DataSetMeta.tsx | 14 +- .../WorkBench/DisambiguationLogic.ts | 3 +- .../lib/components/WorkBench/Results.tsx | 60 +- .../components/WorkBench/WbSpreadsheet.tsx | 9 +- .../lib/components/WorkBench/WbValidation.tsx | 87 +- .../lib/components/WorkBench/WbView.tsx | 3 + .../components/WorkBench/batchEditHelpers.ts | 38 + .../lib/components/WorkBench/handsontable.ts | 38 + .../js_src/lib/components/WorkBench/hooks.ts | 31 +- .../lib/components/WorkBench/hotHelpers.ts | 7 + .../lib/components/WorkBench/hotProps.tsx | 2 +- .../js_src/lib/components/WorkBench/index.tsx | 3 +- .../lib/components/WorkBench/resultsParser.ts | 21 +- .../js_src/lib/localization/batchEdit.ts | 28 + .../js_src/lib/localization/workbench.ts | 39 + .../lib/utils/parser/__tests__/parse.test.ts | 18 + .../js_src/lib/utils/parser/definitions.ts | 10 +- .../frontend/js_src/lib/utils/parser/parse.ts | 4 +- specifyweb/frontend/js_src/tailwind.config.ts | 3 + specifyweb/permissions/permissions.py | 7 +- specifyweb/specify/agent_types.py | 2 +- specifyweb/specify/api.py | 67 +- specifyweb/specify/auditlog.py | 14 +- specifyweb/specify/build_models.py | 33 +- specifyweb/specify/field_change_info.py | 7 + specifyweb/specify/func.py | 53 + specifyweb/specify/model_extras.py | 9 +- specifyweb/specify/model_timestamp.py | 84 +- specifyweb/specify/models.py | 365 +++--- specifyweb/specify/tests/test_api.py | 73 +- specifyweb/specify/tests/test_timestamps.py | 48 + specifyweb/specify/tests/test_trees.py | 181 +-- specifyweb/specify/tree_extras.py | 36 +- specifyweb/specify/tree_stats.py | 25 +- specifyweb/specify/tree_views.py | 112 +- specifyweb/specify/utils.py | 1 + specifyweb/specify/views.py | 4 +- specifyweb/stored_queries/batch_edit.py | 431 +++++-- specifyweb/stored_queries/execution.py | 57 +- specifyweb/stored_queries/format.py | 17 +- specifyweb/stored_queries/queryfieldspec.py | 49 +- specifyweb/stored_queries/tests.py | 11 +- specifyweb/stored_queries/urls.py | 1 + specifyweb/stored_queries/views.py | 22 +- .../migrations/0006_spdataset_isupdate.py | 18 + .../migrations/0007_spdataset_parent.py | 19 + specifyweb/workbench/models.py | 17 +- specifyweb/workbench/tasks.py | 12 +- specifyweb/workbench/tests.py | 2 +- specifyweb/workbench/upload/auditor.py | 38 +- specifyweb/workbench/upload/clone.py | 67 ++ specifyweb/workbench/upload/disambiguation.py | 2 +- specifyweb/workbench/upload/parsing.py | 7 +- specifyweb/workbench/upload/predicates.py | 257 ++++ specifyweb/workbench/upload/preferences.py | 22 + specifyweb/workbench/upload/scoping.py | 96 +- .../workbench/upload/tests/example_plan.py | 2 +- .../workbench/upload/tests/test_bugs.py | 10 +- .../upload/tests/test_upload_results_json.py | 43 +- .../upload/tests/testdisambiguation.py | 24 +- .../workbench/upload/tests/testmustmatch.py | 8 +- .../upload/tests/testnestedtomany.py | 2 +- .../workbench/upload/tests/testonetoone.py | 6 +- .../workbench/upload/tests/testparsing.py | 54 +- .../workbench/upload/tests/testschema.py | 2 +- .../workbench/upload/tests/testscoping.py | 19 +- .../workbench/upload/tests/testunupload.py | 6 +- .../workbench/upload/tests/testuploading.py | 58 +- specifyweb/workbench/upload/treerecord.py | 202 +++- specifyweb/workbench/upload/upload.py | 153 ++- specifyweb/workbench/upload/upload_result.py | 210 +++- .../workbench/upload/upload_results_schema.py | 87 +- specifyweb/workbench/upload/upload_table.py | 676 +++++++---- specifyweb/workbench/upload/uploadable.py | 157 +-- specifyweb/workbench/views.py | 19 +- 119 files changed, 4555 insertions(+), 1722 deletions(-) create mode 100644 specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx create mode 100644 specifyweb/frontend/js_src/lib/components/DataModel/uniqueFields.json create mode 100644 specifyweb/frontend/js_src/lib/components/WorkBench/batchEditHelpers.ts create mode 100644 specifyweb/frontend/js_src/lib/localization/batchEdit.ts create mode 100644 specifyweb/specify/field_change_info.py create mode 100644 specifyweb/specify/func.py create mode 100644 specifyweb/specify/tests/test_timestamps.py create mode 100644 specifyweb/workbench/migrations/0006_spdataset_isupdate.py create mode 100644 specifyweb/workbench/migrations/0007_spdataset_parent.py create mode 100644 specifyweb/workbench/upload/clone.py create mode 100644 specifyweb/workbench/upload/predicates.py create mode 100644 specifyweb/workbench/upload/preferences.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f01824b994..0f130c3222c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## [7.9.6.2](https://github.com/specify/specify7/compare/v7.9.6.1...v7.9.6.2) (22 July 2024) + +- Fixed an issue that prevented `TimestampModified` from being captured upon saving a record since the `v7.9.6` release ([#5108](https://github.com/specify/specify7/issues/5108) – *Reported by the University of Kansas and Ohio State University*) +- Fixed an issue that caused large trees to perform slowly or crash the browser due to using too much memory ([#5115](https://github.com/specify/specify7/pull/5115) – *Reported by The Hebrew University of Jerusalem and Royal Botanic Gardens Edinburgh*) + +## [7.9.6.1](https://github.com/specify/specify7/compare/v7.9.6...v7.9.6.1) (9 July 2024) + +- Fixes an issue that led to tree definition item separators being trimmed ([#5076](https://github.com/specify/specify7/pull/5076)) +- The form system now includes a `whiteSpaceSensitive` attribute, which allows any field to preserve whitespace upon saving + ## [7.9.6](https://github.com/specify/specify7/compare/v7.9.5...v7.9.6) (1 July 2024) diff --git a/requirements.txt b/requirements.txt index 6b3566c2707..f009ca94b9f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,11 +2,11 @@ kombu==5.2.4 celery[redis]==5.2.7 Django==3.2.15 mysqlclient==2.1.1 -SQLAlchemy==1.3.0 +SQLAlchemy==1.2.11 requests==2.32.2 pycryptodome==3.15.0 PyJWT==2.3.0 django-auth-ldap==1.2.15 jsonschema==3.2.0 typing-extensions==4.3.0 -django-model-utils==4.4.0 +django-model-utils==4.4.0 \ No newline at end of file diff --git a/specifyweb/attachment_gw/models.py b/specifyweb/attachment_gw/models.py index f59cfd98d81..6d5573b4f4d 100644 --- a/specifyweb/attachment_gw/models.py +++ b/specifyweb/attachment_gw/models.py @@ -1,8 +1,4 @@ from django.db import models -from django.conf import settings -from django.db.models.deletion import CASCADE, SET_NULL -from django.utils import timezone -from model_utils import FieldTracker from functools import partialmethod from specifyweb.specify.models import datamodel, custom_save from ..workbench.models import Dataset @@ -15,7 +11,6 @@ class Spattachmentdataset(Dataset): class Meta: db_table = 'attachmentdataset' - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) save = partialmethod(custom_save) # from django.apps import apps diff --git a/specifyweb/businessrules/uniqueness_rules.py b/specifyweb/businessrules/uniqueness_rules.py index b2cf9c1318a..80364a391bd 100644 --- a/specifyweb/businessrules/uniqueness_rules.py +++ b/specifyweb/businessrules/uniqueness_rules.py @@ -25,7 +25,6 @@ logger = logging.getLogger(__name__) - @orm_signal_handler('pre_save', None, dispatch_uid=UNIQUENESS_DISPATCH_UID) def check_unique(model, instance): model_name = instance.__class__.__name__ diff --git a/specifyweb/businessrules/views.py b/specifyweb/businessrules/views.py index 23da2480c05..5a0be343833 100644 --- a/specifyweb/businessrules/views.py +++ b/specifyweb/businessrules/views.py @@ -294,4 +294,4 @@ def validate_uniqueness(request): "fields": [{"duplicates": duplicate[duplicates_field], "fields": {field: value for field, value in duplicate.items() if field != duplicates_field}} for duplicate in duplicates]} - return http.JsonResponse(final, safe=False) + return http.JsonResponse(final, safe=False) \ No newline at end of file diff --git a/specifyweb/frontend/js_src/css/main.css b/specifyweb/frontend/js_src/css/main.css index ce1f42ddb8d..43d23842262 100644 --- a/specifyweb/frontend/js_src/css/main.css +++ b/specifyweb/frontend/js_src/css/main.css @@ -254,9 +254,13 @@ --invalid-cell: theme('colors.red.300'); --modified-cell: theme('colors.yellow.250'); --search-result: theme('colors.green.300'); + --updated-cell: theme('colors.cyan.200'); + --deleted-cell: theme('colors.brand.100'); + --matched-and-changed-cell: theme('colors.gray.200'); @apply dark:[--invalid-cell:theme('colors.red.900')] dark:[--modified-cell:theme('colors.yellow.900')] dark:[--new-cell:theme('colors.indigo.900')] + dark:[--updated-cell:theme('colors.indigo.900')] dark:[--search-result:theme('colors.green.900')]; } diff --git a/specifyweb/frontend/js_src/css/workbench.css b/specifyweb/frontend/js_src/css/workbench.css index f7839c71071..d8a0fbfd813 100644 --- a/specifyweb/frontend/js_src/css/workbench.css +++ b/specifyweb/frontend/js_src/css/workbench.css @@ -38,7 +38,10 @@ } /* CONTENT styles */ -.wbs-form.wb-show-upload-results .wb-no-match-cell, +.wbs-form.wb-show-upload-results .wb-no-match-cell +.wbs-form.wb-show-upload-results .wb-updated-cell +.wbs-form.wb-show-upload-results .wb-deleted-cell +.wbs-form.wb-show-upload-results .wb-matched-and-changed-cell .wbs-form.wb-focus-coordinates .wb-coordinate-cell { @apply text-black dark:text-white; } @@ -50,13 +53,28 @@ /* Cell navigation */ .wbs-form - :is(.wb-no-match-cell, .wb-modified-cell, .htCommentCell, .wb-search-match-cell), + :is(.wb-no-match-cell, .wb-modified-cell, .htCommentCell, .wb-search-match-cell, .wb-updated-cell, .wb-deleted-cell, .wb-matched-and-changed-cell), .wb-navigation-section { @apply !bg-[color:var(--accent-color)]; } /* The order here determines the priority of the states * From the lowest till the highest */ +.wbs-form:not(.wb-hide-new-cells) .wb-updated-cell, +.wb-navigation-section[data-navigation-type='updatedCells'] { + --accent-color: var(--updated-cell); +} + +.wbs-form:not(.wb-hide-new-cells) .wb-deleted-cell, +.wb-navigation-section[data-navigation-type='deletedCells'] { + --accent-color: var(--deleted-cell); +} + +.wbs-form:not(.wb-hide-new-cells) .wb-matched-and-changed-cell, +.wb-navigation-section[data-navigation-type='matchedAndChangedCells'] { + --accent-color: var(--matched-and-changed-cell); +} + .wbs-form:not(.wb-hide-new-cells) .wb-no-match-cell, .wb-navigation-section[data-navigation-type='newCells'] { --accent-color: var(--new-cell); diff --git a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/Datasets.tsx b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/Datasets.tsx index 81e51a85e47..ca8d2701be6 100644 --- a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/Datasets.tsx +++ b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/Datasets.tsx @@ -98,7 +98,7 @@ function ModifyDataset({ } const createEmpty = async (name: LocalizedString) => - createEmptyDataSet('/attachment_gw/dataset/', name, { + createEmptyDataSet('bulkAttachment', name, { uploadplan: { staticPathKey: undefined }, uploaderstatus: 'main', }); @@ -241,7 +241,7 @@ const getNamePromise = async () => date: new Date().toDateString(), }), undefined, - '/attachment_gw/dataset/' + 'bulkAttachment' ); function NewDataSet(): JSX.Element | null { diff --git a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/PerformAttachmentTask.ts b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/PerformAttachmentTask.ts index d70bd2e1777..9383b0fdfca 100644 --- a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/PerformAttachmentTask.ts +++ b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/PerformAttachmentTask.ts @@ -69,8 +69,8 @@ export function PerformAttachmentTask({ uploaded: (nextIndex === currentIndex ? 0 : 1) + progress.uploaded, })); workRef.current.mappedFiles = workRef.current.mappedFiles.map( - (uploadble, postIndex) => - postIndex === currentIndex ? postUpload : uploadble + (uploadable, postIndex) => + postIndex === currentIndex ? postUpload : uploadable ); }; diff --git a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/RenameDataSet.tsx b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/RenameDataSet.tsx index 1c9980d459d..1595be31bbc 100644 --- a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/RenameDataSet.tsx +++ b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/RenameDataSet.tsx @@ -28,7 +28,7 @@ export function AttachmentDatasetMeta({ return ( diff --git a/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx b/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx new file mode 100644 index 00000000000..55a070b1f4b --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx @@ -0,0 +1,118 @@ +import React from 'react'; +import { SpecifyResource } from '../DataModel/legacyTypes'; +import { GeographyTreeDefItem, SpQuery, Tables } from '../DataModel/types'; +import { Button } from '../Atoms/Button'; +import { useNavigate } from 'react-router-dom'; +import { keysToLowerCase } from '../../utils/utils'; +import { serializeResource } from '../DataModel/serializers'; +import { ajax } from '../../utils/ajax'; +import { QueryField } from '../QueryBuilder/helpers'; +import { defined, filterArray, localized, RA } from '../../utils/types'; +import { generateMappingPathPreview } from '../WbPlanView/mappingPreview'; +import { batchEditText } from '../../localization/batchEdit'; +import { uniquifyDataSetName } from '../WbImport/helpers'; +import { QueryFieldSpec } from '../QueryBuilder/fieldSpec'; +import { State } from 'typesafe-reducer'; +import { isNestedToMany } from '../WbPlanView/modelHelpers'; +import { LocalizedString } from 'typesafe-i18n'; +import {strictGetTreeDefinitionItems, treeRanksPromise } from '../InitialContext/treeRanks'; +import { SerializedResource } from '../DataModel/helperTypes'; +import { f } from '../../utils/functools'; +import { LoadingContext } from '../Core/Contexts'; +import { commonText } from '../../localization/common'; +import { Dialog } from '../Molecules/Dialog'; +import { formatConjunction } from '../Atoms/Internationalization'; +import { dialogIcons } from '../Atoms/Icons'; +import { userPreferences } from '../Preferences/userPreferences'; + + +export function BatchEdit({ + query, + fields, + baseTableName, +}: { + readonly query: SpecifyResource; + readonly fields: RA; + readonly baseTableName: keyof Tables +}) { + const navigate = useNavigate(); + const post = (dataSetName: string) => + ajax<{ id: number }>('/stored_query/batch_edit/', { + method: 'POST', + errorMode: 'dismissible', + headers: { Accept: 'application/json' }, + body: keysToLowerCase( + { + ...serializeResource(query), + captions: fields.filter(({isDisplay})=>isDisplay).map(({mappingPath})=>generateMappingPathPreview(baseTableName, mappingPath)), + name: dataSetName, + limit: userPreferences.get('batchEdit', 'query', 'limit') + }), + }); + const [errors, setErrors] = React.useState>([]); + const loading = React.useContext(LoadingContext); + return ( + <> + { + loading( + treeRanksPromise.then(()=>{ + const queryFieldSpecs = fields.map((field)=>[field.isDisplay, QueryFieldSpec.fromPath(baseTableName, field.mappingPath)] as const); + const visibleSpecs = filterArray(queryFieldSpecs.map((item)=>item[0] ? item[1] : undefined)); + // Need to only perform checks on display fields, but need to use line numbers from the original query. + const newErrors = filterArray(queryFieldSpecs.flatMap(([isDisplay, fieldSpec], index)=>isDisplay ? validators.map((callback)=>f.maybe(callback(fieldSpec, visibleSpecs), (reason)=>({type: 'Invalid', reason, line: index} as const))) : [])); + if (newErrors.length > 0){ + setErrors(newErrors); + return; + } + const newName = batchEditText.datasetName({queryName: query.get('name'), datePart: new Date().toDateString()}); + return uniquifyDataSetName(newName, undefined, 'batchEdit').then((name)=>post(name).then(({ data }) => navigate(`/specify/workbench/${data.id}`))) + }) + ) + } + } + > + <>{batchEditText.batchEdit()} + + {errors.length > 0 && setErrors([])}/>} + + ); +} + + +type Invalid = State<"Invalid", {readonly line: number, readonly reason: LocalizedString}>; + +type ValidatorItem = (queryField: QueryFieldSpec, allQueryFields: RA) => undefined | LocalizedString; + +const validators: RA = [containsFaultyNestedToMany, containsFaultyTreeRelationships] + +function containsFaultyNestedToMany(queryField: QueryFieldSpec, allQueryFields: RA) : undefined | LocalizedString; +function containsFaultyNestedToMany(queryField: QueryFieldSpec) : undefined | LocalizedString { + const joinPath = queryField.joinPath + if (joinPath.length <= 1) return undefined; + const hasNestedToMany = joinPath.some((currentField, id)=>{ + const nextField = joinPath[id+1]; + return nextField !== undefined && currentField.isRelationship && nextField.isRelationship && isNestedToMany(currentField, nextField); + }); + return hasNestedToMany ? batchEditText.containsNestedToMany() : undefined +} + +const getTreeDefFromName = (rankName: string, treeDefItems: RA>)=>defined(treeDefItems.find((treeRank)=>treeRank.name.toLowerCase() === rankName.toLowerCase())); + +function containsFaultyTreeRelationships(queryField: QueryFieldSpec, allQueryFields: RA) : undefined | LocalizedString { + if (queryField.treeRank === undefined) return undefined; // no treee ranks, nothing to check. + const otherTreeRanks = filterArray(allQueryFields.map((fieldSpec)=>fieldSpec.treeRank)); + const treeDefItems = strictGetTreeDefinitionItems(queryField.table.name as "Geography", false); + const currentRank = defined(getTreeDefFromName(queryField.treeRank, treeDefItems)); + const isHighest = otherTreeRanks.every((item)=>getTreeDefFromName(item, treeDefItems).rankId >= currentRank.rankId); + if (!isHighest) return undefined; // To not possibly duplicate the error multiple times + const missingRanks = treeDefItems.filter((item)=>item.rankId > currentRank.rankId); + if (missingRanks.length !== 0) return + return batchEditText.missingRanks({rankJoined: formatConjunction(missingRanks.map((item)=>localized(item.title ?? '')))}) +} + +function ErrorsDialog({errors, onClose: handleClose}:{readonly errors: RA; readonly onClose: ()=>void }): JSX.Element { + return + {errors.map((error, index)=>
{error.line+1} {error.reason}
)} +
+} \ No newline at end of file diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/resource.test.ts b/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/resource.test.ts index 0b51c70f85d..81d5fbfad99 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/resource.test.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/resource.test.ts @@ -31,6 +31,8 @@ import type { CollectionObject } from '../types'; const { getCarryOverPreference, getFieldsToClone } = exportsForTests; +import uniqueFields from '../uniqueFields.json'; + mockTime(); requireContext(); @@ -248,40 +250,9 @@ describe('getCarryOverPreference', () => { }); }); -describe('getUniqueFields', () => { - test('CollectionObject', () => - expect(getUniqueFields(tables.CollectionObject)).toEqual([ - 'catalogNumber', - 'uniqueIdentifier', - 'guid', - 'collectionObjectAttachments', - 'timestampCreated', - 'version', - 'timestampModified', - ])); - test('Locality', () => - expect(getUniqueFields(tables.Locality)).toEqual([ - 'uniqueIdentifier', - 'localityAttachments', - 'guid', - 'timestampCreated', - 'version', - 'timestampModified', - ])); - test('AccessionAttachment', () => - expect(getUniqueFields(tables.AccessionAttachment)).toEqual([ - 'attachment', - 'timestampCreated', - 'version', - 'timestampModified', - ])); - test('AccessionAgent', () => - expect(getUniqueFields(tables.AccessionAgent)).toEqual([ - 'timestampCreated', - 'version', - 'timestampModified', - ])); -}); +test('checkUniqueFields', ()=>{ + Object.values(tables).map((table)=>expect(getUniqueFields(table, false)).toEqual(uniqueFields[table.name.toLowerCase() as keyof typeof uniqueFields])) +}) test('getFieldsToNotClone', () => { userPreferences.set('form', 'preferences', 'carryForward', { diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/schemaOverrides.test.ts b/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/schemaOverrides.test.ts index 98a008feaa6..948330d7226 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/schemaOverrides.test.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/schemaOverrides.test.ts @@ -12,14 +12,21 @@ theories(getTableOverwrite, [ ]); theories(getGlobalFieldOverwrite, [ - { in: ['Taxon', 'isAccepted'], out: 'readOnly' }, - { in: ['Geography', 'timestampCreated'], out: 'readOnly' }, + { in: ['Taxon', 'isAccepted'], out: { visibility: 'readOnly' } }, + { in: ['Geography', 'timestampCreated'], out: { visibility: 'readOnly' } }, + { + in: ['TaxonTreeDefItem', 'fullNameSeparator'], + out: { whiteSpaceSensitive: true }, + }, { in: ['SpecifyUser', 'id'], out: undefined }, ]); theories(getFieldOverwrite, [ - { in: ['Taxon', 'timestampCreated'], out: 'hidden' }, - { in: ['Agent', 'agentType'], out: 'optional' }, + { in: ['Taxon', 'timestampCreated'], out: { visibility: 'hidden' } }, + { in: ['Agent', 'agentType'], out: { visibility: 'optional' } }, { in: ['Agent', 'lastName'], out: undefined }, - { in: ['Attachment', 'collectingTripAttachments'], out: 'hidden' }, + { + in: ['Attachment', 'collectingTripAttachments'], + out: { visibility: 'hidden' }, + }, ]); diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/resource.ts b/specifyweb/frontend/js_src/lib/components/DataModel/resource.ts index d66501430cf..1d133fa4761 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/resource.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/resource.ts @@ -4,7 +4,7 @@ import { ping } from '../../utils/ajax/ping'; import { eventListener } from '../../utils/events'; import { f } from '../../utils/functools'; import type { DeepPartial, RA, RR } from '../../utils/types'; -import { defined, filterArray } from '../../utils/types'; +import {defined, filterArray, setDevelopmentGlobal} from '../../utils/types'; import { keysToLowerCase, removeKey } from '../../utils/utils'; import type { InteractionWithPreps } from '../Interactions/helpers'; import { @@ -25,8 +25,8 @@ import type { import type { SpecifyResource } from './legacyTypes'; import { schema } from './schema'; import { serializeResource } from './serializers'; -import type { SpecifyTable } from './specifyTable'; -import { genericTables, getTable } from './tables'; +import { SpecifyTable } from './specifyTable'; +import {genericTables, getTable, tables} from './tables'; import type { Tables } from './types'; import { getUniquenessRules } from './uniquenessRules'; @@ -255,7 +255,9 @@ function getCarryOverPreference( return config?.[table.name] ?? getFieldsToClone(table); } -export const getFieldsToClone = (table: SpecifyTable): RA => +export const getFieldsToClone = ( + table: SpecifyTable, +): RA => table.fields .filter( (field) => @@ -275,22 +277,23 @@ const uniqueFields = [ 'timestampModified', ]; -export const getUniqueFields = (table: SpecifyTable): RA => +const getUniqueFieldsFromRules = (table: SpecifyTable)=>(getUniquenessRules(table.name) ?? []) +.filter(({ rule: { scopes } }) => + scopes.every( + (fieldPath) => + ( + getFieldsFromPath(table, fieldPath).at(-1)?.name ?? '' + ).toLowerCase() in schema.domainLevelIds + ) +) +.flatMap(({ rule: { fields } }) => + fields.flatMap((field) => table.getField(field)?.name) +); + +// WARNING: Changing the behaviour here will also change how batch-edit clones records. +export const getUniqueFields = (table: SpecifyTable, schemaAware: boolean =true): RA => f.unique([ - ...filterArray( - (getUniquenessRules(table.name) ?? []) - .filter(({ rule: { scopes } }) => - scopes.every( - (fieldPath) => - ( - getFieldsFromPath(table, fieldPath).at(-1)?.name ?? '' - ).toLowerCase() in schema.domainLevelIds - ) - ) - .flatMap(({ rule: { fields } }) => - fields.flatMap((field) => table.getField(field)?.name) - ) - ), + ...filterArray(schemaAware ? getUniqueFieldsFromRules(table) : []), /* * Each attachment is assumed to refer to a unique attachment file * See https://github.com/specify/specify7/issues/1754#issuecomment-1157796585 @@ -324,3 +327,11 @@ export const exportsForTests = { getCarryOverPreference, getFieldsToClone, }; + +setDevelopmentGlobal('_getUniqueFields', (): void => { + // Batch-editor clones records in independent-to-one no-match cases. It needs to be aware of the fields to not clone. It's fine if it doesn't respect user preferences (for now), but needs to be replicate + // front-end logic. So, the "fields to not clone" must be identical. This is done by storing them as a static file, which frontend and backend both access + a unit test to make sure the file is up-to-date. + // In the case where the user is really doesn't want to carry-over some fields, they can simply add those fields in batch-edit query (and then set them to null) so it handles general use case pretty well. + const allTablesResult = Object.fromEntries(Object.values(tables).map((table)=>[table.name.toLowerCase(), getUniqueFields(table, false)])); + document.body.textContent = JSON.stringify(allTablesResult); +}) diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/schemaOverrides.ts b/specifyweb/frontend/js_src/lib/components/DataModel/schemaOverrides.ts index 1cb630c6517..53ff2a4f58d 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/schemaOverrides.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/schemaOverrides.ts @@ -29,14 +29,18 @@ export type TableConfigOverwrite = */ | 'system'; -export type FieldConfigOverwrite = - // Makes a required field optional - | 'optional' - | 'required' - // Removes a field from the mapper (but not from Query Builder) - | 'readOnly' - // Hides a field. If it was required, it is made optional - | 'hidden'; +type FieldConfigOverwrite = Partial<{ + readonly visibility: + | 'optional' // Makes a required field optional + | 'required' + // Removes a field from the mapper (but not from Query Builder) + | 'readOnly' + // Hides a field. If it was required, it is made optional + | 'hidden'; + + // Indicates white space should not be ignored in the field + readonly whiteSpaceSensitive: true; +}>; const tableOverwrites: Partial> = { Accession: 'commonBaseTable', @@ -100,78 +104,93 @@ const globalFieldOverrides: { } = { // Common overwrites apply to fields in all tables common: { - timestampCreated: 'readOnly', + timestampCreated: { visibility: 'readOnly' }, /** * This is read only in default forms, but not in schema config. * That causes problems as field is editable in autogenerated view. */ - timestampModified: 'readOnly', + timestampModified: { visibility: 'readOnly' }, }, Attachment: { - tableID: 'optional', + tableID: { visibility: 'optional' }, }, CollectionRelationship: { - collectionRelType: 'required', + collectionRelType: { visibility: 'required' }, }, CollectionRelType: { - name: 'required', + name: { visibility: 'required' }, }, DNASequence: { - totalResidues: 'readOnly', - compA: 'readOnly', - compG: 'readOnly', - compC: 'readOnly', - compT: 'readOnly', - ambiguousResidues: 'readOnly', + totalResidues: { visibility: 'readOnly' }, + compA: { visibility: 'readOnly' }, + compG: { visibility: 'readOnly' }, + compC: { visibility: 'readOnly' }, + compT: { visibility: 'readOnly' }, + ambiguousResidues: { visibility: 'readOnly' }, }, Taxon: { - parent: 'required', - isAccepted: 'readOnly', - acceptedTaxon: 'readOnly', - fullName: 'readOnly', + parent: { visibility: 'required' }, + isAccepted: { visibility: 'readOnly' }, + acceptedTaxon: { visibility: 'readOnly' }, + fullName: { visibility: 'readOnly' }, }, Geography: { - parent: 'required', - isAccepted: 'readOnly', - acceptedGeography: 'readOnly', - fullName: 'readOnly', + parent: { visibility: 'required' }, + isAccepted: { visibility: 'readOnly' }, + acceptedGeography: { visibility: 'readOnly' }, + fullName: { visibility: 'readOnly' }, }, LithoStrat: { - parent: 'required', - isAccepted: 'readOnly', - acceptedLithoStrat: 'readOnly', - fullName: 'readOnly', + parent: { visibility: 'required' }, + isAccepted: { visibility: 'readOnly' }, + acceptedLithoStrat: { visibility: 'readOnly' }, + fullName: { visibility: 'readOnly' }, }, GeologicTimePeriod: { - parent: 'required', - isAccepted: 'readOnly', - acceptedGeologicTimePeriod: 'readOnly', - fullName: 'readOnly', + parent: { visibility: 'required' }, + isAccepted: { visibility: 'readOnly' }, + acceptedGeologicTimePeriod: { visibility: 'readOnly' }, + fullName: { visibility: 'readOnly' }, }, Storage: { - parent: 'required', - isAccepted: 'readOnly', - acceptedStorage: 'readOnly', - fullName: 'readOnly', + parent: { visibility: 'required' }, + isAccepted: { visibility: 'readOnly' }, + acceptedStorage: { visibility: 'readOnly' }, + fullName: { visibility: 'readOnly' }, }, SpecifyUser: { - isAdmin: 'readOnly', - password: 'hidden', + isAdmin: { visibility: 'readOnly' }, + password: { visibility: 'hidden' }, }, TaxonTreeDef: { - fullNameDirection: 'readOnly', + fullNameDirection: { visibility: 'readOnly' }, + }, + TaxonTreeDefItem: { + fullNameSeparator: { whiteSpaceSensitive: true }, }, GeographyTreeDef: { - fullNameDirection: 'readOnly', + fullNameDirection: { visibility: 'readOnly' }, + }, + GeographyTreeDefItem: { + fullNameSeparator: { whiteSpaceSensitive: true }, }, StorageTreeDef: { - fullNameDirection: 'readOnly', + fullNameDirection: { visibility: 'readOnly' }, + }, + StorageTreeDefItem: { + fullNameSeparator: { whiteSpaceSensitive: true }, }, GeologicTimePeriodTreeDef: { - fullNameDirection: 'readOnly', + fullNameDirection: { visibility: 'readOnly' }, + }, + GeologicTimePeriodTreeDefItem: { + fullNameSeparator: { whiteSpaceSensitive: true }, }, LithoStratTreeDef: { - fullNameDirection: 'readOnly', + fullNameDirection: { visibility: 'readOnly' }, + }, + LithoStratTreeDefItem: { + fullNameSeparator: { whiteSpaceSensitive: true }, }, }; @@ -183,35 +202,35 @@ const globalFieldOverrides: { */ const fieldOverwrites: typeof globalFieldOverrides = { common: { - timestampCreated: 'hidden', - timestampModified: 'hidden', - createdByAgent: 'hidden', - modifiedByAgent: 'hidden', - collectionMemberId: 'hidden', - rankId: 'hidden', - definition: 'hidden', - definitionItem: 'hidden', - orderNumber: 'hidden', - isPrimary: 'hidden', - isHybrid: 'hidden', - isAccepted: 'hidden', - fullName: 'readOnly', + timestampCreated: { visibility: 'hidden' }, + timestampModified: { visibility: 'hidden' }, + createdByAgent: { visibility: 'hidden' }, + modifiedByAgent: { visibility: 'hidden' }, + collectionMemberId: { visibility: 'hidden' }, + rankId: { visibility: 'hidden' }, + definition: { visibility: 'hidden' }, + definitionItem: { visibility: 'hidden' }, + orderNumber: { visibility: 'hidden' }, + isPrimary: { visibility: 'hidden' }, + isHybrid: { visibility: 'hidden' }, + isAccepted: { visibility: 'hidden' }, + fullName: { visibility: 'readOnly' }, }, Agent: { - agentType: 'optional', + agentType: { visibility: 'optional' }, }, LoanPreparation: { - isResolved: 'optional', + isResolved: { visibility: 'optional' }, }, Locality: { - srcLatLongUnit: 'optional', + srcLatLongUnit: { visibility: 'optional' }, }, PrepType: { - isLoanable: 'readOnly', + isLoanable: { visibility: 'readOnly' }, }, Determination: { - preferredTaxon: 'readOnly', - isCurrent: 'hidden', + preferredTaxon: { visibility: 'readOnly' }, + isCurrent: { visibility: 'hidden' }, }, }; @@ -224,7 +243,7 @@ const endsWithFieldOverwrites: Partial< RR> > = { Attachment: { - Attachments: 'hidden', + Attachments: { visibility: 'hidden' }, }, }; diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/specifyField.ts b/specifyweb/frontend/js_src/lib/components/DataModel/specifyField.ts index e8c4816e4a0..09ef03a437f 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/specifyField.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/specifyField.ts @@ -92,7 +92,7 @@ export abstract class FieldBase { * Overrides are used to overwrite the default data model settings and the * schema config settings. Overrides mostly affect Query Builder and the * WorkBench mapper. They are used to force-hide unsupported fields and - * legacy fields + * legacy fields. */ public readonly overrides: { // eslint-disable-next-line functional/prefer-readonly-type @@ -129,12 +129,13 @@ export abstract class FieldBase { const globalFieldOverride = getGlobalFieldOverwrite(table.name, this.name); this.isReadOnly = - globalFieldOverride === 'readOnly' || fieldDefinition.readOnly === true; + globalFieldOverride?.visibility === 'readOnly' || + fieldDefinition.readOnly === true; this.isRequired = - globalFieldOverride === 'required' + globalFieldOverride?.visibility === 'required' ? true - : globalFieldOverride === 'optional' + : globalFieldOverride?.visibility === 'optional' ? false : fieldDefinition.required; this.type = fieldDefinition.type; @@ -151,18 +152,21 @@ export abstract class FieldBase { : camelToHuman(this.name); this.isHidden = - globalFieldOverride === 'hidden' || (this.localization.ishidden ?? false); + globalFieldOverride?.visibility === 'hidden' || + (this.localization.ishidden ?? false); // Apply overrides const fieldOverwrite = getFieldOverwrite(this.table.name, this.name); - let isRequired = fieldOverwrite !== 'optional' && this.isRequired; + let isRequired = + fieldOverwrite?.visibility !== 'optional' && this.isRequired; let isHidden = this.isHidden; - const isReadOnly = this.isReadOnly || fieldOverwrite === 'readOnly'; + const isReadOnly = + this.isReadOnly || fieldOverwrite?.visibility === 'readOnly'; // Overwritten hidden fields are made not required - if (fieldOverwrite === 'hidden') { + if (fieldOverwrite?.visibility === 'hidden') { isRequired = false; isHidden = true; } @@ -250,9 +254,19 @@ export class LiteralField extends FieldBase { public readonly isRelationship: false = false; + // Indicates white space should not be ignored in the field + public readonly whiteSpaceSensitive: boolean; + public constructor(table: SpecifyTable, fieldDefinition: FieldDefinition) { super(table, fieldDefinition); this.type = fieldDefinition.type; + + const globalFieldOverride = getGlobalFieldOverwrite(table.name, this.name); + const fieldOverwrite = getFieldOverwrite(table.name, this.name); + + this.whiteSpaceSensitive = + (globalFieldOverride?.whiteSpaceSensitive ?? false) || + (fieldOverwrite?.whiteSpaceSensitive ?? false); } // Returns the name of the UIFormatter for the field from the schema config. diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/uniqueFields.json b/specifyweb/frontend/js_src/lib/components/DataModel/uniqueFields.json new file mode 100644 index 00000000000..949877f50c6 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/DataModel/uniqueFields.json @@ -0,0 +1,1045 @@ +{ + "accession": [ + "accessionAttachments", + "timestampCreated", + "version", + "timestampModified" + ], + "accessionagent": [ + "timestampCreated", + "version", + "timestampModified" + ], + "accessionattachment": [ + "attachment", + "timestampCreated", + "version", + "timestampModified" + ], + "accessionauthorization": [ + "timestampCreated", + "version", + "timestampModified" + ], + "accessioncitation": [ + "timestampCreated", + "version", + "timestampModified" + ], + "address": [ + "timestampCreated", + "version", + "isCurrent", + "isPrimary", + "timestampModified" + ], + "addressofrecord": [ + "timestampCreated", + "version", + "timestampModified" + ], + "agent": [ + "agentAttachments", + "guid", + "timestampCreated", + "version", + "timestampModified" + ], + "agentattachment": [ + "attachment", + "timestampCreated", + "version", + "timestampModified" + ], + "agentgeography": [ + "timestampCreated", + "version", + "timestampModified" + ], + "agentidentifier": [ + "timestampCreated", + "version", + "timestampModified" + ], + "agentspecialty": [ + "timestampCreated", + "version", + "timestampModified" + ], + "agentvariant": [ + "timestampCreated", + "version", + "timestampModified" + ], + "appraisal": [ + "timestampCreated", + "version", + "timestampModified" + ], + "attachment": [ + "accessionAttachments", + "agentAttachments", + "borrowAttachments", + "collectingEventAttachments", + "collectingTripAttachments", + "collectionObjectAttachments", + "conservDescriptionAttachments", + "conservEventAttachments", + "deaccessionAttachments", + "disposalAttachments", + "dnaSequenceAttachments", + "dnaSequencingRunAttachments", + "exchangeInAttachments", + "exchangeOutAttachments", + "fieldNotebookAttachments", + "fieldNotebookPageAttachments", + "fieldNotebookPageSetAttachments", + "giftAttachments", + "loanAttachments", + "localityAttachments", + "permitAttachments", + "preparationAttachments", + "referenceWorkAttachments", + "repositoryAgreementAttachments", + "storageAttachments", + "taxonAttachments", + "treatmentEventAttachments", + "guid", + "timestampCreated", + "version", + "timestampModified" + ], + "attachmentimageattribute": [ + "attachments", + "timestampCreated", + "version", + "timestampModified" + ], + "attachmentmetadata": [ + "attachment", + "timestampCreated", + "version", + "timestampModified" + ], + "attachmenttag": [ + "attachment", + "timestampCreated", + "version", + "timestampModified" + ], + "attributedef": [ + "timestampCreated", + "version", + "timestampModified" + ], + "author": [ + "timestampCreated", + "version", + "timestampModified" + ], + "autonumberingscheme": [ + "timestampCreated", + "version", + "timestampModified" + ], + "borrow": [ + "borrowAttachments", + "timestampCreated", + "version", + "timestampModified" + ], + "borrowagent": [ + "timestampCreated", + "version", + "timestampModified" + ], + "borrowattachment": [ + "attachment", + "timestampCreated", + "version", + "timestampModified" + ], + "borrowmaterial": [ + "timestampCreated", + "version", + "timestampModified" + ], + "borrowreturnmaterial": [ + "timestampCreated", + "version", + "timestampModified" + ], + "collectingevent": [ + "collectingEventAttachments", + "guid", + "timestampCreated", + "version", + "timestampModified" + ], + "collectingeventattachment": [ + "attachment", + "timestampCreated", + "version", + "timestampModified" + ], + "collectingeventattr": [ + "timestampCreated", + "version", + "timestampModified" + ], + "collectingeventattribute": [ + "timestampCreated", + "version", + "timestampModified" + ], + "collectingeventauthorization": [ + "timestampCreated", + "version", + "timestampModified" + ], + "collectingtrip": [ + "collectingTripAttachments", + "timestampCreated", + "version", + "timestampModified" + ], + "collectingtripattachment": [ + "attachment", + "timestampCreated", + "version", + "timestampModified" + ], + "collectingtripattribute": [ + "timestampCreated", + "version", + "timestampModified" + ], + "collectingtripauthorization": [ + "timestampCreated", + "version", + "timestampModified" + ], + "collection": [ + "guid", + "timestampCreated", + "version", + "timestampModified" + ], + "collectionobject": [ + "collectionObjectAttachments", + "guid", + "timestampCreated", + "version", + "timestampModified" + ], + "collectionobjectattachment": [ + "attachment", + "timestampCreated", + "version", + "timestampModified" + ], + "collectionobjectattr": [ + "timestampCreated", + "version", + "timestampModified" + ], + "collectionobjectattribute": [ + "timestampCreated", + "version", + "timestampModified" + ], + "collectionobjectcitation": [ + "timestampCreated", + "version", + "timestampModified" + ], + "collectionobjectproperty": [ + "guid", + "timestampCreated", + "version", + "timestampModified" + ], + "collectionreltype": [ + "timestampCreated", + "version", + "timestampModified" + ], + "collectionrelationship": [ + "timestampCreated", + "version", + "timestampModified" + ], + "collector": [ + "timestampCreated", + "version", + "isPrimary", + "timestampModified" + ], + "commonnametx": [ + "timestampCreated", + "version", + "timestampModified" + ], + "commonnametxcitation": [ + "timestampCreated", + "version", + "timestampModified" + ], + "conservdescription": [ + "conservDescriptionAttachments", + "timestampCreated", + "version", + "timestampModified" + ], + "conservdescriptionattachment": [ + "attachment", + "timestampCreated", + "version", + "timestampModified" + ], + "conservevent": [ + "conservEventAttachments", + "timestampCreated", + "version", + "timestampModified" + ], + "conserveventattachment": [ + "attachment", + "timestampCreated", + "version", + "timestampModified" + ], + "container": [ + "timestampCreated", + "version", + "timestampModified" + ], + "dnaprimer": [ + "timestampCreated", + "version", + "timestampModified" + ], + "dnasequence": [ + "attachments", + "timestampCreated", + "version", + "timestampModified" + ], + "dnasequenceattachment": [ + "attachment", + "timestampCreated", + "version", + "timestampModified" + ], + "dnasequencingrun": [ + "attachments", + "timestampCreated", + "version", + "timestampModified" + ], + "dnasequencingrunattachment": [ + "attachment", + "timestampCreated", + "version", + "timestampModified" + ], + "dnasequencingruncitation": [ + "timestampCreated", + "version", + "timestampModified" + ], + "datatype": [ + "timestampCreated", + "version", + "timestampModified" + ], + "deaccession": [ + "deaccessionAttachments", + "timestampCreated", + "version", + "timestampModified" + ], + "deaccessionagent": [ + "timestampCreated", + "version", + "timestampModified" + ], + "deaccessionattachment": [ + "attachment", + "timestampCreated", + "version", + "timestampModified" + ], + "determination": [ + "guid", + "timestampCreated", + "version", + "isCurrent", + "timestampModified" + ], + "determinationcitation": [ + "timestampCreated", + "version", + "timestampModified" + ], + "determiner": [ + "timestampCreated", + "version", + "isPrimary", + "timestampModified" + ], + "discipline": [ + "timestampCreated", + "version", + "timestampModified" + ], + "disposal": [ + "disposalAttachments", + "disposalPreparations", + "timestampCreated", + "version", + "timestampModified" + ], + "disposalagent": [ + "timestampCreated", + "version", + "timestampModified" + ], + "disposalattachment": [ + "attachment", + "timestampCreated", + "version", + "timestampModified" + ], + "disposalpreparation": [ + "timestampCreated", + "version", + "timestampModified" + ], + "division": [ + "timestampCreated", + "version", + "timestampModified" + ], + "exchangein": [ + "exchangeInAttachments", + "exchangeInPreps", + "timestampCreated", + "version", + "timestampModified" + ], + "exchangeinattachment": [ + "attachment", + "timestampCreated", + "version", + "timestampModified" + ], + "exchangeinprep": [ + "timestampCreated", + "version", + "timestampModified" + ], + "exchangeout": [ + "exchangeOutAttachments", + "exchangeOutPreps", + "timestampCreated", + "version", + "timestampModified" + ], + "exchangeoutattachment": [ + "attachment", + "timestampCreated", + "version", + "timestampModified" + ], + "exchangeoutprep": [ + "timestampCreated", + "version", + "timestampModified" + ], + "exsiccata": [ + "timestampCreated", + "version", + "timestampModified" + ], + "exsiccataitem": [ + "timestampCreated", + "version", + "timestampModified" + ], + "extractor": [ + "timestampCreated", + "version", + "timestampModified" + ], + "fieldnotebook": [ + "attachments", + "timestampCreated", + "version", + "timestampModified" + ], + "fieldnotebookattachment": [ + "attachment", + "timestampCreated", + "version", + "timestampModified" + ], + "fieldnotebookpage": [ + "attachments", + "timestampCreated", + "version", + "timestampModified" + ], + "fieldnotebookpageattachment": [ + "attachment", + "timestampCreated", + "version", + "timestampModified" + ], + "fieldnotebookpageset": [ + "attachments", + "timestampCreated", + "version", + "timestampModified" + ], + "fieldnotebookpagesetattachment": [ + "attachment", + "timestampCreated", + "version", + "timestampModified" + ], + "fundingagent": [ + "timestampCreated", + "version", + "isPrimary", + "timestampModified" + ], + "geocoorddetail": [ + "timestampCreated", + "version", + "timestampModified" + ], + "geography": [ + "guid", + "timestampCreated", + "version", + "isCurrent", + "timestampModified" + ], + "geographytreedef": [ + "timestampCreated", + "version", + "timestampModified" + ], + "geographytreedefitem": [ + "timestampCreated", + "version", + "timestampModified" + ], + "geologictimeperiod": [ + "guid", + "timestampCreated", + "version", + "timestampModified" + ], + "geologictimeperiodtreedef": [ + "timestampCreated", + "version", + "timestampModified" + ], + "geologictimeperiodtreedefitem": [ + "timestampCreated", + "version", + "timestampModified" + ], + "gift": [ + "giftAttachments", + "giftPreparations", + "timestampCreated", + "version", + "timestampModified" + ], + "giftagent": [ + "timestampCreated", + "version", + "timestampModified" + ], + "giftattachment": [ + "attachment", + "timestampCreated", + "version", + "timestampModified" + ], + "giftpreparation": [ + "timestampCreated", + "version", + "timestampModified" + ], + "groupperson": [ + "timestampCreated", + "version", + "timestampModified" + ], + "inforequest": [ + "timestampCreated", + "version", + "timestampModified" + ], + "institution": [ + "guid", + "timestampCreated", + "version", + "timestampModified" + ], + "institutionnetwork": [ + "timestampCreated", + "version", + "timestampModified" + ], + "journal": [ + "guid", + "timestampCreated", + "version", + "timestampModified" + ], + "latlonpolygon": [ + "timestampCreated", + "version", + "timestampModified" + ], + "latlonpolygonpnt": [], + "lithostrat": [ + "guid", + "timestampCreated", + "version", + "timestampModified" + ], + "lithostrattreedef": [ + "timestampCreated", + "version", + "timestampModified" + ], + "lithostrattreedefitem": [ + "timestampCreated", + "version", + "timestampModified" + ], + "loan": [ + "loanAttachments", + "loanPreparations", + "timestampCreated", + "version", + "timestampModified" + ], + "loanagent": [ + "timestampCreated", + "version", + "timestampModified" + ], + "loanattachment": [ + "attachment", + "timestampCreated", + "version", + "timestampModified" + ], + "loanpreparation": [ + "timestampCreated", + "version", + "timestampModified" + ], + "loanreturnpreparation": [ + "timestampCreated", + "version", + "timestampModified" + ], + "locality": [ + "localityAttachments", + "guid", + "timestampCreated", + "version", + "timestampModified" + ], + "localityattachment": [ + "attachment", + "timestampCreated", + "version", + "timestampModified" + ], + "localitycitation": [ + "timestampCreated", + "version", + "timestampModified" + ], + "localitydetail": [ + "timestampCreated", + "version", + "timestampModified" + ], + "localitynamealias": [ + "timestampCreated", + "version", + "timestampModified" + ], + "materialsample": [ + "guid", + "timestampCreated", + "version", + "timestampModified" + ], + "morphbankview": [ + "timestampCreated", + "version", + "timestampModified" + ], + "otheridentifier": [ + "timestampCreated", + "version", + "timestampModified" + ], + "paleocontext": [ + "timestampCreated", + "version", + "timestampModified" + ], + "pcrperson": [ + "timestampCreated", + "version", + "timestampModified" + ], + "permit": [ + "permitAttachments", + "timestampCreated", + "version", + "timestampModified" + ], + "permitattachment": [ + "attachment", + "timestampCreated", + "version", + "timestampModified" + ], + "picklist": [ + "timestampCreated", + "version", + "timestampModified" + ], + "picklistitem": [ + "timestampCreated", + "version", + "timestampModified" + ], + "preptype": [ + "timestampCreated", + "version", + "timestampModified" + ], + "preparation": [ + "preparationAttachments", + "guid", + "timestampCreated", + "version", + "timestampModified" + ], + "preparationattachment": [ + "attachment", + "timestampCreated", + "version", + "timestampModified" + ], + "preparationattr": [ + "timestampCreated", + "version", + "timestampModified" + ], + "preparationattribute": [ + "timestampCreated", + "version", + "timestampModified" + ], + "preparationproperty": [ + "guid", + "timestampCreated", + "version", + "timestampModified" + ], + "project": [ + "timestampCreated", + "version", + "timestampModified" + ], + "recordset": [ + "timestampCreated", + "version", + "timestampModified" + ], + "recordsetitem": [], + "referencework": [ + "referenceWorkAttachments", + "guid", + "timestampCreated", + "version", + "timestampModified" + ], + "referenceworkattachment": [ + "attachment", + "timestampCreated", + "version", + "timestampModified" + ], + "repositoryagreement": [ + "repositoryAgreementAttachments", + "timestampCreated", + "version", + "timestampModified" + ], + "repositoryagreementattachment": [ + "attachment", + "timestampCreated", + "version", + "timestampModified" + ], + "shipment": [ + "timestampCreated", + "version", + "timestampModified" + ], + "spappresource": [ + "timestampCreated", + "version" + ], + "spappresourcedata": [ + "timestampCreated", + "version", + "timestampModified" + ], + "spappresourcedir": [ + "timestampCreated", + "version", + "timestampModified" + ], + "spauditlog": [ + "timestampCreated", + "version", + "timestampModified" + ], + "spauditlogfield": [ + "timestampCreated", + "version", + "timestampModified" + ], + "spexportschema": [ + "timestampCreated", + "version", + "timestampModified" + ], + "spexportschemaitem": [ + "timestampCreated", + "version", + "timestampModified" + ], + "spexportschemaitemmapping": [ + "timestampCreated", + "version", + "timestampModified" + ], + "spexportschemamapping": [ + "timestampCreated", + "version", + "timestampModified" + ], + "spfieldvaluedefault": [ + "timestampCreated", + "version", + "timestampModified" + ], + "splocalecontainer": [ + "timestampCreated", + "version", + "timestampModified" + ], + "splocalecontaineritem": [ + "timestampCreated", + "version", + "timestampModified" + ], + "splocaleitemstr": [ + "timestampCreated", + "version", + "timestampModified" + ], + "sppermission": [], + "spprincipal": [ + "timestampCreated", + "version", + "timestampModified" + ], + "spquery": [ + "timestampCreated", + "version", + "timestampModified" + ], + "spqueryfield": [ + "timestampCreated", + "version", + "timestampModified" + ], + "spreport": [ + "timestampCreated", + "version", + "timestampModified" + ], + "spsymbiotainstance": [ + "timestampCreated", + "version", + "timestampModified" + ], + "sptasksemaphore": [ + "timestampCreated", + "version", + "timestampModified" + ], + "spversion": [ + "timestampCreated", + "version", + "timestampModified" + ], + "spviewsetobj": [ + "timestampCreated", + "version", + "timestampModified" + ], + "spvisualquery": [ + "timestampCreated", + "version", + "timestampModified" + ], + "specifyuser": [ + "timestampCreated", + "version", + "timestampModified" + ], + "storage": [ + "storageAttachments", + "timestampCreated", + "version", + "timestampModified" + ], + "storageattachment": [ + "attachment", + "timestampCreated", + "version", + "timestampModified" + ], + "storagetreedef": [ + "timestampCreated", + "version", + "timestampModified" + ], + "storagetreedefitem": [ + "timestampCreated", + "version", + "timestampModified" + ], + "taxon": [ + "taxonAttachments", + "guid", + "timestampCreated", + "version", + "timestampModified" + ], + "taxonattachment": [ + "attachment", + "timestampCreated", + "version", + "timestampModified" + ], + "taxonattribute": [ + "timestampCreated", + "version", + "timestampModified" + ], + "taxoncitation": [ + "timestampCreated", + "version", + "timestampModified" + ], + "taxontreedef": [ + "timestampCreated", + "version", + "timestampModified" + ], + "taxontreedefitem": [ + "timestampCreated", + "version", + "timestampModified" + ], + "treatmentevent": [ + "treatmentEventAttachments", + "timestampCreated", + "version", + "timestampModified" + ], + "treatmenteventattachment": [ + "attachment", + "timestampCreated", + "version", + "timestampModified" + ], + "voucherrelationship": [ + "timestampCreated", + "version", + "timestampModified" + ], + "workbench": [ + "timestampCreated", + "version", + "timestampModified" + ], + "workbenchdataitem": [], + "workbenchrow": [], + "workbenchrowexportedrelationship": [ + "timestampCreated", + "version", + "timestampModified" + ], + "workbenchrowimage": [], + "workbenchtemplate": [ + "timestampCreated", + "version", + "timestampModified" + ], + "workbenchtemplatemappingitem": [ + "timestampCreated", + "version", + "timestampModified" + ], + "spuserexternalid": [], + "spattachmentdataset": [ + "timestampcreated", + "timestampmodified" + ], + "uniquenessrule": [], + "uniquenessrulefield": [], + "message": [ + "timestampcreated" + ], + "spmerging": [ + "timestampcreated", + "timestampmodified" + ], + "localityupdate": [ + "timestampcreated", + "timestampmodified" + ], + "localityupdaterowresult": [], + "userpolicy": [], + "role": [], + "libraryrole": [], + "userrole": [], + "rolepolicy": [], + "libraryrolepolicy": [], + "spdataset": [ + "timestampcreated", + "timestampmodified" + ] +} \ No newline at end of file diff --git a/specifyweb/frontend/js_src/lib/components/FormFields/index.tsx b/specifyweb/frontend/js_src/lib/components/FormFields/index.tsx index 261c27c1e0a..8326cf87091 100644 --- a/specifyweb/frontend/js_src/lib/components/FormFields/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/FormFields/index.tsx @@ -177,7 +177,15 @@ const fieldRenderers: { name, field, isRequired, - fieldDefinition: { defaultValue, min, max, step, maxLength, minLength }, + fieldDefinition: { + defaultValue, + min, + max, + step, + maxLength, + minLength, + whiteSpaceSensitive, + }, }) { const parser = React.useMemo( () => ({ @@ -188,8 +196,18 @@ const fieldRenderers: { required: isRequired, maxLength, minLength, + whiteSpaceSensitive, }), - [defaultValue, min, max, step, isRequired, maxLength, minLength] + [ + defaultValue, + min, + max, + step, + isRequired, + maxLength, + minLength, + whiteSpaceSensitive, + ] ); return ( { type: 'Text', maxLength: undefined, minLength: undefined, + whiteSpaceSensitive: false, }, }) )); @@ -150,10 +151,75 @@ describe('parseFormCell', () => { type: 'Text', maxLength: undefined, minLength: undefined, + whiteSpaceSensitive: false, }, }) )); + test('white space sensitivity required by schema', async () => + expect( + parseFormCell( + tables.TaxonTreeDefItem, + xml('') + ) + ).resolves.toEqual( + cell({ + align: 'left', + ariaLabel: undefined, + colSpan: 1, + fieldDefinition: { + defaultValue: undefined, + isReadOnly: false, + max: undefined, + maxLength: undefined, + min: undefined, + minLength: undefined, + step: undefined, + type: 'Text', + whiteSpaceSensitive: true, + }, + fieldNames: ['fullNameSeparator'], + id: undefined, + isRequired: false, + type: 'Field', + verticalAlign: 'center', + visible: true, + }) + )); + + test('white space sensitivity on field', async () => + expect( + parseFormCell( + tables.CollectionObject, + xml( + '' + ) + ) + ).resolves.toEqual( + cell({ + align: 'left', + ariaLabel: undefined, + colSpan: 1, + fieldDefinition: { + defaultValue: undefined, + isReadOnly: false, + max: undefined, + maxLength: undefined, + min: undefined, + minLength: undefined, + step: undefined, + type: 'Text', + whiteSpaceSensitive: true, + }, + fieldNames: ['text1'], + id: undefined, + isRequired: false, + type: 'Field', + verticalAlign: 'center', + visible: true, + }) + )); + test('unknown field', async () => { jest.spyOn(console, 'error').mockImplementation(); await expect( @@ -193,6 +259,7 @@ describe('parseFormCell', () => { type: 'Text', maxLength: undefined, minLength: undefined, + whiteSpaceSensitive: undefined, }, }) ); @@ -220,6 +287,7 @@ describe('parseFormCell', () => { type: 'Text', minLength: undefined, maxLength: undefined, + whiteSpaceSensitive: false, }, }) )); diff --git a/specifyweb/frontend/js_src/lib/components/FormParse/__tests__/fields.test.ts b/specifyweb/frontend/js_src/lib/components/FormParse/__tests__/fields.test.ts index 54c13e4f6d3..ebfaaf420a4 100644 --- a/specifyweb/frontend/js_src/lib/components/FormParse/__tests__/fields.test.ts +++ b/specifyweb/frontend/js_src/lib/components/FormParse/__tests__/fields.test.ts @@ -35,6 +35,7 @@ describe('parseFormField', () => { min: undefined, step: undefined, type: 'Text', + whiteSpaceSensitive: false, })); test('Readonly Text field', () => @@ -49,6 +50,7 @@ describe('parseFormField', () => { min: 4, step: 3.2, type: 'Text', + whiteSpaceSensitive: false, })); test('Legacy readonly text field', () => @@ -63,6 +65,7 @@ describe('parseFormField', () => { min: 4, step: 3.2, type: 'Text', + whiteSpaceSensitive: false, })); test('Legacy text field', () => @@ -72,6 +75,7 @@ describe('parseFormField', () => { max: undefined, min: undefined, step: undefined, + whiteSpaceSensitive: false, type: 'Text', })); @@ -82,6 +86,7 @@ describe('parseFormField', () => { max: undefined, min: undefined, step: undefined, + whiteSpaceSensitive: false, type: 'Text', })); @@ -196,6 +201,7 @@ describe('parseFormField', () => { max: undefined, min: undefined, step: undefined, + whiteSpaceSensitive: false, }); }); @@ -299,6 +305,7 @@ describe('parseFormField', () => { min: undefined, minLength: undefined, step: undefined, + whiteSpaceSensitive: false, })); }); diff --git a/specifyweb/frontend/js_src/lib/components/FormParse/__tests__/index.test.ts b/specifyweb/frontend/js_src/lib/components/FormParse/__tests__/index.test.ts index eefe1c335ae..570600e4100 100644 --- a/specifyweb/frontend/js_src/lib/components/FormParse/__tests__/index.test.ts +++ b/specifyweb/frontend/js_src/lib/components/FormParse/__tests__/index.test.ts @@ -106,6 +106,7 @@ const parsedFormView = { step: undefined, isReadOnly: false, defaultValue: undefined, + whiteSpaceSensitive: false, }, fieldNames: ['catalogNumber'], isRequired: false, @@ -584,6 +585,7 @@ test('parseRows', async () => { minLength: undefined, step: undefined, type: 'Text', + whiteSpaceSensitive: false, }, fieldNames: ['stationFieldNumber'], id: 'tt', diff --git a/specifyweb/frontend/js_src/lib/components/FormParse/__tests__/postProcessFormDef.test.ts b/specifyweb/frontend/js_src/lib/components/FormParse/__tests__/postProcessFormDef.test.ts index 63eef459667..edb9259e971 100644 --- a/specifyweb/frontend/js_src/lib/components/FormParse/__tests__/postProcessFormDef.test.ts +++ b/specifyweb/frontend/js_src/lib/components/FormParse/__tests__/postProcessFormDef.test.ts @@ -100,6 +100,7 @@ const missingLabelTextField = ensure()({ type: 'Text', minLength: undefined, maxLength: undefined, + whiteSpaceSensitive: undefined, }, } as const); diff --git a/specifyweb/frontend/js_src/lib/components/FormParse/fields.ts b/specifyweb/frontend/js_src/lib/components/FormParse/fields.ts index 8383018049a..1f06902f7f8 100644 --- a/specifyweb/frontend/js_src/lib/components/FormParse/fields.ts +++ b/specifyweb/frontend/js_src/lib/components/FormParse/fields.ts @@ -79,6 +79,7 @@ export type FieldTypes = { readonly step: number | 'any' | undefined; readonly minLength: number | undefined; readonly maxLength: number | undefined; + readonly whiteSpaceSensitive: boolean | undefined; } >; readonly Plugin: State< @@ -202,6 +203,10 @@ const processFieldType: { if (defaults.defaultValue === undefined && field === undefined) return { type: 'Blank' }; + const whiteSpaceSensitive = + getProperty('whiteSpaceSensitive')?.toLowerCase() === 'true' || + (field?.isRelationship ? undefined : field?.whiteSpaceSensitive); + return { type: 'Text', ...defaults, @@ -210,6 +215,7 @@ const processFieldType: { step: f.parseFloat(getProperty('step')), minLength: f.parseInt(getProperty('minLength')), maxLength: f.parseInt(getProperty('maxLength')), + whiteSpaceSensitive, }; }, QueryComboBox({ getProperty, fields }) { diff --git a/specifyweb/frontend/js_src/lib/components/FormPlugins/__tests__/dateUtils.test.ts b/specifyweb/frontend/js_src/lib/components/FormPlugins/__tests__/dateUtils.test.ts index 8d580c6aed9..f38a72f6a8e 100644 --- a/specifyweb/frontend/js_src/lib/components/FormPlugins/__tests__/dateUtils.test.ts +++ b/specifyweb/frontend/js_src/lib/components/FormPlugins/__tests__/dateUtils.test.ts @@ -32,6 +32,7 @@ describe('getDateParser', () => { [Function], ], "value": "2022-08-31", + "whiteSpaceSensitive": false, } `)); @@ -64,6 +65,7 @@ describe('getDateParser', () => { [Function], ], "value": undefined, + "whiteSpaceSensitive": false, } `)); }); diff --git a/specifyweb/frontend/js_src/lib/components/Forms/generateFormDefinition.ts b/specifyweb/frontend/js_src/lib/components/Forms/generateFormDefinition.ts index 81be83fc4bb..6bfb93f25e2 100644 --- a/specifyweb/frontend/js_src/lib/components/Forms/generateFormDefinition.ts +++ b/specifyweb/frontend/js_src/lib/components/Forms/generateFormDefinition.ts @@ -272,6 +272,7 @@ function getFieldDefinition( step: parser.step, minLength: parser.minLength, maxLength: parser.maxLength, + whiteSpaceSensitive: parser.whiteSpaceSensitive, }), }, }; diff --git a/specifyweb/frontend/js_src/lib/components/Header/menuItemDefinitions.ts b/specifyweb/frontend/js_src/lib/components/Header/menuItemDefinitions.ts index f4aaaaebd3d..390ed36feba 100644 --- a/specifyweb/frontend/js_src/lib/components/Header/menuItemDefinitions.ts +++ b/specifyweb/frontend/js_src/lib/components/Header/menuItemDefinitions.ts @@ -3,6 +3,7 @@ */ import { attachmentsText } from '../../localization/attachments'; +import { batchEditText } from '../../localization/batchEdit'; import { commonText } from '../../localization/common'; import { headerText } from '../../localization/header'; import { interactionsText } from '../../localization/interactions'; @@ -109,6 +110,11 @@ const rawMenuItems = ensure>>()({ icon: icons.chartBar, enabled: () => hasPermission('/querybuilder/query', 'execute'), }, + batchEdit: { + url: '/specify/overlay/batch-edit', + title: batchEditText.batchEdit(), + icon: icons.table, + } } as const); export type MenuItemName = keyof typeof rawMenuItems | 'search'; diff --git a/specifyweb/frontend/js_src/lib/components/Merging/CompareField.tsx b/specifyweb/frontend/js_src/lib/components/Merging/CompareField.tsx index 13050775fa8..933815f7548 100644 --- a/specifyweb/frontend/js_src/lib/components/Merging/CompareField.tsx +++ b/specifyweb/frontend/js_src/lib/components/Merging/CompareField.tsx @@ -173,6 +173,7 @@ function fieldToDefinition( minLength: undefined, maxLength: undefined, step: undefined, + whiteSpaceSensitive: undefined, }; } diff --git a/specifyweb/frontend/js_src/lib/components/Preferences/UserDefinitions.tsx b/specifyweb/frontend/js_src/lib/components/Preferences/UserDefinitions.tsx index 53b31b4bebc..f69fd0882a0 100644 --- a/specifyweb/frontend/js_src/lib/components/Preferences/UserDefinitions.tsx +++ b/specifyweb/frontend/js_src/lib/components/Preferences/UserDefinitions.tsx @@ -53,6 +53,7 @@ import { } from './Renderers'; import type { GenericPreferences, PreferencesVisibilityContext } from './types'; import { definePref } from './types'; +import { batchEditText } from '../../localization/batchEdit'; const isLightMode = ({ isDarkMode, @@ -1936,6 +1937,26 @@ export const userPreferenceDefinitions = { }, }, }, + batchEdit: { + title: batchEditText.batchEdit(), + subCategories: { + query: { + title: queryText.query(), + items: { + limit: definePref({ + title: batchEditText.numberOfRecords(), + requiresReload: false, + visible: true, + defaultValue: 5000, + type: "java.lang.Double", + parser: { + min: 0 + } + }) + } + } + } + } } as const; // Use tree table labels as titles for the tree editor sections diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx index 7cfe981d1f8..4230abaa12b 100644 --- a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx +++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx @@ -52,6 +52,7 @@ import { getInitialState, reducer } from './reducer'; import type { QueryResultRow } from './Results'; import { QueryResultsWrapper } from './ResultsWrapper'; import { QueryToolbar } from './Toolbar'; +import { BatchEdit } from '../BatchEdit'; const fetchTreeRanks = async (): Promise => treeRanksPromise.then(f.true); @@ -588,7 +589,9 @@ function Wrapped({ ) : undefined } extraButtons={ - query.countOnly ? undefined : ( + <> + + {query.countOnly ? undefined : ( - ) + )} + } fields={state.fields} forceCollection={forceCollection} diff --git a/specifyweb/frontend/js_src/lib/components/Router/OverlayRoutes.tsx b/specifyweb/frontend/js_src/lib/components/Router/OverlayRoutes.tsx index 08769efeb6f..6516db38c46 100644 --- a/specifyweb/frontend/js_src/lib/components/Router/OverlayRoutes.tsx +++ b/specifyweb/frontend/js_src/lib/components/Router/OverlayRoutes.tsx @@ -15,6 +15,7 @@ import { wbText } from '../../localization/workbench'; import type { RA } from '../../utils/types'; import { Redirect } from './Redirect'; import type { EnhancedRoute } from './RouterUtils'; +import { batchEditText } from '../../localization/batchEdit'; /* eslint-disable @typescript-eslint/promise-function-async */ /** @@ -239,6 +240,15 @@ export const overlayRoutes: RA = [ ({ TableUniquenessRules }) => TableUniquenessRules ), }, + { + // There's no physical difference between a workbench and batch-edit dataset, but separating them out helps UI. + path: 'batch-edit', + title: batchEditText.batchEdit(), + element: () => + import('../Toolbar/WbsDialog').then( + ({ BatchEditDataSetsOverlay }) => BatchEditDataSetsOverlay + ), + }, ], }, ]; diff --git a/specifyweb/frontend/js_src/lib/components/Toolbar/WbsDialog.tsx b/specifyweb/frontend/js_src/lib/components/Toolbar/WbsDialog.tsx index e6de4ab12d9..a4b2a9853c5 100644 --- a/specifyweb/frontend/js_src/lib/components/Toolbar/WbsDialog.tsx +++ b/specifyweb/frontend/js_src/lib/components/Toolbar/WbsDialog.tsx @@ -29,13 +29,14 @@ import { SortIndicator, useSortConfig } from '../Molecules/Sorting'; import { TableIcon } from '../Molecules/TableIcon'; import { hasPermission } from '../Permissions/helpers'; import { OverlayContext } from '../Router/Router'; -import { uniquifyDataSetName } from '../WbImport/helpers'; +import { DatasetVariants, uniquifyDataSetName } from '../WbImport/helpers'; import type { Dataset, DatasetBriefPlan } from '../WbPlanView/Wrapped'; import { WbDataSetMeta } from '../WorkBench/DataSetMeta'; +import { formatUrl } from '../Router/queryString'; const createWorkbenchDataSet = async () => createEmptyDataSet( - '/api/workbench/dataset/', + 'workbench', wbText.newDataSetName({ date: new Date().toDateString() }), { importedfilename: '', @@ -46,14 +47,14 @@ const createWorkbenchDataSet = async () => export const createEmptyDataSet = async < DATASET extends AttachmentDataSet | Dataset >( - datasetUrl: string, + datasetVariant: keyof typeof DatasetVariants, name: LocalizedString, props?: Partial -): Promise => - ajax(datasetUrl, { +): Promise => + ajax(DatasetVariants[datasetVariant], { method: 'POST', body: { - name: await uniquifyDataSetName(name, undefined, datasetUrl), + name: await uniquifyDataSetName(name, undefined, datasetVariant), rows: [], ...props, }, @@ -129,21 +130,31 @@ function TableHeader({ ); } +type DataSetFilter = { + readonly with_plan: number; + readonly isupdate: number +} /** Render a dialog for choosing a data set */ export function DataSetsDialog({ onClose: handleClose, showTemplates, onDataSetSelect: handleDataSetSelect, + filterByBatchEdit=false }: { readonly showTemplates: boolean; readonly onClose: () => void; readonly onDataSetSelect?: (id: number) => void; + readonly filterByBatchEdit?: boolean; }): JSX.Element | null { + const datasetFilter: DataSetFilter = { + with_plan: showTemplates ? 1 : 0, + isupdate: filterByBatchEdit ? 1 : 0 + } const [unsortedDatasets] = useAsyncState( React.useCallback( async () => ajax>( - `/api/workbench/dataset/${showTemplates ? '?with_plan' : ''}`, + formatUrl('/api/workbench/dataset/', datasetFilter), { headers: { Accept: 'application/json' } } ).then(({ data }) => data), [showTemplates] @@ -169,8 +180,9 @@ export function DataSetsDialog({ ) : undefined; + // While being granular in permissions is nice, it is redudant here, since batch-edit datasets cannot be created. const canImport = - hasPermission('/workbench/dataset', 'create') && !showTemplates; + hasPermission('/workbench/dataset', 'create') && !showTemplates && !filterByBatchEdit; const navigate = useNavigate(); const loading = React.useContext(LoadingContext); return Array.isArray(datasets) ? ( @@ -285,3 +297,8 @@ export function DataSetsOverlay(): JSX.Element { const handleClose = React.useContext(OverlayContext); return ; } + +export function BatchEditDataSetsOverlay(): JSX.Element { + const handleClose = React.useContext(OverlayContext); + return ; +} \ No newline at end of file diff --git a/specifyweb/frontend/js_src/lib/components/TreeView/Row.tsx b/specifyweb/frontend/js_src/lib/components/TreeView/Row.tsx index 2e4e53eadf2..902deb8d8fa 100644 --- a/specifyweb/frontend/js_src/lib/components/TreeView/Row.tsx +++ b/specifyweb/frontend/js_src/lib/components/TreeView/Row.tsx @@ -1,6 +1,5 @@ import React from 'react'; -import { useAsyncState } from '../../hooks/useAsyncState'; import { useId } from '../../hooks/useId'; import { commonText } from '../../localization/common'; import { treeText } from '../../localization/tree'; @@ -8,7 +7,6 @@ import type { RA } from '../../utils/types'; import { Button } from '../Atoms/Button'; import { className } from '../Atoms/className'; import { icons } from '../Atoms/Icons'; -import { fetchRows } from '../DataModel/collection'; import type { AnyTree } from '../DataModel/helperTypes'; import { getPref } from '../InitialContext/remotePrefs'; import { userPreferences } from '../Preferences/userPreferences'; @@ -150,21 +148,6 @@ export function TreeRow({ const hasNoChildrenNodes = nodeStats?.directCount === 0 && nodeStats.childCount === 0; - const acceptedChildrenKey = `accepted${treeName.toLowerCase()}`; - const [synonymsNames] = useAsyncState( - React.useCallback( - async () => - fetchRows(treeName as 'Taxon', { - fields: { name: ['string'] }, - limit: 0, - [acceptedChildrenKey]: row.nodeId, - domainFilter: false, - }).then((rows) => rows.map(({ name }) => name)), - [acceptedChildrenKey, treeName, row.nodeId] - ), - false - ); - return hideEmptyNodes && hasNoChildrenNodes ? null : (
  • {ranks.map((rankId) => { @@ -247,12 +230,11 @@ export function TreeRow({ ? treeText.acceptedName({ name: row.acceptedName ?? row.acceptedId.toString(), }) - : synonymsNames === undefined || - synonymsNames.length === 0 - ? undefined - : treeText.synonyms({ - names: synonymsNames.join(', '), + : typeof row.synonyms === 'string' + ? treeText.synonyms({ + names: row.synonyms, }) + : undefined } > {doIncludeAuthorPref && diff --git a/specifyweb/frontend/js_src/lib/components/TreeView/helpers.ts b/specifyweb/frontend/js_src/lib/components/TreeView/helpers.ts index f1404be9d4d..069fd4dc0cc 100644 --- a/specifyweb/frontend/js_src/lib/components/TreeView/helpers.ts +++ b/specifyweb/frontend/js_src/lib/components/TreeView/helpers.ts @@ -24,8 +24,9 @@ export const fetchRows = async (fetchUrl: string) => number, number | null, string | null, - string, - number + string | null, + number, + string | null ] > >(fetchUrl, { @@ -44,6 +45,7 @@ export const fetchRows = async (fetchUrl: string) => acceptedName = undefined, author = undefined, children, + synonyms = undefined, ], index, { length } @@ -59,6 +61,7 @@ export const fetchRows = async (fetchUrl: string) => author, children, isLastChild: index + 1 === length, + synonyms, }) ) ); diff --git a/specifyweb/frontend/js_src/lib/components/WbImport/helpers.ts b/specifyweb/frontend/js_src/lib/components/WbImport/helpers.ts index df6c0fc9559..710bd1b2ec7 100644 --- a/specifyweb/frontend/js_src/lib/components/WbImport/helpers.ts +++ b/specifyweb/frontend/js_src/lib/components/WbImport/helpers.ts @@ -157,12 +157,18 @@ function guessDelimiter(text: string): string { const MAX_NAME_LENGTH = 64; +export const DatasetVariants = { + 'workbench': '/api/workbench/dataset/', + 'batchEdit': '/api/workbench/dataset/?isupdate=1', + 'bulkAttachment': '/attachment_gw/dataset/' +} as const; + export async function uniquifyDataSetName( name: string, currentDataSetId?: number, - datasetsUrl = '/api/workbench/dataset/' + datasetsUrl: keyof typeof DatasetVariants = 'workbench' ): Promise { - return ajax>(datasetsUrl, { + return ajax>(DatasetVariants[datasetsUrl], { headers: { Accept: 'application/json' }, }).then(({ data: datasets }) => getUniqueName( diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/Mapper.tsx b/specifyweb/frontend/js_src/lib/components/WbPlanView/Mapper.tsx index 044dbca8ab3..bdb59ff42b4 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/Mapper.tsx +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/Mapper.tsx @@ -105,6 +105,8 @@ export type MappingState = State< } >; +export type ReadonlySpec = {readonly mustMatch: boolean; readonly columnOptions: boolean}; + export const getDefaultMappingState = ({ changesMade, lines, @@ -139,6 +141,7 @@ export function Mapper(props: { readonly changesMade: boolean; readonly lines: RA; readonly mustMatchPreferences: IR; + readonly readonlySpec?: ReadonlySpec }): JSX.Element { const [state, dispatch] = React.useReducer( reducer, @@ -249,8 +252,7 @@ export function Mapper(props: { const id = useId('wbplanviewmapper'); - const validate = (): RA => - findRequiredMissingFields( + const validate = (): RA => findRequiredMissingFields( props.baseTableName, state.lines .map(({ mappingPath }) => mappingPath) @@ -283,6 +285,8 @@ export function Mapper(props: { state.lines.length > 0 && mappingPathIsComplete(state.mappingView) && getMappedFieldsBind(state.mappingView).length === 0; + + const disableSave = props.readonlySpec === undefined ? isReadOnly : Object.values(props.readonlySpec).every(Boolean); return ( + => getMustMatchTables({ @@ -359,6 +364,7 @@ export function Mapper(props: { }); }} /> + {!isReadOnly && ( {isReadOnly ? wbText.dataEditor() : commonText.cancel()} - {!isReadOnly && ( + {!disableSave && ( handleSave(false)} + // This is a bit complicated to resolve correctly. Each component should have its own validator.. + onClick={(): void => handleSave(isReadOnly)} > {commonText.save()} @@ -547,7 +554,7 @@ export function Mapper(props: { customSelectSubtype: 'simple', fieldsData: mappingOptionsMenu({ id: (suffix) => id(`column-options-${line}-${suffix}`), - isReadOnly, + isReadOnly: props.readonlySpec?.columnOptions ?? isReadOnly, columnOptions, onChangeMatchBehaviour: (matchBehavior) => dispatch({ diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/Wrapped.tsx b/specifyweb/frontend/js_src/lib/components/WbPlanView/Wrapped.tsx index 4f6e50edefa..081ad3a131d 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/Wrapped.tsx +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/Wrapped.tsx @@ -18,7 +18,7 @@ import { ProtectedAction } from '../Permissions/PermissionDenied'; import type { UploadResult } from '../WorkBench/resultsParser'; import { savePlan } from './helpers'; import { getLinesFromHeaders, getLinesFromUploadPlan } from './linesGetter'; -import type { MappingLine } from './Mapper'; +import type { MappingLine, ReadonlySpec } from './Mapper'; import { Mapper } from './Mapper'; import { BaseTableSelection } from './State'; import type { UploadPlan } from './uploadPlanParser'; @@ -77,6 +77,7 @@ export type Dataset = DatasetBase & readonly rows: RA>; readonly uploadplan: UploadPlan | null; readonly visualorder: RA | null; + readonly isupdate: boolean }; /** @@ -86,11 +87,13 @@ export function WbPlanView({ dataset, uploadPlan, headers, + readonlySpec }: { readonly uploadPlan: UploadPlan | null; readonly headers: RA; readonly dataset: Dataset; -}): JSX.Element { + readonly readonlySpec?: ReadonlySpec + }): JSX.Element { useTitle(dataset.name); const [state, setState] = useLiveState< @@ -169,6 +172,7 @@ export function WbPlanView({ mustMatchPreferences, }).then(() => navigate(`/specify/workbench/${dataset.id}/`)) } + readonlySpec={readonlySpec} /> ); } diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/index.tsx b/specifyweb/frontend/js_src/lib/components/WbPlanView/index.tsx index cbe523559c7..3e77ae42fb1 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/index.tsx @@ -47,7 +47,9 @@ export function WbPlanViewWrapper(): JSX.Element | null { React.useContext(ReadOnlyContext) || !hasPermission('/workbench/dataset', 'update') || typeof dataSet !== 'object' || - dataSet.uploadresult?.success === true; + dataSet.uploadresult?.success === true || + // FEATURE: Remove this + dataSet.isupdate; return dataSet === false ? ( @@ -64,6 +66,7 @@ export function WbPlanViewWrapper(): JSX.Element | null { ) } uploadPlan={dataSet.uploadplan} + readonlySpec={dataSet.isupdate ? {mustMatch: false, columnOptions: false}: undefined} /> ) : null; diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/modelHelpers.ts b/specifyweb/frontend/js_src/lib/components/WbPlanView/modelHelpers.ts index 2788e791d62..e6a31a0f3de 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/modelHelpers.ts +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/modelHelpers.ts @@ -145,3 +145,6 @@ export const isCircularRelationship = ( parentRelationship.otherSideName === relationship.name) || (relationship.relatedTable === parentRelationship.table && relationship.otherSideName === parentRelationship.name); + +export const isNestedToMany = (parentRelationship: Relationship, relationship: Relationship) => relationshipIsToMany(relationship) && +relationshipIsToMany(parentRelationship) \ No newline at end of file diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts b/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts index 67b8edd6f1b..2dd9f382719 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts @@ -40,7 +40,7 @@ import { valueIsToManyIndex, valueIsTreeRank, } from './mappingHelpers'; -import { getMaxToManyIndex, isCircularRelationship } from './modelHelpers'; +import { getMaxToManyIndex, isCircularRelationship, isNestedToMany } from './modelHelpers'; import type { NavigatorSpec } from './navigatorSpecs'; type NavigationCallbackPayload = { @@ -177,8 +177,6 @@ export type MappingLineData = Pick< readonly defaultValue: string; }; -const queryBuilderTreeFields = new Set(['fullName', 'author', 'groupNumber']); - /** * Get data required to build a mapping line from a source mapping path * Handles circular dependencies and must match tables @@ -391,8 +389,7 @@ export function getMappingLineData({ ((generateFieldData === 'all' && (!isTreeTable(table.name) || mappingPath[internalState.position - 1] === - formatTreeRank(anyTreeRank) || - queryBuilderTreeFields.has(formattedEntry))) || + formatTreeRank(anyTreeRank))) || internalState.defaultValue === formattedEntry) ? ([ formattedEntry, @@ -474,8 +471,7 @@ export function getMappingLineData({ spec.includeAllTreeFields || !isTreeTable(table.name) || mappingPath[internalState.position - 1] === - formatTreeRank(anyTreeRank) || - queryBuilderTreeFields.has(field.name); + formatTreeRank(anyTreeRank) isIncluded &&= getFrontEndOnlyFields()[table.name]?.includes(field.name) !== @@ -485,10 +481,7 @@ export function getMappingLineData({ isIncluded &&= parentRelationship === undefined || (!isCircularRelationship(parentRelationship, field) && - (spec.allowNestedToMany || !( - relationshipIsToMany(field) && - relationshipIsToMany(parentRelationship) - ))); + (spec.allowNestedToMany || !isNestedToMany(parentRelationship, field))); isIncluded &&= !canDoAction || diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/navigatorSpecs.ts b/specifyweb/frontend/js_src/lib/components/WbPlanView/navigatorSpecs.ts index 1f6043202ab..6e3ed46b2cd 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/navigatorSpecs.ts +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/navigatorSpecs.ts @@ -88,7 +88,7 @@ const queryBuilder: NavigatorSpec = { allowTransientToMany: true, useSchemaOverrides: false, // All tree fields are only available for "any rank" - includeAllTreeFields: false, + includeAllTreeFields: true, allowNestedToMany: true, ensurePermission: () => userPreferences.get('queryBuilder', 'general', 'showNoReadTables') diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/uploadPlanBuilder.ts b/specifyweb/frontend/js_src/lib/components/WbPlanView/uploadPlanBuilder.ts index a67e518e386..d22b4f581d3 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/uploadPlanBuilder.ts +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/uploadPlanBuilder.ts @@ -1,5 +1,5 @@ import type { IR, RA, RR } from '../../utils/types'; -import { group, removeKey, split, toLowerCase } from '../../utils/utils'; +import { group, split, toLowerCase } from '../../utils/utils'; import type { SpecifyTable } from '../DataModel/specifyTable'; import { strictGetTable } from '../DataModel/tables'; import type { Tables } from '../DataModel/types'; diff --git a/specifyweb/frontend/js_src/lib/components/WbUtils/Navigation.tsx b/specifyweb/frontend/js_src/lib/components/WbUtils/Navigation.tsx index 75cf78db3da..93e9198979d 100644 --- a/specifyweb/frontend/js_src/lib/components/WbUtils/Navigation.tsx +++ b/specifyweb/frontend/js_src/lib/components/WbUtils/Navigation.tsx @@ -62,6 +62,7 @@ export function Navigation({ if (totalCount === 0) setCurrentPosition(0); }, [totalCount]); + const isDisabled = !['newCells', 'searchResults', 'updatedCells', 'deletedCells'].includes(name) && isReadOnly; return ( @@ -102,7 +103,7 @@ export function Navigation({ + + + {!isUploaded && ( ; readonly originalValue: string | undefined; + readonly isUpdated: boolean; + readonly isDeleted: boolean; + readonly isMatchedAndChanged: boolean; }; export type WbCellCounts = { @@ -26,6 +32,9 @@ export type WbCellCounts = { readonly invalidCells: number; readonly searchResults: number; readonly modifiedCells: number; + readonly updatedCells: number; + readonly deletedCells: number; + readonly matchedAndChangedCells: number; }; // REFACTOR: replace usages of WbMetaArray with WbMeta and test performance/memory @@ -35,7 +44,10 @@ export type WbMetaArray = [ isModified: boolean, isSearchResult: boolean, issues: RA, - originalValue: string | undefined + originalValue: string | undefined, + isUpdated: boolean, + isDeleted: boolean, + isMatchedAndChanged: boolean ]; const defaultMetaValues = Object.freeze([ @@ -44,6 +56,9 @@ const defaultMetaValues = Object.freeze([ false, Object.freeze([]), undefined, + false, + false, + false ] as const); /* eslint-disable functional/no-this-expression */ @@ -100,7 +115,7 @@ export class WbCellMeta { const metaValueChanged = issuesChanged || cellValueChanged || - (['isNew', 'isModified', 'isSearchResult'].includes(key) && + (['isNew', 'isModified', 'isSearchResult', 'isUpdated', 'isDeleted', 'isMatchedAndChanged'].includes(key) && currentValue !== value); if (!metaValueChanged) return false; @@ -195,8 +210,14 @@ export class WbCellMeta { if (key === 'isNew') cell?.classList[value === true ? 'add' : 'remove']('wb-no-match-cell'); + else if (key === 'isUpdated') + cell?.classList[value === true ? 'add' : 'remove']('wb-updated-cell'); + else if (key === 'isDeleted') + cell?.classList[value === true ? 'add' : 'remove']('wb-deleted-cell'); else if (key === 'isModified') cell?.classList[value === true ? 'add' : 'remove']('wb-modified-cell'); + else if (key === 'isMatchedAndChanged') + cell?.classList[value === true ? 'add' : 'remove']('wb-matched-and-changed-cell'); else if (key === 'isSearchResult') cell?.classList[value === true ? 'add' : 'remove']( 'wb-search-match-cell' @@ -347,6 +368,15 @@ export class WbCellMeta { count + (this.getCellMetaFromArray(info, 'isModified') ? 1 : 0), 0 ), + updatedCells: cellMeta.reduce( + (count, info)=>count + (this.getCellMetaFromArray(info, 'isUpdated') ? 1 : 0), 0 + ), + deletedCells: cellMeta.reduce( + (count, info)=>count + (this.getCellMetaFromArray(info, 'isDeleted') ? 1 : 0), 0 + ), + matchedAndChangedCells: cellMeta.reduce( + (count, info)=>count + (this.getCellMetaFromArray(info, 'isMatchedAndChanged') ? 1 : 0), 0 + ) }); } @@ -364,6 +394,14 @@ export class WbCellMeta { case 'searchResults': { return this.getCellMetaFromArray(metaArray, 'isSearchResult'); } + case 'updatedCells': { + return this.getCellMetaFromArray(metaArray, 'isUpdated'); + } + case 'deletedCells': { + return this.getCellMetaFromArray(metaArray, 'isDeleted'); + } + case 'matchedAndChangedCells': + return this.getCellMetaFromArray(metaArray, 'isMatchedAndChanged'); default: { return false; } diff --git a/specifyweb/frontend/js_src/lib/components/WorkBench/DataSetMeta.tsx b/specifyweb/frontend/js_src/lib/components/WorkBench/DataSetMeta.tsx index 1d0f5c41944..afc2cdeceeb 100644 --- a/specifyweb/frontend/js_src/lib/components/WorkBench/DataSetMeta.tsx +++ b/specifyweb/frontend/js_src/lib/components/WorkBench/DataSetMeta.tsx @@ -27,7 +27,7 @@ import { FormattedResourceUrl } from '../Molecules/FormattedResource'; import { TableIcon } from '../Molecules/TableIcon'; import { hasPermission } from '../Permissions/helpers'; import { unsafeNavigate } from '../Router/Router'; -import { getMaxDataSetLength, uniquifyDataSetName } from '../WbImport/helpers'; +import { DatasetVariants, getMaxDataSetLength, uniquifyDataSetName } from '../WbImport/helpers'; import type { Dataset } from '../WbPlanView/Wrapped'; const syncNameAndRemarks = async ( @@ -43,7 +43,7 @@ const syncNameAndRemarks = async ( type DataSetMetaProps = { readonly dataset: Dataset | EagerDataSet; - readonly datasetUrl: '/api/workbench/dataset/' | '/attachment_gw/dataset/'; + readonly datasetVariant: keyof typeof DatasetVariants; readonly getRowCount?: () => number; readonly permissionResource: | '/attachment_import/dataset' @@ -65,7 +65,7 @@ type DataSetMetaProps = { export function WbDataSetMeta( props: Omit< DataSetMetaProps, - 'datasetUrl' | 'deleteDescription' | 'onChange' | 'permissionResource' + 'datasetVariant' | 'deleteDescription' | 'onChange' | 'permissionResource' > & { readonly onChange: ({ name, @@ -80,7 +80,7 @@ export function WbDataSetMeta( return ( @@ -101,7 +101,7 @@ export const blueTable = {icons.table}; export function DataSetMeta({ dataset, getRowCount = (): number => dataset.rows.length, - datasetUrl, + datasetVariant, permissionResource, deleteDescription, onClose: handleClose, @@ -118,6 +118,8 @@ export function DataSetMeta({ const [showDeleteConfirm, setShowDeleteConfirm] = React.useState(false); + const datasetUrl = DatasetVariants[datasetVariant]; + return isDeleted ? ( {commonText.close()}} @@ -193,7 +195,7 @@ export function DataSetMeta({ name: dataset.name, remarks: localized(dataset.remarks), }) - : uniquifyDataSetName(name.trim(), dataset.id, datasetUrl).then( + : uniquifyDataSetName(name.trim(), dataset.id, datasetVariant).then( (uniqueName) => ({ needsSaved: true, name: uniqueName, diff --git a/specifyweb/frontend/js_src/lib/components/WorkBench/DisambiguationLogic.ts b/specifyweb/frontend/js_src/lib/components/WorkBench/DisambiguationLogic.ts index 15a9f608e66..71bdfc07f90 100644 --- a/specifyweb/frontend/js_src/lib/components/WorkBench/DisambiguationLogic.ts +++ b/specifyweb/frontend/js_src/lib/components/WorkBench/DisambiguationLogic.ts @@ -1,4 +1,5 @@ import type { IR } from '../../utils/types'; +import { removeKey } from '../../utils/utils'; import type { MappingPath } from '../WbPlanView/Mapper'; import { mappingPathToString } from '../WbPlanView/mappingHelpers'; import { getSelectedLast } from './hotHelpers'; @@ -98,7 +99,7 @@ export class Disambiguation { if (Object.keys(disambiguation).length === 0) // Nothing to clear return; - this.changeDisambiguation(physicalRow, () => ({}), 'Disambiguation.Clear'); + this.changeDisambiguation(physicalRow, (row) => removeKey(row, 'disambiguation'), 'Disambiguation.Clear'); } public setDisambiguation( diff --git a/specifyweb/frontend/js_src/lib/components/WorkBench/Results.tsx b/specifyweb/frontend/js_src/lib/components/WorkBench/Results.tsx index 1c493dcdd50..7e8c0ab4d4b 100644 --- a/specifyweb/frontend/js_src/lib/components/WorkBench/Results.tsx +++ b/specifyweb/frontend/js_src/lib/components/WorkBench/Results.tsx @@ -11,7 +11,7 @@ import { wbText } from '../../localization/workbench'; import { f } from '../../utils/functools'; import type { RR, ValueOf } from '../../utils/types'; import { sortFunction } from '../../utils/utils'; -import { H2, Ul } from '../Atoms'; +import { H2, H3, Ul } from '../Atoms'; import { Button } from '../Atoms/Button'; import { formatNumber } from '../Atoms/Internationalization'; import { strictGetTable } from '../DataModel/tables'; @@ -19,6 +19,16 @@ import type { Tables } from '../DataModel/types'; import { ErrorBoundary } from '../Errors/ErrorBoundary'; import { TableIcon } from '../Molecules/TableIcon'; import { CreateRecordSetButton } from './RecordSet'; +import { RecordCounts } from './WbValidation'; +import { LocalizedString } from 'typesafe-i18n'; + + +const localizationMap: Record = { + 'Uploaded': wbText.recordsCreated(), + 'Deleted': wbText.recordsDeleted(), + 'MatchedAndChanged': wbText.recordsMatchedAndChanged(), + 'Updated': wbText.recordsUpdated() +} export function WbUploaded({ recordCounts, @@ -27,7 +37,7 @@ export function WbUploaded({ isUploaded, onClose: handleClose, }: { - readonly recordCounts: Partial, number>>; + readonly recordCounts: RecordCounts; readonly datasetId: number; readonly datasetName: string; readonly isUploaded: boolean; @@ -37,31 +47,17 @@ export function WbUploaded({
    -

    - {isUploaded - ? wbText.uploadResults() - : wbText.potentialUploadResults()} -

    +

    {isUploaded ? wbText.affectedResults() : wbText.potentialAffectedResults()}

    {isUploaded - ? wbText.wbUploadedDescription() - : wbText.wbUploadedPotentialDescription()} + ? wbText.wbAffectedDescription() + : wbText.wbAffectedPotentialDescription() + }

      - {Object.entries(recordCounts) - .sort( - sortFunction(([_tableName, recordCount]) => recordCount, false) - ) - .map(([tableName, recordCount], index) => - typeof recordCount === 'number' ? ( - - ) : null - )} + {Object.entries(recordCounts).sort(sortFunction(([value])=>value)).map( + ([resultType, recordsPerType], id)=>)}
    {isUploaded && ( @@ -81,6 +77,24 @@ export function WbUploaded({ ); } +function ResultsPerType({resultType, recordsPerType}:{readonly resultType: keyof RecordCounts; readonly recordsPerType: ValueOf}): JSX.Element { + return <> +

    {localizationMap[resultType]}

    + {Object.entries(recordsPerType ?? {}).sort( + sortFunction(([_tableName, recordCount]) => recordCount, false) + ) + .map(([tableName, recordCount], index) => + typeof recordCount === 'number' ? ( + + ) : null + )} + +} + export function TableRecordCounts({ recordCounts, sortFunction: rawSortFunction, @@ -131,4 +145,4 @@ function TableResults({
  • ); -} +} \ No newline at end of file diff --git a/specifyweb/frontend/js_src/lib/components/WorkBench/WbSpreadsheet.tsx b/specifyweb/frontend/js_src/lib/components/WorkBench/WbSpreadsheet.tsx index 76421f90275..569f8a81c35 100644 --- a/specifyweb/frontend/js_src/lib/components/WorkBench/WbSpreadsheet.tsx +++ b/specifyweb/frontend/js_src/lib/components/WorkBench/WbSpreadsheet.tsx @@ -20,7 +20,7 @@ import { getIcon, unknownIcon } from '../InitialContext/icons'; import type { Dataset } from '../WbPlanView/Wrapped'; import { configureHandsontable } from './handsontable'; import { useHotHooks } from './hooks'; -import { getSelectedRegions, setHotData } from './hotHelpers'; +import { getPhysicalColToMappingCol, getSelectedRegions, setHotData } from './hotHelpers'; import { useHotProps } from './hotProps'; import type { WbMapping } from './mapping'; import { fetchWbPickLists } from './pickLists'; @@ -54,10 +54,7 @@ function WbSpreadsheetComponent({ readonly onClickDisambiguate: () => void; }): JSX.Element { const isReadOnly = React.useContext(ReadOnlyContext); - const physicalColToMappingCol = (physicalCol: number): number | undefined => - mappings?.lines.findIndex( - ({ headerName }) => headerName === dataset.columns[physicalCol] - ); + const physicalColToMappingCol = getPhysicalColToMappingCol(mappings, dataset); const { validation, cells, disambiguation } = workbench; @@ -78,7 +75,7 @@ function WbSpreadsheetComponent({ const physicalCol = hot.toPhysicalColumn(visualCol ?? 0); const createdRecords = - validation.uploadResults.newRecords[physicalRow]?.[ + validation.uploadResults.interestingRecords[physicalRow]?.[ physicalCol ]; diff --git a/specifyweb/frontend/js_src/lib/components/WorkBench/WbValidation.tsx b/specifyweb/frontend/js_src/lib/components/WorkBench/WbValidation.tsx index b220a90885e..05ee35f733b 100644 --- a/specifyweb/frontend/js_src/lib/components/WorkBench/WbValidation.tsx +++ b/specifyweb/frontend/js_src/lib/components/WorkBench/WbValidation.tsx @@ -11,11 +11,30 @@ import { formatToManyIndex, formatTreeRank, } from '../WbPlanView/mappingHelpers'; -import type { WbMeta } from './CellMeta'; +import type { WbCellCounts, WbMeta } from './CellMeta'; import type { UploadResult } from './resultsParser'; import { resolveValidationMessage } from './resultsParser'; import type { Workbench } from './WbView'; +type Records = WritableArray< + WritableArray< + WritableArray< + Readonly< + readonly [ + tableName: Lowercase, + id: number, + alternativeLabel: string | '' + ] + > + > + > +> + +// just to make things manageable +type RecordCountsKey = keyof Pick; + +export type RecordCounts = Partial, number>>>>; + type UploadResults = { readonly ambiguousMatches: WritableArray< WritableArray<{ @@ -25,24 +44,12 @@ type UploadResults = { readonly key: string; }> >; - readonly recordCounts: Partial, number>>; - readonly newRecords: Partial< - WritableArray< - WritableArray< - WritableArray< - Readonly< - readonly [ - tableName: Lowercase, - id: number, - alternativeLabel: string | '' - ] - > - > - > - > - >; + readonly recordCounts: RecordCounts; + // Updated + MatchedAndChanged + New + readonly interestingRecords: Records; }; + /* eslint-disable functional/no-this-expression */ export class WbValidation { // eslint-disable-next-line functional/prefer-readonly-type @@ -58,7 +65,7 @@ export class WbValidation { public uploadResults: UploadResults = { ambiguousMatches: [], recordCounts: {}, - newRecords: [], + interestingRecords: [] }; public constructor(private readonly workbench: Workbench) { @@ -78,7 +85,7 @@ export class WbValidation { this.uploadResults = { ambiguousMatches: [], recordCounts: {}, - newRecords: [], + interestingRecords: [], }; this.workbench.cells.cellMeta = []; @@ -89,8 +96,8 @@ export class WbValidation { (_, visualRow) => this.workbench.hot!.toPhysicalRow(visualRow) ).reverse(); this.triggerLiveValidation(); - this.workbench.utils?.toggleCellTypes('newCells', 'remove'); - this.workbench.utils?.toggleCellTypes('invalidCells', 'remove'); + const toRemove: RA = ['newCells', 'updatedCells', 'deletedCells', 'matchedAndChangedCells']; + toRemove.forEach((key)=>this.workbench.utils?.toggleCellTypes(key, 'remove')); break; } case 'off': { @@ -261,7 +268,7 @@ export class WbValidation { ); // Ignore these statuses - if (['NullRecord', 'PropagatedFailure', 'Matched'].includes(uploadStatus)) { + if ((['NullRecord', 'PropagatedFailure', 'Matched', 'NoChange']).includes(uploadStatus)) { } else if (uploadStatus === 'ParseFailures') recordResult.ParseFailures.failures.forEach((line) => { const [issueMessage, payload, column] = @@ -311,27 +318,37 @@ export class WbValidation { recordResult.MatchedMultiple.info.columns, resolveColumns ); - } else if (uploadStatus === 'Uploaded') { + } + // TODO: Discuss if MatchedAndChanged needs to shown. or whatever. + else if (uploadStatus === 'Uploaded' || uploadStatus === 'Updated' || uploadStatus === 'MatchedAndChanged' || uploadStatus === 'Deleted') { + // All these meta ones are interesting + const metaKey = uploadStatus === 'Uploaded' ? 'isNew' : uploadStatus === 'Updated' ? 'isUpdated' : uploadStatus === 'MatchedAndChanged' ? 'isMatchedAndChanged' : 'isDeleted'; setMetaCallback( - 'isNew', + metaKey, true, - recordResult.Uploaded.info.columns, + recordResult[uploadStatus].info.columns, undefined ); - const tableName = toLowerCase(recordResult.Uploaded.info.tableName); - this.uploadResults.recordCounts[tableName] ??= 0; - this.uploadResults.recordCounts[tableName]! += 1; - this.uploadResults.newRecords[physicalRow] ??= []; + + const tableName = toLowerCase(recordResult[uploadStatus].info.tableName); + this.uploadResults.recordCounts[uploadStatus] ??= {}; + this.uploadResults.recordCounts[uploadStatus]![tableName]! ??= 0; + this.uploadResults.recordCounts[uploadStatus]![tableName]! += 1; + + if (uploadStatus === 'Deleted') return; // Not sure if there is any value in showing deleted id's itself, right? + + const writable = this.uploadResults.interestingRecords; + writable[physicalRow] ??= []; this.resolveValidationColumns( - recordResult.Uploaded.info.columns, + recordResult[uploadStatus].info.columns, undefined ).forEach((physicalCol) => { - this.uploadResults.newRecords[physicalRow]![physicalCol] ??= []; - this.uploadResults.newRecords[physicalRow]![physicalCol].push([ + writable[physicalRow]![physicalCol] ??= []; + writable[physicalRow]![physicalCol].push([ tableName, - recordResult.Uploaded.id, - recordResult.Uploaded.info?.treeInfo - ? `${recordResult.Uploaded.info.treeInfo.name} (${recordResult.Uploaded.info.treeInfo.rank})` + recordResult[uploadStatus].id, + recordResult[uploadStatus].info?.treeInfo + ? `${recordResult[uploadStatus].info.treeInfo!.name} (${recordResult[uploadStatus].info.treeInfo!.rank})` : '', ]); }); diff --git a/specifyweb/frontend/js_src/lib/components/WorkBench/WbView.tsx b/specifyweb/frontend/js_src/lib/components/WorkBench/WbView.tsx index cfbba9c157a..13754a0c75d 100644 --- a/specifyweb/frontend/js_src/lib/components/WorkBench/WbView.tsx +++ b/specifyweb/frontend/js_src/lib/components/WorkBench/WbView.tsx @@ -109,6 +109,9 @@ export function WbView({ invalidCells: 0, searchResults: 0, modifiedCells: 0, + updatedCells: 0, + deletedCells: 0, + matchedAndChangedCells: 0 }); const workbench = React.useMemo(() => { diff --git a/specifyweb/frontend/js_src/lib/components/WorkBench/batchEditHelpers.ts b/specifyweb/frontend/js_src/lib/components/WorkBench/batchEditHelpers.ts new file mode 100644 index 00000000000..0bc0c8c0913 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/WorkBench/batchEditHelpers.ts @@ -0,0 +1,38 @@ +import { defined, R, RA } from "../../utils/types" +import { SpecifyTable } from "../DataModel/specifyTable" +import { isTreeTable } from "../InitialContext/treeRanks" +import { MappingPath } from "../WbPlanView/Mapper" +import { getNumberFromToManyIndex, relationshipIsToMany } from "../WbPlanView/mappingHelpers" + +const NULL_RECORD = 'null_record'; + +// The key in the last column +export const BATCH_EDIT_KEY = 'batch_edit'; + +type BatchEditRecord = { + readonly id: typeof NULL_RECORD | number | undefined, + readonly ordernumber: number | undefined, + readonly version: number | undefined +} + +export type BatchEditPack = { + readonly self: BatchEditRecord, + readonly to_one: R, + readonly to_many: R> +} + +export const isBatchEditNullRecord = (batchEditPack: BatchEditPack, currentTable: SpecifyTable, mappingPath: MappingPath): boolean => { + if (mappingPath.length <= 1) return batchEditPack.self.id === NULL_RECORD; + const [node, ...rest] = mappingPath; + if (isTreeTable(currentTable.name)) return false; + const relationship = defined(currentTable.getRelationship(node)); + const relatedTable = relationship.relatedTable; + const name = node.toLowerCase(); + if (relationshipIsToMany(relationship)){ + // id starts with 1... + const toManyId = getNumberFromToManyIndex(rest[0]) - 1; + const toMany = batchEditPack.to_many[name][toManyId]; + return toMany && isBatchEditNullRecord(toMany, relatedTable, rest.slice(1)); + } + return isBatchEditNullRecord(batchEditPack.to_one[name], relatedTable, rest); +} \ No newline at end of file diff --git a/specifyweb/frontend/js_src/lib/components/WorkBench/handsontable.ts b/specifyweb/frontend/js_src/lib/components/WorkBench/handsontable.ts index 0ac6dfe3cda..4277198f6e6 100644 --- a/specifyweb/frontend/js_src/lib/components/WorkBench/handsontable.ts +++ b/specifyweb/frontend/js_src/lib/components/WorkBench/handsontable.ts @@ -8,6 +8,8 @@ import { userPreferences } from '../Preferences/userPreferences'; import type { Dataset } from '../WbPlanView/Wrapped'; import type { WbMapping } from './mapping'; import type { WbPickLists } from './pickLists'; +import { getPhysicalColToMappingCol } from './hotHelpers'; +import { BATCH_EDIT_KEY, BatchEditPack, isBatchEditNullRecord } from './batchEditHelpers'; export function configureHandsontable( hot: Handsontable, @@ -18,8 +20,44 @@ export function configureHandsontable( identifyDefaultValues(hot, mappings); identifyPickLists(hot, pickLists); setSort(hot, dataset); + makeUnmappedColumnsReadonly(hot, mappings, dataset); + makeNullRecordsReadOnly(hot, mappings, dataset); } +// TODO: Playaround with making this part of React +function makeUnmappedColumnsReadonly(hot: Handsontable, mappings: WbMapping | undefined, dataset: Dataset): void { + if (dataset.isupdate !== true || mappings === undefined) return; + const physicalColToMappingCol = getPhysicalColToMappingCol(mappings, dataset); + hot.updateSettings({ + // not sure if anything else is needeed.. + columns: (index) => ({readOnly: physicalColToMappingCol(index) === -1}) + }) +} + +function makeNullRecordsReadOnly(hot: Handsontable, mappings: WbMapping | undefined, dataset: Dataset): void { + if (dataset.isupdate !== true || mappings === undefined) return; + const physicalColToMappingCol = getPhysicalColToMappingCol(mappings, dataset); + hot.updateSettings({ + cells: (physicalRow, physicalCol, _property) => { + const mappingCol = physicalColToMappingCol(physicalCol); + const batchEditRaw: string | undefined = hot.getDataAtRow(physicalRow).at(-1) + const batchEditPack: BatchEditPack | undefined = batchEditRaw === undefined || batchEditRaw === null ? undefined : JSON.parse(batchEditRaw)[BATCH_EDIT_KEY]; + if (mappingCol !== -1 && mappingCol !== undefined && batchEditPack !== undefined){ + return { + readOnly: isBatchEditNullRecord(batchEditPack, mappings.baseTable, mappings.lines[mappingCol].mappingPath) + } + } + if (mappingCol === -1){ + return {readOnly: true} + } + return { + readOnly: false + } + } + }) +} + + export function identifyDefaultValues( hot: Handsontable, mappings: WbMapping | undefined diff --git a/specifyweb/frontend/js_src/lib/components/WorkBench/hooks.ts b/specifyweb/frontend/js_src/lib/components/WorkBench/hooks.ts index a58971906e9..2593a36ef63 100644 --- a/specifyweb/frontend/js_src/lib/components/WorkBench/hooks.ts +++ b/specifyweb/frontend/js_src/lib/components/WorkBench/hooks.ts @@ -16,6 +16,7 @@ import { LoadingContext } from '../Core/Contexts'; import { schema } from '../DataModel/schema'; import { getHotPlugin } from './handsontable'; import type { Workbench } from './WbView'; +import { WbMeta } from './CellMeta'; export function useHotHooks({ workbench, @@ -56,30 +57,12 @@ export function useHotHooks({ : workbench.hot.toPhysicalColumn(visualCol); if (physicalCol >= workbench.dataset.columns.length) return; const metaArray = workbench.cells.cellMeta?.[physicalRow]?.[physicalCol]; - if (workbench.cells.getCellMetaFromArray(metaArray, 'isModified')) - workbench.cells.runMetaUpdateEffects( - td, - 'isModified', - true, - visualRow, - visualCol - ); - if (workbench.cells.getCellMetaFromArray(metaArray, 'isNew')) - workbench.cells.runMetaUpdateEffects( - td, - 'isNew', - true, - visualRow, - visualCol - ); - if (workbench.cells.getCellMetaFromArray(metaArray, 'isSearchResult')) - workbench.cells.runMetaUpdateEffects( - td, - 'isSearchResult', - true, - visualRow, - visualCol - ); + const cellMetaToUpdate: RA = ['isModified', 'isNew', 'isSearchResult', 'isUpdated', 'isMatchedAndChanged', 'isDeleted']; + cellMetaToUpdate.forEach((metaType)=>{ + if(workbench.cells.getCellMetaFromArray(metaArray, metaType)){ + workbench.cells.runMetaUpdateEffects(td, metaType, true, visualRow, visualCol) + } + }) if (workbench.mappings?.mappedHeaders?.[physicalCol] === undefined) td.classList.add('text-gray-500'); if (workbench.mappings?.coordinateColumns?.[physicalCol] !== undefined) diff --git a/specifyweb/frontend/js_src/lib/components/WorkBench/hotHelpers.ts b/specifyweb/frontend/js_src/lib/components/WorkBench/hotHelpers.ts index 941510941c5..b47aa327e2f 100644 --- a/specifyweb/frontend/js_src/lib/components/WorkBench/hotHelpers.ts +++ b/specifyweb/frontend/js_src/lib/components/WorkBench/hotHelpers.ts @@ -1,6 +1,8 @@ import type Handsontable from 'handsontable'; import type { RA, RR, WritableArray } from '../../utils/types'; +import { WbMapping } from './mapping'; +import { Dataset } from '../WbPlanView/Wrapped'; export function getSelectedRegions(hot: Handsontable): RA<{ readonly startRow: number; @@ -82,3 +84,8 @@ export const setHotData = ( ): void => // eslint-disable-next-line functional/prefer-readonly-type hot.setDataAtCell(changes as WritableArray<[number, number, string | null]>); + +export const getPhysicalColToMappingCol = (mappings: WbMapping | undefined, dataset: Dataset) => (physicalCol: number): number | undefined => + mappings?.lines.findIndex( + ({ headerName }) => headerName === dataset.columns[physicalCol] + ); \ No newline at end of file diff --git a/specifyweb/frontend/js_src/lib/components/WorkBench/hotProps.tsx b/specifyweb/frontend/js_src/lib/components/WorkBench/hotProps.tsx index e91763f109c..4d5fb304f63 100644 --- a/specifyweb/frontend/js_src/lib/components/WorkBench/hotProps.tsx +++ b/specifyweb/frontend/js_src/lib/components/WorkBench/hotProps.tsx @@ -46,7 +46,7 @@ export function useHotProps({ { length: dataset.columns.length + 1 }, (_, physicalCol) => ({ // Get data from nth column for nth column - data: physicalCol, + data: physicalCol }) ), [dataset.columns.length] diff --git a/specifyweb/frontend/js_src/lib/components/WorkBench/index.tsx b/specifyweb/frontend/js_src/lib/components/WorkBench/index.tsx index 5bb210a296f..159597668a7 100644 --- a/specifyweb/frontend/js_src/lib/components/WorkBench/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/WorkBench/index.tsx @@ -17,7 +17,6 @@ import type { Dataset } from '../WbPlanView/Wrapped'; import { WbView } from './WbView'; export function WorkBench(): JSX.Element { - useMenuItem('workBench'); const [treeRanksLoaded = false] = useAsyncState(fetchTreeRanks, true); const { id } = useParams(); @@ -26,6 +25,8 @@ export function WorkBench(): JSX.Element { const [dataset, setDataset] = useDataset(datasetId); useErrorContext('dataSet', dataset); + useMenuItem(dataset?.isupdate ? 'batchEdit' : 'workBench'); + const loading = React.useContext(LoadingContext); const [isDeleted, handleDeleted] = useBooleanState(); diff --git a/specifyweb/frontend/js_src/lib/components/WorkBench/resultsParser.ts b/specifyweb/frontend/js_src/lib/components/WorkBench/resultsParser.ts index 71efea0b4a3..113a0f59f0c 100644 --- a/specifyweb/frontend/js_src/lib/components/WorkBench/resultsParser.ts +++ b/specifyweb/frontend/js_src/lib/components/WorkBench/resultsParser.ts @@ -134,9 +134,24 @@ type ParseFailures = State< } >; +type Updated = State< + "Updated", + Omit +> + +type NoChange = State<"NoChange", + { + readonly id: number; + readonly info: ReportInfo; + } +> + +type Deleted = State<"Deleted", {readonly id: number; readonly info: ReportInfo}> // Indicates failure due to a failure to upload a related record type PropagatedFailure = State<'PropagatedFailure'>; +type MatchedAndChanged= State<"MatchedAndChanged", Omit>; + type RecordResultTypes = | FailedBusinessRule | Matched @@ -145,7 +160,11 @@ type RecordResultTypes = | NullRecord | ParseFailures | PropagatedFailure - | Uploaded; + | Uploaded + | Updated + | NoChange + | Deleted + | MatchedAndChanged // Records the specific result of attempting to upload a particular record type WbRecordResult = { diff --git a/specifyweb/frontend/js_src/lib/localization/batchEdit.ts b/specifyweb/frontend/js_src/lib/localization/batchEdit.ts new file mode 100644 index 00000000000..96210e9d4e2 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/localization/batchEdit.ts @@ -0,0 +1,28 @@ +/** + * Localization strings used for displaying Attachments + * + * @module + */ + +import { createDictionary } from "./utils"; + +export const batchEditText = createDictionary({ + batchEdit: { + 'en-us': "Batch Edit" + }, + numberOfRecords: { + 'en-us': "Number of records selected from the query" + }, + containsNestedToMany: { + 'en-us': "The query contains non-hidden nested-to-many relationships. Either remove the field, or make the field hidden." + }, + missingRanks: { + 'en-us': "The following tree ranks need to be added to the query: {rankJoined:string}" + }, + datasetName: { + 'en-us': "{queryName:string} {datePart:string}" + }, + errorInQuery: { + 'en-us': "Following errors were found in the query" + } +} as const) \ No newline at end of file diff --git a/specifyweb/frontend/js_src/lib/localization/workbench.ts b/specifyweb/frontend/js_src/lib/localization/workbench.ts index 133aaac36c6..720ef5be65a 100644 --- a/specifyweb/frontend/js_src/lib/localization/workbench.ts +++ b/specifyweb/frontend/js_src/lib/localization/workbench.ts @@ -1584,4 +1584,43 @@ export const wbText = createDictionary({ 'fr-fr': '{node:string} (dans {parent:string})', 'uk-ua': '{node:string} (у {parent:string})', }, + updatedCells: { + 'en-us': "Updated Cells" + }, + deletedCells: { + 'en-us': "Deleted Cells" + }, + updateResults: { + 'en-us': 'Update Results' + }, + potentialUpdateResults: { + 'en-us': 'Potential Update Results' + }, + affectedResults: { + 'en-us': "Records affected" + }, + potentialAffectedResults: { + 'en-us': "Potential records affected" + }, + wbAffectedDescription: { + 'en-us': 'Number of new records affected in each table:', + }, + wbAffectedPotentialDescription: { + 'en-us': 'Number of new records that would be affected in each table:', + }, + recordsCreated: { + 'en-us': "Records created" + }, + recordsUpdated: { + 'en-us': "Records updated" + }, + recordsDeleted: { + 'en-us': "Records deleted (not including dependents)" + }, + recordsMatchedAndChanged: { + 'en-us': "Records matched, different from current related" + }, + matchAndChanged: { + 'en-us': "Matched and changed cells" + } } as const); diff --git a/specifyweb/frontend/js_src/lib/utils/parser/__tests__/parse.test.ts b/specifyweb/frontend/js_src/lib/utils/parser/__tests__/parse.test.ts index aa3b45e38f3..fa2d77800b5 100644 --- a/specifyweb/frontend/js_src/lib/utils/parser/__tests__/parse.test.ts +++ b/specifyweb/frontend/js_src/lib/utils/parser/__tests__/parse.test.ts @@ -79,6 +79,24 @@ test('required String empty', () => { expectInvalid(result, formsText.requiredField()); }); +test('white space sensitive', () => { + const parser = resolveParser({ + type: 'java.lang.String', + whiteSpaceSensitive: true, + }); + const whiteSpaceString = ' \n\t '; + const result = parseValue(parser, undefined, whiteSpaceString); + expectValid(result, whiteSpaceString); +}); + +test('non white space sensitive', () => { + const parser = resolveParser({ + type: 'java.lang.String', + }); + const result = parseValue(parser, undefined, ' \n\t '); + expectValid(result, null); +}); + describe('Boolean', () => { const parser = () => resolveParser({ diff --git a/specifyweb/frontend/js_src/lib/utils/parser/definitions.ts b/specifyweb/frontend/js_src/lib/utils/parser/definitions.ts index 5ec89c47d3c..7fad71ad889 100644 --- a/specifyweb/frontend/js_src/lib/utils/parser/definitions.ts +++ b/specifyweb/frontend/js_src/lib/utils/parser/definitions.ts @@ -83,6 +83,7 @@ export type Parser = Partial<{ readonly value: boolean | number | string; // This is different from field.getPickList() for Month partial date readonly pickListName: string; + readonly whiteSpaceSensitive: boolean; }>; const numberPrintFormatter = (value: unknown, { step }: Parser): string => @@ -289,6 +290,9 @@ export function resolveParser( // Don't make checkboxes required required: fullField.isRequired === true && parser.type !== 'checkbox', maxLength: fullField.length, + whiteSpaceSensitive: fullField.isRelationship + ? undefined + : (fullField as LiteralField).whiteSpaceSensitive, ...(typeof formatter === 'object' ? formatterToParser(field, formatter) : {}), @@ -308,13 +312,17 @@ export function mergeParsers(base: Parser, extra: Parser): Parser { 'required', base?.required === true || extra?.required === true ? true : undefined, ], + [ + 'whiteSpaceSensitive', + base.whiteSpaceSensitive || extra.whiteSpaceSensitive, + ], + ['step', resolveStep(base.step, extra.step)], ...uniqueConcat .map((key) => [ key, f.unique([...(base[key] ?? []), ...(extra[key] ?? [])]), ]) .filter(([_key, value]) => value.length > 0), - ['step', resolveStep(base.step, extra.step)], ...takeMin.map((key) => [key, resolveDate(base[key], extra[key], true)]), ...takeMax .map((key) => [key, resolveDate(base[key], extra[key], false)]) diff --git a/specifyweb/frontend/js_src/lib/utils/parser/parse.ts b/specifyweb/frontend/js_src/lib/utils/parser/parse.ts index e19eb616604..67f2b26e38e 100644 --- a/specifyweb/frontend/js_src/lib/utils/parser/parse.ts +++ b/specifyweb/frontend/js_src/lib/utils/parser/parse.ts @@ -23,9 +23,9 @@ export function parseValue( parser: Parser, input: Input | undefined, value: string, - trim: boolean = true + trim: boolean = !parser.whiteSpaceSensitive ): InvalidParseResult | ValidParseResult { - if (trim && value.trim() === '') + if (!parser.whiteSpaceSensitive && trim && value.trim() === '') return parser.required === true ? { value, diff --git a/specifyweb/frontend/js_src/tailwind.config.ts b/specifyweb/frontend/js_src/tailwind.config.ts index 706c94fc5b8..8cfb91339af 100644 --- a/specifyweb/frontend/js_src/tailwind.config.ts +++ b/specifyweb/frontend/js_src/tailwind.config.ts @@ -62,6 +62,9 @@ const config: Config = { indigo: { 350: 'hsl(232deg 92% 79%)', }, + peach:{ + 250: 'hsl(23deg, 92%, 75%)', + }, neutral: { 350: 'hsl(0deg 0% 73%)', }, diff --git a/specifyweb/permissions/permissions.py b/specifyweb/permissions/permissions.py index 1fa6f90ec86..5aded68c87f 100644 --- a/specifyweb/permissions/permissions.py +++ b/specifyweb/permissions/permissions.py @@ -1,4 +1,4 @@ -from typing import Any, Callable, Tuple, List, Dict, Union, Iterable, Optional, NamedTuple +from typing import Any, Callable, Literal, Tuple, List, Dict, Union, Iterable, Optional, NamedTuple import logging logger = logging.getLogger(__name__) @@ -171,8 +171,9 @@ def query(collectionid: Optional[int], userid: int, resource: str, action: str) matching_role_policies=rps, ) +PERMISSION_ACTIONS = Union[Literal['read'], Literal['update'], Literal['create'], Literal['delete']] -def check_table_permissions(collection, actor, obj, action: str) -> None: +def check_table_permissions(collection, actor, obj, action: PERMISSION_ACTIONS) -> None: if isinstance(obj, Table): name = obj.name.lower() else: @@ -186,7 +187,7 @@ def check_field_permissions(collection, actor, obj, fields: Iterable[str], actio table = obj.specify_model.name.lower() enforce(collection, actor, [f'/field/{table}/{field}' for field in fields], action) -def table_permissions_checker(collection, actor, action: str) -> Callable[[Any], None]: +def table_permissions_checker(collection, actor, action: PERMISSION_ACTIONS) -> Callable[[Any], None]: def checker(obj) -> None: check_table_permissions(collection, actor, obj, action) return checker diff --git a/specifyweb/specify/agent_types.py b/specifyweb/specify/agent_types.py index 84c912430cc..77445d9df71 100644 --- a/specifyweb/specify/agent_types.py +++ b/specifyweb/specify/agent_types.py @@ -1 +1 @@ -agent_types = ['Organization', 'Person', 'Other', 'Group',] +agent_types = ['Organization', 'Person', 'Other', 'Group',] \ No newline at end of file diff --git a/specifyweb/specify/api.py b/specifyweb/specify/api.py index 4dfd4d4b88a..a04aaff01b5 100644 --- a/specifyweb/specify/api.py +++ b/specifyweb/specify/api.py @@ -11,6 +11,8 @@ from typing_extensions import TypedDict +from specifyweb.specify.field_change_info import FieldChangeInfo + logger = logging.getLogger(__name__) from django import forms @@ -368,6 +370,10 @@ def set_field_if_exists(obj, field: str, value) -> None: if f.concrete: setattr(obj, field, value) +def _maybe_delete(data: Dict[str, Any], to_delete: str): + if to_delete in data: + del data[to_delete] + def cleanData(model, data: Dict[str, Any], agent) -> Dict[str, Any]: """Returns a copy of data with only fields that are part of model, removing metadata fields and warning on unexpected extra fields.""" @@ -400,30 +406,18 @@ def cleanData(model, data: Dict[str, Any], agent) -> Dict[str, Any]: if model is models.Agent: # setting user agents is part of the user management system. - try: - del cleaned['specifyuser'] - except KeyError: - pass + _maybe_delete(cleaned, 'specifyuser') # guid should only be updatable for taxon and geography if model not in (models.Taxon, models.Geography): - try: - del cleaned['guid'] - except KeyError: - pass + _maybe_delete(cleaned, 'guid') # timestampcreated should never be updated. - # ... well it is now ¯\_(ツ)_/¯ - # New requirments are for timestampcreated to be overridable. - try: - # del cleaned['timestampcreated'] - pass - except KeyError: - pass + # _maybe_delete(cleaned, 'timestampcreated') # Password should be set though the /api/set_password// endpoint - if model is models.Specifyuser and 'password' in cleaned: - del cleaned['password'] + if model is models.Specifyuser: + _maybe_delete(cleaned, 'password') return cleaned @@ -449,8 +443,6 @@ def create_obj(collection, agent, model, data: Dict[str, Any], parent_obj=None): handle_to_many(collection, agent, obj, data) return obj -FieldChangeInfo = TypedDict('FieldChangeInfo', {'field_name': str, 'old_value': Any, 'new_value': Any}) - def fld_change_info(obj, field, val) -> Optional[FieldChangeInfo]: if field.name != 'timestampmodified': value = prepare_value(field, val) @@ -458,7 +450,7 @@ def fld_change_info(obj, field, val) -> Optional[FieldChangeInfo]: value = None if value is None else float(value) old_value = getattr(obj, field.name) if str(old_value) != str(value): # ugh - return {'field_name': field.name, 'old_value': old_value, 'new_value': value} + return FieldChangeInfo(field_name=field.name, old_value=old_value, new_value=value) return None def set_fields_from_data(obj, data: Dict[str, Any]) -> List[FieldChangeInfo]: @@ -584,7 +576,7 @@ def handle_fk_fields(collection, agent, obj, data: Dict[str, Any]) -> Tuple[List else: raise Exception('bad foreign key field in data') if str(old_related_id) != str(new_related_id): - dirty.append({'field_name': field_name, 'old_value': old_related_id, 'new_value': new_related_id}) + dirty.append(FieldChangeInfo(field_name=field_name, old_value=old_related_id, new_value=new_related_id)) return dependents_to_delete, dirty @@ -641,9 +633,16 @@ def delete_resource(collection, agent, name, id, version) -> None: locking 'version'. """ obj = get_object_or_404(name, id=int(id)) - return delete_obj(obj, version, collection=collection, agent=agent) + return delete_obj(obj, (make_default_deleter(collection, agent)), version) + +def make_default_deleter(collection=None, agent=None): + def _deleter(parent_obj, obj): + if collection and agent: + check_table_permissions(collection, agent, obj, "delete") + auditlog.remove(obj, agent, parent_obj) + return _deleter -def delete_obj(obj, version=None, parent_obj=None, collection=None, agent=None, clean_predelete=None) -> None: +def delete_obj(obj, deleter: Optional[Callable[[Any, Any], None]]=None, version=None, parent_obj=None, clean_predelete=None) -> None: # need to delete dependent -to-one records # e.g. delete CollectionObjectAttribute when CollectionObject is deleted # but have to delete the referring record first @@ -653,19 +652,22 @@ def delete_obj(obj, version=None, parent_obj=None, collection=None, agent=None, if (field.many_to_one or field.one_to_one) and is_dependent_field(obj, field.name) ) if _f] - if collection and agent: - check_table_permissions(collection, agent, obj, "delete") - auditlog.remove(obj, agent, parent_obj) if version is not None: bump_version(obj, version) + if clean_predelete: clean_predelete(obj) + if hasattr(obj, 'pre_constraints_delete'): obj.pre_constraints_delete() + + if deleter: + deleter(parent_obj, obj) + obj.delete() for dep in dependents_to_delete: - delete_obj(dep, version, parent_obj=obj, collection=collection, agent=agent, clean_predelete=clean_predelete) + delete_obj(dep, deleter, version, parent_obj=obj, clean_predelete=clean_predelete) @transaction.atomic @@ -685,18 +687,15 @@ def update_obj(collection, agent, name: str, id, version, data: Dict[str, Any], check_field_permissions(collection, agent, obj, [d['field_name'] for d in dirty], "update") - try: - obj._meta.get_field('modifiedbyagent') - except FieldDoesNotExist: - pass - else: - obj.modifiedbyagent = agent + if hasattr(obj, 'modifiedbyagent'): + setattr(obj, 'modifiedbyagent', agent) bump_version(obj, version) obj.save(force_update=True) auditlog.update(obj, agent, parent_obj, dirty) + deleter = make_default_deleter(collection=collection, agent=agent) for dep in dependents_to_delete: - delete_obj(dep, parent_obj=obj, collection=collection, agent=agent) + delete_obj(dep, deleter, parent_obj=obj) handle_to_many(collection, agent, obj, data) return obj diff --git a/specifyweb/specify/auditlog.py b/specifyweb/specify/auditlog.py index 6d9edece8bd..b31dc18ebd0 100644 --- a/specifyweb/specify/auditlog.py +++ b/specifyweb/specify/auditlog.py @@ -1,5 +1,10 @@ import logging from time import time +from typing import Any +from typing_extensions import TypedDict + +from specifyweb.specify.field_change_info import FieldChangeInfo + logger = logging.getLogger(__name__) import re @@ -16,8 +21,9 @@ Discipline = datamodel.get_table_strict('Discipline') Division = datamodel.get_table_strict('Division') -from . import auditcodes +from . import auditcodes + class AuditLog(object): _auditingFlds = None @@ -67,7 +73,7 @@ def remove(self, obj, agent, parent_record=None): if fldattr != 'version' and hasattr(obj, fldattr): val = getattr(obj, fldattr) if val is not None: - self._log_fld_update({'field_name': fldattr, 'old_value': val, 'new_value': None}, log_obj, agent) + self._log_fld_update(FieldChangeInfo(field_name=fldattr, old_value=val, new_value=None), log_obj, agent) for spfld in obj.specify_model.relationships: if spfld.type.lower().endswith("many-to-one"): fldattr = spfld.name.lower() @@ -75,11 +81,11 @@ def remove(self, obj, agent, parent_record=None): val = getattr(obj, fldattr) field = obj._meta.get_field(fldattr); if isinstance(val, field.related_model): - self._log_fld_update({'field_name': fldattr, 'old_value': val.id, 'new_value': None}, log_obj, agent) + self._log_fld_update(FieldChangeInfo(field_name=fldattr, old_value=val.id, new_value=None), log_obj, agent) elif isinstance(val, str) and not val.endswith('.None'): fk_model, fk_id = parse_uri(val) if fk_model == field.related_model.__name__.lower() and fk_id is not None: - self._log_fld_update({'field_name': fldattr, 'old_value': fk_id, 'new_value': None}, log_obj, agent) + self._log_fld_update(FieldChangeInfo(field_name=fldattr, old_value=fk_id, new_value=None), log_obj, agent) return log_obj def _log(self, action, obj, agent, parent_record): diff --git a/specifyweb/specify/build_models.py b/specifyweb/specify/build_models.py index 2200a9dc9b0..0044585dec2 100644 --- a/specifyweb/specify/build_models.py +++ b/specifyweb/specify/build_models.py @@ -1,12 +1,9 @@ from django.db import models from django.db.models.signals import pre_delete -from model_utils import FieldTracker -from requests import get - from specifyweb.businessrules.exceptions import AbortSave from . import model_extras -from .model_timestamp import pre_save_auto_timestamp_field_with_override +from .model_timestamp import save_auto_timestamp_field_with_override appname = __name__.split('.')[-2] @@ -64,7 +61,7 @@ class Meta: def save(self, *args, **kwargs): try: - return super(model, self).save(*args, **kwargs) + return save_auto_timestamp_field_with_override(super(model, self).save, args, kwargs, self) except AbortSave: return @@ -76,40 +73,16 @@ def pre_constraints_delete(self): # This is not currently used, but is here for future use. pre_delete.send(sender=self.__class__, instance=self) - def save_timestamped(self, *args, **kwargs): - timestamp_override = kwargs.pop('timestamp_override', False) - pre_save_auto_timestamp_field_with_override(self, timestamp_override) - try: - super(model, self).save(*args, **kwargs) - except AbortSave: - return - - field_names = [field.name.lower() for field in table.fields] - timestamp_fields = ['timestampcreated', 'timestampmodified'] - has_timestamp_fields = any(field in field_names for field in timestamp_fields) - - if has_timestamp_fields: - tracked_fields = [field for field in timestamp_fields if field in field_names] - attrs['timestamptracker'] = FieldTracker(fields=tracked_fields) - for field in tracked_fields: - attrs[field] = models.DateTimeField(db_column=field) # default=timezone.now is handled in pre_save_auto_timestamp_field_with_override - attrs['Meta'] = Meta if table.django_name in tables_with_pre_constraints_delete: # This is not currently used, but is here for future use. attrs['pre_constraints_delete'] = pre_constraints_delete - if has_timestamp_fields: - attrs['save'] = save_timestamped - else: - attrs['save'] = save + attrs['save'] = save supercls = models.Model if hasattr(model_extras, table.django_name): supercls = getattr(model_extras, table.django_name) - elif has_timestamp_fields: - # FUTURE: supercls = SpTimestampedModel - pass model = type(table.django_name, (supercls,), attrs) return model diff --git a/specifyweb/specify/field_change_info.py b/specifyweb/specify/field_change_info.py new file mode 100644 index 00000000000..a3a233272f5 --- /dev/null +++ b/specifyweb/specify/field_change_info.py @@ -0,0 +1,7 @@ +from typing import Any, TypedDict + +# All field change infos are of this type. Placing it here to avoid circular dependencies with, almost every data modification file. +class FieldChangeInfo(TypedDict): + field_name: str + old_value: Any + new_value: Any \ No newline at end of file diff --git a/specifyweb/specify/func.py b/specifyweb/specify/func.py new file mode 100644 index 00000000000..32224eed7f4 --- /dev/null +++ b/specifyweb/specify/func.py @@ -0,0 +1,53 @@ + +from functools import reduce +from typing import Callable, Dict, Generator, List, Optional, Tuple, TypeVar +from django.db.models import Q + +# made as a class to encapsulate type variables and prevent pollution of export +class Func: + I = TypeVar('I') + O = TypeVar('O') + + @staticmethod + def maybe(value: Optional[I], callback: Callable[[I], O]): + if value is None: + return None + return callback(value) + + @staticmethod + def sort_by_key(to_sort: Dict[I, O], reverse=False) -> List[Tuple[I, O]]: + return sorted(to_sort.items(), key=lambda t: t[0], reverse=reverse) + + @staticmethod + def make_ors(eprns: List[Q]) -> Q: + assert len(eprns) > 0 + return reduce(lambda accum, curr: accum | curr, eprns) + + @staticmethod + def make_generator(step=1): + def _generator(step=step): + i = 0 + while True: + yield i + i += step + return _generator(step) + + @staticmethod + def tap_call(callback: Callable[[], O], generator: Generator[int, None, None]) -> Tuple[bool, O]: + init_1 = next(generator) + init_2 = next(generator) + step = init_2 - init_1 + to_return = callback() + post = next(generator) + called = (post - init_2) != step + assert (post - init_2) % step == 0, "(sanity check failed): made irregular generator" + return called, to_return + + + @staticmethod + def remove_keys(source: Dict[I, O], callback: Callable[[O], bool]) -> Dict[I, O]: + return { + key: value + for key, value in source.items() + if callback(value) + } \ No newline at end of file diff --git a/specifyweb/specify/model_extras.py b/specifyweb/specify/model_extras.py index 0f3bea3cf96..3e505a70247 100644 --- a/specifyweb/specify/model_extras.py +++ b/specifyweb/specify/model_extras.py @@ -5,7 +5,7 @@ from django.conf import settings from django.utils import timezone -from .model_timestamp import SpTimestampedModel, pre_save_auto_timestamp_field_with_override +from .model_timestamp import save_auto_timestamp_field_with_override from .tree_extras import Tree, TreeRank if settings.AUTH_LDAP_SERVER_URI is not None: @@ -20,7 +20,7 @@ def create_user(self, name, password=None): def create_superuser(self, name, password=None): raise NotImplementedError() -class Specifyuser(models.Model): # FUTURE: class Specifyuser(SpTimestampedModel): +class Specifyuser(models.Model): USERNAME_FIELD = 'name' REQUIRED_FIELDS = [] is_active = True @@ -117,15 +117,14 @@ def save(self, *args, **kwargs): if self.id and self.usertype != 'Manager': self.clear_admin() - pre_save_auto_timestamp_field_with_override(self) - return super(Specifyuser, self).save(*args, **kwargs) + return save_auto_timestamp_field_with_override(super(Specifyuser, self).save, args, kwargs, self) class Meta: abstract = True -class Preparation(models.Model): # FUTURE: class Preparation(SpTimestampedModel): +class Preparation(models.Model): def isonloan(self): # TODO: needs unit tests from django.db import connection diff --git a/specifyweb/specify/model_timestamp.py b/specifyweb/specify/model_timestamp.py index 1acf5d0dbd7..0080174df18 100644 --- a/specifyweb/specify/model_timestamp.py +++ b/specifyweb/specify/model_timestamp.py @@ -1,51 +1,35 @@ -from django.db import models from django.utils import timezone -from django.conf import settings - -from model_utils import FieldTracker - -def pre_save_auto_timestamp_field_with_override(obj, timestamp_override=None): - # Normal behavior is to update the timestamps automatically when saving. - # If timestampcreated or timestampmodified have been edited, don't update them to the current time. - cur_time = timezone.now() - timestamp_override = ( - timestamp_override - if timestamp_override is not None - else getattr(settings, "TIMESTAMP_SAVE_OVERRIDE", False) - ) - timestamp_fields = ['timestampcreated', 'timestampmodified'] - for field in timestamp_fields: - if hasattr(obj, field) and hasattr(obj, 'timestamptracker'): - if not timestamp_override and field not in obj.timestamptracker.changed() and \ - (not obj.id or not getattr(obj, field)): - setattr(obj, field, cur_time) - elif timestamp_override and not getattr(obj, field): - setattr(obj, field, cur_time) - - avoid_null_timestamp_fields(obj) - -def avoid_null_timestamp_fields(obj): - cur_time = timezone.now() - if hasattr(obj, 'timestampcreated') and getattr(obj, 'timestampcreated') is None: - obj.timestampcreated = cur_time - if hasattr(obj, 'timestampmodified') and getattr(obj, 'timestampmodified') is None: - obj.timestampmodified = cur_time - -# NOTE: This class is needed for when we get rid of dynamic model creation from Specify 6 datamodel.xml file. -# NOTE: Currently in sperate file to avoid circular import. -class SpTimestampedModel(models.Model): - """ - SpTimestampedModel(id, timestampcreated, timestampmodified) - """ - - timestampcreated = models.DateTimeField(db_column='TimestampCreated', default=timezone.now) - timestampmodified = models.DateTimeField(db_column='TimestampModified', default=timezone.now) - - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) - - class Meta: - abstract = True - - def save(self, *args, **kwargs): - pre_save_auto_timestamp_field_with_override(self) - super().save(*args, **kwargs) +from django.db.models import Model + +timestamp_fields = [('timestampmodified', True), ('timestampcreated', False)] + +fields_to_skip = [field[0] for field in timestamp_fields if not field[1]] + +def save_auto_timestamp_field_with_override(save_func, args, kwargs, obj): + # If object already is present, reset timestamps to null. + model: Model = obj.__class__ + is_forced_insert = kwargs.get('force_insert', False) + fields_to_update = kwargs.get('update_fields', None) + if fields_to_update is None: + fields_to_update = [ + field.name for field in model._meta.get_fields(include_hidden=True) if field.concrete + and not field.primary_key + ] + + if obj.id is not None: + fields_to_update = [ + field for field in fields_to_update + if field not in fields_to_skip + ] + + current = timezone.now() + _set_if_empty(obj, timestamp_fields, current, obj.pk is not None) + new_kwargs = {**kwargs, 'update_fields': fields_to_update} if obj.pk is not None and not is_forced_insert else kwargs + return save_func(*args, **new_kwargs) + +def _set_if_empty(obj, fields, default_value, override=False): + for field, can_override in fields: + if not hasattr(obj, field): + continue + if (override and can_override) or getattr(obj, field) is None: + setattr(obj, field, default_value) \ No newline at end of file diff --git a/specifyweb/specify/models.py b/specifyweb/specify/models.py index 78355910e32..664407505f0 100644 --- a/specifyweb/specify/models.py +++ b/specifyweb/specify/models.py @@ -1,9 +1,8 @@ from functools import partialmethod from django.db import models from django.utils import timezone -from model_utils import FieldTracker from specifyweb.businessrules.exceptions import AbortSave -from specifyweb.specify.model_timestamp import pre_save_auto_timestamp_field_with_override +from specifyweb.specify.model_timestamp import save_auto_timestamp_field_with_override from specifyweb.specify import model_extras from .datamodel import datamodel import logging @@ -19,8 +18,7 @@ def protect_with_blockers(collector, field, sub_objs, using): def custom_save(self, *args, **kwargs): try: # Custom save logic here, if necessary - pre_save_auto_timestamp_field_with_override(self) - super(self.__class__, self).save(*args, **kwargs) + save_auto_timestamp_field_with_override(super(self.__class__, self).save, args, kwargs, self) except AbortSave as e: # Handle AbortSave exception as needed logger.error("Save operation aborted: %s", e) @@ -77,7 +75,7 @@ class Meta: # models.Index(fields=['DateAccessioned'], name='AccessionDateIDX') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Accessionagent(models.Model): @@ -104,7 +102,7 @@ class Meta: db_table = 'accessionagent' ordering = () - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Accessionattachment(models.Model): @@ -130,7 +128,7 @@ class Meta: db_table = 'accessionattachment' ordering = () - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Accessionauthorization(models.Model): @@ -156,7 +154,7 @@ class Meta: db_table = 'accessionauthorization' ordering = () - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Accessioncitation(models.Model): @@ -185,7 +183,7 @@ class Meta: db_table = 'accessioncitation' ordering = () - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Address(models.Model): @@ -230,7 +228,7 @@ class Meta: db_table = 'address' ordering = () - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Addressofrecord(models.Model): @@ -260,7 +258,7 @@ class Meta: db_table = 'addressofrecord' ordering = () - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Agent(models.Model): @@ -328,7 +326,7 @@ class Meta: # models.Index(fields=['Abbreviation'], name='AbbreviationIDX') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Agentattachment(models.Model): @@ -354,7 +352,7 @@ class Meta: db_table = 'agentattachment' ordering = () - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Agentgeography(models.Model): @@ -380,7 +378,7 @@ class Meta: db_table = 'agentgeography' ordering = () - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Agentidentifier(models.Model): @@ -420,7 +418,7 @@ class Meta: db_table = 'agentidentifier' ordering = () - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Agentspecialty(models.Model): @@ -445,7 +443,7 @@ class Meta: db_table = 'agentspecialty' ordering = () - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Agentvariant(models.Model): @@ -473,7 +471,7 @@ class Meta: db_table = 'agentvariant' ordering = () - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Appraisal(models.Model): @@ -506,7 +504,7 @@ class Meta: # models.Index(fields=['AppraisalDate'], name='AppraisalDateIDX') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Attachment(models.Model): @@ -562,7 +560,7 @@ class Meta: # models.Index(fields=['GUID'], name='AttchmentGuidIDX') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Attachmentimageattribute(models.Model): @@ -602,7 +600,7 @@ class Meta: db_table = 'attachmentimageattribute' ordering = () - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Attachmentmetadata(models.Model): @@ -627,7 +625,7 @@ class Meta: db_table = 'attachmentmetadata' ordering = () - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Attachmenttag(models.Model): @@ -651,7 +649,7 @@ class Meta: db_table = 'attachmenttag' ordering = () - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Attributedef(models.Model): @@ -678,7 +676,7 @@ class Meta: db_table = 'attributedef' ordering = () - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Author(models.Model): @@ -704,7 +702,7 @@ class Meta: db_table = 'author' ordering = ('ordernumber',) - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Autonumberingscheme(models.Model): @@ -734,7 +732,7 @@ class Meta: # models.Index(fields=['SchemeName'], name='SchemeNameIDX') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Borrow(models.Model): @@ -781,7 +779,7 @@ class Meta: # models.Index(fields=['CollectionMemberID'], name='BorColMemIDX') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Borrowagent(models.Model): @@ -811,7 +809,7 @@ class Meta: # models.Index(fields=['CollectionMemberID'], name='BorColMemIDX2') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Borrowattachment(models.Model): @@ -837,7 +835,7 @@ class Meta: db_table = 'borrowattachment' ordering = () - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Borrowmaterial(models.Model): @@ -875,7 +873,7 @@ class Meta: # models.Index(fields=['Description'], name='DescriptionIDX') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Borrowreturnmaterial(models.Model): @@ -907,7 +905,7 @@ class Meta: # models.Index(fields=['CollectionMemberID'], name='BorrowReturnedColMemIDX') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Collectingevent(models.Model): @@ -976,7 +974,7 @@ class Meta: # models.Index(fields=['GUID'], name='CEGuidIDX') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Collectingeventattachment(models.Model): @@ -1006,7 +1004,7 @@ class Meta: # models.Index(fields=['CollectionMemberID'], name='CEAColMemIDX') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Collectingeventattr(models.Model): @@ -1036,7 +1034,7 @@ class Meta: # models.Index(fields=['CollectionMemberID'], name='COLEVATColMemIDX') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Collectingeventattribute(models.Model): @@ -1109,7 +1107,7 @@ class Meta: # models.Index(fields=['DisciplineID'], name='COLEVATSDispIDX') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Collectingeventauthorization(models.Model): @@ -1134,7 +1132,7 @@ class Meta: db_table = 'collectingeventauthorization' ordering = () - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Collectingtrip(models.Model): @@ -1195,7 +1193,7 @@ class Meta: # models.Index(fields=['StartDate'], name='COLTRPStartDateIDX') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Collectingtripattachment(models.Model): @@ -1225,7 +1223,7 @@ class Meta: # models.Index(fields=['CollectionMemberID'], name='CTAColMemIDX') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Collectingtripattribute(models.Model): @@ -1297,7 +1295,7 @@ class Meta: # models.Index(fields=['DisciplineID'], name='COLTRPSDispIDX') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Collectingtripauthorization(models.Model): @@ -1322,7 +1320,7 @@ class Meta: db_table = 'collectingtripauthorization' ordering = () - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Collection(models.Model): @@ -1372,7 +1370,7 @@ class Meta: # models.Index(fields=['GUID'], name='CollectionGuidIDX') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Collectionobject(models.Model): @@ -1474,7 +1472,7 @@ class Meta: # models.Index(fields=['CollectionmemberID'], name='COColMemIDX') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Collectionobjectattachment(models.Model): @@ -1504,7 +1502,7 @@ class Meta: # models.Index(fields=['CollectionMemberID'], name='COLOBJATTColMemIDX') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Collectionobjectattr(models.Model): @@ -1534,7 +1532,7 @@ class Meta: # models.Index(fields=['CollectionMemberID'], name='COLOBJATRSColMemIDX') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Collectionobjectattribute(models.Model): @@ -1681,7 +1679,7 @@ class Meta: # models.Index(fields=['CollectionMemberID'], name='COLOBJATTRSColMemIDX') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Collectionobjectcitation(models.Model): @@ -1714,7 +1712,7 @@ class Meta: # models.Index(fields=['CollectionMemberID'], name='COCITColMemIDX') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Collectionobjectproperty(models.Model): @@ -1903,7 +1901,7 @@ class Meta: # models.Index(fields=['CollectionMemberID'], name='COLOBJPROPColMemIDX') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Collectionreltype(models.Model): @@ -1929,7 +1927,7 @@ class Meta: db_table = 'collectionreltype' ordering = () - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Collectionrelationship(models.Model): @@ -1956,7 +1954,7 @@ class Meta: db_table = 'collectionrelationship' ordering = () - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Collector(models.Model): @@ -1991,7 +1989,7 @@ class Meta: # models.Index(fields=['DivisionID'], name='COLTRDivIDX') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Commonnametx(models.Model): @@ -2023,7 +2021,7 @@ class Meta: # models.Index(fields=['Country'], name='CommonNameTxCountryIDX') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Commonnametxcitation(models.Model): @@ -2058,7 +2056,7 @@ class Meta: db_table = 'commonnametxcitation' ordering = () - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Conservdescription(models.Model): @@ -2130,7 +2128,7 @@ class Meta: # models.Index(fields=['ShortDesc'], name='ConservDescShortDescIDX') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Conservdescriptionattachment(models.Model): @@ -2156,7 +2154,7 @@ class Meta: db_table = 'conservdescriptionattachment' ordering = () - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Conservevent(models.Model): @@ -2207,7 +2205,7 @@ class Meta: # models.Index(fields=['completedDate'], name='ConservCompletedDateIDX') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Conserveventattachment(models.Model): @@ -2233,7 +2231,7 @@ class Meta: db_table = 'conserveventattachment' ordering = () - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Container(models.Model): @@ -2266,7 +2264,7 @@ class Meta: # models.Index(fields=['CollectionMemberID'], name='ContainerMemIDX') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Dnaprimer(models.Model): @@ -2316,7 +2314,7 @@ class Meta: # models.Index(fields=['PrimerDesignator'], name='DesignatorIDX') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Dnasequence(models.Model): @@ -2376,7 +2374,7 @@ class Meta: # models.Index(fields=['BOLDSampleID'], name='BOLDSampleIDX') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Dnasequenceattachment(models.Model): @@ -2402,7 +2400,7 @@ class Meta: db_table = 'dnasequenceattachment' ordering = () - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Dnasequencingrun(models.Model): @@ -2458,7 +2456,7 @@ class Meta: db_table = 'dnasequencingrun' ordering = () - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Dnasequencingrunattachment(models.Model): @@ -2484,7 +2482,7 @@ class Meta: db_table = 'dnasequencerunattachment' ordering = () - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Dnasequencingruncitation(models.Model): @@ -2519,7 +2517,7 @@ class Meta: db_table = 'dnasequencingruncitation' ordering = () - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Datatype(models.Model): @@ -2542,7 +2540,7 @@ class Meta: db_table = 'datatype' ordering = () - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Deaccession(models.Model): @@ -2597,7 +2595,7 @@ class Meta: # models.Index(fields=['DeaccessionDate'], name='DeaccessionDateIDX') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Deaccessionagent(models.Model): @@ -2623,7 +2621,7 @@ class Meta: db_table = 'deaccessionagent' ordering = () - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Deaccessionattachment(models.Model): @@ -2649,7 +2647,7 @@ class Meta: db_table = 'deaccessionattachment' ordering = () - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Determination(models.Model): @@ -2721,7 +2719,7 @@ class Meta: # models.Index(fields=['TypeStatusName'], name='TypeStatusNameIDX') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Determinationcitation(models.Model): @@ -2754,7 +2752,7 @@ class Meta: # models.Index(fields=['CollectionMemberID'], name='DetCitColMemIDX') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Determiner(models.Model): @@ -2785,7 +2783,7 @@ class Meta: db_table = 'determiner' ordering = ('ordernumber',) - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Discipline(models.Model): @@ -2823,7 +2821,7 @@ class Meta: # models.Index(fields=['Name'], name='DisciplineNameIDX') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Disposal(models.Model): @@ -2861,7 +2859,7 @@ class Meta: # models.Index(fields=['DisposalDate'], name='DisposalDateIDX') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Disposalagent(models.Model): @@ -2887,7 +2885,7 @@ class Meta: db_table = 'disposalagent' ordering = () - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Disposalattachment(models.Model): @@ -2913,7 +2911,7 @@ class Meta: db_table = 'disposalattachment' ordering = () - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Disposalpreparation(models.Model): @@ -2940,7 +2938,7 @@ class Meta: db_table = 'disposalpreparation' ordering = () - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Division(models.Model): @@ -2976,7 +2974,7 @@ class Meta: # models.Index(fields=['Name'], name='DivisionNameIDX') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Exchangein(models.Model): @@ -3020,7 +3018,7 @@ class Meta: # models.Index(fields=['DescriptionOfMaterial'], name='DescriptionOfMaterialIDX') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Exchangeinattachment(models.Model): @@ -3046,7 +3044,7 @@ class Meta: db_table = 'exchangeinattachment' ordering = () - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Exchangeinprep(models.Model): @@ -3080,7 +3078,7 @@ class Meta: # models.Index(fields=['DisciplineID'], name='ExchgInPrepDspMemIDX') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Exchangeout(models.Model): @@ -3126,7 +3124,7 @@ class Meta: # models.Index(fields=['ExchangeOutNumber'], name='ExchangeOutNumberIDX') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Exchangeoutattachment(models.Model): @@ -3152,7 +3150,7 @@ class Meta: db_table = 'exchangeoutattachment' ordering = () - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Exchangeoutprep(models.Model): @@ -3186,7 +3184,7 @@ class Meta: # models.Index(fields=['DisciplineID'], name='ExchgOutPrepDspMemIDX') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Exsiccata(models.Model): @@ -3212,7 +3210,7 @@ class Meta: db_table = 'exsiccata' ordering = () - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Exsiccataitem(models.Model): @@ -3238,7 +3236,7 @@ class Meta: db_table = 'exsiccataitem' ordering = () - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Extractor(models.Model): @@ -3268,7 +3266,7 @@ class Meta: db_table = 'extractor' ordering = ('ordernumber',) - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Fieldnotebook(models.Model): @@ -3303,7 +3301,7 @@ class Meta: # models.Index(fields=['EndDate'], name='FNBEndDateIDX') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Fieldnotebookattachment(models.Model): @@ -3329,7 +3327,7 @@ class Meta: db_table = 'fieldnotebookattachment' ordering = () - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Fieldnotebookpage(models.Model): @@ -3360,7 +3358,7 @@ class Meta: # models.Index(fields=['ScanDate'], name='FNBPScanDateIDX') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Fieldnotebookpageattachment(models.Model): @@ -3386,7 +3384,7 @@ class Meta: db_table = 'fieldnotebookpageattachment' ordering = () - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Fieldnotebookpageset(models.Model): @@ -3420,7 +3418,7 @@ class Meta: # models.Index(fields=['EndDate'], name='FNBPSEndDateIDX') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Fieldnotebookpagesetattachment(models.Model): @@ -3446,7 +3444,7 @@ class Meta: db_table = 'fieldnotebookpagesetattachment' ordering = () - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Fundingagent(models.Model): @@ -3478,7 +3476,7 @@ class Meta: # models.Index(fields=['DivisionID'], name='COLTRIPDivIDX') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Geocoorddetail(models.Model): @@ -3540,7 +3538,7 @@ class Meta: db_table = 'geocoorddetail' ordering = () - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Geography(model_extras.Geography): @@ -3590,7 +3588,7 @@ class Meta: # models.Index(fields=['FullName'], name='GeoFullNameIDX') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Geographytreedef(models.Model): @@ -3615,7 +3613,7 @@ class Meta: db_table = 'geographytreedef' ordering = () - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Geographytreedefitem(model_extras.Geographytreedefitem): @@ -3648,7 +3646,7 @@ class Meta: db_table = 'geographytreedefitem' ordering = () - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Geologictimeperiod(model_extras.Geologictimeperiod): @@ -3695,7 +3693,7 @@ class Meta: # models.Index(fields=['GUID'], name='GTPGuidIDX') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Geologictimeperiodtreedef(models.Model): @@ -3720,7 +3718,7 @@ class Meta: db_table = 'geologictimeperiodtreedef' ordering = () - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Geologictimeperiodtreedefitem(model_extras.Geologictimeperiodtreedefitem): @@ -3753,7 +3751,7 @@ class Meta: db_table = 'geologictimeperiodtreedefitem' ordering = () - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Gift(models.Model): @@ -3809,7 +3807,7 @@ class Meta: # models.Index(fields=['GiftDate'], name='GiftDateIDX') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Giftagent(models.Model): @@ -3840,7 +3838,7 @@ class Meta: # models.Index(fields=['DisciplineID'], name='GiftAgDspMemIDX') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Giftattachment(models.Model): @@ -3866,7 +3864,7 @@ class Meta: db_table = 'giftattachment' ordering = () - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Giftpreparation(models.Model): @@ -3904,7 +3902,7 @@ class Meta: # models.Index(fields=['DisciplineID'], name='GiftPrepDspMemIDX') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Groupperson(models.Model): @@ -3931,7 +3929,7 @@ class Meta: db_table = 'groupperson' ordering = () - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Inforequest(models.Model): @@ -3966,7 +3964,7 @@ class Meta: # models.Index(fields=['CollectionMemberID'], name='IRColMemIDX') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Institution(models.Model): @@ -4020,7 +4018,7 @@ class Meta: # models.Index(fields=['GUID'], name='InstGuidIDX') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Institutionnetwork(models.Model): @@ -4058,7 +4056,7 @@ class Meta: # models.Index(fields=['Name'], name='InstNetworkNameIDX') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Journal(models.Model): @@ -4091,7 +4089,7 @@ class Meta: # models.Index(fields=['GUID'], name='JournalGUIDIDX') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Latlonpolygon(models.Model): @@ -4118,7 +4116,7 @@ class Meta: db_table = 'latlonpolygon' ordering = () - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Latlonpolygonpnt(models.Model): @@ -4184,7 +4182,7 @@ class Meta: # models.Index(fields=['GUID'], name='LithoGuidIDX') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Lithostrattreedef(models.Model): @@ -4209,7 +4207,7 @@ class Meta: db_table = 'lithostrattreedef' ordering = () - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Lithostrattreedefitem(model_extras.Lithostrattreedefitem): @@ -4242,7 +4240,7 @@ class Meta: db_table = 'lithostrattreedefitem' ordering = () - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Loan(models.Model): @@ -4301,7 +4299,7 @@ class Meta: # models.Index(fields=['CurrentDueDate'], name='CurrentDueDateIDX') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Loanagent(models.Model): @@ -4331,7 +4329,7 @@ class Meta: # models.Index(fields=['DisciplineID'], name='LoanAgDspMemIDX') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Loanattachment(models.Model): @@ -4357,7 +4355,7 @@ class Meta: db_table = 'loanattachment' ordering = () - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Loanpreparation(models.Model): @@ -4398,7 +4396,7 @@ class Meta: # models.Index(fields=['DisciplineID'], name='LoanPrepDspMemIDX') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Loanreturnpreparation(models.Model): @@ -4431,7 +4429,7 @@ class Meta: # models.Index(fields=['DisciplineID'], name='LoanRetPrepDspMemIDX') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Locality(models.Model): @@ -4506,7 +4504,7 @@ class Meta: # models.Index(fields=['RelationToNamedPlace'], name='RelationToNamedPlaceIDX') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Localityattachment(models.Model): @@ -4532,7 +4530,7 @@ class Meta: db_table = 'localityattachment' ordering = () - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Localitycitation(models.Model): @@ -4565,7 +4563,7 @@ class Meta: # models.Index(fields=['DisciplineID'], name='LocCitDspMemIDX') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Localitydetail(models.Model): @@ -4635,7 +4633,7 @@ class Meta: db_table = 'localitydetail' ordering = () - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Localitynamealias(models.Model): @@ -4664,7 +4662,7 @@ class Meta: # models.Index(fields=['Name'], name='LocalityNameAliasIDX') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Materialsample(models.Model): @@ -4731,7 +4729,7 @@ class Meta: # models.Index(fields=['GGBNSampleDesignation'], name='DesignationIDX') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Morphbankview(models.Model): @@ -4762,7 +4760,7 @@ class Meta: db_table = 'morphbankview' ordering = () - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Otheridentifier(models.Model): @@ -4808,7 +4806,7 @@ class Meta: # models.Index(fields=['CollectionMemberID'], name='OthIdColMemIDX') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Paleocontext(models.Model): @@ -4856,7 +4854,7 @@ class Meta: # models.Index(fields=['DisciplineID'], name='PaleoCxtDisciplineIDX') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Pcrperson(models.Model): @@ -4886,7 +4884,7 @@ class Meta: db_table = 'pcrperson' ordering = () - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Permit(models.Model): @@ -4938,7 +4936,7 @@ class Meta: # models.Index(fields=['IssuedDate'], name='IssuedDateIDX') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Permitattachment(models.Model): @@ -4964,7 +4962,7 @@ class Meta: db_table = 'permitattachment' ordering = () - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Picklist(models.Model): @@ -5001,7 +4999,7 @@ class Meta: # models.Index(fields=['Name'], name='PickListNameIDX') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Picklistitem(models.Model): @@ -5027,7 +5025,7 @@ class Meta: db_table = 'picklistitem' ordering = ('ordinal',) - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Preptype(models.Model): @@ -5052,7 +5050,7 @@ class Meta: db_table = 'preptype' ordering = () - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Preparation(model_extras.Preparation): @@ -5128,7 +5126,7 @@ class Meta: # models.Index(fields=['BarCode'], name='PrepBarCodeIDX') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Preparationattachment(models.Model): @@ -5158,7 +5156,7 @@ class Meta: # models.Index(fields=['CollectionMemberID'], name='PrepAttColMemIDX') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Preparationattr(models.Model): @@ -5188,7 +5186,7 @@ class Meta: # models.Index(fields=['CollectionMemberID'], name='PrepAttrColMemIDX') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Preparationattribute(models.Model): @@ -5255,7 +5253,7 @@ class Meta: # models.Index(fields=['CollectionMemberID'], name='PrepAttrsColMemIDX') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Preparationproperty(models.Model): @@ -5444,7 +5442,7 @@ class Meta: # models.Index(fields=['CollectionMemberID'], name='PREPPROPColMemIDX') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Project(models.Model): @@ -5487,7 +5485,7 @@ class Meta: # models.Index(fields=['ProjectNumber'], name='ProjectNumberIDX') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Recordset(models.Model): @@ -5523,7 +5521,7 @@ class Meta: # models.Index(fields=['name'], name='RecordSetNameIDX') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Recordsetitem(models.Model): @@ -5594,7 +5592,7 @@ class Meta: # models.Index(fields=['ISBN'], name='ISBNIDX') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Referenceworkattachment(models.Model): @@ -5620,7 +5618,7 @@ class Meta: db_table = 'referenceworkattachment' ordering = () - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Repositoryagreement(models.Model): @@ -5662,7 +5660,7 @@ class Meta: # models.Index(fields=['StartDate'], name='RefWrkStartDate') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Repositoryagreementattachment(models.Model): @@ -5688,7 +5686,7 @@ class Meta: db_table = 'repositoryagreementattachment' ordering = () - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Shipment(models.Model): @@ -5737,7 +5735,7 @@ class Meta: # models.Index(fields=['ShipmentMethod'], name='ShipmentMethodIDX') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Spappresource(models.Model): @@ -5772,8 +5770,7 @@ class Meta: # models.Index(fields=['Name'], name='SpAppResNameIDX'), # models.Index(fields=['MimeType'], name='SpAppResMimeTypeIDX') ] - - timestamptracker = FieldTracker(fields=['timestampcreated']) + save = partialmethod(custom_save) class Spappresourcedata(models.Model): @@ -5827,7 +5824,7 @@ def save_spappresourcedata(self, *args, **kwargs): # else: # self._data = value - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(save_spappresourcedata) class Spappresourcedir(models.Model): @@ -5858,7 +5855,7 @@ class Meta: # models.Index(fields=['DisciplineType'], name='SpAppResourceDirDispTypeIDX') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Spauditlog(models.Model): @@ -5891,7 +5888,7 @@ def save_spauditlog(self, *args, **kwargs): self.recordversion = 0 # or some other default value custom_save(self, *args, **kwargs) - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(save_spauditlog) class Spauditlogfield(models.Model): @@ -5917,7 +5914,7 @@ class Meta: db_table = 'spauditlogfield' ordering = () - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Spexportschema(models.Model): @@ -5943,7 +5940,7 @@ class Meta: db_table = 'spexportschema' ordering = () - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Spexportschemaitem(models.Model): @@ -5971,7 +5968,7 @@ class Meta: db_table = 'spexportschemaitem' ordering = () - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Spexportschemaitemmapping(models.Model): @@ -6000,7 +5997,7 @@ class Meta: db_table = 'spexportschemaitemmapping' ordering = () - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Spexportschemamapping(models.Model): @@ -6029,7 +6026,7 @@ class Meta: # models.Index(fields=['CollectionMemberID'], name='SPEXPSCHMMAPColMemIDX') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Spfieldvaluedefault(models.Model): @@ -6059,7 +6056,7 @@ class Meta: # models.Index(fields=['CollectionMemberID'], name='SpFieldValueDefaultColMemIDX') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Splocalecontainer(models.Model): @@ -6095,7 +6092,7 @@ class Meta: # models.Index(fields=['Name'], name='SpLocaleContainerNameIDX') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Splocalecontaineritem(models.Model): @@ -6130,7 +6127,7 @@ class Meta: # models.Index(fields=['Name'], name='SpLocaleContainerItemNameIDX') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Splocaleitemstr(models.Model): @@ -6164,7 +6161,7 @@ class Meta: # models.Index(fields=['Country'], name='SpLocaleCountyIDX') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Sppermission(models.Model): @@ -6210,7 +6207,7 @@ class Meta: db_table = 'spprincipal' ordering = () - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Spquery(models.Model): @@ -6248,7 +6245,7 @@ class Meta: # models.Index(fields=['Name'], name='SpQueryNameIDX') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Spqueryfield(models.Model): @@ -6289,7 +6286,7 @@ class Meta: db_table = 'spqueryfield' ordering = ('position',) - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Spreport(models.Model): @@ -6324,7 +6321,7 @@ class Meta: # models.Index(fields=['Name'], name='SpReportNameIDX') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Spsymbiotainstance(models.Model): @@ -6358,7 +6355,7 @@ class Meta: # models.Index(fields=['CollectionMemberID'], name='SPSYMINSTColMemIDX') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Sptasksemaphore(models.Model): @@ -6390,7 +6387,7 @@ class Meta: db_table = 'sptasksemaphore' ordering = () - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Spversion(models.Model): @@ -6418,7 +6415,7 @@ class Meta: db_table = 'spversion' ordering = () - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Spviewsetobj(models.Model): @@ -6449,7 +6446,7 @@ class Meta: # models.Index(fields=['Name'], name='SpViewObjNameIDX') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Spvisualquery(models.Model): @@ -6477,7 +6474,7 @@ class Meta: # models.Index(fields=['Name'], name='SpVisualQueryNameIDX') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Specifyuser(model_extras.Specifyuser): @@ -6509,7 +6506,7 @@ class Meta: db_table = 'specifyuser' ordering = () - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + # save = partialmethod(custom_save) class Storage(model_extras.Storage): @@ -6552,7 +6549,7 @@ class Meta: # models.Index(fields=['FullName'], name='StorFullNameIDX') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Storageattachment(models.Model): @@ -6578,7 +6575,7 @@ class Meta: db_table = 'storageattachment' ordering = () - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Storagetreedef(models.Model): @@ -6603,7 +6600,7 @@ class Meta: db_table = 'storagetreedef' ordering = () - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Storagetreedefitem(model_extras.Storagetreedefitem): @@ -6636,7 +6633,7 @@ class Meta: db_table = 'storagetreedefitem' ordering = () - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Taxon(model_extras.Taxon): @@ -6756,7 +6753,7 @@ class Meta: # models.Index(fields=['EnvironmentalProtectionStatus'], name='EnvironmentalProtectionStatusIDX') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Taxonattachment(models.Model): @@ -6782,7 +6779,7 @@ class Meta: db_table = 'taxonattachment' ordering = () - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Taxonattribute(models.Model): @@ -6968,7 +6965,7 @@ class Meta: db_table = 'taxonattribute' ordering = () - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Taxoncitation(models.Model): @@ -7003,7 +7000,7 @@ class Meta: db_table = 'taxoncitation' ordering = () - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Taxontreedef(models.Model): @@ -7030,7 +7027,7 @@ class Meta: db_table = 'taxontreedef' ordering = () - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Taxontreedefitem(model_extras.Taxontreedefitem): @@ -7064,7 +7061,7 @@ class Meta: db_table = 'taxontreedefitem' ordering = () - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Treatmentevent(models.Model): @@ -7122,7 +7119,7 @@ class Meta: # models.Index(fields=['TreatmentNumber'], name='TETreatmentNumberIDX') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Treatmenteventattachment(models.Model): @@ -7148,7 +7145,7 @@ class Meta: db_table = 'treatmenteventattachment' ordering = () - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Voucherrelationship(models.Model): @@ -7192,7 +7189,7 @@ class Meta: # models.Index(fields=['CollectionMemberID'], name='VRXDATColMemIDX') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Workbench(models.Model): @@ -7231,7 +7228,7 @@ class Meta: # models.Index(fields=['name'], name='WorkbenchNameIDX') ] - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Workbenchdataitem(models.Model): @@ -7344,7 +7341,7 @@ class Meta: db_table = 'workbenchrowexportedrelationship' ordering = () - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Workbenchrowimage(models.Model): @@ -7420,7 +7417,7 @@ class Meta: db_table = 'workbenchtemplate' ordering = () - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) class Workbenchtemplatemappingitem(models.Model): @@ -7460,5 +7457,5 @@ class Meta: db_table = 'workbenchtemplatemappingitem' ordering = () - timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) + save = partialmethod(custom_save) diff --git a/specifyweb/specify/tests/test_api.py b/specifyweb/specify/tests/test_api.py index b7568983f42..bdec47e896d 100644 --- a/specifyweb/specify/tests/test_api.py +++ b/specifyweb/specify/tests/test_api.py @@ -17,7 +17,6 @@ def get_table(name: str): return getattr(models, name.capitalize()) - class MainSetupTearDown: def setUp(self): disconnect_signal('pre_save', None, dispatch_uid=UNIQUENESS_DISPATCH_UID) @@ -94,6 +93,7 @@ class ApiTests(MainSetupTearDown, TestCase): pass skip_perms_check = lambda x: None class SimpleApiTests(ApiTests): + def test_get_collection(self): data = api.get_collection(self.collection, 'collectionobject', skip_perms_check) self.assertEqual(data['meta']['total_count'], len(self.collectionobjects)) @@ -132,77 +132,6 @@ def test_delete_object(self): api.delete_resource(self.collection, self.agent, 'collectionobject', obj.id, obj.version) self.assertEqual(models.Collectionobject.objects.filter(id=obj.id).count(), 0) - def test_timestamp_override_object(self): - manual_datetime_1 = datetime(1960, 1, 1, 0, 0, 0) - manual_datetime_2 = datetime(2020, 1, 1, 0, 0, 0) - cur_time = datetime.now() - - def timestamp_field_assert(obj, manual_datetime): - self.assertEqual(obj.timestampcreated, manual_datetime) - self.assertEqual(obj.timestampmodified, manual_datetime) - - # Test with api.create_obj - obj = api.create_obj(self.collection, self.agent, 'collectionobject', { - 'collection': api.uri_for_model('collection', self.collection.id), - 'catalognumber': 'foobar', - 'timestampcreated': manual_datetime_1, 'timestampmodified': manual_datetime_1}) - obj = models.Collectionobject.objects.get(id=obj.id) - timestamp_field_assert(obj, manual_datetime_1) - - # Test editing obj after creating with api.create_obj - data = api.get_resource('collection', self.collection.id, skip_perms_check) - data['timestampcreated'] = manual_datetime_2 - data['timestampmodified'] = manual_datetime_2 - api.update_obj(self.collection, self.agent, 'collection', - data['id'], data['version'], data) - obj = models.Collection.objects.get(id=self.collection.id) - timestamp_field_assert(obj, manual_datetime_2) - - # Test with direct object creation - CollectionObject = getattr(models, 'Collectionobject') - obj = CollectionObject( - timestampcreated=manual_datetime_1, - timestampmodified=manual_datetime_1, - collectionmemberid=1, - collection=self.collection) - obj.save() - timestamp_field_assert(obj, manual_datetime_1) - - # Test editing obj after creating with direct object creation - CollectionObject = getattr(models, 'Collectionobject') - obj = CollectionObject.objects.create( - timestampcreated=manual_datetime_2, - timestampmodified=manual_datetime_2, - collectionmemberid=1, - collection=self.collection) - obj.save() - timestamp_field_assert(obj, manual_datetime_2) - - # Test with objects.create - CollectionObject = getattr(models, 'Collectionobject') - obj = CollectionObject.objects.create( - timestampcreated=manual_datetime_1, - timestampmodified=manual_datetime_1, - collectionmemberid=1, - collection=self.collection) - obj.save() - timestamp_field_assert(obj, manual_datetime_1) - - # Test editing obj after creating with objects.create - obj.timestampcreated = manual_datetime_2 - obj.timestampmodified = manual_datetime_2 - obj.save() - timestamp_field_assert(obj, manual_datetime_2) - - # Test with current time - CollectionObject = getattr(models, 'Collectionobject') - obj = CollectionObject.objects.create( - collectionmemberid=1, - collection=self.collection) - obj.save() - self.assertGreaterEqual(obj.timestampcreated, cur_time) - self.assertGreaterEqual(obj.timestampmodified, cur_time) - class RecordSetTests(ApiTests): def setUp(self): super(RecordSetTests, self).setUp() diff --git a/specifyweb/specify/tests/test_timestamps.py b/specifyweb/specify/tests/test_timestamps.py new file mode 100644 index 00000000000..da5584af039 --- /dev/null +++ b/specifyweb/specify/tests/test_timestamps.py @@ -0,0 +1,48 @@ +""" +Tests for timestamps additional logic +""" +from datetime import datetime +from django.utils import timezone +from specifyweb.specify.tests.test_api import ApiTests, skip_perms_check +from specifyweb.specify import api +from specifyweb.specify.models import Collectionobject + +class TimeStampTests(ApiTests): + + def test_blank_timestamps(self): + cur_time = timezone.now() + + obj = Collectionobject.objects.create( + collectionmemberid=1, + collection=self.collection) + + self.assertGreaterEqual(obj.timestampcreated, cur_time) + self.assertGreaterEqual(obj.timestampmodified, cur_time) + + def test_can_override_new_timestamps_api(self): + datetime_1 = datetime(1960, 1, 1, 0, 0, 0) + datetime_2 = datetime(2020, 1, 1, 0, 0, 0) + + obj = api.create_obj(self.collection, self.agent, 'collectionobject', { + 'collection': api.uri_for_model('collection', self.collection.id), + 'catalognumber': 'foobar', + 'timestampcreated': datetime_1, 'timestampmodified': datetime_2}) + + self.assertEqual(datetime_1, obj.timestampcreated) + self.assertEqual(datetime_2, obj.timestampmodified) + + def test_cannot_override_old_timestamps_api(self): + datetime_1 = datetime(1960, 1, 1, 0, 0, 0) + datetime_2 = datetime(2020, 1, 1, 0, 0, 0) + current = timezone.now() + co_to_edit = self.collectionobjects[0] + data = api.get_resource('collectionobject', co_to_edit.id, skip_perms_check) + data['timestampcreated'] = datetime_1 + data['timestampmodified'] = datetime_2 + obj = api.update_obj(self.collection, self.agent, 'collectionobject', data['id'], data['version'], data) + + obj.refresh_from_db() + self.assertNotEqual(obj.timestampcreated, datetime_1, "Was able to override!") + self.assertNotEqual(obj.timestampmodified, datetime_2, "Was able to override!") + self.assertGreaterEqual(obj.timestampmodified, current, "Timestampmodified did not update correctly!") + self.assertGreater(current, obj.timestampcreated, "Timestampcreated should be at the past for this record!") \ No newline at end of file diff --git a/specifyweb/specify/tests/test_trees.py b/specifyweb/specify/tests/test_trees.py index 289379294e3..598c53e83c1 100644 --- a/specifyweb/specify/tests/test_trees.py +++ b/specifyweb/specify/tests/test_trees.py @@ -4,7 +4,11 @@ from specifyweb.specify.tests.test_api import ApiTests, get_table from specifyweb.specify.tree_stats import get_tree_stats from specifyweb.specify.tree_extras import set_fullnames +from specifyweb.specify.tree_views import get_tree_rows +from specifyweb.stored_queries.execution import set_group_concat_max_len from specifyweb.stored_queries.tests import SQLAlchemySetup +from contextlib import contextmanager +from django.db import connection class TestTreeSetup(ApiTests): def setUp(self) -> None: @@ -30,103 +34,42 @@ def setUp(self) -> None: self.taxontreedef.treedefitems.create(name='Subspecies', rankid=230) class TestTree: + def setUp(self)->None: super().setUp() - self.earth = get_table('Geography').objects.create( - name="Earth", - definitionitem=get_table('Geographytreedefitem').objects.get(name="Planet"), - definition=self.geographytreedef, - ) + + self.earth = self.make_geotree("Earth", "Planet") - self.na = get_table('Geography').objects.create( - name="North America", - definitionitem=get_table('Geographytreedefitem').objects.get(name="Continent"), - definition=self.geographytreedef, - parent=self.earth, - ) + self.na = self.make_geotree("North America", "Continent", parent=self.earth) - self.usa = get_table('Geography').objects.create( - name="USA", - definitionitem=get_table('Geographytreedefitem').objects.get(name="Country"), - definition=self.geographytreedef, - parent=self.na, - ) - - self.kansas = get_table('Geography').objects.create( - name="Kansas", - definitionitem=get_table('Geographytreedefitem').objects.get(name="State"), - definition=self.geographytreedef, - parent=self.usa, - ) + self.usa = self.make_geotree("USA", "Country", parent=self.na) - self.mo = get_table('Geography').objects.create( - name="Missouri", - definitionitem=get_table('Geographytreedefitem').objects.get(name="State"), - definition=self.geographytreedef, - parent=self.usa, - ) + self.kansas = self.make_geotree("Kansas", "State", parent=self.usa) + self.mo = self.make_geotree("Missouri", "State", parent=self.usa) + self.ohio = self.make_geotree("Ohio", "State", parent=self.usa) + self.ill = self.make_geotree("Illinois", "State", parent=self.usa) - self.ohio = get_table('Geography').objects.create( - name="Ohio", - definitionitem=get_table('Geographytreedefitem').objects.get(name="State"), - definition=self.geographytreedef, - parent=self.usa, - ) + self.doug = self.make_geotree("Douglas", "County", parent=self.kansas) + self.greene = self.make_geotree("Greene", "County", parent=self.mo) + self.greeneoh = self.make_geotree("Greene", "County", parent=self.ohio) + self.sangomon = self.make_geotree("Sangamon", "County", parent=self.ill) - self.ill = get_table('Geography').objects.create( - name="Illinois", - definitionitem=get_table('Geographytreedefitem').objects.get(name="State"), - definition=self.geographytreedef, - parent=self.usa, - ) + self.springmo = self.make_geotree("Springfield", "City", parent=self.greene) + self.springill = self.make_geotree("Springfield", "City", parent=self.sangomon) - self.doug = get_table('Geography').objects.create( - name="Douglas", - definitionitem=get_table('Geographytreedefitem').objects.get(name="County"), + def make_geotree(self, name, rank_name, **extra_kwargs): + return get_table("Geography").objects.create( + name=name, + definitionitem=get_table('Geographytreedefitem').objects.get(name=rank_name), definition=self.geographytreedef, - parent=self.kansas, + **extra_kwargs ) - - self.greene = get_table('Geography').objects.create( - name="Greene", - definitionitem=get_table('Geographytreedefitem').objects.get(name="County"), - definition=self.geographytreedef, - parent=self.mo, - ) - - self.greeneoh = get_table('Geography').objects.create( - name="Greene", - definitionitem=get_table('Geographytreedefitem').objects.get(name="County"), - definition=self.geographytreedef, - parent=self.ohio, - ) - - self.sangomon = get_table('Geography').objects.create( - name="Sangamon", - definitionitem=get_table('Geographytreedefitem').objects.get(name="County"), - definition=self.geographytreedef, - parent=self.ill, - ) - - self.springmo = get_table('Geography').objects.create( - name="Springfield", - definitionitem=get_table('Geographytreedefitem').objects.get(name="City"), - definition=self.geographytreedef, - parent=self.greene, - ) - - self.springill = get_table('Geography').objects.create( - name="Springfield", - definitionitem=get_table('Geographytreedefitem').objects.get(name="City"), - definition=self.geographytreedef, - parent=self.sangomon, - ) - + class GeographyTree(TestTree, TestTreeSetup): pass class SqlTreeSetup(SQLAlchemySetup, GeographyTree): pass -class TreeStatsTest(SqlTreeSetup): +class TreeViewsTest(SqlTreeSetup): def setUp(self): super().setUp() @@ -180,13 +123,14 @@ def setUp(self): ) def _run_nn_and_cte(*args, **kwargs): - cte_results = get_tree_stats(*args, **kwargs, session_context=TreeStatsTest.test_session_context, using_cte=True) - node_number_results = get_tree_stats(*args, **kwargs, session_context=TreeStatsTest.test_session_context, using_cte=False) + cte_results = get_tree_stats(*args, **kwargs, session_context=TreeViewsTest.test_session_context, using_cte=True) + node_number_results = get_tree_stats(*args, **kwargs, session_context=TreeViewsTest.test_session_context, using_cte=False) self.assertCountEqual(cte_results, node_number_results) return cte_results self.validate_tree_stats = lambda *args, **kwargs: ( lambda true_results: self.assertCountEqual(_run_nn_and_cte(*args, **kwargs), true_results)) + def test_counts_correctness(self): correct_results = { @@ -204,12 +148,77 @@ def test_counts_correctness(self): self.sangomon.id: [ (self.springill.id, 1, 1) ] - } + } _results = [ self.validate_tree_stats(self.geographytreedef.id, 'geography', parent_id, self.collection)(correct) for parent_id, correct in correct_results.items() ] + + def test_test_synonyms_concat(self): + self.maxDiff = None + na_syn_0 = self.make_geotree("NA Syn 0", "Continent", + acceptedgeography=self.na, + # fullname is not set by default for not-accepted + fullname="NA Syn 0", + parent=self.earth + ) + na_syn_1 = self.make_geotree("NA Syn 1", "Continent", acceptedgeography=self.na, fullname="NA Syn 1", parent=self.earth) + + usa_syn_0 = self.make_geotree("USA Syn 0", "Country", acceptedgeography=self.usa, parent=self.na, fullname="USA Syn 0") + usa_syn_1 = self.make_geotree("USA Syn 1", "Country", acceptedgeography=self.usa, parent=self.na, fullname="USA Syn 1") + usa_syn_2 = self.make_geotree("USA Syn 2", "Country", acceptedgeography=self.usa, parent=self.na, fullname="USA Syn 2") + + # need to refresh _some_ nodes (but not all) + # just the immediate parents and siblings inserted before us + # to be safe, we could refresh all, but I'm not going to, so that bug in those sections can be caught here + self.earth.refresh_from_db() + self.na.refresh_from_db() + self.usa.refresh_from_db() + + na_syn_0.refresh_from_db() + na_syn_1.refresh_from_db() + + usa_syn_1.refresh_from_db() + usa_syn_0.refresh_from_db() + + @contextmanager + def _run_for_row(): + with TreeViewsTest.test_session_context() as session: + # this needs to be run via django, but not directly via test_session_context + set_group_concat_max_len(connection.cursor()) + yield session + + with _run_for_row() as session: + results = get_tree_rows( + self.geographytreedef.id, "Geography", self.earth.id, "geographyid", False, session + ) + expected = [ + (self.na.id, self.na.name, self.na.fullname, self.na.nodenumber, self.na.highestchildnodenumber, self.na.rankid, None, None, 'NULL', self.na.children.count(), 'NA Syn 0, NA Syn 1'), + (na_syn_0.id, na_syn_0.name, na_syn_0.fullname, na_syn_0.nodenumber, na_syn_0.highestchildnodenumber, na_syn_0.rankid, self.na.id, self.na.fullname, 'NULL', na_syn_0.children.count(), None), + (na_syn_1.id, na_syn_1.name, na_syn_1.fullname, na_syn_1.nodenumber, na_syn_1.highestchildnodenumber, na_syn_1.rankid, self.na.id, self.na.fullname, 'NULL', na_syn_1.children.count(), None), + ] + + self.assertCountEqual( + results, + expected + ) + + with _run_for_row() as session: + results = get_tree_rows( + self.geographytreedef.id, "Geography", self.na.id, "name", False, session + ) + expected = [ + (self.usa.id, self.usa.name, self.usa.fullname, self.usa.nodenumber, self.usa.highestchildnodenumber, self.usa.rankid, None, None, 'NULL', self.usa.children.count(), 'USA Syn 0, USA Syn 1, USA Syn 2'), + (usa_syn_0.id, usa_syn_0.name, usa_syn_0.fullname, usa_syn_0.nodenumber, usa_syn_0.highestchildnodenumber, usa_syn_0.rankid, self.usa.id, self.usa.fullname, 'NULL', 0, None), + (usa_syn_1.id, usa_syn_1.name, usa_syn_1.fullname, usa_syn_1.nodenumber, usa_syn_1.highestchildnodenumber, usa_syn_1.rankid, self.usa.id, self.usa.fullname, 'NULL', 0, None), + (usa_syn_2.id, usa_syn_2. name, usa_syn_2.fullname, usa_syn_2.nodenumber, usa_syn_2.highestchildnodenumber, usa_syn_2.rankid, self.usa.id, self.usa.fullname, 'NULL', 0, None) + + ] + self.assertCountEqual( + results, + expected + ) class AddDeleteRankResourcesTest(ApiTests): def test_add_ranks_without_defaults(self): diff --git a/specifyweb/specify/tree_extras.py b/specifyweb/specify/tree_extras.py index b6d66bbb420..b5bd2fbcb03 100644 --- a/specifyweb/specify/tree_extras.py +++ b/specifyweb/specify/tree_extras.py @@ -1,10 +1,12 @@ import re from contextlib import contextmanager import logging +from typing import List from specifyweb.specify.tree_ranks import RankOperation, post_tree_rank_save, pre_tree_rank_deletion, \ verify_rank_parent_chain_integrity, pre_tree_rank_init, post_tree_rank_deletion -from specifyweb.specify.model_timestamp import pre_save_auto_timestamp_field_with_override +from specifyweb.specify.model_timestamp import save_auto_timestamp_field_with_override +from specifyweb.specify.field_change_info import FieldChangeInfo logger = logging.getLogger(__name__) @@ -27,14 +29,13 @@ def validate_node_numbers(table, revalidate_after=True): if revalidate_after: validate_tree_numbering(table) -class Tree(models.Model): # FUTURE: class Tree(SpTimestampedModel): +class Tree(models.Model): class Meta: abstract = True def save(self, *args, skip_tree_extras=False, **kwargs): def save(): - pre_save_auto_timestamp_field_with_override(self) - super(Tree, self).save(*args, **kwargs) + save_auto_timestamp_field_with_override(super(Tree, self).save, args, kwargs, self) if skip_tree_extras: return save() @@ -91,7 +92,7 @@ def save(): "rankid" : self.parent.rankid, "fullName": self.parent.fullname, "parentid": self.parent.parent.id, - "children": list(self.parent.children.values('id', 'fullName')) + "children": list(self.parent.children.values('id', 'fullname')) } }) @@ -275,7 +276,7 @@ def moving_node(to_save): to_save.nodenumber = current.nodenumber to_save.highestchildnodenumber = current.highestchildnodenumber -def mutation_log(action, node, agent, parent, dirty_flds): +def mutation_log(action, node, agent, parent, dirty_flds: List[FieldChangeInfo]): from .auditlog import auditlog auditlog.log_action(action, node, agent, node.parent, dirty_flds) @@ -327,7 +328,7 @@ def merge(node, into, agent): node.delete() node.id = id mutation_log(TREE_MERGE, node, agent, node.parent, - [{'field_name': model.specify_model.idFieldName, 'old_value': node.id, 'new_value': into.id}]) + [FieldChangeInfo(field_name, model.specify_model.idFieldName, old_value=node.id, new_value=into.id)]) return except ProtectedError as e: """ Cannot delete some instances of TREE because they are referenced @@ -357,8 +358,8 @@ def bulk_move(node, into, agent): models.Preparation.objects.filter(storage = node).update(storage = into) - mutation_log(TREE_BULK_MOVE, node, agent, node.parent, - [{'field_name': model.specify_model.idFieldName, 'old_value': node.id, 'new_value': into.id}]) + field_change_info: FieldChangeInfo = FieldChangeInfo(field_name=model.specify_model.idFieldName, old_value=node.id, new_value=into.id) + mutation_log(TREE_BULK_MOVE, node, agent, node.parent, [field_change_info]) def synonymize(node, into, agent): logger.info('synonymizing %s to %s', node, into) @@ -418,9 +419,11 @@ def synonymize(node, into, agent): }}) node.acceptedchildren.update(**{node.accepted_id_attr().replace('_id', ''): target}) #assuming synonym can't be synonymized - mutation_log(TREE_SYNONYMIZE, node, agent, node.parent, - [{'field_name': 'acceptedid','old_value': None, 'new_value': target.id}, - {'field_name': 'isaccepted','old_value': True, 'new_value': False}]) + field_change_infos = [ + FieldChangeInfo(field_name='acceptedid', old_value=None, new_value=target.id), + FieldChangeInfo(field_name='isaccepted', old_value=True, new_value=False) + ] + mutation_log(TREE_SYNONYMIZE, node, agent, node.parent, field_change_infos) if model._meta.db_table == 'taxon': node.determinations.update(preferredtaxon=target) @@ -434,9 +437,12 @@ def desynonymize(node, agent): node.accepted_id = None node.isaccepted = True node.save() - mutation_log(TREE_DESYNONYMIZE, node, agent, node.parent, - [{'field_name': 'acceptedid','old_value': old_acceptedid, 'new_value': None}, - {'field_name': 'isaccepted','old_value': False, 'new_value': True}]) + + field_change_infos = [ + FieldChangeInfo(field_name='acceptedid', old_value=old_acceptedid, new_value=None), + FieldChangeInfo(field_name='isaccepted', old_value=False, new_value=True) + ] + mutation_log(TREE_DESYNONYMIZE, node, agent, node.parent, field_change_infos) if model._meta.db_table == 'taxon': node.determinations.update(preferredtaxon=F('taxon')) diff --git a/specifyweb/specify/tree_stats.py b/specifyweb/specify/tree_stats.py index 2ff038cf9d7..30d6cca9a97 100644 --- a/specifyweb/specify/tree_stats.py +++ b/specifyweb/specify/tree_stats.py @@ -12,6 +12,7 @@ def get_tree_stats(treedef, tree, parentid, specify_collection, session_context, using_cte): tree_table = datamodel.get_table(tree) + tree_def_item = getattr(models, tree_table.name + 'TreeDefItem') parentid = None if parentid == 'null' else int(parentid) treedef_col = tree_table.name + "TreeDefID" @@ -62,19 +63,21 @@ def wrap_cte_query(cte_query, query): results = None - with session_context() as session: # The join depth only needs to be enough to reach the bottom of the tree. - # That will be the number of distinct rankID values not less than - # the rankIDs of the children of parentid. - - # Also used in Recursive CTE to make sure cycles don't cause crash (very rare) - - highest_rank = session.query(sql.func.min(tree_node.rankId)).filter( - tree_node.ParentID == parentid).as_scalar() - depth, = \ - session.query(sql.func.count(distinct(tree_node.rankId))).filter( - tree_node.rankId >= highest_rank)[0] + # "correct" depth is depth based on actual tree + # "incorrect" depth > "correct" is naively based on item-table + # If we use "correct" depth, we will make less joins in CTE (so CTE will be faster) + # but, "correct" depth takes too long to compute (needs to look at main tree table) + # As a compromise, we look at defitem table for "incorrect" depth, will be higher than "correct" + # depth. So yes, technicallly CTE will take "more" time, but experimentation reveals that + # CTE's "more" time, is still very much low than time taken to compute "correct" depth. + # I don't even want to use depth, but some pathological tree might have cycles, and CTE depth + # might be in millions as a custom setting.. + + depth_query = session.query(sql.func.count(getattr(tree_def_item, tree_def_item._id))).filter( + getattr(tree_def_item, treedef_col) == int(treedef)) + depth, = list(depth_query)[0] query = None try: if using_cte: diff --git a/specifyweb/specify/tree_views.py b/specifyweb/specify/tree_views.py index 9bf78957513..cda822ac56d 100644 --- a/specifyweb/specify/tree_views.py +++ b/specifyweb/specify/tree_views.py @@ -3,7 +3,7 @@ from django.db import transaction from django.http import HttpResponse from django.views.decorators.http import require_POST -from sqlalchemy import sql +from sqlalchemy import sql, distinct from sqlalchemy.orm import aliased from specifyweb.middleware.general import require_GET @@ -11,7 +11,10 @@ from specifyweb.permissions.permissions import PermissionTarget, \ PermissionTargetAction, check_permission_targets from specifyweb.specify.tree_ranks import tree_rank_count +from specifyweb.specify.field_change_info import FieldChangeInfo from specifyweb.stored_queries import models +from specifyweb.stored_queries.execution import set_group_concat_max_len +from specifyweb.stored_queries.group_concat import group_concat from . import tree_extras from .api import get_object_or_404, obj_to_data, toJson from .auditcodes import TREE_MOVE @@ -110,7 +113,10 @@ def wrapper(*args, **kwargs): "type" : "integer", "description" : "The number of children the child node has" - + }, + { + "type": "string", + "description": "Concat of fullname of syonyms" } ], } @@ -128,48 +134,74 @@ def tree_view(request, treedef, tree, parentid, sortfield): the tree defined by treedefid = . The nodes are sorted according to . """ + """ + Also include the author of the node in the response if requested and the tree is the taxon tree. + There is a preference which can be enabled from within Specify which adds the author next to the + fullname on the front end. + See https://github.com/specify/specify7/pull/2818 for more context and a breakdown regarding + implementation/design decisions + """ + include_author = request.GET.get('includeauthor', False) and tree == 'taxon' + with models.session_context() as session: + set_group_concat_max_len(session.connection()) + results = get_tree_rows(treedef, tree, parentid, sortfield, include_author, session) + return HttpResponse(toJson(results), content_type='application/json') + + +def get_tree_rows(treedef, tree, parentid, sortfield, include_author, session): tree_table = datamodel.get_table(tree) parentid = None if parentid == 'null' else int(parentid) node = getattr(models, tree_table.name) child = aliased(node) accepted = aliased(node) + synonym = aliased(node) id_col = getattr(node, node._id) child_id = getattr(child, node._id) treedef_col = getattr(node, tree_table.name + "TreeDefID") orderby = tree_table.name.lower() + '.' + sortfield - - """ - Also include the author of the node in the response if requested and the tree is the taxon tree. - There is a preference which can be enabled from within Specify which adds the author next to the - fullname on the front end. - See https://github.com/specify/specify7/pull/2818 for more context and a breakdown regarding - implementation/design decisions - """ - includeAuthor = request.GET.get( - 'includeauthor') if 'includeauthor' in request.GET else False - - with models.session_context() as session: - query = session.query(id_col, - node.name, - node.fullName, - node.nodeNumber, - node.highestChildNodeNumber, - node.rankId, - node.AcceptedID, - accepted.fullName, - node.author if ( - includeAuthor and tree == 'taxon') else "NULL", - sql.functions.count(child_id)) \ - .outerjoin(child, child.ParentID == id_col) \ - .outerjoin(accepted, node.AcceptedID == getattr(accepted, node._id)) \ - .group_by(id_col) \ - .filter(treedef_col == int(treedef)) \ - .filter(node.ParentID == parentid) \ - .order_by(orderby) - results = list(query) - return HttpResponse(toJson(results), content_type='application/json') - + + col_args = [ + node.name, + node.fullName, + node.nodeNumber, + node.highestChildNodeNumber, + node.rankId, + node.AcceptedID, + accepted.fullName, + node.author if include_author else "NULL", + ] + + apply_min = [ + # for some reason, SQL is rejecting the group_by in some dbs + # due to "only_full_group_by". It is somehow not smart enough to see + # that there is no dependency in the columns going from main table to the to-manys (child, and syns) + # I want to use ANY_VALUE() but that's not supported by MySQL 5.6- and MariaDB. + # I don't want to disable "only_full_group_by" in case someone misuses it... + # applying min to fool into thinking it is aggregated. + # these values are guarenteed to be the same + sql.func.min(arg) for arg in col_args + ] + + grouped = [ + *apply_min, + # syns are to-many, so child can be duplicated + sql.func.count(distinct(child_id)), + # child are to-many, so syn's full name can be duplicated + # FEATURE: Allow users to select a separator?? Maybe that's too nice + group_concat(distinct(synonym.fullName), separator=', ') + ] + + query = session.query(id_col, *grouped) \ + .outerjoin(child, child.ParentID == id_col) \ + .outerjoin(accepted, node.AcceptedID == getattr(accepted, node._id)) \ + .outerjoin(synonym, synonym.AcceptedID == id_col) \ + .group_by(id_col) \ + .filter(treedef_col == int(treedef)) \ + .filter(node.ParentID == parentid) \ + .order_by(orderby) + results = list(query) + return results @login_maybe_required @require_GET @@ -250,14 +282,12 @@ def move(request, tree, id): node.save() node = get_object_or_404(tree, id=id) if old_stamp is None or (node.timestampmodified > old_stamp): + field_change_infos = [ + FieldChangeInfo(field_name='parentid', old_value=old_parentid, new_value=target.id), + FieldChangeInfo(field_name='fullname', old_value=old_fullname, new_value=node.fullname) + ] tree_extras.mutation_log(TREE_MOVE, node, request.specify_user_agent, - node.parent, - [{'field_name': 'parentid', - 'old_value': old_parentid, - 'new_value': target.id}, - {'field_name': 'fullname', - 'old_value': old_fullname, - 'new_value': node.fullname}]) + node.parent, field_change_infos) @openapi(schema={ "post": { diff --git a/specifyweb/specify/utils.py b/specifyweb/specify/utils.py index 8c6342488fe..7fd6aea0394 100644 --- a/specifyweb/specify/utils.py +++ b/specifyweb/specify/utils.py @@ -1,3 +1,4 @@ +from typing import Any, TypedDict from specifyweb.accounts import models as acccounts_models from specifyweb.attachment_gw import models as attachment_gw_models from specifyweb.businessrules import models as businessrules_models diff --git a/specifyweb/specify/views.py b/specifyweb/specify/views.py index bfec8b868d2..0abd0c45fc3 100644 --- a/specifyweb/specify/views.py +++ b/specifyweb/specify/views.py @@ -22,7 +22,6 @@ from specifyweb.celery_tasks import app, CELERY_TASK_STATE from specifyweb.specify.record_merging import record_merge_fx, record_merge_task, resolve_record_merge_response from specifyweb.specify.update_locality import localityupdate_parse_success, localityupdate_parse_error, parse_locality_set as _parse_locality_set, upload_locality_set as _upload_locality_set, create_localityupdate_recordset, update_locality_task, parse_locality_task, LocalityUpdateStatus -from specifyweb.stored_queries.batch_edit import run_batch_edit from . import api, models as spmodels from .specify_jar import specify_jar @@ -86,8 +85,7 @@ def raise_error(request): """This endpoint intentionally throws an error in the server for testing purposes. """ - run_batch_edit(None, None, None) - raise Exception('This error iswwww a teswwwwt. You may now return to your regularly ' + raise Exception('This error is a test. You may now return to your regularly ' 'scheduled hacking.') diff --git a/specifyweb/stored_queries/batch_edit.py b/specifyweb/stored_queries/batch_edit.py index 3d5f2b365e8..e494ba60ea3 100644 --- a/specifyweb/stored_queries/batch_edit.py +++ b/specifyweb/stored_queries/batch_edit.py @@ -1,25 +1,21 @@ from functools import reduce -from typing import Any, Callable, Dict, List, NamedTuple, Optional, Set, Tuple, TypeVar, TypedDict +from typing import Any, Callable, Dict, List, NamedTuple, Optional, Tuple, TypeVar from specifyweb.specify.models import datamodel -from specifyweb.specify.load_datamodel import Field, Relationship, Table +from specifyweb.specify.load_datamodel import Field, Table from specifyweb.stored_queries.execution import execute from specifyweb.stored_queries.queryfield import QueryField, fields_from_json from specifyweb.stored_queries.queryfieldspec import FieldSpecJoinPath, QueryFieldSpec, TreeRankQuery +from specifyweb.workbench.models import Spdataset +from specifyweb.workbench.upload.treerecord import TreeRecord +from specifyweb.workbench.upload.upload_plan_schema import parse_column_options +from specifyweb.workbench.upload.upload_table import UploadTable +from specifyweb.workbench.upload.uploadable import NULL_RECORD, Uploadable +from specifyweb.workbench.views import regularize_rows +from specifyweb.specify.func import Func from . import models -# as a note to myself to find which branches/conditions are tricky and need a unit test -def test_case(x): return x +from django.db import transaction -# made as a class to encapsulate type variables and prevent pollution of export -class Func: - I = TypeVar('I') - O = TypeVar('O') - - @staticmethod - def maybe(value: Optional[I], callback: Callable[[I], O]): - if value is None: - return None - return callback(value) MaybeField = Callable[[QueryFieldSpec], Optional[Field]] @@ -27,7 +23,8 @@ def maybe(value: Optional[I], callback: Callable[[I], O]): # Investigate if any/some/most of the logic for making an upload plan could be moved to frontend and reused. # - does generation of upload plan in the backend bc upload plan is not known (we don't know count of to-many). # - seemed complicated to merge upload plan from the frontend -# - need to place id markers at correct level, so need to follow upload plan anyways. +# - need to place id markers at correct level, so need to follow upload plan anyways. + def _get_nested_order(field_spec: QueryFieldSpec): @@ -44,9 +41,9 @@ def _get_nested_order(field_spec: QueryFieldSpec): # dataset construction takes place. 'id': (lambda field_spec: field_spec.table.idField, 1), # version control gets added here. no sort. - 'version': (lambda field_spec: field_spec.table.get_field('version'), None), + 'version': (lambda field_spec: field_spec.table.get_field('version'), 0), # ordernumber. no sort (actually adding a sort here is useless) - 'order': (_get_nested_order, 0) + 'order': (_get_nested_order, 1) } class BatchEditFieldPack(NamedTuple): @@ -67,11 +64,9 @@ def from_field_spec(field_spec: QueryFieldSpec) -> 'BatchEditPack': if ( batch_edit_fields['id'][1] == 0 or batch_edit_fields['order'][1] == 0 ): raise Exception("the ID field should always be sorted!") extend_callback = lambda field: field_spec._replace(join_path=(*field_spec.join_path, field), date_part=None) new_field_specs = { - key: Func.maybe(Func.maybe( - callback(field_spec), - extend_callback - ), - lambda field_spec: BatchEditFieldPack(field=BatchEditPack._query_field(field_spec, sort_type)) + key: Func.maybe( + Func.maybe(callback(field_spec), extend_callback), + lambda field_spec: BatchEditFieldPack(field=BatchEditPack._query_field(field_spec, sort_type)) ) for key, (callback, sort_type) in batch_edit_fields.items() } @@ -93,7 +88,7 @@ def _query_field(field_spec: QueryFieldSpec, sort_type: int): def _index( self, start_idx: int, - current: Tuple[Dict[str, Optional[BatchEditFieldPack]], List[BatchEditFieldPack]], + current: Tuple[Dict[str, Optional[BatchEditFieldPack]], List[QueryField]], next: Tuple[int, Tuple[str, Tuple[MaybeField, int]]]): current_dict, fields = current field_idx, (field_name, _) = next @@ -103,7 +98,7 @@ def _index( return new_dict, new_fields - def index_plan(self, start_index=0) -> Tuple['BatchEditPack', List[BatchEditFieldPack]]: + def index_plan(self, start_index=0) -> Tuple['BatchEditPack', List[QueryField]]: _dict, fields = reduce( lambda accum, next: self._index(start_idx=start_index, current=accum, next=next), enumerate(batch_edit_fields.items()), @@ -118,8 +113,25 @@ def bind(self, row: Tuple[Any]): lambda pack: pack._replace(value=row[pack.idx])) for key in batch_edit_fields.keys() }) - - + def to_json(self) -> Dict[str, Any]: + return { + 'id': self.id.value, + 'ordernumber': self.order.value if self.order is not None else None, + 'version': self.version.value if self.version is not None else None + } + + # we not only care that it is part of tree, but also care that there is rank to tree + def is_part_of_tree(self, query_fields: List[QueryField]) -> bool: + if self.id is None or self.id.idx is None: + return False + id_field = self.id.idx + field = query_fields[id_field - 1] + join_path = field.fieldspec.join_path + if len(join_path) < 2: + return False + return isinstance(join_path[-2], TreeRankQuery) + + # FUTURE: this already supports nested-to-many for most part # wb plan, but contains query fields along with indexes to look-up in a result row. # TODO: see if it can be moved + combined with front-end logic. I kept all parsing on backend, but there might be possible beneft in doing this @@ -129,69 +141,86 @@ class RowPlanMap(NamedTuple): to_one: Dict[str, 'RowPlanMap'] = {} to_many: Dict[str, 'RowPlanMap'] = {} batch_edit_pack: Optional[BatchEditPack] = None + has_filters: bool = True @staticmethod - def _merge(current: Dict[str, 'RowPlanMap'], other: Tuple[str, 'RowPlanMap']) -> Dict[str, 'RowPlanMap']: - key, other_plan = other - return { - **current, - # merge if other is also found in ours - key: other_plan if key not in current else current[key].merge(other_plan) - } + def _merge(has_filters: bool): + def _merger(current: Dict[str, 'RowPlanMap'], other: Tuple[str, 'RowPlanMap']) -> Dict[str, 'RowPlanMap']: + key, other_plan = other + return { + **current, + # merge if other is also found in ours + key: other_plan if key not in current else current[key].merge(other_plan, has_filter_on_parent=has_filters) + } + return _merger - # takes two row plans, combines them together - def merge(self: 'RowPlanMap', other: 'RowPlanMap') -> 'RowPlanMap': + # takes two row plans, combines them together. Adjusts has_filters. + def merge(self: 'RowPlanMap', other: 'RowPlanMap', has_filter_on_parent=False) -> 'RowPlanMap': new_columns = [*self.columns, *other.columns] batch_edit_pack = self.batch_edit_pack or other.batch_edit_pack - to_one = reduce(RowPlanMap._merge, other.to_one.items(), self.to_one) - to_many = reduce(RowPlanMap._merge, other.to_many.items(), self.to_many) - return RowPlanMap(new_columns, to_one, to_many, batch_edit_pack) + has_self_filters = has_filter_on_parent or self.has_filters or other.has_filters + to_one = reduce(RowPlanMap._merge(has_self_filters), other.to_one.items(), self.to_one) + to_many = reduce(RowPlanMap._merge(False), other.to_many.items(), self.to_many) + return RowPlanMap(new_columns, to_one, to_many, batch_edit_pack, has_filters=has_self_filters) - def _index(current: Tuple[int, Dict[str, 'RowPlanMap'], List[BatchEditFieldPack]], other: Tuple[str, 'RowPlanMap']): + def _index(current: Tuple[int, Dict[str, 'RowPlanMap'], List[QueryField]], other: Tuple[str, 'RowPlanMap']): next_start_index = current[0] other_indexed, fields = other[1].index_plan(start_index=next_start_index) to_return = ((next_start_index + len(fields)), {**current[1], other[0]: other_indexed}, [*current[2], *fields]) return to_return # to make things simpler, returns the QueryFields along with indexed plan, which are expected to be used together - def index_plan(self, start_index=0) -> Tuple['RowPlanMap', List[BatchEditFieldPack]]: + def index_plan(self, start_index=1) -> Tuple['RowPlanMap', List[QueryField]]: next_index = len(self.columns) + start_index - _columns = [column._replace(idx=index) for index, column in zip(range(start_index, next_index), self.columns)] - next_index, _to_one, fields = reduce( + # For optimization, and sanity, we remove the field from columns, as they are now completely redundant (we always know what they are using the id) + _columns = [column._replace(idx=index, field=None) for index, column in zip(range(start_index, next_index), self.columns)] + _batch_indexed, _batch_fields = self.batch_edit_pack.index_plan(start_index=next_index) if self.batch_edit_pack else (None, []) + next_index += len(_batch_fields) + next_index, _to_one, to_one_fields = reduce( RowPlanMap._index, # makes the order deterministic, would be funny otherwise - sorted(self.to_one.items(), key=lambda x: x[0]), - (next_index, {}, self.columns)) - next_index, _to_many, fields = reduce(RowPlanMap._index, sorted(self.to_many.items(), key=lambda x: x[0]), (next_index, {}, fields)) - _batch_indexed, _batch_fields = self.batch_edit_pack.index_plan(start_index=next_index) if self.batch_edit_pack else (None, []) - return (RowPlanMap(columns=_columns, to_one=_to_one, to_many=_to_many, batch_edit_pack=_batch_indexed), [*fields, *_batch_fields]) + Func.sort_by_key(self.to_one), + (next_index, {}, [])) + next_index, _to_many, to_many_fields = reduce(RowPlanMap._index, Func.sort_by_key(self.to_many), (next_index, {}, [])) + column_fields = [column.field for column in self.columns] + return (RowPlanMap(columns=_columns, to_one=_to_one, to_many=_to_many, batch_edit_pack=_batch_indexed, has_filters=self.has_filters), [*column_fields, *_batch_fields, *to_one_fields, *to_many_fields]) - @staticmethod # helper for generating an row plan for a single query field # handles formatted/aggregated self or relationships correctly (places them in upload-plan at correct level) + # it's complicated to place aggregated within the to-many table. but, since we don't map it to anything, we equivalently place it + # on the penultimate table's column. that is, say collectingevent -> collectors (aggregated). Semantically, (aggregated) should be on + # on the colletors table (as a column). Instead, we put it as a column in collectingevent. This has no visual difference (it is unmapped) anyways. + @staticmethod def _recur_row_plan( running_path: FieldSpecJoinPath, next_path: FieldSpecJoinPath, next_table: Table, # bc queryfieldspecs will be terminated early on - original_field: QueryField) -> 'RowPlanMap': + original_field: QueryField, + ) -> 'RowPlanMap': original_field_spec = original_field.fieldspec # contains partial path partial_field_spec = original_field_spec._replace(join_path=running_path, table=next_table) - node, *rest = (None,) if not next_path else next_path # to handle CO (formatted) + + # to handle CO->(formatted), that's it. this function will never be called with empty path other than top-level formatted/aggregated + node, *rest = (None,) if not next_path else next_path # we can't edit relationships's formatted/aggregated anyways. batch_edit_pack = None if original_field_spec.needs_formatted() else BatchEditPack.from_field_spec(partial_field_spec) - if node is None or not node.is_relationship: + if node is None or (len(rest) == 0): # we are at the end - return RowPlanMap(columns=[BatchEditFieldPack(field=original_field)], batch_edit_pack=batch_edit_pack) + return RowPlanMap(columns=[BatchEditFieldPack(field=original_field)], batch_edit_pack=batch_edit_pack, has_filters=(original_field.op_num != 8)) + + assert node.is_relationship, "using a non-relationship as a pass through!" - rel_type = 'to_one' if node.type.endswith('to-one') else 'to_many' + rel_type = 'to_many' if node.type.endswith('to-many') or node.type == 'zero-to-one' else 'to_one' + + rel_name = node.name.lower() if not isinstance(node, TreeRankQuery) else node.name return RowPlanMap( **{rel_type: { - node.name: RowPlanMap._recur_row_plan( + rel_name: RowPlanMap._recur_row_plan( (*running_path, node), rest, datamodel.get_table(node.relatedModelName), @@ -211,13 +240,22 @@ def get_row_plan(fields: List[QueryField]) -> 'RowPlanMap': RowPlanMap._recur_row_plan((), field.fieldspec.join_path, field.fieldspec.root_table, field) for field in fields ] - return reduce(lambda current, other: current.merge(other), iter, RowPlanMap()) + return reduce(lambda current, other: current.merge( + other, + has_filter_on_parent=False + ), iter, RowPlanMap()) + @staticmethod + def _bind_null(value: 'RowPlanCanonical') -> List['RowPlanCanonical']: + if value.batch_edit_pack.id.value is None: + return [] + return [value] + def bind(self, row: Tuple[Any]) -> 'RowPlanCanonical': columns = [column._replace(value=row[column.idx], field=None) for column in self.columns] to_ones = {key: value.bind(row) for (key, value) in self.to_one.items()} to_many = { - key: [value.bind(row)] + key: RowPlanMap._bind_null(value.bind(row)) for (key, value) in self.to_many.items() } pack = self.batch_edit_pack.bind(row) if self.batch_edit_pack else None @@ -226,11 +264,12 @@ def bind(self, row: Tuple[Any]) -> 'RowPlanCanonical': # gets a null record to fill-out empty space # doesn't support nested-to-many's yet - complicated def nullify(self) -> 'RowPlanCanonical': - columns = [pack._replace(value=None, idx=None) for pack in self.columns] + columns = [pack._replace(value='(Not included in results)' if self.has_filters else None) for pack in self.columns] to_ones = {key: value.nullify() for (key, value) in self.to_one.items()} - return RowPlanCanonical(columns, to_ones) + batch_edit_pack = BatchEditPack(id=BatchEditFieldPack(value=NULL_RECORD)) if self.has_filters else None + return RowPlanCanonical(columns, to_ones, batch_edit_pack=batch_edit_pack) - # a fake upload plan that keeps track of the maximum ids / order numbrs seen in to-manys + # a fake upload plan that keeps track of the maximum ids / ord er numbrs seen in to-manys def to_many_planner(self) -> 'RowPlanMap': to_one = {key: value.to_many_planner() for (key, value) in self.to_one.items()} to_many = { @@ -252,7 +291,7 @@ class RowPlanCanonical(NamedTuple): batch_edit_pack: Optional[BatchEditPack] = None @staticmethod - def _maybe_extend(values: List[Optional['RowPlanCanonical']], result:Tuple[bool, 'RowPlanCanonical'] ): + def _maybe_extend(values: List[Optional['RowPlanCanonical']], result:Tuple[bool, 'RowPlanCanonical']): is_new = result[0] new_values = (is_new, [*values, result[1]] if is_new else values) return new_values @@ -278,29 +317,28 @@ def merge(self, row: Tuple[Any], indexed_plan: RowPlanMap) -> Tuple[bool, 'RowPl for (key, value) in self.to_one.items() } - to_many_packed = [ - (key, (True, [indexed_plan.to_many.get(key).bind(row)]) - if test_case(len(value) == 0) - # tricky. basically, if the value is absolutely new, then only extend. - # since ids are already sorted, we don't care about matching to any-other record. - # but we still need to possibly merge due to nested to-manys - # NOW: If it causes performance problems, simply make the subsequent merge no-op since we don't handle nested-to-many's anywhere else - else RowPlanCanonical._maybe_extend(value, (value[-1].merge(row, indexed_plan.to_many.get(key))))) - for (key, value) in self.to_many.items() - ] + # the most tricky lines in this file + def _reduce_to_many(accum: Tuple[int, List[Tuple[str, bool, List['RowPlanCanonical']]]], current: Tuple[str, List[RowPlanCanonical]]): + key, values = current + previous_length, previous_chain = accum + stalled = (previous_length > 1) or len(values) == 0 + is_new, new_values = (False, values) if stalled else RowPlanCanonical._maybe_extend(values, values[-1].merge(row, indexed_plan.to_many.get(key))) + return (max(len(new_values), previous_length), [*previous_chain, (key, is_new, new_values)]) + + _, to_many_result = reduce(_reduce_to_many, self.to_many.items(), (0, [])) - to_many_new = any(results[1][0] for results in to_many_packed) + to_many_new = any(results[1] for results in to_many_result) if to_many_new: # a "meh" optimization to_many = { key: values - for (key, (_, values)) in to_many_packed + for (key, _, values) in to_many_result } else: to_many = self.to_many # TODO: explain why those arguments - return to_many_new, RowPlanCanonical( + return False, RowPlanCanonical( self.columns, to_one, to_many, @@ -333,15 +371,13 @@ def _extend_id_order(values: List['RowPlanCanonical'], to_many_planner: RowPlanM assert len(set([value.batch_edit_pack.order.value for value in values])) == len(values) # fill-in before, out happens later anyways fill_in_range = range(min(max_order, to_many_planner.batch_edit_pack.order.value)+1) - for fill_in in fill_in_range: - _test = next(filter(lambda pack: pack.batch_edit_pack.order.value == fill_in, values), null_record) # TODO: this is generic and doesn't assume items aren't sorted by order. maybe we can optimize, knowing that. filled_in = [next(filter(lambda pack: pack.batch_edit_pack.order.value == fill_in, values), null_record) for fill_in in fill_in_range] values = filled_in fill_out = to_many_planner.batch_edit_pack.order.value - max_order if fill_out is None: - fill_out = to_many_planner.batch_edit_pack.id - len(values) + fill_out = to_many_planner.batch_edit_pack.id.value - len(values) assert fill_out >= 0, "filling out in opposite directon!" rest = range(fill_out) @@ -353,57 +389,208 @@ def extend(self, to_many_planner: RowPlanMap, plan: RowPlanMap) -> 'RowPlanCanon to_many = {key: RowPlanCanonical._extend_id_order(values, to_many_planner.to_many.get(key), plan.to_many.get(key)) for (key, values) in self.to_many.items()} return self._replace(to_one=to_ones, to_many=to_many) -import time -def run_batch_edit(collection, user, spquery): - """ - start = time.perf_counter() - limit = 20 + @staticmethod + def _make_to_one_flat(callback: Callable[[str, Func.I], Func.O]): + def _flat(accum: Tuple[List[Any], Dict[str, Func.O]], current: Tuple[str, Func.I]): + to_one_fields, to_one_pack = callback(*current) + return [*accum[0], *to_one_fields], {**accum[1], current[0]: to_one_pack} + return _flat + + @staticmethod + def _make_to_many_flat(callback: Callable[[str, Func.I], Func.O]): + def _flat(accum: Tuple[List[Any], Dict[str, Any]], current: Tuple[str, List['RowPlanCanonical']]): + rel_name, to_many = current + to_many_flattened = [callback(rel_name, canonical) for canonical in to_many] + row_data = [cell for row in to_many_flattened for cell in row[0]] + to_many_pack = [cell[1] for cell in to_many_flattened] + return [*accum[0], *row_data], {**accum[1], rel_name: to_many_pack} + return _flat + + def flatten(self) -> Tuple[List[Any], Dict[str, Any]]: + cols = [col.value for col in self.columns] + base_pack = self.batch_edit_pack.to_json() if self.batch_edit_pack is not None else None + def _flatten(_: str, _self: 'RowPlanCanonical'): + return _self.flatten() + _to_one_reducer = RowPlanCanonical._make_to_one_flat(_flatten) + _to_many_reducer = RowPlanCanonical._make_to_many_flat(_flatten) + to_ones = reduce(_to_one_reducer, Func.sort_by_key(self.to_one), ([], {})) + to_many = reduce(_to_many_reducer, Func.sort_by_key(self.to_many), ([], {})) + all_data = [*cols, *to_ones[0], *to_many[0]] + return all_data, {'self': base_pack, 'to_one': to_ones[1], 'to_many': to_many[1]} if base_pack else None + + def to_upload_plan(self, base_table: Table, localization_dump: Dict[str, str], query_fields: List[QueryField], fields_added: Dict[str, int], get_column_id: Callable[[str], int]) -> Tuple[List[Tuple[Tuple[int, int], str]], Uploadable]: + # Yuk, finally. + + # Whether we are something like [det-> (T -- what we are) -> tree] + intermediary_to_tree = any(canonical.batch_edit_pack is not None and canonical.batch_edit_pack.is_part_of_tree(query_fields) for canonical in self.to_one.values()) + + def _lookup_in_fields(_id: Optional[int]): + assert _id is not None, "invalid lookup used!" + field = query_fields[_id - 1] # Need to go off by 1, bc we added 1 to account for distinct + string_id = field.fieldspec.to_stringid() + localized_label = localization_dump.get(string_id, naive_field_format(field.fieldspec)) + fields_added[localized_label] = fields_added.get(localized_label, 0) + 1 + _count = fields_added[localized_label] + if _count > 1: + localized_label += f' #{_count}' + fieldspec = field.fieldspec + is_null = fieldspec.needs_formatted() or intermediary_to_tree or (fieldspec.is_temporal() and fieldspec.date_part != 'Full Date') or fieldspec.get_field().name.lower() == 'fullname' + id_in_original_fields = get_column_id(string_id) + # bc we can't edit formatted and others. we also can't have intermediary-to-trees as editable... + return (id_in_original_fields, _count), (None if is_null else fieldspec.get_field().name.lower()), localized_label + + key_and_fields_and_headers = [_lookup_in_fields(column.idx) for column in self.columns] + + wb_cols = { + key: parse_column_options(value) + for _, key, value in key_and_fields_and_headers + if key is not None # will happen for formatters/aggregators + } + + def _to_upload_plan(rel_name: str, _self: 'RowPlanCanonical'): + related_model = base_table if intermediary_to_tree else datamodel.get_table_strict(base_table.get_relationship(rel_name).relatedModelName) + return _self.to_upload_plan(related_model, localization_dump, query_fields, fields_added, get_column_id) + + _to_one_reducer = RowPlanCanonical._make_to_one_flat(_to_upload_plan) + _to_many_reducer = RowPlanCanonical._make_to_many_flat(_to_upload_plan) + + to_one_headers, to_one_upload_tables = reduce(_to_one_reducer, Func.sort_by_key(self.to_one), ([], {})) + to_many_headers, to_many_upload_tables = reduce(_to_many_reducer, Func.sort_by_key(self.to_many), ([], {})) + + raw_headers = [(key, header) for (key, __, header) in key_and_fields_and_headers] + all_headers = [*raw_headers, *to_one_headers, *to_many_headers] + + if intermediary_to_tree: + assert len(to_many_upload_tables) == 0, "Found to-many for tree!" + upload_plan = TreeRecord( + name=base_table.django_name, + ranks={ + key: upload_table.wbcols + for (key, upload_table) + in to_one_upload_tables.items() + } + ) + else: + upload_plan = UploadTable( + name=base_table.django_name, + overrideScope=None, + wbcols=wb_cols, + static={}, + toOne=to_one_upload_tables, + toMany=to_many_upload_tables + ) + + return all_headers, upload_plan + +# TODO: This really only belongs on the front-end. +# Using this as a last resort to show fields +def naive_field_format(fieldspec: QueryFieldSpec): + field = fieldspec.get_field() + if field is None: + return f"{fieldspec.table.name} (formatted)" + if field.is_relationship: + return f"{fieldspec.table.name} ({'formatted' if field.type.endswith('to-one') else 'aggregatd'})" + return f"{fieldspec.table.name} {field.name}" + +import json +def run_batch_edit(collection, user, spquery, agent): + offset = 0 - tableid = spquery['contexttableid'] + tableid = int(spquery['contexttableid']) + captions = spquery['captions'] + limit = int(spquery['limit']) + + recordsetid = spquery.get('recordsetid', None) fields = fields_from_json(spquery['fields']) - #_plan = RowPlanMap.get_row_plan([field for field in fields if field.display]) - #indexed, fields = plan.index_plan() - #non_display_fields = [field for field in fields if not field.field.display] - #all_fields = [*fields, *non_display_fields] - ss = time.perf_counter() - """ - plan = RowPlanMap( - columns=[BatchEditFieldPack(field=None, idx=0)], - to_one={}, - to_many={ - 'random': RowPlanMap( - columns=[BatchEditFieldPack(field=None, idx=2)], - batch_edit_pack=BatchEditPack( - id=BatchEditFieldPack(idx=3, value=None), - order=BatchEditFieldPack(idx=4, value=None) - ) - ) - }, - batch_edit_pack=BatchEditPack(id=BatchEditFieldPack(idx=1, value=None)) - ) - """ + visible_fields = [field for field in fields if field.display] + + assert len(visible_fields) == len(captions), "Got misaligned captions!" + + localization_dump: Dict[str, str] = { + # we cannot use numbers since they can very off + field.fieldspec.to_stringid(): caption + for field, caption in zip(visible_fields, captions) + } + + row_plan = RowPlanMap.get_row_plan(visible_fields) + + indexed, query_fields = row_plan.index_plan() + + # we don't really care about these fields, since we'have already done the numbering (and it won't break with + # more fields). We also don't caree about their sort, since their sort is guaranteed to be after ours + query_with_hidden = [*query_fields, *[field for field in fields if not field.display]] + with models.session_context() as session: rows = execute( - session, collection, user, tableid, True, False, all_fields, limit, offset, None, False + session, collection, user, tableid, True, False, query_with_hidden, limit, offset, True, recordsetid, False ) - """ - print(plan) - rows = [ - ("sme value 1", 1, 'nested to many 1', 2, 0), - ("sme value 1", 1, 'nested to many 2', 3, 2), - ("sme value 1", 1, 'nested to many 3', 42, 8), - ] - row1 = RowPlanCanonical() - to_many_planner = plan.to_many_planner() - for row in rows: - new, row1 = row1.merge(row, plan) - to_many_planner = row1.update_to_manys(to_many_planner) - print(new, row1) - print("sssssssssssssssssssssssssssssssssssssssssssssssssssssssssss") - print(to_many_planner) - print("sssssssssssssssssssssssssssssssssssssssssssssssssssssssssss") - print(row1) - print(len(row1.extend(to_many_planner, plan).to_many['random'])) + + current_row = None + to_many_planner = indexed.to_many_planner() + + visited_rows: List[RowPlanCanonical] = [] + row_to_commit = RowPlanCanonical() + for row in rows['results']: + is_new, current_row = row_to_commit.merge(row, indexed) + to_many_planner = current_row.update_to_manys(to_many_planner) + if is_new: + visited_rows.append(row_to_commit) + row_to_commit = current_row + + # The very last row will not have anybody to commit by, so we need to add it. + # At this point though, we _know_ we need to commit it + visited_rows.append(row_to_commit) + + visited_rows = visited_rows[1:] + assert len(visited_rows) > 0, "nothing to return!" + + raw_rows: List[RowPlanCanonical] = [] + for visited_row in visited_rows: + extend_row = visited_row.extend(to_many_planner, indexed) + row_data, row_batch_edit_pack = extend_row.flatten() + row_data = [*row_data, json.dumps({'batch_edit': row_batch_edit_pack})] + raw_rows.append(row_data) + + assert len(set([len(raw_row) for raw_row in raw_rows])) == 1, "Made irregular rows somewhere!" + + def _get_orig_column(string_id: str): + return next(filter(lambda field: field[1].fieldspec.to_stringid() == string_id, enumerate(visible_fields)))[0] + + # The keys are lookups into original query field (not modified by us). Used to get ids in the original one. + key_and_headers, upload_plan = extend_row.to_upload_plan(datamodel.get_table_by_id(tableid), localization_dump, query_fields, {}, _get_orig_column) + headers_enumerated = enumerate(key_and_headers) + visual_order = [_id for (_id, _) in sorted(headers_enumerated, key=lambda tup: tup[1])] + + headers = [header for (_, header) in key_and_headers] + + regularized_rows = regularize_rows(len(headers), raw_rows) + + json_upload_plan = upload_plan.unparse() + + # We are _finally_ ready to make a new dataset + + with transaction.atomic(): + ds = Spdataset.objects.create( + specifyuser=user, + collection=collection, + name=spquery['name'], + columns=headers, + data=regularized_rows, + importedfilename=spquery['name'], + createdbyagent=agent, + modifiedbyagent=agent, + uploadplan=json.dumps(json_upload_plan), + visualorder=visual_order, + isupdate=True + ) + + ds_id, ds_name = (ds.id, ds.name) + ds.id = None + ds.name = f"Backs - {ds.name}" + ds.parent_id = ds_id + # Create the backer. + ds.save() + return (ds_id, ds_name) \ No newline at end of file diff --git a/specifyweb/stored_queries/execution.py b/specifyweb/stored_queries/execution.py index 6c51f7397fd..da4be4e3479 100644 --- a/specifyweb/stored_queries/execution.py +++ b/specifyweb/stored_queries/execution.py @@ -4,7 +4,7 @@ import os import re -from typing import List +from typing import List, NamedTuple, Optional, TypedDict import xml.dom.minidom from collections import namedtuple, defaultdict from datetime import datetime, timedelta @@ -15,6 +15,8 @@ from sqlalchemy import sql, orm, func, select from sqlalchemy.sql.expression import asc, desc, insert, literal +from specifyweb.specify.field_change_info import FieldChangeInfo + from . import models from .format import ObjectFormatter from .query_construct import QueryConstruct @@ -33,12 +35,20 @@ SORT_TYPES = [None, asc, desc] -def set_group_concat_max_len(session): +class BuildQueryProps(NamedTuple): + recordsetid: Optional[int] = None + replace_nulls: bool = False + formatauditobjs: bool = False + distinct: bool = False + implicit_or: bool = True + format_agent_type: bool = False + +def set_group_concat_max_len(connection): """The default limit on MySQL group concat function is quite small. This function increases it for the database connection for the given session. """ - session.connection().execute('SET group_concat_max_len = 1024 * 1024 * 1024') + connection.execute('SET group_concat_max_len = 1024 * 1024 * 1024') def filter_by_collection(model, query, collection): """Add predicates to the given query to filter result to items scoped @@ -178,8 +188,8 @@ def query_to_csv(session, collection, user, tableid, field_specs, path, See build_query for details of the other accepted arguments. """ - set_group_concat_max_len(session) - query, __ = build_query(session, collection, user, tableid, field_specs, recordsetid, replace_nulls=True, distinct=distinct) + set_group_concat_max_len(session.connection()) + query, __ = build_query(session, collection, user, tableid, field_specs, BuildQueryProps(recordsetid=recordsetid, replace_nulls=True, distinct=distinct)) logger.debug('query_to_csv starting') @@ -215,8 +225,8 @@ def query_to_kml(session, collection, user, tableid, field_specs, path, captions See build_query for details of the other accepted arguments. """ - set_group_concat_max_len(session) - query, __ = build_query(session, collection, user, tableid, field_specs, recordsetid, replace_nulls=True) + set_group_concat_max_len(session.connection()) + query, __ = build_query(session, collection, user, tableid, field_specs, BuildQueryProps(recordsetid=recordsetid, replace_nulls=True)) logger.debug('query_to_kml starting') @@ -482,9 +492,9 @@ def return_loan_preps(collection, user, agent, data): lp.save() auditlog.update(lp, agent, None, [ - {'field_name': 'quantityresolved', 'old_value': lp.quantityresolved - quantity, 'new_value': lp.quantityresolved}, - {'field_name': 'quantityreturned', 'old_value': lp.quantityreturned - quantity, 'new_value': lp.quantityreturned}, - {'field_name': 'isresolved', 'old_value': was_resolved, 'new_value': True}, + FieldChangeInfo(field_name='quantityresolved', old_value=lp.quantityresolved - quantity, new_value=lp.quantityresolved), + FieldChangeInfo(field_name='quantityreturned', old_value=lp.quantityreturned - quantity, new_value=lp.quantityreturned), + FieldChangeInfo(field_name='isresolved', old_value=was_resolved, new_value=True) ]) new_lrp = Loanreturnpreparation.objects.create( @@ -511,11 +521,11 @@ def return_loan_preps(collection, user, agent, data): ]) return to_return -def execute(session, collection, user, tableid, distinct, count_only, field_specs, limit, offset, recordsetid=None, formatauditobjs=False): +def execute(session, collection, user, tableid, distinct, count_only, field_specs, limit, offset, format_agent_type=False, recordsetid=None, formatauditobjs=False): "Build and execute a query, returning the results as a data structure for json serialization" - set_group_concat_max_len(session) - query, order_by_exprs = build_query(session, collection, user, tableid, field_specs, recordsetid=recordsetid, formatauditobjs=formatauditobjs, distinct=distinct) + set_group_concat_max_len(session.connection()) + query, order_by_exprs = build_query(session, collection, user, tableid, field_specs, BuildQueryProps(recordsetid=recordsetid, formatauditobjs=formatauditobjs, distinct=distinct, format_agent_type=format_agent_type)) if count_only: return {'count': query.count()} @@ -527,8 +537,7 @@ def execute(session, collection, user, tableid, distinct, count_only, field_spec return {'results': list(query)} -def build_query(session, collection, user, tableid, field_specs, - recordsetid=None, replace_nulls=False, formatauditobjs=False, distinct=False, implicit_or=True): +def build_query(session, collection, user, tableid, field_specs, props: BuildQueryProps = BuildQueryProps()): """Build a sqlalchemy query using the QueryField objects given by field_specs. @@ -563,8 +572,8 @@ def build_query(session, collection, user, tableid, field_specs, query = QueryConstruct( collection=collection, - objectformatter=ObjectFormatter(collection, user, replace_nulls), - query=session.query(func.group_concat(id_field.distinct(), separator=',')) if distinct else session.query(id_field), + objectformatter=ObjectFormatter(collection, user, props.replace_nulls, format_agent_type=props.format_agent_type), + query=session.query(func.group_concat(id_field.distinct(), separator=',')) if props.distinct else session.query(id_field), ) tables_to_read = set([ @@ -578,9 +587,9 @@ def build_query(session, collection, user, tableid, field_specs, query = filter_by_collection(model, query, collection) - if recordsetid is not None: - logger.debug("joining query to recordset: %s", recordsetid) - recordset = session.query(models.RecordSet).get(recordsetid) + if props.recordsetid is not None: + logger.debug("joining query to recordset: %s", props.recordsetid) + recordset = session.query(models.RecordSet).get(props.recordsetid) if not (recordset.dbTableId == tableid): raise AssertionError( f"Unexpected tableId '{tableid}' in request. Expected '{recordset.dbTableId}'", {"tableId" : tableid, @@ -596,7 +605,7 @@ def build_query(session, collection, user, tableid, field_specs, for fs in field_specs: sort_type = SORT_TYPES[fs.sort_type] - query, field, predicate = fs.add_to_query(query, formatauditobjs=formatauditobjs) + query, field, predicate = fs.add_to_query(query, formatauditobjs=props.formatauditobjs) if fs.display: formatted_field = query.objectformatter.fieldformat(fs, field) query = query.add_columns(formatted_field) @@ -608,7 +617,7 @@ def build_query(session, collection, user, tableid, field_specs, if predicate is not None: predicates_by_field[fs.fieldspec].append(predicate) - if implicit_or: + if props.implicit_or: implicit_ors = [ reduce(sql.or_, ps) for ps in predicates_by_field.values() @@ -622,8 +631,8 @@ def build_query(session, collection, user, tableid, field_specs, where = reduce(sql.and_, (p for ps in predicates_by_field.values() for p in ps)) query = query.filter(where) - if distinct: + if props.distinct: query = group_by_displayed_fields(query, selected_fields) - logger.warning("query: %s", query.query) + logger.debug("query: %s", query.query) return query.query, order_by_exprs diff --git a/specifyweb/stored_queries/format.py b/specifyweb/stored_queries/format.py index b3eab8ec98d..05ae6326f74 100644 --- a/specifyweb/stored_queries/format.py +++ b/specifyweb/stored_queries/format.py @@ -8,18 +8,18 @@ from sqlalchemy import orm, Table as SQLTable, inspect from sqlalchemy.sql.expression import case, func, cast, literal, Label -from sqlalchemy.sql.functions import concat, count +from sqlalchemy.sql.functions import concat from sqlalchemy.orm.attributes import InstrumentedAttribute from sqlalchemy.sql.elements import Extract from sqlalchemy import types -from typing import Tuple, Optional, Union, Any +from typing import Tuple, Optional, Union from specifyweb.context.app_resource import get_app_resource from specifyweb.context.remote_prefs import get_remote_prefs -from specifyweb.specify.models import datamodel, Spappresourcedata, \ - Splocalecontainer, Splocalecontaineritem +from specifyweb.specify.agent_types import agent_types +from specifyweb.specify.models import datamodel, Splocalecontainer from specifyweb.specify.datamodel import Field, Relationship, Table from specifyweb.stored_queries.queryfield import QueryField @@ -38,7 +38,7 @@ class ObjectFormatter(object): - def __init__(self, collection, user, replace_nulls): + def __init__(self, collection, user, replace_nulls, format_agent_type=False): formattersXML, _, __ = get_app_resource(collection, user, 'DataObjFormatters') self.formattersDom = ElementTree.fromstring(formattersXML) @@ -48,6 +48,7 @@ def __init__(self, collection, user, replace_nulls): self.collection = collection self.replace_nulls = replace_nulls self.aggregator_count = 0 + self.format_agent_type = format_agent_type def getFormatterDef(self, specify_model: Table, formatter_name) -> Optional[Element]: def lookup(attr: str, val: str) -> Optional[Element]: @@ -327,6 +328,12 @@ def _dateformat(self, specify_field, field): def _fieldformat(self, specify_field: Field, field: Union[InstrumentedAttribute, Extract]): + + if self.format_agent_type and specify_field is Agent_model.get_field("agenttype"): + cases = [(field == _id, name) for (_id, name) in enumerate(agent_types)] + _case = case(cases) + return blank_nulls(_case) if self.replace_nulls else _case + if specify_field.type == "java.lang.Boolean": return field != 0 diff --git a/specifyweb/stored_queries/queryfieldspec.py b/specifyweb/stored_queries/queryfieldspec.py index 9912f83ec8b..c984ca51eec 100644 --- a/specifyweb/stored_queries/queryfieldspec.py +++ b/specifyweb/stored_queries/queryfieldspec.py @@ -23,12 +23,6 @@ # to request a filter on that subportion of the date. DATE_PART_RE = re.compile(r'(.*)((NumericDay)|(NumericMonth)|(NumericYear))$') -# Pull out author or groupnumber field from taxon query fields. -TAXON_FIELD_RE = re.compile(r'(.*) ((Author)|(groupNumber))$') - -# Look to see if we are dealing with a tree node ID. -TREE_ID_FIELD_RE = re.compile(r'(.*) (ID)$') - def extract_date_part(fieldname): match = DATE_PART_RE.match(fieldname) if match: @@ -53,17 +47,39 @@ def field_to_elem(field): rest = [field_to_elem(f) for f in path if not isinstance(f, TreeRankQuery)] return ','.join(first + rest) +def make_tree_fieldnames(table: Table, reverse=False): + mapping = { + 'ID': table.idFieldName.lower(), + '': 'name' + } + if reverse: + return {value: key for (key, value) in mapping.items()} + return mapping + +def find_tree_and_field(table, fieldname: str): + fieldname = fieldname.strip() + if fieldname == '': + return None, None + tree_rank_and_field = fieldname.split(' ') + mapping = make_tree_fieldnames(table) + if len(tree_rank_and_field) == 1: + return tree_rank_and_field[0], mapping[""] + tree_rank, tree_field = tree_rank_and_field + return tree_rank, mapping.get(tree_field, tree_field) + def make_stringid(fs, table_list): tree_ranks = [f.name for f in fs.join_path if isinstance(f, TreeRankQuery)] if tree_ranks: - # FUTURE: Just having this for backwards compatibility; - field_name = tree_ranks[0] + field_name = tree_ranks + reverse = make_tree_fieldnames(fs.table, reverse=True) + last_field_name = fs.join_path[-1].name + field_name = " ".join([*field_name, reverse.get(last_field_name.lower(), last_field_name)]) else: # BUG: Malformed previous stringids are rejected. they desrve it. field_name = (fs.join_path[-1].name if fs.join_path else '') if fs.date_part is not None and fs.date_part != "Full Date": field_name += 'Numeric' + fs.date_part - return table_list, fs.table.name.lower(), field_name + return table_list, fs.table.name.lower(), field_name.strip() class TreeRankQuery(Relationship): # FUTURE: used to remember what the previous value was. Useless after 6 retires @@ -131,19 +147,8 @@ def from_stringid(cls, stringid, is_relation): field = node.get_field(extracted_fieldname, strict=False) if field is None: # try finding tree - tree_id_match = TREE_ID_FIELD_RE.match(extracted_fieldname) - if tree_id_match: - tree_rank_name = tree_id_match.group(1) - field = node.idFieldName - else: - tree_field_match = TAXON_FIELD_RE.match(extracted_fieldname) \ - if node is datamodel.get_table('Taxon') else None - if tree_field_match: - tree_rank_name = tree_field_match.group(1) - field = (tree_field_match.group(2)) - else: - tree_rank_name = extracted_fieldname if extracted_fieldname else None - if tree_rank_name is not None: + tree_rank_name, field = find_tree_and_field(node, extracted_fieldname) + if tree_rank_name: tree_rank = TreeRankQuery(name=tree_rank_name) # doesn't make sense to query across ranks of trees. no, it doesn't block a theoretical query like family -> continent tree_rank.relatedModelName = node.name diff --git a/specifyweb/stored_queries/tests.py b/specifyweb/stored_queries/tests.py index bc5ab6a71dd..9db4034c780 100644 --- a/specifyweb/stored_queries/tests.py +++ b/specifyweb/stored_queries/tests.py @@ -1,15 +1,6 @@ from sqlalchemy import orm, inspect from unittest import skip, expectedFailure -from specifyweb.accounts import models as acccounts_models -from specifyweb.attachment_gw import models as attachment_gw_models -from specifyweb.businessrules import models as businessrules_models -from specifyweb.context import models as context_models -from specifyweb.notifications import models as notifications_models -from specifyweb.permissions import models as permissions_models -from specifyweb.interactions import models as interactions_models -from specifyweb.workbench import models as workbench_models - from django.test import TestCase import specifyweb.specify.models as spmodels from specifyweb.specify.tests.test_api import ApiTests @@ -73,7 +64,7 @@ def run_django_query(conn, cursor, statement, parameters, context, executemany): result_set = django_cursor.fetchall() columns = django_cursor.description # SqlAlchemy needs to find columns back in the rows, hence adding label to columns - selects = [sqlalchemy.select([sqlalchemy.literal(column).label(columns[idx][0]) for idx, column in enumerate(row)]) for row + selects = [sqlalchemy.select([(sqlalchemy.null() if column is None else sqlalchemy.sql.expression.literal(column) if isinstance(column, str) else column) for idx, column in enumerate(row)]) for row in result_set] # union all instead of union because rows can be duplicated in the original query, # but still need to preserve the duplication diff --git a/specifyweb/stored_queries/urls.py b/specifyweb/stored_queries/urls.py index d448991deb6..a06135814ec 100644 --- a/specifyweb/stored_queries/urls.py +++ b/specifyweb/stored_queries/urls.py @@ -9,4 +9,5 @@ url(r'^exportkml/$', views.export_kml), url(r'^make_recordset/$', views.make_recordset), url(r'^return_loan_preps/$', views.return_loan_preps), + url(r'^batch_edit/$', views.batch_edit) ] diff --git a/specifyweb/stored_queries/views.py b/specifyweb/stored_queries/views.py index 30499a48d36..f36b7c3ac9a 100644 --- a/specifyweb/stored_queries/views.py +++ b/specifyweb/stored_queries/views.py @@ -10,6 +10,7 @@ from django.views.decorators.http import require_POST from specifyweb.middleware.general import require_GET +from specifyweb.stored_queries.batch_edit import run_batch_edit from . import models from .execution import execute, run_ephemeral_query, do_export, recordset, \ return_loan_preps as rlp @@ -96,22 +97,35 @@ def query(request, id): @never_cache def ephemeral(request): """Executes and returns the results of the query provided as JSON in the POST body.""" + + spquery, collection = get_query(request) + data = run_ephemeral_query(collection, request.specify_user, spquery) + + return HttpResponse(toJson(data), content_type='application/json') + +def get_query(request): try: spquery = json.load(request) except ValueError as e: return HttpResponseBadRequest(e) - if 'collectionid' in spquery: collection = Collection.objects.get(pk=spquery['collectionid']) logger.debug('forcing collection to %s', collection.collectionname) else: collection = request.specify_collection - + check_permission_targets(collection.id, request.specify_user.id, [QueryBuilderPt.execute]) - data = run_ephemeral_query(collection, request.specify_user, spquery) - return HttpResponse(toJson(data), content_type='application/json') + return spquery, collection +@require_POST +@login_maybe_required +@never_cache +def batch_edit(request): + """Executes and returns the results of the query provided as JSON in the POST body.""" + spquery, collection = get_query(request) + ds_id, ds_name = run_batch_edit(collection, request.specify_user, spquery, request.specify_user_agent) + return HttpResponse(toJson({"id": ds_id, "name": ds_name}), status=201, content_type='application/json') @require_POST @login_maybe_required diff --git a/specifyweb/workbench/migrations/0006_spdataset_isupdate.py b/specifyweb/workbench/migrations/0006_spdataset_isupdate.py new file mode 100644 index 00000000000..c3911773fcb --- /dev/null +++ b/specifyweb/workbench/migrations/0006_spdataset_isupdate.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.15 on 2024-08-11 17:53 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('workbench', '0005_auto_20210428_1634'), + ] + + operations = [ + migrations.AddField( + model_name='spdataset', + name='isupdate', + field=models.BooleanField(default=False), + ), + ] diff --git a/specifyweb/workbench/migrations/0007_spdataset_parent.py b/specifyweb/workbench/migrations/0007_spdataset_parent.py new file mode 100644 index 00000000000..60e006ac16b --- /dev/null +++ b/specifyweb/workbench/migrations/0007_spdataset_parent.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.15 on 2024-08-16 14:50 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('workbench', '0006_spdataset_isupdate'), + ] + + operations = [ + migrations.AddField( + model_name='spdataset', + name='parent', + field=models.OneToOneField(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='backer', to='workbench.spdataset'), + ), + ] diff --git a/specifyweb/workbench/models.py b/specifyweb/workbench/models.py index c055f0702aa..8e4f945c7ab 100644 --- a/specifyweb/workbench/models.py +++ b/specifyweb/workbench/models.py @@ -1,10 +1,8 @@ import json -from functools import partialmethod -from model_utils import FieldTracker from django import http from django.core.exceptions import ObjectDoesNotExist -from django.db import models, transaction +from django.db import models from django.http import Http404 from django.utils import timezone @@ -86,8 +84,6 @@ def get_dataset_as_dict(self): class Meta: abstract = True - # save = partialmethod(custom_save) - class Spdataset(Dataset): specify_model = datamodel.get_table('spdataset') @@ -96,19 +92,22 @@ class Spdataset(Dataset): visualorder = models.JSONField(null=True) rowresults = models.TextField(null=True) + isupdate = models.BooleanField(default=False) + + # very complicated. Essentially, each batch-edit dataset gets backed by another dataset (for rollbacks). + # This should be a one-to-one field, imagine the mess otherwise. + parent = models.OneToOneField('Spdataset', related_name='backer', null=True, on_delete=models.CASCADE) class Meta: db_table = 'spdataset' - # timestamptracker = FieldTracker(fields=['timestampcreated', 'timestampmodified']) - # save = partialmethod(custom_save) - def get_dataset_as_dict(self): ds_dict = super().get_dataset_as_dict() ds_dict.update({ "columns": self.columns, "visualorder": self.visualorder, - "rowresults": self.rowresults and json.loads(self.rowresults) + "rowresults": self.rowresults and json.loads(self.rowresults), + "isupdate": self.isupdate }) return ds_dict diff --git a/specifyweb/workbench/tasks.py b/specifyweb/workbench/tasks.py index ea24552b954..7ff9cfc1c6a 100644 --- a/specifyweb/workbench/tasks.py +++ b/specifyweb/workbench/tasks.py @@ -10,7 +10,7 @@ from .models import Spdataset -from .upload.upload import do_upload_dataset, unupload_dataset +from .upload.upload import do_upload_dataset, rollback_batch_edit, unupload_dataset logger = get_task_logger(__name__) @@ -46,7 +46,7 @@ def progress(current: int, total: Optional[int]) -> None: ds.save(update_fields=['uploaderstatus']) @app.task(base=LogErrorsTask, bind=True) -def unupload(self, ds_id: int, agent_id: int) -> None: +def unupload(self, collection_id: int, ds_id: int, agent_id: int) -> None: def progress(current: int, total: Optional[int]) -> None: if not self.request.called_directly: @@ -55,6 +55,7 @@ def progress(current: int, total: Optional[int]) -> None: with transaction.atomic(): ds = Spdataset.objects.select_for_update().get(id=ds_id) agent = Agent.objects.get(id=agent_id) + collection = Collection.objects.get(id=collection_id) if ds.uploaderstatus is None: logger.info("dataset is not assigned to an upload task") @@ -71,7 +72,10 @@ def progress(current: int, total: Optional[int]) -> None: "expectedUploadStatus" : "unuoloading", "localizationKey" : "invalidUploadStatus"}) - unupload_dataset(ds, agent, progress) + if ds.isupdate: + rollback_batch_edit(ds, collection, agent, progress) + else: + unupload_dataset(ds, agent, progress) ds.uploaderstatus = None - ds.save(update_fields=['uploaderstatus']) + ds.save(update_fields=['uploaderstatus']) \ No newline at end of file diff --git a/specifyweb/workbench/tests.py b/specifyweb/workbench/tests.py index edeeb521103..01019706640 100644 --- a/specifyweb/workbench/tests.py +++ b/specifyweb/workbench/tests.py @@ -73,7 +73,7 @@ def test_create_record_set(self) -> None: self.assertEqual(response.status_code, 204) dataset = Spdataset.objects.get(id=datasetid) - results = uploader.do_upload_dataset(self.collection, self.agent.id, dataset, no_commit=False, allow_partial=False, session_url=settings.SA_TEST_DB_URL) + results = uploader.do_upload_dataset(self.collection, self.agent.id, dataset, no_commit=False, allow_partial=False) self.assertTrue(dataset.uploadresult['success']) diff --git a/specifyweb/workbench/upload/auditor.py b/specifyweb/workbench/upload/auditor.py index 6c91ec2cc94..f29d96b7490 100644 --- a/specifyweb/workbench/upload/auditor.py +++ b/specifyweb/workbench/upload/auditor.py @@ -1,9 +1,11 @@ import logging -from typing import Any, NamedTuple, Optional, Union +from typing import Any, Callable, List, Literal, NamedTuple, Optional, Union + from specifyweb.specify.auditlog import AuditLog -from specifyweb.permissions.permissions import check_table_permissions +from specifyweb.permissions.permissions import PERMISSION_ACTIONS, check_table_permissions from specifyweb.specify.models import Agent +from specifyweb.specify.field_change_info import FieldChangeInfo logger = logging.getLogger(__name__) @@ -11,16 +13,30 @@ class Auditor(NamedTuple): collection: Any audit_log: Optional[AuditLog] skip_create_permission_check: bool = False - def insert(self, inserted_obj: Any, agent: Union[int, Any], parent_record: Optional[Any]) -> None: + agent: Optional[Agent] = None - if agent is None: - logger.warn('WB inserting %s with no createdbyagent. Skipping permissions check.', inserted_obj) - elif not self.skip_create_permission_check: - if isinstance(agent, int): - # TODO: Optimize this potential w/ just memoization - agent_obj = Agent.objects.get(id=agent) + def pre_log(self, obj: Any, action_name: PERMISSION_ACTIONS): + if self.skip_create_permission_check: + return + if self.agent is None: + logger.warning("WB %s %s with no agent. Skipping Permissions check", action_name, obj) + return + check_table_permissions(self.collection, self.agent, obj, action_name) + return + + def insert(self, inserted_obj: Any, parent_record: Optional[Any]) -> None: + self.pre_log(inserted_obj, 'create') - check_table_permissions(self.collection, agent_obj, inserted_obj, "create") + if self.audit_log is not None: + self.audit_log.insert(inserted_obj, self.agent, parent_record) + + def delete(self, deleted_obj: Any, parent_record: Optional[Any]) -> None: + self.pre_log(deleted_obj, 'delete') if self.audit_log is not None: - self.audit_log.insert(inserted_obj, agent, parent_record) + self.audit_log.remove(deleted_obj, self.agent, parent_record) + + def update(self, updated_obj: Any, parent_obj, dirty_fields: List[FieldChangeInfo]) -> None: + self.pre_log(updated_obj, 'update') + if self.audit_log is not None: + self.audit_log.update(updated_obj, self.agent, parent_obj, dirty_fields) \ No newline at end of file diff --git a/specifyweb/workbench/upload/clone.py b/specifyweb/workbench/upload/clone.py new file mode 100644 index 00000000000..3a56c51133b --- /dev/null +++ b/specifyweb/workbench/upload/clone.py @@ -0,0 +1,67 @@ +# generic logic for cloning records. TODO: Make this part of the generic API and server-side cloning + +import json +from typing import Any, Callable, Dict, List + +from django.db.models import Model +from django.db import transaction + +from specifyweb.specify.func import Func +from specifyweb.specify.load_datamodel import Table + +FIELDS_TO_NOT_CLONE: Dict[str, List[str]] = json.load( + open('specifyweb/frontend/js_src/lib/components/DataModel/uniqueFields.json')) + +# These fields are system fields. This is a bit different than uniqueFields on frontend at DataModel/resource.ts in that we don't want to skip all those fields +# when checking whether the record is null. Those fields would be, skipped, during cloning, but not when checking whether record is null. +# TODO: See if we have enough reason to just directly take those fields... + +GENERIC_FIELDS_TO_SKIP = [ + 'timestampcreated', + 'timestampmodified', + 'version', + 'id', + 'createdbyagent_id', + 'modifiedbyagent_id' + ] + +@transaction.atomic() +def clone_record(reference_record, inserter: Callable[[Model, Dict[str, Any]], Model], one_to_ones={}, to_ignore: List[str] = [], override_attrs={}) -> Model: + model: Model = type(reference_record) # type: ignore + model_name = model._meta.model_name + assert model_name is not None + specify_model: Table = model.specify_model # type: ignore + # We could be smarter here, and make our own list, but this is indication that there were new tables or something, and we can't assume our schema version is correct. + assert model_name.lower() in FIELDS_TO_NOT_CLONE, f"Schema mismatch detected at {model_name}" + + fields_to_ignore = [*[field.lower() for field in FIELDS_TO_NOT_CLONE[model_name.lower()]], *to_ignore, *GENERIC_FIELDS_TO_SKIP] + + all_fields = [field for field in model._meta.get_fields() if field.name not in fields_to_ignore] + + marked = [(field, (field.is_relation and specify_model.get_relationship(field.name).dependent or field.name.lower() in one_to_ones.get(model_name.lower(), []) and field.name is not None)) for field in all_fields] + + def _cloned(value, field, is_dependent): + if not is_dependent: + return value + # Don't fetch the actual object till the very end, and when we _need_ to clone (as an optimization) + return clone_record(getattr(reference_record, field.name), inserter, one_to_ones) + + attrs = { + field.attname: Func.maybe(getattr(reference_record, field.attname), lambda obj: _cloned(obj, field, is_dependent)) # type: ignore + for (field, is_dependent) in marked + # This will handle many-to-ones + one-to-ones + if field.concrete + } + + attrs = { + **attrs, + **override_attrs + } + + inserted = inserter(model, attrs) + + to_many_cloned = [[clone_record(to_many_record, inserter, one_to_ones, override_attrs = {field.remote_field.attname: inserted.pk}) # type: ignore + for to_many_record in getattr(reference_record, field.name).all() # Clone all records separatetly + ] for (field, is_dependent) in marked if is_dependent and not field.concrete] # Should be a relationship, but not on our side + + return inserted \ No newline at end of file diff --git a/specifyweb/workbench/upload/disambiguation.py b/specifyweb/workbench/upload/disambiguation.py index 1758d4cb4d0..dc8ac73760f 100644 --- a/specifyweb/workbench/upload/disambiguation.py +++ b/specifyweb/workbench/upload/disambiguation.py @@ -38,4 +38,4 @@ def disambiguate_to_many(self, to_many: str, record_index: int) -> Disambiguatio def from_json(data: Dict[str, int]) -> DisambiguationInfo: return DisambiguationInfo({ tuple(path.split('.') if path else []): id for path, id in data.items() - }) + }) \ No newline at end of file diff --git a/specifyweb/workbench/upload/parsing.py b/specifyweb/workbench/upload/parsing.py index c87cc701c2d..b8624e2f90c 100644 --- a/specifyweb/workbench/upload/parsing.py +++ b/specifyweb/workbench/upload/parsing.py @@ -4,6 +4,7 @@ from django.core.exceptions import ObjectDoesNotExist from specifyweb.specify.datamodel import datamodel +from specifyweb.workbench.upload.predicates import filter_match_key from .column_options import ExtendedColumnOptions from specifyweb.specify.parse import parse_field, is_latlong, ParseSucess, ParseFailure @@ -31,10 +32,9 @@ def from_parse_failure(cls, pf: ParseFailure, column: str): def to_json(self) -> List: return list(self) - class ParseResult(NamedTuple): filter_on: Filter - upload: Dict[str, Any] + upload: Filter add_to_picklist: Optional[PicklistAddition] column: str missing_required: Optional[str] @@ -44,7 +44,6 @@ def from_parse_success(cls, ps: ParseSucess, filter_on: Filter, add_to_picklist: return cls(filter_on=filter_on, upload=ps.to_upload, add_to_picklist=add_to_picklist, column=column, missing_required=missing_required) def match_key(self) -> str: - from .uploadable import filter_match_key return filter_match_key(self.filter_on) @@ -76,7 +75,7 @@ def parse_value(tablename: str, fieldname: str, value_in: str, colopts: Extended "field is required by schema config" if required_by_schema else None ) - result = ParseResult({fieldname: None}, {}, + result = ParseResult({fieldname: None}, {fieldname: None}, None, colopts.column, missing_required) else: result = _parse(tablename, fieldname, diff --git a/specifyweb/workbench/upload/predicates.py b/specifyweb/workbench/upload/predicates.py new file mode 100644 index 00000000000..0e2c0504e82 --- /dev/null +++ b/specifyweb/workbench/upload/predicates.py @@ -0,0 +1,257 @@ + +from functools import reduce +from typing import Callable, Dict, NamedTuple, Optional, Any, Generator, List, Tuple, Union +from typing_extensions import TypedDict + +from django.db.models import QuerySet, Q, F, Model, Exists, OuterRef + +import specifyweb.specify.models as spmodels +from specifyweb.specify.func import Func + +from django.core.exceptions import ObjectDoesNotExist + +from specifyweb.workbench.upload.clone import GENERIC_FIELDS_TO_SKIP + +Filter = Dict[str, Any] + +Value = Optional[Union[str, int, F]] + +class ToRemoveMatchee(TypedDict): + filter_on: Filter + # It is possible that the node we need to filter on may be present. In this case, we'll remove valid entries. + # To avoid that, we track the present ones too. I can't think why this might need more cont, so making it Q + remove: Optional[Q] + +ToRemoveNode = Dict[str, List[ToRemoveMatchee]] + +get_model = lambda model_name: getattr(spmodels, model_name.lower().capitalize()) + +def add_to_remove_node(previous: ToRemoveNode, new_node: ToRemoveNode) -> ToRemoveNode: + return { + **previous, + **{ + key: [*previous.get(key, []), *values] + for key, values in new_node.items() + } + } + +class ToRemove(NamedTuple): + model_name: str + filter_on: Filter + + def to_cache_key(self): + return repr((self.model_name, filter_match_key(self.filter_on))) + +class DjangoPredicates(NamedTuple): + filters: Dict[str, Union[Value, List[Any]]] = {} # type: ignore + to_remove: Optional[ToRemove] = None + + def reduce_for_to_one(self): + if not self.filters and not self.to_remove and not isinstance(self, SkippablePredicate): + # If we get here, we know that we can directly reduce to-one + # to an empty none match, and don't need to bother matching + # more. + return None + return [self] + + def reduce_for_to_many( + self, + uploadable: Any # type: ignore + ): + if self.filters or isinstance(self, SkippablePredicate): + return self + + # nested excludes don't make sense and complicates everything. + # this avoids it (while keeping semantics same). + # That is, if we hit a "to_remove", we're done, and don't need to go any deeper. + # Having a "higher" to_remove is just a bad idea (and redundant, we don't care about a deeper one at that point) + return DjangoPredicates(to_remove=uploadable.get_to_remove()) + + @staticmethod + def _map_reduce(values): + if not isinstance(values, list): + return values is None + return all(value.is_reducible() for value in values) + + def is_reducible(self): + if not self.filters: + return True + return all(DjangoPredicates._map_reduce(values) for values in self.filters.values()) + + def get_cache_key(self, basetable_name: str=None) -> str: + filters = [ + # we don't care about table past the first one, since rels will uniquely denote the related table.. + (key, tuple([value.get_cache_key() for value in values]) + if isinstance(values, list) else repr(values)) + for key, values in Func.sort_by_key(self.filters)] + to_remove = None if self.to_remove is None else self.to_remove.to_cache_key() + return repr((basetable_name, tuple(filters), to_remove)) + + def _smart_apply( + self, + query: QuerySet, + get_unique_alias: Generator[str, None, None], + current_model: Model, + path: Optional[str] = None, + aliases: List[Tuple[str, str]] = [], + to_remove_node: 'ToRemoveNode' = {} + ): + _get_field_name = lambda raw_name: raw_name if path is None else (path + '__' + raw_name) + + # it's useless to match on nothing. caller should be aware of that + assert self.filters is not None and self.filters, "trying to force match on nothing" + + base_predicates = { + _get_field_name(field_name): value + for (field_name, value) in self.filters.items() + if not isinstance(value, list) + } + + filtered = { + **base_predicates, + **{name: F(alias) for (name, alias) in aliases[1:]} + } + + unique_alias = next(get_unique_alias) + + alias_path = _get_field_name('id') + query = query.filter(**filtered).alias(**{unique_alias: F(alias_path)}) + aliases = [ + *aliases, + (alias_path, unique_alias) + ] + + def _reduce_by_key(rel_name: str): + # mypy isn't able to infer types correctly + new_model: Model = current_model._meta.get_field(rel_name).related_model # type: ignore + assert new_model is not None + def _reduce(previous: Tuple[QuerySet, ToRemoveNode, List[Any]], current: DjangoPredicates): + # Don't do anything + if isinstance(current, SkippablePredicate): + return previous + + previous_query, previous_to_remove_node, internal_exclude = previous + + if (current.to_remove): + assert not current.filters, "Hmm, we are trying to filter on something we'll remove. How??" + reverse_side: str = current_model._meta.get_field(rel_name).remote_field.name # type: ignore + model_name: str = new_model._meta.model_name # type: ignore + assert reverse_side is not None + to_remove_node = add_to_remove_node(previous_to_remove_node, { + model_name: [{ + 'filter_on': {**current.to_remove.filter_on, reverse_side: OuterRef(unique_alias)}, + 'remove': None if len(internal_exclude) == 0 else Func.make_ors([Q(id=OuterRef(prev)) for prev in internal_exclude]) + }]}) + return previous_query, to_remove_node, internal_exclude + + new_path = _get_field_name(rel_name) + + new_query, node_to_remove, alias_of_child = current._smart_apply(previous_query, get_unique_alias, new_model, new_path, aliases, previous_to_remove_node) + record_aligned: QuerySet = reduce(lambda _query, _to_remove: _query.exclude(**{alias_of_child: F(_to_remove)}), internal_exclude, new_query) + return record_aligned, node_to_remove, [*internal_exclude, alias_of_child] + + return _reduce + + def _reduce_by_rel(accum: Tuple[QuerySet, ToRemoveNode], by_rels: List[DjangoPredicates], rel_name: str): + modified_query, modified_to_remove, _ = reduce(_reduce_by_key(rel_name), sorted(by_rels, key=lambda pred: int(pred.to_remove is not None)), (accum[0], accum[1], [])) + return modified_query, modified_to_remove + + rels = [(key, values) for (key, values) in self.filters.items() if isinstance(values, list)] + query, to_remove = reduce( + lambda accum, current: _reduce_by_rel(accum, current[1], current[0]), + rels, + (query, to_remove_node) + ) + return query, to_remove, unique_alias + + def apply_to_query(self, base_name: str) -> QuerySet: + base_model = get_model(base_name) + query: QuerySet = base_model.objects.all() + getter = get_unique_predicate() + if not self.filters: + return query + query, to_remove, alias = self._smart_apply(query, getter, base_model, None, aliases=[]) + if to_remove: + query = query.filter(canonicalize_remove_node(to_remove)) + return query + + +# Use this in places where we need to guarantee unique values. We could use random numbers, but using this +# makes things sane for debugging. +def get_unique_predicate(pre="predicate-") -> Generator[str, None, None]: + _id = 0 + while True: + yield f"{pre}{_id}" + _id += 1 + +# This predicates is a magic predicate, and is completely ignored during query-building. If a uploadable returns this, it won't be considered for matching +# NOTE: There's a difference between Skipping and returning a null filter (if within to-ones, null will really - correctly - filter for null). +class SkippablePredicate(DjangoPredicates): + def get_cache_key(self, basetable_name: str = None) -> str: + return repr(('Skippable', basetable_name)) + + def apply_to_query(self, base_name: str) -> QuerySet: + raise Exception("Attempting to apply skippable predicates to a query!") + + def is_reducible(self): + # Don't reduce it. Doesn't make sense for top-level. But does if in rels + return False + +def filter_match_key(f: Filter) -> str: + return repr(sorted(f.items())) + +def canonicalize_remove_node(node: ToRemoveNode) -> Q: + all_exists = [Q(_map_matchee(matchee, name)) for name, matchee in node.items()] + all_or = Func.make_ors(all_exists) + # Don't miss the negation below! + return ~all_or + +def _map_matchee(matchee: List[ToRemoveMatchee], model_name: str) -> Exists: + model: Model = get_model(model_name) + qs = [Q(**match['filter_on']) for match in matchee] + qs_or = Func.make_ors(qs) + query = model.objects.filter(qs_or) + to_remove = [match['remove'] for match in matchee if match['remove'] is not None] + if to_remove: + query = query.exclude(Func.make_ors(to_remove)) + return Exists(query) + +class ContetRef(Exception): + pass + +def safe_fetch(model: Model, filters, version): + if filters is None: + return None + try: + reference_record = model.objects.select_for_update().get(**filters) + except ObjectDoesNotExist: + raise ContetRef(f"Object {filters} at {model._meta.model_name} is no longer present in the database") + + incoming_version = getattr(reference_record, 'version', None) + + if incoming_version is not None and version is not None and version != incoming_version: + raise ContetRef(f"Object {filters} of {model._meta.model_name} is out of date. Please re-run the query") + + return reference_record + +def resolve_reference_attributes(fields_to_skip, model, reference_record) -> Dict[str, Any]: + + if reference_record is None: + return {} + + fields_to_skip = [ + *GENERIC_FIELDS_TO_SKIP, + *fields_to_skip, + ] + + all_fields = [ + field.attname for field in model._meta.get_fields(include_hidden=True) + if field.concrete and (field.attname not in fields_to_skip) + ] + + clone_attrs = { + field: getattr(reference_record, field) + for field in all_fields + } + + return clone_attrs \ No newline at end of file diff --git a/specifyweb/workbench/upload/preferences.py b/specifyweb/workbench/upload/preferences.py new file mode 100644 index 00000000000..2cc5e4d52e2 --- /dev/null +++ b/specifyweb/workbench/upload/preferences.py @@ -0,0 +1,22 @@ +import re +from typing import Dict, Literal, TypedDict, Union +from specifyweb.context.remote_prefs import get_remote_prefs + + +DEFER_KEYS = Union[Literal['match'], Literal['null_check']] + +class PrefItem(TypedDict): + path: 'str' + default: bool + +DeferFieldPrefs: Dict[DEFER_KEYS, PrefItem] = { + 'match': PrefItem(path=r'sp7\.batchEdit.deferForMatch=(.+)', default=True), + 'null_check': PrefItem(path=r'sp7\.batchEdit.deferForNullCheck=(.+)', default=False) +} + +def should_defer_fields(for_type: DEFER_KEYS) -> bool: + pref_item = DeferFieldPrefs[for_type] + match = re.search(pref_item['path'], get_remote_prefs()) + if match is None: + return pref_item['default'] + return match.group(1).strip().lower() == 'true' \ No newline at end of file diff --git a/specifyweb/workbench/upload/scoping.py b/specifyweb/workbench/upload/scoping.py index 50dea46b55d..33081c57f9f 100644 --- a/specifyweb/workbench/upload/scoping.py +++ b/specifyweb/workbench/upload/scoping.py @@ -1,11 +1,12 @@ -from typing import Dict, Any, Optional, Tuple, Callable, Union, List +from functools import reduce +from typing import Dict, Any, Generator, Tuple, Callable, List from specifyweb.specify.datamodel import datamodel, Table from specifyweb.specify.load_datamodel import DoesNotExistError from specifyweb.specify import models from specifyweb.specify.uiformatters import get_uiformatter from specifyweb.stored_queries.format import get_date_format -from .uploadable import ScopedUploadable +from .uploadable import ScopeGenerator, ScopedUploadable from .upload_table import UploadTable, ScopedUploadTable, ScopedOneToOneTable from .treerecord import TreeRecord, ScopedTreeRecord from .column_options import ColumnOptions, ExtendedColumnOptions, CustomRepr @@ -112,18 +113,21 @@ def extend_columnoptions(colopts: ColumnOptions, collection, tablename: str, fie schemaitem=schemaitem, uiformatter=None if scoped_formatter is None else CustomRepr(scoped_formatter, friendly_repr), picklist=picklist, - dateformat=get_date_format(), + dateformat=get_date_format() ) -def get_deferred_scoping(key, table_name, uploadable, row, base_ut): +def get_deferred_scoping(key: str, table_name: str, uploadable: UploadTable, row: Dict[str, Any], base_ut, generator: ScopeGenerator): deferred_key = (table_name, key) deferred_scoping = DEFERRED_SCOPING.get(deferred_key, None) if deferred_scoping is None: - return True, uploadable + return uploadable if row: related_key, filter_field, relationship_name = deferred_scoping + other = base_ut.toOne[related_key] + if not isinstance(other, UploadTable): + raise Exception("invalid scoping scheme!") related_column_name = base_ut.toOne[related_key].wbcols[filter_field].column filter_value = row[related_column_name] filter_search = {filter_field: filter_value} @@ -135,42 +139,32 @@ def get_deferred_scoping(key, table_name, uploadable, row, base_ut): collection_id = None # don't cache anymore, since values can be dependent on rows. - return False, uploadable._replace(overrideScope = {'collection': collection_id}) - -def _apply_scoping_to_uploadtable(table, row, collection, base_ut): - def _update_uploadtable(field: str, uploadable: UploadTable): - can_cache_this, uploadable = get_deferred_scoping(field, table.django_name, uploadable, row, base_ut) - can_cache_sub, scoped = uploadable.apply_scoping(collection, row) - return can_cache_this and can_cache_sub, scoped - return _update_uploadtable - -def apply_scoping_to_one(ut, collection, table, callback) -> Tuple[bool, Dict[str, ScopedUploadable]]: - adjust_to_ones = to_one_adjustments(collection, table) - to_ones_items = list(ut.toOne.items()) - to_one_results = [(f, callback(f, u)) for (f, u) in to_ones_items] - to_ones = {f: adjust_to_ones(u, f) for f, (_, u) in to_one_results} - return all(_can_cache for (_, (_can_cache, __)) in to_one_results), to_ones - -def apply_scoping_to_uploadtable(ut: UploadTable, collection, row=None) -> Tuple[bool, ScopedUploadTable]: + if generator is not None: + next(generator) # a bit hacky + return uploadable._replace(overrideScope = {'collection': collection_id}) + +def apply_scoping_to_uploadtable(ut: UploadTable, collection, generator: ScopeGenerator = None, row=None) -> ScopedUploadTable: table = datamodel.get_table_strict(ut.name) if ut.overrideScope is not None and isinstance(ut.overrideScope['collection'], int): - collection = models.Collection.objects.filter(id=ut.overrideScope['collection']).get() + collection = models.Collection.objects.get(id=ut.overrideScope['collection']) + to_one_fields = get_to_one_fields(collection) + + adjuster = reduce(lambda accum, curr: _make_one_to_one(curr, accum), to_one_fields.get(table.name.lower(), []), lambda u, f: u) - callback = _apply_scoping_to_uploadtable(table, row, collection, ut) - can_cache_to_one, to_ones = apply_scoping_to_one(ut, collection, table, callback) + apply_scoping = lambda key, value: get_deferred_scoping(key, table.django_name, value, row, ut, generator).apply_scoping(collection, generator, row) - to_many_results: List[Tuple[str, List[Tuple[bool, ScopedUploadTable]]]] = [ - (f, [(callback(f, r)) for r in rs]) for (f, rs) in ut.toMany.items() - ] + to_ones = { + key: adjuster(apply_scoping(key, value), key) + for (key, value) in ut.toOne.items() + } - can_cache_to_many = all(_can_cache for (_, tmr) in to_many_results for (_can_cache, __) in tmr) to_many = { - f: [set_order_number(i, tmr) for i, (_, tmr) in enumerate(scoped_tmrs)] - for f, scoped_tmrs in to_many_results + key: [set_order_number(i, apply_scoping(key, record)) for i, record in enumerate(records)] + for (key, records) in ut.toMany.items() } - return can_cache_to_many and can_cache_to_one, ScopedUploadTable( + scoped_table = ScopedUploadTable( name=ut.name, wbcols={f: extend_columnoptions(colopts, collection, table.name, f) for f, colopts in ut.wbcols.items()}, static=static_adjustments(table, ut.wbcols, ut.static), @@ -178,28 +172,22 @@ def apply_scoping_to_uploadtable(ut: UploadTable, collection, row=None) -> Tuple toMany=to_many, #type: ignore scopingAttrs=scoping_relationships(collection, table), disambiguation=None, + # Often, we'll need to recur down to clone (nested one-to-ones). Having this entire is handy in such a case + to_one_fields = to_one_fields, + match_payload=None ) -def to_one_adjustments(collection, table: Table) -> AdjustToOnes: - adjust_to_ones: AdjustToOnes = lambda u, f: u - if collection.isembeddedcollectingevent and table.name == 'CollectionObject': - adjust_to_ones = _make_one_to_one('collectingevent', adjust_to_ones) - - elif collection.discipline.ispaleocontextembedded and table.name.lower() == collection.discipline.paleocontextchildtable.lower(): - adjust_to_ones = _make_one_to_one('paleocontext', adjust_to_ones) - - if table.name == 'CollectionObject': - adjust_to_ones = _make_one_to_one('collectionobjectattribute', adjust_to_ones) - if table.name == 'CollectingEvent': - adjust_to_ones = _make_one_to_one('collectingeventattribute', adjust_to_ones) - if table.name == 'Attachment': - adjust_to_ones = _make_one_to_one('attachmentimageattribute', adjust_to_ones) - if table.name == 'CollectingTrip': - adjust_to_ones = _make_one_to_one('collectingtripattribute', adjust_to_ones) - if table.name == 'Preparation': - adjust_to_ones = _make_one_to_one('preparationattribute', adjust_to_ones) + return scoped_table - return adjust_to_ones +def get_to_one_fields(collection) -> Dict[str, List['str']]: + return { + 'collectionobject': [*(['collectingevent'] if collection.isembeddedcollectingevent else []), 'collectionobjectattribute'], + 'collectingevent': ['collectingeventattribute'], + 'attachment': ['attachmentimageattribute'], + 'collectingtrip': ['collectingtripattribute'], + 'preparation': ['preparationattribute'], + **({collection.discipline.paleocontextchildtable.lower(): ['paleocontext']} if collection.discipline.ispaleocontextembedded else {}) + } def static_adjustments(table: Table, wbcols: Dict[str, ColumnOptions], static: Dict[str, Any]) -> Dict[str, Any]: # not sure if this is the right place for this, but it will work for now. @@ -217,7 +205,7 @@ def set_order_number(i: int, tmr: ScopedUploadTable) -> ScopedUploadTable: return tmr._replace(scopingAttrs={**tmr.scopingAttrs, 'ordernumber': i}) return tmr -def apply_scoping_to_treerecord(tr: TreeRecord, collection) -> Tuple[bool, ScopedTreeRecord]: +def apply_scoping_to_treerecord(tr: TreeRecord, collection) -> ScopedTreeRecord: table = datamodel.get_table_strict(tr.name) if table.name == 'Taxon': @@ -246,12 +234,12 @@ def apply_scoping_to_treerecord(tr: TreeRecord, collection) -> Tuple[bool, Scope root = list(getattr(models, table.name.capitalize()).objects.filter(definitionitem=treedefitems[0])[:1]) # assume there is only one - # don't imagine a use-case for making it non-cachable - return True, ScopedTreeRecord( + return ScopedTreeRecord( name=tr.name, ranks={r: {f: extend_columnoptions(colopts, collection, table.name, f) for f, colopts in cols.items()} for r, cols in tr.ranks.items()}, treedef=treedef, treedefitems=list(treedef.treedefitems.order_by('rankid')), root=root[0] if root else None, disambiguation={}, + batch_edit_pack=None ) diff --git a/specifyweb/workbench/upload/tests/example_plan.py b/specifyweb/workbench/upload/tests/example_plan.py index 49daa71b0c4..cc548b96823 100644 --- a/specifyweb/workbench/upload/tests/example_plan.py +++ b/specifyweb/workbench/upload/tests/example_plan.py @@ -269,4 +269,4 @@ ) def with_scoping(collection) -> ScopedUploadTable: - return upload_plan.apply_scoping(collection)[1] + return upload_plan.apply_scoping(collection) diff --git a/specifyweb/workbench/upload/tests/test_bugs.py b/specifyweb/workbench/upload/tests/test_bugs.py index 2a95bde2d56..bae3344f247 100644 --- a/specifyweb/workbench/upload/tests/test_bugs.py +++ b/specifyweb/workbench/upload/tests/test_bugs.py @@ -12,10 +12,8 @@ from .base import UploadTestsBase from specifyweb.specify.tests.test_api import get_table -from django.conf import settings -class BugTests(UploadTestsBase): - @expectedFailure # FIX ME +class BugTests(UploadTestsBase): def test_bogus_null_record(self) -> None: Taxon = get_table('Taxon') life = Taxon.objects.create(name='Life', definitionitem=self.taxontreedef.treedefitems.get(name='Taxonomy Root')) @@ -45,9 +43,9 @@ def test_bogus_null_record(self) -> None: row = ["", "Fundulus", "olivaceus"] - up = parse_plan(plan).apply_scoping(self.collection)[1] + up = parse_plan(plan).apply_scoping(self.collection) - result = validate_row(self.collection, up, self.agent.id, dict(zip(cols, row)), None, session_url=settings.SA_TEST_DB_URL) + result = validate_row(self.collection, up, self.agent.id, dict(zip(cols, row)), None) self.assertNotIsInstance(result.record_result, NullRecord, "The CO should be created b/c it has determinations.") def test_duplicate_refworks(self) -> None: @@ -170,6 +168,6 @@ def test_duplicate_refworks(self) -> None: } } ''')) - upload_results = do_upload_csv(self.collection, reader, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) + upload_results = do_upload_csv(self.collection, reader, plan, self.agent.id) rr = [r.record_result.__class__ for r in upload_results] self.assertEqual(expected, rr) diff --git a/specifyweb/workbench/upload/tests/test_upload_results_json.py b/specifyweb/workbench/upload/tests/test_upload_results_json.py index 539421b8fb5..f6a38405589 100644 --- a/specifyweb/workbench/upload/tests/test_upload_results_json.py +++ b/specifyweb/workbench/upload/tests/test_upload_results_json.py @@ -14,37 +14,62 @@ def test_schema_valid(self) -> None: @given(uploaded=infer) def testUploaded(self, uploaded: Uploaded): j = json.dumps(uploaded.to_json()) - self.assertEqual(uploaded, json_to_Uploaded(json.loads(j))) + self.assertEqual(uploaded, Uploaded.from_json(json.loads(j))) @given(matched=infer) def testMatched(self, matched: Matched): j = json.dumps(matched.to_json()) - self.assertEqual(matched, json_to_Matched(json.loads(j))) + self.assertEqual(matched, Matched.from_json(json.loads(j))) @given(matchedMultiple=infer) def testMatchedMultiple(self, matchedMultiple: MatchedMultiple): j = json.dumps(matchedMultiple.to_json()) - self.assertEqual(matchedMultiple, json_to_MatchedMultiple(json.loads(j))) + self.assertEqual(matchedMultiple, MatchedMultiple.from_json(json.loads(j))) @given(nullRecord=infer) def testNullRecord(self, nullRecord: NullRecord): j = json.dumps(nullRecord.to_json()) - self.assertEqual(nullRecord, json_to_NullRecord(json.loads(j))) + self.assertEqual(nullRecord, NullRecord.from_json(json.loads(j))) @given(failedBusinessRule=infer) def testFailedBusinessRule(self, failedBusinessRule: FailedBusinessRule): j = json.dumps(failedBusinessRule.to_json()) - self.assertEqual(failedBusinessRule, json_to_FailedBusinessRule(json.loads(j))) + self.assertEqual(failedBusinessRule, FailedBusinessRule.from_json(json.loads(j))) @given(noMatch=infer) def testNoMatch(self, noMatch: NoMatch): j = json.dumps(noMatch.to_json()) - self.assertEqual(noMatch, json_to_NoMatch(json.loads(j))) + self.assertEqual(noMatch, NoMatch.from_json(json.loads(j))) @given(parseFailures=infer) def testParseFailures(self, parseFailures: ParseFailures): j = json.dumps(parseFailures.to_json()) - self.assertEqual(parseFailures, json_to_ParseFailures(json.loads(j))) + self.assertEqual(parseFailures, ParseFailures.from_json(json.loads(j))) + + @given(noChange=infer) + def testParseFailures(self, noChange: NoChange): + j = json.dumps(noChange.to_json()) + self.assertEqual(noChange, NoChange.from_json(json.loads(j))) + + @given(noChange=infer) + def testParseFailures(self, noChange: NoChange): + j = json.dumps(noChange.to_json()) + self.assertEqual(noChange, NoChange.from_json(json.loads(j))) + + @given(updated=infer) + def testUploaded(self, updated: Updated): + j = json.dumps(updated.to_json()) + self.assertEqual(updated, Updated.from_json(json.loads(j))) + + @given(deleted=infer) + def testUploaded(self, deleted: Deleted): + j = json.dumps(deleted.to_json()) + self.assertEqual(deleted, Deleted.from_json(json.loads(j))) + + @given(matchedAndChanged=infer) + def testUploaded(self, matchedAndChanged: MatchedAndChanged): + j = json.dumps(matchedAndChanged.to_json()) + self.assertEqual(matchedAndChanged, MatchedAndChanged.from_json(json.loads(j))) @settings(suppress_health_check=[HealthCheck.too_slow]) @given(record_result=infer, toOne=infer, toMany=infer) @@ -58,7 +83,7 @@ def testUploadResult(self, record_result: RecordResult, toOne: Dict[str, RecordR j = json.dumps(d) e = json.loads(j) validate([e], schema) - self.assertEqual(uploadResult, json_to_UploadResult(e)) + self.assertEqual(uploadResult, UploadResult.from_json(e)) def testUploadResultExplicit(self): failed_bussiness_rule: FailedBusinessRule = FailedBusinessRule( @@ -88,5 +113,5 @@ def testUploadResultExplicit(self): j = json.dumps(d) e = json.loads(j) validate([e], schema) - self.assertEqual(uploadResult, json_to_UploadResult(e)) + self.assertEqual(uploadResult, UploadResult.from_json(e)) diff --git a/specifyweb/workbench/upload/tests/testdisambiguation.py b/specifyweb/workbench/upload/tests/testdisambiguation.py index 1e4f58501c0..ad50662dddf 100644 --- a/specifyweb/workbench/upload/tests/testdisambiguation.py +++ b/specifyweb/workbench/upload/tests/testdisambiguation.py @@ -77,7 +77,7 @@ def test_disambiguation(self) -> None: {'title': "A Natural History of Mung Beans 3", 'author1': "Mungophilius", 'author2': "Mungophilius"}, ] - results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) + results = do_upload(self.collection, data, plan, self.agent.id) for result in results: assert result.contains_failure() @@ -87,7 +87,7 @@ def test_disambiguation(self) -> None: DisambiguationInfo({("authors", "#1", "agent"): senior.id, ("authors", "#2", "agent"): junior.id}), ] - results = do_upload(self.collection, data, plan, self.agent.id, disambiguations, session_url=settings.SA_TEST_DB_URL) + results = do_upload(self.collection, data, plan, self.agent.id, disambiguations) for result in results: assert not result.contains_failure() @@ -125,9 +125,9 @@ def test_disambiguate_taxon(self) -> None: cols = ["Cat #", "Genus", "Species"] row = ["123", "Fundulus", "olivaceus"] - up = parse_plan(plan).apply_scoping(self.collection)[1] + up = parse_plan(plan).apply_scoping(self.collection) - result = validate_row(self.collection, up, self.agent.id, dict(zip(cols, row)), None, session_url=settings.SA_TEST_DB_URL) + result = validate_row(self.collection, up, self.agent.id, dict(zip(cols, row)), None) taxon_result = result.toMany['determinations'][0].toOne['taxon'].record_result assert isinstance(taxon_result, MatchedMultiple) self.assertEqual(set(taxon_result.ids), {fundulus1.id, fundulus2.id}) @@ -135,7 +135,7 @@ def test_disambiguate_taxon(self) -> None: da_row = ["123", "Fundulus", "olivaceus", "{\"disambiguation\":{\"determinations.#1.taxon.$Genus\":%d}}" % fundulus1.id] da = get_disambiguation_from_row(len(cols), da_row) - result = validate_row(self.collection, up, self.agent.id, dict(zip(cols, row)), da, session_url=settings.SA_TEST_DB_URL) + result = validate_row(self.collection, up, self.agent.id, dict(zip(cols, row)), da) taxon_result = result.toMany['determinations'][0].toOne['taxon'].toOne['parent'].record_result assert isinstance(taxon_result, Matched) self.assertEqual(fundulus1.id, taxon_result.id) @@ -168,9 +168,9 @@ def test_disambiguate_taxon_deleted(self) -> None: cols = ["Cat #", "Genus", "Species"] row = ["123", "Fundulus", "olivaceus"] - up = parse_plan(plan).apply_scoping(self.collection)[1] + up = parse_plan(plan).apply_scoping(self.collection) - result = validate_row(self.collection, up, self.agent.id, dict(zip(cols, row)), None, session_url=settings.SA_TEST_DB_URL) + result = validate_row(self.collection, up, self.agent.id, dict(zip(cols, row)), None) taxon_result = result.toMany['determinations'][0].toOne['taxon'].record_result assert isinstance(taxon_result, MatchedMultiple) self.assertEqual(set(taxon_result.ids), {fundulus1.id, fundulus2.id}) @@ -181,7 +181,7 @@ def test_disambiguate_taxon_deleted(self) -> None: da = get_disambiguation_from_row(len(cols), da_row) - result = validate_row(self.collection, up, self.agent.id, dict(zip(cols, row)), da, session_url=settings.SA_TEST_DB_URL) + result = validate_row(self.collection, up, self.agent.id, dict(zip(cols, row)), da) taxon_result = result.toMany['determinations'][0].toOne['taxon'].toOne['parent'].record_result assert isinstance(taxon_result, Matched) self.assertEqual(fundulus2.id, taxon_result.id) @@ -209,9 +209,9 @@ def test_disambiguate_agent_deleted(self) -> None: cols = ["Cat #", "Cat last"] row = ["123", "Bentley"] - up = parse_plan(plan).apply_scoping(self.collection)[1] + up = parse_plan(plan).apply_scoping(self.collection) - result = validate_row(self.collection, up, self.agent.id, dict(zip(cols, row)), None, session_url=settings.SA_TEST_DB_URL) + result = validate_row(self.collection, up, self.agent.id, dict(zip(cols, row)), None) agent_result = result.toOne['cataloger'].record_result assert isinstance(agent_result, MatchedMultiple) self.assertEqual(set(agent_result.ids), {andy.id, bogus.id}) @@ -220,14 +220,14 @@ def test_disambiguate_agent_deleted(self) -> None: da = get_disambiguation_from_row(len(cols), da_row) - result = validate_row(self.collection, up, self.agent.id, dict(zip(cols, row)), da, session_url=settings.SA_TEST_DB_URL) + result = validate_row(self.collection, up, self.agent.id, dict(zip(cols, row)), da) agent_result = result.toOne['cataloger'].record_result assert isinstance(agent_result, Matched) self.assertEqual(bogus.id, agent_result.id) bogus.delete() - result = validate_row(self.collection, up, self.agent.id, dict(zip(cols, row)), da, session_url=settings.SA_TEST_DB_URL) + result = validate_row(self.collection, up, self.agent.id, dict(zip(cols, row)), da) assert not result.contains_failure() agent_result = result.toOne['cataloger'].record_result diff --git a/specifyweb/workbench/upload/tests/testmustmatch.py b/specifyweb/workbench/upload/tests/testmustmatch.py index 216be58c335..9b46942ec27 100644 --- a/specifyweb/workbench/upload/tests/testmustmatch.py +++ b/specifyweb/workbench/upload/tests/testmustmatch.py @@ -39,7 +39,7 @@ def upload_some_geography(self) -> None: dict(name="Douglas Co. KS", Continent="North America", Country="USA", State="Kansas", County="Douglas"), dict(name="Greene Co. MO", Continent="North America", Country="USA", State="Missouri", County="Greene") ] - results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) + results = do_upload(self.collection, data, plan, self.agent.id) for r in results: assert isinstance(r.record_result, Uploaded) @@ -102,7 +102,7 @@ def test_mustmatchtree(self) -> None: dict(name="Douglas Co. KS", Continent="North America", Country="USA", State="Kansas", County="Douglas"), dict(name="Emerald City", Continent="North America", Country="USA", State="Kansas", County="Oz"), ] - results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) + results = do_upload(self.collection, data, plan, self.agent.id) self.assertIsInstance(results[0].record_result, Uploaded) self.assertNotIsInstance(results[1].record_result, Uploaded) self.assertIsInstance(results[1].toOne['geography'].record_result, NoMatch) @@ -128,7 +128,7 @@ def test_mustmatch_uploading(self) -> None: starting_ce_count = get_table('Collectingevent').objects.count() starting_co_count = get_table('Collectionobject').objects.count() - results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) + results = do_upload(self.collection, data, plan, self.agent.id) for r, expected in zip(results, [Matched, NoMatch, Matched, NoMatch]): self.assertIsInstance(r.toOne['collectingevent'].record_result, expected) @@ -151,7 +151,7 @@ def test_mustmatch_with_null(self) -> None: ce_count_before_upload = get_table('Collectingevent').objects.count() - results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) + results = do_upload(self.collection, data, plan, self.agent.id) ces = set() for r, expected in zip(results, [Matched, NoMatch, NullRecord, Matched, NoMatch]): self.assertIsInstance(r.toOne['collectingevent'].record_result, expected) diff --git a/specifyweb/workbench/upload/tests/testnestedtomany.py b/specifyweb/workbench/upload/tests/testnestedtomany.py index dc1e3a97af2..a3a30727d08 100644 --- a/specifyweb/workbench/upload/tests/testnestedtomany.py +++ b/specifyweb/workbench/upload/tests/testnestedtomany.py @@ -207,7 +207,7 @@ def test_basic_uploading(self) -> None: ) ] - results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) + results = do_upload(self.collection, data, plan, self.agent.id) expected_results = [(Uploaded, -1), (Uploaded, -1), (Uploaded, -1), (Uploaded, -1), (Matched, 3), (Matched, 1)] for r, (e, check) in zip(results, expected_results): self.assertIsInstance(r.record_result, e) diff --git a/specifyweb/workbench/upload/tests/testonetoone.py b/specifyweb/workbench/upload/tests/testonetoone.py index a97a1319d81..30f89116fa9 100644 --- a/specifyweb/workbench/upload/tests/testonetoone.py +++ b/specifyweb/workbench/upload/tests/testonetoone.py @@ -62,7 +62,7 @@ def test_onetoone_uploading(self) -> None: # dict(catno='4', sfn='2'), # This fails because the CE has multiple matches ] - results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) + results = do_upload(self.collection, data, plan, self.agent.id) ces = set() for r in results: assert isinstance(r.record_result, Uploaded), r @@ -83,7 +83,7 @@ def test_manytoone_uploading(self) -> None: dict(catno='4', sfn='2'), ] - results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) + results = do_upload(self.collection, data, plan, self.agent.id) ces = set() for r, expected in zip(results, [Uploaded, Matched, Uploaded, Matched, Matched]): assert isinstance(r.record_result, Uploaded) @@ -105,7 +105,7 @@ def test_onetoone_with_null(self) -> None: ce_count_before_upload = get_table('Collectingevent').objects.count() - results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) + results = do_upload(self.collection, data, plan, self.agent.id) ces = set() for r, expected in zip(results, [Uploaded, Uploaded, Uploaded, NullRecord, NullRecord]): assert isinstance(r.record_result, Uploaded) diff --git a/specifyweb/workbench/upload/tests/testparsing.py b/specifyweb/workbench/upload/tests/testparsing.py index 6f1d0d884ff..ed4940e79d8 100644 --- a/specifyweb/workbench/upload/tests/testparsing.py +++ b/specifyweb/workbench/upload/tests/testparsing.py @@ -161,7 +161,7 @@ def test_nonreadonly_picklist(self) -> None: self.assertEqual(0, get_table('Spauditlog').objects.filter(tablenum=get_table('Picklistitem').specify_model.tableId).count(), "No picklistitems in audit log yet.") - results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) + results = do_upload(self.collection, data, plan, self.agent.id) for result in results: validate([result.to_json()], upload_results_schema) self.assertIsInstance(result.record_result, Uploaded) @@ -200,7 +200,7 @@ def test_uiformatter_match(self) -> None: {'catno': 'bar'}, {'catno': '567'}, ] - results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) + results = do_upload(self.collection, data, plan, self.agent.id) for result, expected in zip(results, [Uploaded, Uploaded, ParseFailures, ParseFailures, Uploaded]): self.assertIsInstance(result.record_result, expected) @@ -227,7 +227,7 @@ def test_numeric_types(self) -> None: {'catno': '5', 'bool': 'true', 'integer': '10', 'float': '24.5', 'decimal': '10.23bogus'}, {'catno': '6', 'bool': 'true', 'integer': '10.5', 'float': '24.5', 'decimal': '10.23'}, ] - results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) + results = do_upload(self.collection, data, plan, self.agent.id) self.assertIsInstance(results[0].record_result, Uploaded) for result in results[1:]: self.assertIsInstance(result.record_result, ParseFailures) @@ -248,7 +248,7 @@ def test_required_field(self) -> None: {'catno': '', 'habitat': ''}, {'catno': '5', 'habitat': 'Lagoon'}, ] - results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) + results = do_upload(self.collection, data, plan, self.agent.id) for result, expected in zip(results, [Uploaded, ParseFailures, ParseFailures, NullRecord, Uploaded]): self.assertIsInstance(result.record_result, expected) @@ -269,7 +269,7 @@ def test_readonly_picklist(self) -> None: {'title': "Dr.", 'lastname': 'Zoidberg'}, {'title': "Hon.", 'lastname': 'Juju'}, ] - results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) + results = do_upload(self.collection, data, plan, self.agent.id) result0 = results[0].record_result assert isinstance(result0, Uploaded) @@ -327,7 +327,7 @@ def test_parsing_errors_reported(self) -> None: 1367,Gastropoda,Fissurelloidea,Fissurellidae,Emarginula,,sicula,,"J.E. Gray, 1825",,,,,,, , ,,USA,Foobar,,[Lat-long site],Gulf of Mexico,NW Atlantic O.,Date unk'n,foobar,,,,1,0,0,Dry; shell,Dry,,,In coral rubble,57,65,0,,,,313,,,JSG,MJP,22/01/2003,28° 06.07' N,,91° 02.42' W,,Point,D-7(1),JSG,19/06/2003,0,Marine,0,Emilio Garcia,,Emilio,,Garcia,,,,,,,,,,,, 1368,Gastropoda,Fissurelloidea,Fissurellidae,Emarginula,,tuberculosa,,"Libassi, 1859",,Emilio Garcia,,Emilio,,Garcia,Jan 2002,00/01/2002,,USA,LOUISIANA,off Louisiana coast,[Lat-long site],Gulf of Mexico,NW Atlantic O.,Date unk'n,,,,,11,0,0,Dry; shell,Dry,,,"Subtidal 65-91 m, in coralline [sand]",65,91,0,,,,313,,Dredged. Original label no. 23331.,JSG,MJP,22/01/2003,27° 59.14' N,,91° 38.83' W,,Point,D-4(1),JSG,19/06/2003,0,Marine,0,Emilio Garcia,,Emilio,,Garcia,,,,,,,,,,,, ''')) - upload_results = do_upload_csv(self.collection, reader, self.example_plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) + upload_results = do_upload_csv(self.collection, reader, self.example_plan, self.agent.id) failed_result = upload_results[2] self.assertIsInstance(failed_result.record_result, ParseFailures) for result in upload_results: @@ -341,7 +341,7 @@ def test_multiple_parsing_errors_reported(self) -> None: '''BMSM No.,Class,Superfamily,Family,Genus,Subgenus,Species,Subspecies,Species Author,Subspecies Author,Who ID First Name,Determiner 1 Title,Determiner 1 First Name,Determiner 1 Middle Initial,Determiner 1 Last Name,ID Date Verbatim,ID Date,ID Status,Country,State/Prov/Pref,Region,Site,Sea Basin,Continent/Ocean,Date Collected,Start Date Collected,End Date Collected,Collection Method,Verbatim Collecting method,No. of Specimens,Live?,W/Operc,Lot Description,Prep Type 1,- Paired valves,for bivalves - Single valves,Habitat,Min Depth (M),Max Depth (M),Fossil?,Stratum,Sex / Age,Lot Status,Accession No.,Original Label,Remarks,Processed by,Cataloged by,DateCataloged,Latitude1,Latitude2,Longitude1,Longitude2,Lat Long Type,Station No.,Checked by,Label Printed,Not for publication on Web,Realm,Estimated,Collected Verbatim,Collector 1 Title,Collector 1 First Name,Collector 1 Middle Initial,Collector 1 Last Name,Collector 2 Title,Collector 2 First Name,Collector 2 Middle Initial,Collector 2 Last name,Collector 3 Title,Collector 3 First Name,Collector 3 Middle Initial,Collector 3 Last Name,Collector 4 Title,Collector 4 First Name,Collector 4 Middle Initial,Collector 4 Last Name 1367,Gastropoda,Fissurelloidea,Fissurellidae,Emarginula,,sicula,,"J.E. Gray, 1825",,,,,,, ,bad date,,USA,Foobar,,[Lat-long site],Gulf of Mexico,NW Atlantic O.,Date unk'n,foobar,,,,1,0,0,Dry; shell,Dry,,,In coral rubble,57,65,0,,,,313,,,JSG,MJP,22/01/2003,28° 06.07' N,,91° 02.42' W,,Point,D-7(1),JSG,19/06/2003,0,Marine,0,Emilio Garcia,,Emilio,,Garcia,,,,,,,,,,,, ''')) - upload_results = do_upload_csv(self.collection, reader, self.example_plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) + upload_results = do_upload_csv(self.collection, reader, self.example_plan, self.agent.id) failed_result = upload_results[0].record_result self.assertIsInstance(failed_result, ParseFailures) assert isinstance(failed_result, ParseFailures) # make typechecker happy @@ -352,7 +352,7 @@ def test_out_of_range_lat_long(self) -> None: '''BMSM No.,Class,Superfamily,Family,Genus,Subgenus,Species,Subspecies,Species Author,Subspecies Author,Who ID First Name,Determiner 1 Title,Determiner 1 First Name,Determiner 1 Middle Initial,Determiner 1 Last Name,ID Date Verbatim,ID Date,ID Status,Country,State/Prov/Pref,Region,Site,Sea Basin,Continent/Ocean,Date Collected,Start Date Collected,End Date Collected,Collection Method,Verbatim Collecting method,No. of Specimens,Live?,W/Operc,Lot Description,Prep Type 1,- Paired valves,for bivalves - Single valves,Habitat,Min Depth (M),Max Depth (M),Fossil?,Stratum,Sex / Age,Lot Status,Accession No.,Original Label,Remarks,Processed by,Cataloged by,DateCataloged,Latitude1,Latitude2,Longitude1,Longitude2,Lat Long Type,Station No.,Checked by,Label Printed,Not for publication on Web,Realm,Estimated,Collected Verbatim,Collector 1 Title,Collector 1 First Name,Collector 1 Middle Initial,Collector 1 Last Name,Collector 2 Title,Collector 2 First Name,Collector 2 Middle Initial,Collector 2 Last name,Collector 3 Title,Collector 3 First Name,Collector 3 Middle Initial,Collector 3 Last Name,Collector 4 Title,Collector 4 First Name,Collector 4 Middle Initial,Collector 4 Last Name 1367,Gastropoda,Fissurelloidea,Fissurellidae,Emarginula,,sicula,,"J.E. Gray, 1825",,,,,,, ,,,USA,,,[Lat-long site],Gulf of Mexico,NW Atlantic O.,Date unk'n,,,,,1,0,0,Dry; shell,Dry,,,In coral rubble,57,65,0,,,,313,,,JSG,MJP,22/01/2003,128° 06.07' N,,191° 02.42' W,,Point,D-7(1),JSG,19/06/2003,0,Marine,0,Emilio Garcia,,Emilio,,Garcia,,,,,,,,,,,, ''')) - upload_results = do_upload_csv(self.collection, reader, self.example_plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) + upload_results = do_upload_csv(self.collection, reader, self.example_plan, self.agent.id) failed_result = upload_results[0].record_result self.assertIsInstance(failed_result, ParseFailures) assert isinstance(failed_result, ParseFailures) # make typechecker happy @@ -377,7 +377,7 @@ def test_agent_type(self) -> None: {'agenttype': "other", 'lastname': 'Juju'}, {'agenttype': "group", 'lastname': 'Van Halen'}, ] - results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) + results = do_upload(self.collection, data, plan, self.agent.id) result0 = results[0].record_result assert isinstance(result0, Uploaded) @@ -411,7 +411,7 @@ def test_tree_cols_without_name(self) -> None: {'Genus': 'Eupatorium', 'Species': 'serotinum', 'Species Author': 'Michx.'}, {'Genus': 'Eupatorium', 'Species': '', 'Species Author': 'L.'}, ] - results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) + results = do_upload(self.collection, data, plan, self.agent.id) self.assertIsInstance(results[0].record_result, Uploaded) self.assertEqual(results[1].record_result, ParseFailures(failures=[WorkBenchParseFailure(message='invalidPartialRecord', payload={'column':'Species'}, column='Species Author')])) @@ -429,7 +429,7 @@ def test_value_too_long(self) -> None: {'Genus': 'Eupatorium', 'Species': 'barelyfits', 'Species Author': 'x'*128}, {'Genus': 'Eupatorium', 'Species': 'toolong', 'Species Author': 'x'*129}, ] - results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) + results = do_upload(self.collection, data, plan, self.agent.id) self.assertIsInstance(results[0].record_result, Uploaded) self.assertIsInstance(results[1].record_result, Uploaded) @@ -452,7 +452,7 @@ def test_tree_cols_with_ignoreWhenBlank(self) -> None: {'Genus': 'Eupatorium', 'Species': 'serotinum', 'Species Author': ''}, {'Genus': 'Eupatorium', 'Species': 'serotinum', 'Species Author': 'Bogus'}, ] - results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) + results = do_upload(self.collection, data, plan, self.agent.id) self.assertIsInstance(results[0].record_result, Uploaded) self.assertIsInstance(results[1].record_result, Matched, "Second record matches first despite blank author.") @@ -475,7 +475,7 @@ def test_higher_tree_cols_with_ignoreWhenBlank(self) -> None: {'Genus': 'Eupatorium', 'Species': 'serotinum', 'Species Author': '', 'Subspecies': 'a'}, {'Genus': 'Eupatorium', 'Species': 'serotinum', 'Species Author': 'Bogus', 'Subspecies': 'a'}, ] - results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) + results = do_upload(self.collection, data, plan, self.agent.id) self.assertIsInstance(results[0].record_result, Uploaded) self.assertIsInstance(results[1].record_result, Matched, "Second record matches first despite blank author.") @@ -496,7 +496,7 @@ def test_tree_cols_with_ignoreNever(self) -> None: {'Genus': 'Eupatorium', 'Species': 'serotinum', 'Species Author': ''}, {'Genus': 'Eupatorium', 'Species': 'serotinum', 'Species Author': 'Bogus'}, ] - results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) + results = do_upload(self.collection, data, plan, self.agent.id) self.assertIsInstance(results[0].record_result, Uploaded) self.assertIsInstance(results[1].record_result, Uploaded, "Second record doesn't match first due to blank author.") @@ -517,7 +517,7 @@ def test_tree_cols_with_required(self) -> None: {'Genus': 'Eupatorium', 'Species': 'serotinum', 'Species Author': 'Bogus'}, {'Genus': 'Eupatorium', 'Species': '', 'Species Author': ''}, ] - results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) + results = do_upload(self.collection, data, plan, self.agent.id) self.assertIsInstance(results[0].record_result, Uploaded) self.assertIsInstance(results[1].record_result, ParseFailures, "Second record fails due to blank author.") @@ -538,7 +538,7 @@ def test_tree_cols_with_ignoreAlways(self) -> None: {'Genus': 'Eupatorium', 'Species': 'serotinum', 'Species Author': 'Bogus'}, {'Genus': 'Eupatorium', 'Species': 'serotinum', 'Species Author': ''}, ] - results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) + results = do_upload(self.collection, data, plan, self.agent.id) self.assertIsInstance(results[0].record_result, Uploaded) self.assertIsInstance(results[1].record_result, Matched, "Second record matches first despite different author.") @@ -563,7 +563,7 @@ def test_wbcols_with_ignoreWhenBlank(self) -> None: {'lastname': 'Doe', 'firstname': ''}, {'lastname': 'Doe', 'firstname': 'Stream'}, ] - results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) + results = do_upload(self.collection, data, plan, self.agent.id) for result in results: validate([result.to_json()], upload_results_schema) @@ -590,7 +590,7 @@ def test_wbcols_with_ignoreWhenBlank_and_default(self) -> None: {'lastname': 'Doe', 'firstname': 'Stream'}, {'lastname': 'Smith', 'firstname': ''}, ] - results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) + results = do_upload(self.collection, data, plan, self.agent.id) for result in results: validate([result.to_json()], upload_results_schema) @@ -621,7 +621,7 @@ def test_wbcols_with_ignoreNever(self) -> None: {'lastname': 'Doe', 'firstname': ''}, {'lastname': 'Doe', 'firstname': 'Stream'}, ] - results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) + results = do_upload(self.collection, data, plan, self.agent.id) for result in results: validate([result.to_json()], upload_results_schema) @@ -646,7 +646,7 @@ def test_wbcols_with_ignoreAlways(self) -> None: {'lastname': 'Doe', 'firstname': ''}, {'lastname': 'Doe', 'firstname': 'Stream'}, ] - results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) + results = do_upload(self.collection, data, plan, self.agent.id) for result in results: validate([result.to_json()], upload_results_schema) @@ -674,7 +674,7 @@ def test_wbcols_with_default(self) -> None: {'lastname': 'Doe', 'firstname': ''}, {'lastname': 'Doe', 'firstname': 'Stream'}, ] - results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) + results = do_upload(self.collection, data, plan, self.agent.id) for result in results: validate([result.to_json()], upload_results_schema) @@ -703,7 +703,7 @@ def test_wbcols_with_default_matching(self) -> None: {'lastname': 'Doe', 'firstname': ''}, {'lastname': 'Doe', 'firstname': 'Stream'}, ] - results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) + results = do_upload(self.collection, data, plan, self.agent.id) for result in results: validate([result.to_json()], upload_results_schema) @@ -733,7 +733,7 @@ def test_wbcols_with_default_and_null_disallowed(self) -> None: {'lastname': 'Doe', 'firstname': ''}, {'lastname': 'Doe', 'firstname': 'Stream'}, ] - results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) + results = do_upload(self.collection, data, plan, self.agent.id) for result in results: validate([result.to_json()], upload_results_schema) @@ -762,7 +762,7 @@ def test_wbcols_with_default_blank(self) -> None: {'lastname': 'Doe', 'firstname': ''}, {'lastname': 'Doe', 'firstname': 'Stream'}, ] - results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) + results = do_upload(self.collection, data, plan, self.agent.id) for result in results: validate([result.to_json()], upload_results_schema) @@ -792,7 +792,7 @@ def test_wbcols_with_null_disallowed(self) -> None: {'lastname': 'Doe', 'firstname': ''}, {'lastname': 'Doe', 'firstname': 'Stream'}, ] - results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) + results = do_upload(self.collection, data, plan, self.agent.id) for result in results: validate([result.to_json()], upload_results_schema) @@ -819,7 +819,7 @@ def test_wbcols_with_null_disallowed_and_ignoreWhenBlank(self) -> None: {'lastname': 'Doe1', 'firstname': ''}, {'lastname': 'Doe1', 'firstname': 'Stream'}, ] - results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) + results = do_upload(self.collection, data, plan, self.agent.id) for result in results: validate([result.to_json()], upload_results_schema) @@ -848,7 +848,7 @@ def test_wbcols_with_null_disallowed_and_ignoreAlways(self) -> None: {'lastname': 'Doe1', 'firstname': ''}, {'lastname': 'Doe1', 'firstname': 'Stream'}, ] - results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) + results = do_upload(self.collection, data, plan, self.agent.id) for result in results: validate([result.to_json()], upload_results_schema) diff --git a/specifyweb/workbench/upload/tests/testschema.py b/specifyweb/workbench/upload/tests/testschema.py index d8e0ef19057..7818942e053 100644 --- a/specifyweb/workbench/upload/tests/testschema.py +++ b/specifyweb/workbench/upload/tests/testschema.py @@ -17,7 +17,7 @@ class SchemaTests(UploadTestsBase): def test_schema_parsing(self) -> None: Draft7Validator.check_schema(schema) validate(example_plan.json, schema) - plan = parse_plan(example_plan.json).apply_scoping(self.collection)[1] # keeping this same + plan = parse_plan(example_plan.json).apply_scoping(self.collection) # keeping this same # have to test repr's here because NamedTuples of different # types can be equal if their fields are equal. self.assertEqual(repr(plan), repr(self.example_plan_scoped)) diff --git a/specifyweb/workbench/upload/tests/testscoping.py b/specifyweb/workbench/upload/tests/testscoping.py index 50a44fde5de..c5fb882e58b 100644 --- a/specifyweb/workbench/upload/tests/testscoping.py +++ b/specifyweb/workbench/upload/tests/testscoping.py @@ -1,3 +1,4 @@ +from specifyweb.specify.func import Func from ..upload_plan_schema import schema, parse_plan from ..upload_table import UploadTable, OneToOneTable, ScopedUploadTable, ScopedOneToOneTable, ColumnOptions, ExtendedColumnOptions from ..upload import do_upload @@ -81,7 +82,7 @@ def test_embedded_collectingevent(self) -> None: self.assertNotIsInstance(ce_rel, OneToOneTable) - scoped = plan.apply_scoping(self.collection)[1] + scoped = plan.apply_scoping(self.collection) assert isinstance(scoped, ScopedUploadTable) scoped_ce_rel = scoped.toOne['collectingevent'] @@ -106,19 +107,19 @@ def test_embedded_paleocontext_in_collectionobject(self) -> None: wbcols={}, static={}, toMany={}, - ).apply_scoping(self.collection)[1] + ).apply_scoping(self.collection) self.assertIsInstance(plan.toOne['paleocontext'], ScopedOneToOneTable) def test_caching_scoped_false(self) -> None: - - plan = parse_plan(self.collection_rel_plan).apply_scoping(self.collection) - - self.assertFalse(plan[0], 'contains collection relationship, should never be cached') + generator = Func.make_generator() + plan = Func.tap_call(lambda: parse_plan(self.collection_rel_plan).apply_scoping(self.collection, generator), generator) + self.assertTrue(plan[0], 'contains collection relationship, should never be cached') def test_caching_true(self): - plan = self.example_plan.apply_scoping(self.collection) - self.assertTrue(plan[0], 'caching is possible here, since no dynamic scope is being used') + generator = Func.make_generator() + plan = Func.tap_call(lambda: self.example_plan.apply_scoping(self.collection), generator) + self.assertFalse(plan[0], 'caching is possible here, since no dynamic scope is being used') def test_collection_rel_uploaded_in_correct_collection(self): scoped_plan = parse_plan(self.collection_rel_plan) @@ -126,7 +127,7 @@ def test_collection_rel_uploaded_in_correct_collection(self): {'Collection Rel Type': self.rel_type_name, 'Cat # (2)': '999', 'Cat #': '23'}, {'Collection Rel Type': self.rel_type_name, 'Cat # (2)': '888', 'Cat #': '32'} ] - result = do_upload(self.collection, rows, scoped_plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) + result = do_upload(self.collection, rows, scoped_plan, self.agent.id) left_side_cat_nums = [n.zfill(9) for n in '32 23'.split()] right_side_cat_nums = '999 888'.split() diff --git a/specifyweb/workbench/upload/tests/testunupload.py b/specifyweb/workbench/upload/tests/testunupload.py index 0018aa5bef6..2c1e0fcca49 100644 --- a/specifyweb/workbench/upload/tests/testunupload.py +++ b/specifyweb/workbench/upload/tests/testunupload.py @@ -64,7 +64,7 @@ def test_unupload_picklist(self) -> None: ).count(), "No picklistitems in audit log yet.") - results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) + results = do_upload(self.collection, data, plan, self.agent.id) self.assertEqual(3, get_table('Picklistitem').objects.filter(picklist__name='Habitat').count(), "There are now three items in the picklist.") @@ -121,7 +121,7 @@ def test_unupload_tree(self) -> None: ).count(), "No geography in audit log yet.") - results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) + results = do_upload(self.collection, data, plan, self.agent.id) self.assertEqual(9, get_table('Spauditlog').objects.filter( @@ -220,6 +220,6 @@ def test_tricky_sequencing(self) -> None: {'catno': '1', 'cataloger': 'Doe', 'collector': 'Doe'}, ] - results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) + results = do_upload(self.collection, data, plan, self.agent.id) for result in reversed(results): unupload_record(result, self.agent) diff --git a/specifyweb/workbench/upload/tests/testuploading.py b/specifyweb/workbench/upload/tests/testuploading.py index 1fbfb188cf7..901f99b665a 100644 --- a/specifyweb/workbench/upload/tests/testuploading.py +++ b/specifyweb/workbench/upload/tests/testuploading.py @@ -49,7 +49,7 @@ def test_enforced_state(self) -> None: {'County': 'Johnson', 'City': 'Olathe'}, {'County': 'Johnson', 'City': 'Olathe'}, ] - results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) + results = do_upload(self.collection, data, plan, self.agent.id) self.assertIsInstance(results[0].record_result, Uploaded) self.assertIsInstance(results[1].record_result, Matched) self.assertEqual(results[0].get_id(), results[1].get_id()) @@ -79,7 +79,7 @@ def test_enforced_county(self) -> None: {'State': 'Texas', 'City': 'Austin'}, {'State': 'Missouri', 'City': 'Columbia'}, ] - results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) + results = do_upload(self.collection, data, plan, self.agent.id) self.assertEqual( results[0].record_result, FailedBusinessRule( @@ -113,7 +113,7 @@ def test_match_skip_level(self) -> None: {'State': 'Missouri', 'City': 'Springfield'}, {'State': 'Illinois', 'City': 'Springfield'}, ] - results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) + results = do_upload(self.collection, data, plan, self.agent.id) for r in results: self.assertIsInstance(r.record_result, Matched) self.assertEqual(self.springmo.id, results[0].record_result.get_id()) @@ -134,7 +134,7 @@ def test_match_multiple(self) -> None: {'City': 'Springfield'}, {'City': 'Springfield'}, ] - results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) + results = do_upload(self.collection, data, plan, self.agent.id) for r in results: assert isinstance(r.record_result, MatchedMultiple) self.assertEqual(set([self.springmo.id, self.springill.id]), set(r.record_result.ids)) @@ -155,7 +155,7 @@ def test_match_higher(self) -> None: data = [ {'State': 'Missouri', 'County': 'Greene', 'City': 'Springfield'}, ] - results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) + results = do_upload(self.collection, data, plan, self.agent.id) for r in results: assert isinstance(r.record_result, Matched) self.assertEqual(self.springmo.id, r.record_result.id) @@ -176,7 +176,7 @@ def test_match_uploaded(self) -> None: {'County': 'Johnson', 'City': 'Olathe'}, {'County': 'Johnson', 'City': 'Olathe'}, ] - results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) + results = do_upload(self.collection, data, plan, self.agent.id) self.assertIsInstance(results[0].record_result, Uploaded) self.assertIsInstance(results[1].record_result, Matched) self.assertEqual(results[0].get_id(), results[1].get_id()) @@ -202,7 +202,7 @@ def test_match_uploaded_just_enforced(self) -> None: {'County': 'Johnson', 'City': 'Olathe'}, {'County': 'Shawnee', 'City': 'Topeka'}, ] - results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) + results = do_upload(self.collection, data, plan, self.agent.id) self.assertIsInstance(results[0].record_result, Uploaded) self.assertIsInstance(results[1].record_result, Uploaded) @@ -229,7 +229,7 @@ def test_upload_partial_match(self) -> None: data = [ {'State': 'Missouri', 'County': 'Greene', 'City': 'Rogersville'}, ] - results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) + results = do_upload(self.collection, data, plan, self.agent.id) self.assertIsInstance(results[0].record_result, Uploaded) self.assertEqual(self.greene.id, get_table('Geography').objects.get(id=results[0].get_id()).parent_id) @@ -257,7 +257,7 @@ def test_attachmentimageattribute(self) -> None: {'guid': str(uuid4()), 'height': "100"}, {'guid': str(uuid4()), 'height': "200"}, ] - results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) + results = do_upload(self.collection, data, plan, self.agent.id) for r in results: self.assertIsInstance(r.record_result, Uploaded) aias = [get_table('Attachment').objects.get(id=r.get_id()).attachmentimageattribute_id for r in results] @@ -285,7 +285,7 @@ def test_collectingtripattribute(self) -> None: {'guid': str(uuid4()), 'integer': "100"}, {'guid': str(uuid4()), 'integer': "200"}, ] - results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) + results = do_upload(self.collection, data, plan, self.agent.id) for r in results: self.assertIsInstance(r.record_result, Uploaded) ctas = [get_table('Collectingtrip').objects.get(id=r.get_id()).collectingtripattribute_id for r in results] @@ -331,7 +331,7 @@ def test_preparationattribute(self) -> None: {'guid': str(uuid4()), 'integer': "100", 'catno': '1', 'preptype': 'tissue'}, {'guid': str(uuid4()), 'integer': "200", 'catno': '1', 'preptype': 'tissue'}, ] - results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) + results = do_upload(self.collection, data, plan, self.agent.id) for r in results: self.assertIsInstance(r.record_result, Uploaded) pas = [get_table('Preparation').objects.get(id=r.get_id()).preparationattribute_id for r in results] @@ -362,7 +362,7 @@ def test_collectionobjectattribute(self) -> None: {'catno': "3", 'number': "100"}, {'catno': "4", 'number': "200"}, ] - results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) + results = do_upload(self.collection, data, plan, self.agent.id) for r in results: self.assertIsInstance(r.record_result, Uploaded) coas = [get_table('Collectionobject').objects.get(id=r.get_id()).collectionobjectattribute_id for r in results] @@ -392,7 +392,7 @@ def test_collectingeventattribute(self) -> None: {'sfn': "3", 'number': "100"}, {'sfn': "4", 'number': "200"}, ] - results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) + results = do_upload(self.collection, data, plan, self.agent.id) for r in results: self.assertIsInstance(r.record_result, Uploaded) ceas = [get_table('Collectingevent').objects.get(id=r.get_id()).collectingeventattribute_id for r in results] @@ -432,7 +432,7 @@ def test_null_ce_with_ambiguous_collectingeventattribute(self) -> None: data = [ {'sfn': "", 'number': "100"}, ] - results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) + results = do_upload(self.collection, data, plan, self.agent.id) for r in results: self.assertIsInstance(r.record_result, MatchedMultiple) @@ -467,7 +467,7 @@ def test_ambiguous_one_to_one_match(self) -> None: data = [ {'sfn': "1", 'number': "100"}, ] - results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) + results = do_upload(self.collection, data, plan, self.agent.id) for r in results: self.assertIsInstance(r.record_result, Matched) @@ -493,7 +493,7 @@ def test_null_record_with_ambiguous_one_to_one(self) -> None: {'catno': "2", 'number': "100"}, {'catno': "", 'number': "100"}, ] - results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) + results = do_upload(self.collection, data, plan, self.agent.id) for r in results: self.assertIsInstance(r.record_result, Uploaded) coas = [get_table('Collectionobject').objects.get(id=r.get_id()).collectionobjectattribute_id for r in results] @@ -539,7 +539,7 @@ def test_determination_default_iscurrent(self) -> None: {'Catno': '2', 'Genus': 'Foo', 'Species': 'Bar'}, {'Catno': '3', 'Genus': 'Foo', 'Species': 'Bar'}, ] - results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) + results = do_upload(self.collection, data, plan, self.agent.id) dets = [get_table('Collectionobject').objects.get(id=r.get_id()).determinations.get() for r in results] self.assertTrue(all(d.iscurrent for d in dets), "created determinations have iscurrent = true by default") @@ -581,7 +581,7 @@ def test_determination_override_iscurrent(self) -> None: {'Catno': '2', 'Genus': 'Foo', 'Species': 'Bar', 'iscurrent': 'false'}, {'Catno': '3', 'Genus': 'Foo', 'Species': 'Bar', 'iscurrent': 'false'}, ] - results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) + results = do_upload(self.collection, data, plan, self.agent.id) dets = [get_table('Collectionobject').objects.get(id=r.get_id()).determinations.get() for r in results] self.assertFalse(any(d.iscurrent for d in dets), "created determinations have iscurrent = false by override") @@ -628,7 +628,7 @@ def test_ordernumber(self) -> None: {'title': "A Natural History of Mung Beans", 'author1': "Mungophilius", 'author2': "Philomungus"}, {'title': "A Natural History of Mung Beans", 'author1': "Philomungus", 'author2': "Mungophilius"}, ] - results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) + results = do_upload(self.collection, data, plan, self.agent.id) self.assertIsInstance(results[0].record_result, Uploaded) self.assertIsInstance(results[1].record_result, Uploaded, "The previous record should not be matched b/c the authors are in a different order.") self.assertIsInstance(results[2].record_result, Matched, "The previous record should be matched b/c the authors are in the same order.") @@ -675,7 +675,7 @@ def test_no_override_ordernumber(self) -> None: {'title': "A Natural History of Mung Beans", 'author1': "Mungophilius", 'on1': '1', 'author2': "Philomungus", 'on2': '0'}, {'title': "A Natural History of Mung Beans", 'author1': "Philomungus", 'on1': '0', 'author2': "Mungophilius", 'on2': '1'}, ] - results = do_upload(self.collection, data, plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) + results = do_upload(self.collection, data, plan, self.agent.id) self.assertIsInstance(results[0].record_result, Uploaded) self.assertIsInstance(results[1].record_result, Uploaded, "The previous record should not be matched b/c the authors are in a different order.") self.assertIsInstance(results[2].record_result, Matched, "The previous record should be matched b/c the authors are in the same order.") @@ -767,7 +767,7 @@ def test_big(self) -> None: 5091,Gastropoda,Muricoidea,Marginellidae,Prunum,,donovani,,"(Olsson, 1967)",,,,,,, , ,,USA,FLORIDA,Hendry Co.,"Cochran Pit, N of Rt. 80, W of LaBelle",,North America,Date unk'n,,,,,2,0,0,Dry; shell,Dry,,,,,,1,,,,150,,,LWD,MJP,03/12/1997,26° 44.099' N,,81° 29.027' W,,Point,,,25/10/2016,0,Marine,0,G. Moller,,G.,,Moller,,,,,,,,,,,, 5097,Gastropoda,Muricoidea,Marginellidae,Prunum,,onchidella,,"(Dall, 1890)",,,,,,,,,,USA,FLORIDA,Hendry Co.,"Cochran Pit, N of Route 80, W of LaBelle",,North America,1972,1972,,,,10,0,0,Dry; shell,Dry,,,,,,1,,,,241,,Taken from spoil from 1972-1975.,LWD,MJP,03/12/1997,26° 44.099' N,,81° 29.027' W,,Point,,,16/08/2016,0,Marine,0,M. Buffington,,M.,,Buffington,,,,,,,,,,,, ''')) - upload_results = do_upload_csv(self.collection, reader, self.example_plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) + upload_results = do_upload_csv(self.collection, reader, self.example_plan, self.agent.id) uploaded_catnos = [] for r in upload_results: self.assertIsInstance(r.record_result, Uploaded) @@ -913,9 +913,9 @@ def test_tree_1(self) -> None: 'State': {'name': parse_column_options('State/Prov/Pref')}, 'County': {'name': parse_column_options('Region')}, } - ).apply_scoping(self.collection)[1] + ).apply_scoping(self.collection) row = next(reader) - bt = tree_record.bind(row, None, Auditor(self.collection, auditlog), sql_alchemy_session=None) + bt = tree_record.bind(row, None, Auditor(self.collection, auditlog)) assert isinstance(bt, BoundTreeRecord) to_upload, matched = bt._match(bt._to_match()) @@ -964,7 +964,7 @@ def test_tree_1(self) -> None: # parent=state, # ) - bt = tree_record.bind(row, None, Auditor(self.collection, auditlog), sql_alchemy_session=None) + bt = tree_record.bind(row, None, Auditor(self.collection, auditlog)) assert isinstance(bt, BoundTreeRecord) to_upload, matched = bt._match(bt._to_match()) self.assertEqual( @@ -975,7 +975,7 @@ def test_tree_1(self) -> None: self.assertEqual(state.id, matched.id) self.assertEqual(set(['State/Prov/Pref', 'Country', 'Continent/Ocean']), set(matched.info.columns)) - bt = tree_record.bind(row, None, Auditor(self.collection, auditlog), sql_alchemy_session=None) + bt = tree_record.bind(row, None, Auditor(self.collection, auditlog)) assert isinstance(bt, BoundTreeRecord) upload_result = bt.process_row() self.assertIsInstance(upload_result.record_result, Uploaded) @@ -985,7 +985,7 @@ def test_tree_1(self) -> None: self.assertEqual(uploaded.definitionitem.name, "County") self.assertEqual(uploaded.parent.id, state.id) - bt = tree_record.bind(row, None, Auditor(self.collection, auditlog), sql_alchemy_session=None) + bt = tree_record.bind(row, None, Auditor(self.collection, auditlog)) assert isinstance(bt, BoundTreeRecord) to_upload, matched = bt._match(bt._to_match()) self.assertEqual([], to_upload) @@ -993,7 +993,7 @@ def test_tree_1(self) -> None: self.assertEqual(uploaded.id, matched.id) self.assertEqual(set(['Region', 'State/Prov/Pref', 'Country', 'Continent/Ocean']), set(matched.info.columns)) - bt = tree_record.bind(row, None, Auditor(self.collection, auditlog), sql_alchemy_session=None) + bt = tree_record.bind(row, None, Auditor(self.collection, auditlog)) assert isinstance(bt, BoundTreeRecord) upload_result = bt.process_row() expected_info = ReportInfo(tableName='Geography', columns=['Continent/Ocean', 'Country', 'State/Prov/Pref', 'Region',], treeInfo=TreeInfo('County', 'Hendry Co.')) @@ -1011,7 +1011,7 @@ def test_rollback_bad_rows(self) -> None: co_entries = get_table('Spauditlog').objects.filter(tablenum=get_table('Collectionobject').specify_model.tableId) self.assertEqual(0, co_entries.count(), "No collection objects in audit log yet.") - upload_results = do_upload_csv(self.collection, reader, self.example_plan, self.agent.id, session_url=settings.SA_TEST_DB_URL) + upload_results = do_upload_csv(self.collection, reader, self.example_plan, self.agent.id) failed_result = upload_results[2] self.assertIsInstance(failed_result.record_result, FailedBusinessRule) for result in upload_results: @@ -1054,7 +1054,7 @@ def test_disallow_partial(self) -> None: get_table('collector').objects.count(), ] - upload_results = do_upload_csv(self.collection, reader, self.example_plan, self.agent.id, allow_partial=False, session_url=settings.SA_TEST_DB_URL) + upload_results = do_upload_csv(self.collection, reader, self.example_plan, self.agent.id, allow_partial=False) failed_result = upload_results[2] self.assertIsInstance(failed_result.record_result, FailedBusinessRule) diff --git a/specifyweb/workbench/upload/treerecord.py b/specifyweb/workbench/upload/treerecord.py index 67769156e18..b762c12eb21 100644 --- a/specifyweb/workbench/upload/treerecord.py +++ b/specifyweb/workbench/upload/treerecord.py @@ -3,23 +3,23 @@ """ import logging -from typing import List, Dict, Any, Tuple, NamedTuple, Optional, Union, Set +from typing import Generator, List, Dict, Any, Tuple, NamedTuple, Optional, Union, Set from django.db import transaction, IntegrityError from typing_extensions import TypedDict from specifyweb.businessrules.exceptions import BusinessRuleException from specifyweb.specify import models +from specifyweb.workbench.upload.clone import clone_record +from specifyweb.workbench.upload.predicates import ContetRef, DjangoPredicates, SkippablePredicate, ToRemove, resolve_reference_attributes, safe_fetch +from specifyweb.workbench.upload.preferences import should_defer_fields from .column_options import ColumnOptions, ExtendedColumnOptions from .parsing import ParseResult, WorkBenchParseFailure, parse_many, filter_and_upload, Filter from .upload_result import UploadResult, NullRecord, NoMatch, Matched, \ MatchedMultiple, Uploaded, ParseFailures, FailedBusinessRule, ReportInfo, \ TreeInfo -from .uploadable import FilterPredicate, PredicateWithQuery, Row, Disambiguation as DA, Auditor - -from sqlalchemy.orm import Query # type: ignore -from sqlalchemy import Table as SQLTable # type: ignore +from .uploadable import Row, Disambiguation as DA, Auditor, ScopeGenerator logger = logging.getLogger(__name__) @@ -28,7 +28,7 @@ class TreeRecord(NamedTuple): name: str ranks: Dict[str, Dict[str, ColumnOptions]] - def apply_scoping(self, collection, row=None) -> Tuple[bool, "ScopedTreeRecord"]: + def apply_scoping(self, collection, generator: ScopeGenerator = None, row=None) -> "ScopedTreeRecord": from .scoping import apply_scoping_to_treerecord as apply_scoping return apply_scoping(self, collection) @@ -45,7 +45,7 @@ def to_json(self) -> Dict: return { 'treeRecord': result } def unparse(self) -> Dict: - return { 'baseTableName': self.name, 'uploadble': self.to_json() } + return { 'baseTableName': self.name, 'uploadable': self.to_json() } class ScopedTreeRecord(NamedTuple): name: str @@ -54,14 +54,22 @@ class ScopedTreeRecord(NamedTuple): treedefitems: List root: Optional[Any] disambiguation: Dict[str, int] + batch_edit_pack: Optional[Dict[str, Any]] def disambiguate(self, disambiguation: DA) -> "ScopedTreeRecord": return self._replace(disambiguation=disambiguation.disambiguate_tree()) if disambiguation is not None else self + def apply_batch_edit_pack(self, batch_edit_pack: Optional[Dict[str, Any]]) -> "ScopedTreeRecord": + if batch_edit_pack is None: + return self + # batch-edit considers ranks as self-relationships, and are trivially stored in to-one + rank_from_pack = batch_edit_pack['to_one'] + return self._replace(batch_edit_pack={rank: pack['self'] for (rank, pack) in rank_from_pack.items()}) + def get_treedefs(self) -> Set: return {self.treedef} - def bind(self, row: Row, uploadingAgentId: Optional[int], auditor: Auditor, sql_alchemy_session, cache: Optional[Dict]=None) -> Union["BoundTreeRecord", ParseFailures]: + def bind(self, row: Row, uploadingAgentId: Optional[int], auditor: Auditor, cache: Optional[Dict]=None) -> Union["BoundTreeRecord", ParseFailures]: parsedFields: Dict[str, List[ParseResult]] = {} parseFails: List[WorkBenchParseFailure] = [] for rank, cols in self.ranks.items(): @@ -70,7 +78,7 @@ def bind(self, row: Row, uploadingAgentId: Optional[int], auditor: Auditor, sql_ parsedFields[rank] = presults parseFails += pfails filters = {k: v for result in presults for k, v in result.filter_on.items()} - if filters.get('name', None) is None and False: + if filters.get('name', None) is None: parseFails += [ WorkBenchParseFailure('invalidPartialRecord',{'column':nameColumn.column}, result.column) for result in presults @@ -90,16 +98,17 @@ def bind(self, row: Row, uploadingAgentId: Optional[int], auditor: Auditor, sql_ uploadingAgentId=uploadingAgentId, auditor=auditor, cache=cache, + batch_edit_pack=self.batch_edit_pack ) class MustMatchTreeRecord(TreeRecord): - def apply_scoping(self, collection, row=None) -> Tuple[bool, "ScopedMustMatchTreeRecord"]: - _can_cache, s = super().apply_scoping(collection) - return _can_cache, ScopedMustMatchTreeRecord(*s) + def apply_scoping(self, collection, generator: ScopeGenerator=None, row=None) -> "ScopedMustMatchTreeRecord": + s = super().apply_scoping(collection) + return ScopedMustMatchTreeRecord(*s) class ScopedMustMatchTreeRecord(ScopedTreeRecord): - def bind(self, row: Row, uploadingAgentId: Optional[int], auditor: Auditor, sql_alchemy_session, cache: Optional[Dict]=None) -> Union["BoundMustMatchTreeRecord", ParseFailures]: - b = super().bind(row, uploadingAgentId, auditor, sql_alchemy_session, cache) + def bind(self, row: Row, uploadingAgentId: Optional[int], auditor: Auditor, cache: Optional[Dict]=None) -> Union["BoundMustMatchTreeRecord", ParseFailures]: + b = super().bind(row, uploadingAgentId, auditor, cache) return b if isinstance(b, ParseFailures) else BoundMustMatchTreeRecord(*b) class TreeDefItemWithParseResults(NamedTuple): @@ -113,6 +122,8 @@ def match_key(self) -> str: MatchInfo = TypedDict('MatchInfo', {'id': int, 'name': str, 'definitionitem__name': str, 'definitionitem__rankid': int}) +FETCHED_ATTRS = ['id', 'name', 'definitionitem__name', 'definitionitem__rankid'] + class BoundTreeRecord(NamedTuple): name: str treedef: Any @@ -123,6 +134,7 @@ class BoundTreeRecord(NamedTuple): auditor: Auditor cache: Optional[Dict] disambiguation: Dict[str, int] + batch_edit_pack: Optional[Dict[str, Any]] def is_one_to_one(self) -> bool: return False @@ -130,11 +142,14 @@ def is_one_to_one(self) -> bool: def must_match(self) -> bool: return False - def get_predicates(self, query: Query, sql_table: SQLTable, to_one_override: Dict[str, UploadResult]={}, path: List[str] = []) -> PredicateWithQuery: - return query, FilterPredicate() + def get_django_predicates(self, should_defer_match: bool, to_one_override: Dict[str, UploadResult]={}) -> DjangoPredicates: + return SkippablePredicate() - def map_static_to_db(self) -> Filter: - raise NotImplementedError("to-many to trees not supported!") + def can_save(self) -> bool: + return False + + def delete_row(self, info, parent_obj=None) -> UploadResult: + raise NotImplementedError() def match_row(self) -> UploadResult: return self._handle_row(must_match=True) @@ -142,15 +157,22 @@ def match_row(self) -> UploadResult: def process_row(self) -> UploadResult: return self._handle_row(must_match=False) + def save_row(self, force=False) -> UploadResult: + raise NotImplementedError() + + def get_to_remove(self) -> ToRemove: + raise NotImplementedError() + def _handle_row(self, must_match: bool) -> UploadResult: - tdiwprs = self._to_match() + references = self._get_reference() + tdiwprs = self._to_match(references) if not tdiwprs: columns = [pr.column for prs in self.parsedFields.values() for pr in prs] info = ReportInfo(tableName=self.name, columns=columns, treeInfo=None) return UploadResult(NullRecord(info), {}, {}) - unmatched, match_result = self._match(tdiwprs) + unmatched, match_result = self._match(tdiwprs, references) if isinstance(match_result, MatchedMultiple): return UploadResult(match_result, {}, {}) @@ -159,18 +181,20 @@ def _handle_row(self, must_match: bool) -> UploadResult: info = ReportInfo(tableName=self.name, columns=[r.column for tdiwpr in unmatched for r in tdiwpr.results], treeInfo=None) return UploadResult(NoMatch(info), {}, {}) else: - return self._upload(unmatched, match_result) + return self._upload(unmatched, match_result, references) else: return UploadResult(match_result, {}, {}) - def _to_match(self) -> List[TreeDefItemWithParseResults]: + def _to_match(self, references=None) -> List[TreeDefItemWithParseResults]: return [ TreeDefItemWithParseResults(tdi, self.parsedFields[tdi.name]) for tdi in self.treedefitems - if tdi.name in self.parsedFields and any(v is not None for r in self.parsedFields[tdi.name] for v in r.filter_on.values()) + if tdi.name in self.parsedFields + and (any(v is not None for r in self.parsedFields[tdi.name] for v in r.filter_on.values()) + and ((references is None) or any(v is not None for v in references[tdi.name]['attrs']))) ] - def _match(self, tdiwprs: List[TreeDefItemWithParseResults]) -> Tuple[List[TreeDefItemWithParseResults], MatchResult]: + def _match(self, tdiwprs: List[TreeDefItemWithParseResults], references=None) -> Tuple[List[TreeDefItemWithParseResults], MatchResult]: assert tdiwprs, "There has to be something to match." model = getattr(models, self.name) @@ -182,14 +206,13 @@ def _match(self, tdiwprs: List[TreeDefItemWithParseResults]) -> Tuple[List[TreeD tried_to_match.append(to_match) da = self.disambiguation.get(to_match.treedefitem.name, None) + matches = None + if da is not None: - matches = list(model.objects.filter(id=da).values('id', 'name', 'definitionitem__name', 'definitionitem__rankid')[:10]) - if not matches: - # disambigation target was deleted or something - # revert to regular matching mechanism - matches = self._find_matching_descendent(parent, to_match) - else: - matches = self._find_matching_descendent(parent, to_match) + matches = list(model.objects.filter(id=da).values(*FETCHED_ATTRS)[:10]) + + if not matches: + matches = self._find_matching_descendent(parent, to_match, None if references is None else references.get(to_match.treedefitem.name)) if len(matches) != 1: # matching failed at to_match level @@ -225,7 +248,7 @@ def _match(self, tdiwprs: List[TreeDefItemWithParseResults]) -> Tuple[List[TreeD info = ReportInfo(tableName=self.name, columns=matched_cols + [r.column for r in to_match.results], treeInfo=None) return tdiwprs, NoMatch(info) # no levels matched at all - def _find_matching_descendent(self, parent: Optional[MatchInfo], to_match: TreeDefItemWithParseResults) -> List[MatchInfo]: + def _find_matching_descendent(self, parent: Optional[MatchInfo], to_match: TreeDefItemWithParseResults, reference=None) -> List[MatchInfo]: steps = sum(1 for tdi in self.treedefitems if parent['definitionitem__rankid'] < tdi.rankid <= to_match.treedefitem.rankid) \ if parent is not None else 1 @@ -233,7 +256,10 @@ def _find_matching_descendent(self, parent: Optional[MatchInfo], to_match: TreeD filters = {field: value for r in to_match.results for field, value in r.filter_on.items()} - cache_key = (self.name, steps, parent and parent['id'], to_match.treedefitem.id, tuple(sorted(filters.items()))) + reference_id = None if reference is None else reference['ref'].pk + # Just adding the id of the reference is enough here + cache_key = (self.name, steps, parent and parent['id'], to_match.treedefitem.id, tuple(sorted(filters.items())), reference_id) + cached: Optional[List[MatchInfo]] = self.cache.get(cache_key, None) if self.cache is not None else None if cached is not None: return cached @@ -241,11 +267,24 @@ def _find_matching_descendent(self, parent: Optional[MatchInfo], to_match: TreeD model = getattr(models, self.name) for d in range(steps): - matches = list(model.objects.filter( - definitionitem_id=to_match.treedefitem.id, - **filters, - **({'__'.join(["parent_id"]*(d+1)): parent['id']} if parent is not None else {}) - ).values('id', 'name', 'definitionitem__name', 'definitionitem__rankid')[:10]) + _filter = { + **(reference['attrs'] if reference is not None else {}), + **filters, + **({'__'.join(["parent_id"]*(d+1)): parent['id']} if parent is not None else {}), + **{'definitionitem_id': to_match.treedefitem.id} + } + + query = model.objects.filter(**_filter).values(*FETCHED_ATTRS) + + matches: List[MatchInfo] = [] + + if reference_id is not None: + query_with_id = query.filter(id=reference_id) + matches = list(query_with_id[:10]) + + if not matches: + matches = list(query[:10]) + if matches: if self.cache is not None: self.cache[cache_key] = matches @@ -253,13 +292,13 @@ def _find_matching_descendent(self, parent: Optional[MatchInfo], to_match: TreeD return matches - def _upload(self, to_upload: List[TreeDefItemWithParseResults], matched: Union[Matched, NoMatch]) -> UploadResult: + def _upload(self, to_upload: List[TreeDefItemWithParseResults], matched: Union[Matched, NoMatch], references=None) -> UploadResult: assert to_upload, f"Invalid Error: {to_upload}, can not upload matched resluts: {matched}" model = getattr(models, self.name) parent_info: Optional[Dict] if isinstance(matched, Matched): - parent_info = model.objects.values('id', 'name', 'definitionitem__rankid', 'definitionitem__name').get(id=matched.id) + parent_info = model.objects.values(*FETCHED_ATTRS).get(id=matched.id) parent_result = {'parent': UploadResult(matched, {}, {})} else: parent_info = None @@ -276,13 +315,13 @@ def _upload(self, to_upload: List[TreeDefItemWithParseResults], matched: Union[M if placeholders: # dummy values were added above the nodes we want to upload # rerun the match in case those dummy values already exist - unmatched, new_match_result = self._match(placeholders + to_upload) + unmatched, new_match_result = self._match(placeholders + to_upload, references) if isinstance(new_match_result, MatchedMultiple): return UploadResult( FailedBusinessRule('invalidTreeStructure', {}, new_match_result.info), {}, {} ) - return self._upload(unmatched, new_match_result) + return self._upload(unmatched, new_match_result, references) uploading_rankids = [u.treedefitem.rankid for u in to_upload] skipped_enforced = [ @@ -327,21 +366,33 @@ def _upload(self, to_upload: List[TreeDefItemWithParseResults], matched: Union[M treeInfo=TreeInfo(tdiwpr.treedefitem.name, attrs.get('name', "")) ) - with transaction.atomic(): - try: - obj = self._do_insert( - model, + new_attrs = dict( createdbyagent_id=self.uploadingAgentId, definitionitem=tdiwpr.treedefitem, rankid=tdiwpr.treedefitem.rankid, definition=self.treedef, parent_id=parent_info and parent_info['id'], - **attrs, - ) + ) + + reference_payload = None if references is None else references.get(tdiwpr.treedefitem.name, None) + + new_attrs = { + **(reference_payload['attrs'] if reference_payload is not None else {}), + **attrs, + **new_attrs, + } + + ref = None if reference_payload is None else reference_payload['ref'] + + with transaction.atomic(): + try: + if ref is not None: + obj = self._do_clone(ref, new_attrs) + else: + obj = self._do_insert(model, **new_attrs) except (BusinessRuleException, IntegrityError) as e: return UploadResult(FailedBusinessRule(str(e), {}, info), parent_result, {}) - self.auditor.insert(obj, self.uploadingAgentId, None) result = UploadResult(Uploaded(obj.id, info, []), parent_result, {}) parent_info = {'id': obj.id, 'definitionitem__rankid': obj.definitionitem.rankid} @@ -350,13 +401,58 @@ def _upload(self, to_upload: List[TreeDefItemWithParseResults], matched: Union[M return result def _do_insert(self, model, **kwargs): - obj = model(**kwargs) - obj.save(skip_tree_extras=True) - return obj - + _inserter = self._get_inserter() + return _inserter(model, kwargs) + + def _get_inserter(self): + def _inserter(model, attrs): + obj = model(**attrs) + if model.specify_model.get_field('nodenumber'): + obj.save(skip_tree_extras=True) + else: + obj.save(force_insert=True) + self.auditor.insert(obj, None) + return obj + return _inserter + + def _do_clone(self, ref, attrs): + _inserter = self._get_inserter() + return clone_record(ref, _inserter, {}, [], attrs) + def force_upload_row(self) -> UploadResult: raise NotImplementedError() + def _get_reference(self) -> Optional[Dict[str, Any]]: + # Much simpler than uploadTable. Just fetch all rank's references. Since we also require name to be not null, + # the "deferForNull" is redundant. We, do, however need to look at deferForMatch, and we are done. + + if self.batch_edit_pack is None: + return None + + model = getattr(models, self.name) + + should_defer = should_defer_fields('match') + + references = {} + + previous_parent_id = None + for tdi in self.treedefitems: + if tdi.name not in self.batch_edit_pack: + continue + columns = [pr.column for pr in self.parsedFields[tdi.name]] + info = ReportInfo(tableName=self.name, columns=columns, treeInfo=None) + pack = self.batch_edit_pack[tdi.name] + try: + reference = safe_fetch(model, {'id': pack['id']}, pack.get('version', None)) + if previous_parent_id is not None and previous_parent_id != reference.pk: + raise BusinessRuleException("Tree structure changed, please re-run the query") + except (ContetRef, BusinessRuleException) as e: + raise BusinessRuleException(str(e), {}, info) + + previous_parent_id = reference.parent_id + references[tdi.name] = None if should_defer else {'ref': reference, 'attrs': resolve_reference_attributes([], model, reference)} + + return references class BoundMustMatchTreeRecord(BoundTreeRecord): def must_match(self) -> bool: diff --git a/specifyweb/workbench/upload/upload.py b/specifyweb/workbench/upload/upload.py index 4632ffd0219..2922326e594 100644 --- a/specifyweb/workbench/upload/upload.py +++ b/specifyweb/workbench/upload/upload.py @@ -13,13 +13,11 @@ from specifyweb.specify import models from specifyweb.specify.auditlog import auditlog from specifyweb.specify.datamodel import Table +from specifyweb.specify.func import Func from specifyweb.specify.tree_extras import renumber_tree, set_fullnames -from specifyweb.stored_queries.tests import setup_sqlalchemy -from django.conf import settings from . import disambiguation from .upload_plan_schema import schema, parse_plan_with_basetable -from .upload_result import Uploaded, UploadResult, ParseFailures, \ - json_to_UploadResult +from .upload_result import Deleted, RecordResult, Updated, Uploaded, UploadResult, ParseFailures from .uploadable import ScopedUploadable, Row, Disambiguation, Auditor, Uploadable from ..models import Spdataset @@ -60,7 +58,7 @@ def unupload_dataset(ds: Spdataset, agent, progress: Optional[Progress]=None) -> with transaction.atomic(): for row in reversed(results): logger.info(f"rolling back row {current} of {total}") - upload_result = json_to_UploadResult(row) + upload_result = UploadResult.from_json(row) if not upload_result.contains_failure(): unupload_record(upload_result, agent) @@ -112,8 +110,7 @@ def do_upload_dataset( ds: Spdataset, no_commit: bool, allow_partial: bool, - progress: Optional[Progress]=None, - session_url: Optional[str] = None + progress: Optional[Progress]=None ) -> List[UploadResult]: if ds.was_uploaded(): raise AssertionError("Dataset already uploaded", {"localizationKey" : "datasetAlreadyUploaded"}) ds.rowresults = None @@ -122,10 +119,12 @@ def do_upload_dataset( ncols = len(ds.columns) rows = [dict(zip(ds.columns, row)) for row in ds.data] + disambiguation = [get_disambiguation_from_row(ncols, row) for row in ds.data] + batch_edit_packs = [get_batch_edit_pack_from_row(ncols, row) for row in ds.data] base_table, upload_plan = get_raw_ds_upload_plan(ds) - results = do_upload(collection, rows, upload_plan, uploading_agent_id, disambiguation, no_commit, allow_partial, progress, session_url=session_url) + results = do_upload(collection, rows, upload_plan, uploading_agent_id, disambiguation, no_commit, allow_partial, progress, batch_edit_packs=batch_edit_packs) success = not any(r.contains_failure() for r in results) if not no_commit: ds.uploadresult = { @@ -157,7 +156,7 @@ def create_recordset(ds: Spdataset, name: str): table, upload_plan = get_ds_upload_plan(ds.collection, ds) assert ds.rowresults is not None results = json.loads(ds.rowresults) - + rs = models.Recordset.objects.create( collectionmemberid=ds.collection.id, dbtableid=table.tableId, @@ -167,8 +166,8 @@ def create_recordset(ds: Spdataset, name: str): ) models.Recordsetitem.objects.bulk_create([ models.Recordsetitem(order=i, recordid=record_id, recordset=rs) - for i, r in enumerate(map(json_to_UploadResult, results)) - if isinstance(r.record_result, Uploaded) and (record_id := r.get_id()) is not None and record_id != 'Failure' + for i, r in enumerate(map(UploadResult.from_json, results)) + if (isinstance(r.record_result, Uploaded) or isinstance(r.record_result, Updated)) and (record_id := r.get_id()) is not None and record_id != 'Failure' ]) return rs @@ -176,6 +175,10 @@ def get_disambiguation_from_row(ncols: int, row: List) -> Disambiguation: extra = json.loads(row[ncols]) if row[ncols] else None return disambiguation.from_json(extra['disambiguation']) if extra and 'disambiguation' in extra else None +def get_batch_edit_pack_from_row(ncols: int, row: List) -> Optional[Dict[str, Any]]: + extra: Optional[Dict[str, Any]] = json.loads(row[ncols]) if row[ncols] else None + return extra.get('batch_edit', None) if extra else None + def get_raw_ds_upload_plan(ds: Spdataset) -> Tuple[Table, Uploadable]: if ds.uploadplan is None: raise Exception("no upload plan defined for dataset") @@ -191,8 +194,7 @@ def get_raw_ds_upload_plan(ds: Spdataset) -> Tuple[Table, Uploadable]: def get_ds_upload_plan(collection, ds: Spdataset) -> Tuple[Table, ScopedUploadable]: base_table, plan = get_raw_ds_upload_plan(ds) - return base_table, plan.apply_scoping(collection)[1] - + return base_table, plan.apply_scoping(collection) def do_upload( collection, @@ -203,36 +205,49 @@ def do_upload( no_commit: bool=False, allow_partial: bool=True, progress: Optional[Progress]=None, - session_url = None, + batch_edit_packs: Optional[List[Optional[Dict[str, Any]]]] = None ) -> List[UploadResult]: cache: Dict = {} _auditor = Auditor(collection=collection, audit_log=None if no_commit else auditlog, # Done to allow checking skipping write permission check # during validation - skip_create_permission_check=no_commit) + skip_create_permission_check=no_commit, + agent=models.Agent.objects.get(id=uploading_agent_id) + ) total = len(rows) if isinstance(rows, Sized) else None cached_scope_table = None - wb_session_context = setup_sqlalchemy_wb(session_url) + + # I'd make this a generator (so "global" variable is internal, rather than a rogue callback setting a global variable) + gen = Func.make_generator() + with savepoint("main upload"): tic = time.perf_counter() results: List[UploadResult] = [] for i, row in enumerate(rows): _cache = cache.copy() if cache is not None and allow_partial else cache da = disambiguations[i] if disambiguations else None + batch_edit_pack = batch_edit_packs[i] if batch_edit_packs else None with savepoint("row upload") if allow_partial else no_savepoint(): # the fact that upload plan is cachable, is invariant across rows. - # so, we just apply scoping once. + # so, we just apply scoping once. Honestly, see if it causes enough overhead to even warrant caching if cached_scope_table is None: - can_cache, scoped_table = upload_plan.apply_scoping(collection, row) + cannot_cache, scoped_table = Func.tap_call(lambda: upload_plan.apply_scoping(collection, gen, row), gen) + can_cache = not cannot_cache if can_cache: cached_scope_table = scoped_table else: scoped_table = cached_scope_table - with wb_session_context() as session: - bind_result = scoped_table.disambiguate(da).bind(row, uploading_agent_id, _auditor, session, cache) - result = UploadResult(bind_result, {}, {}) if isinstance(bind_result, ParseFailures) else bind_result.process_row() - + bind_result = scoped_table.disambiguate(da).apply_batch_edit_pack(batch_edit_pack).bind(row, uploading_agent_id, _auditor, cache) + if isinstance(bind_result, ParseFailures): + result = UploadResult(bind_result, {}, {}) + else: + can_save = bind_result.can_save() + # We need to have additional context on whether we can save or not. This could, hackily, be taken from ds's isupdate field. + # But, that seeems very hacky. Instead, we can easily check if the base table can be saved. Legacy ones will simply return false, + # so we'll be able to proceed fine. + result = (bind_result.save_row(force=True) if can_save else bind_result.process_row()) + results.append(result) if progress is not None: progress(len(results), total) @@ -253,15 +268,13 @@ def do_upload( do_upload_csv = do_upload -def validate_row(collection, upload_plan: ScopedUploadable, uploading_agent_id: int, row: Row, da: Disambiguation, session_url: Optional[str]=None) -> UploadResult: +def validate_row(collection, upload_plan: ScopedUploadable, uploading_agent_id: int, row: Row, da: Disambiguation) -> UploadResult: retries = 3 - session_context = setup_sqlalchemy_wb(session_url) while True: try: with savepoint("row validation"): - with session_context() as session: - bind_result = upload_plan.disambiguate(da).bind(row, uploading_agent_id, Auditor(collection, None), session) - result = UploadResult(bind_result, {}, {}) if isinstance(bind_result, ParseFailures) else bind_result.process_row() + bind_result = upload_plan.disambiguate(da).bind(row, uploading_agent_id, Auditor(collection, None)) + result = UploadResult(bind_result, {}, {}) if isinstance(bind_result, ParseFailures) else bind_result.process_row() raise Rollback("validating only") break @@ -304,7 +317,85 @@ class NopLog(object): def insert(self, inserted_obj: Any, agent: Union[int, Any], parent_record: Optional[Any]) -> None: pass -# to allow for unit tests to run -def setup_sqlalchemy_wb(url: Optional[str]): - _, session_context = setup_sqlalchemy(url or settings.SA_DATABASE_URL) - return session_context \ No newline at end of file +def adjust_pack(pack: Optional[Dict[str, Any]], upload_result: UploadResult, commit_uploader: Callable[[RecordResult], None]): + if isinstance(upload_result.record_result, Uploaded): + commit_uploader(upload_result.record_result) + if pack is None: + return None + current_result = upload_result.record_result + self = pack['self'] + self = {**self, 'version': None, 'id': None if isinstance(current_result, Deleted) else self['id']} + to_ones = Func.maybe(pack.get('to_one'), lambda to_one: {key: adjust_pack(value, upload_result.toOne[key], commit_uploader) for key, value in to_one.items()}) # type: ignore + to_many = Func.maybe(pack.get('to_many'), lambda to_many: {key: [adjust_pack(record, upload_result.toMany[key][_id], commit_uploader) for _id, record in enumerate(records)] for (key, records) in to_many.items()}) # type: ignore + return { + 'self': self, + 'to_one': to_ones, + 'to_many': to_many + } + +def rollback_batch_edit(parent: Spdataset, collection, agent, progress: Optional[Progress]=None) -> None: + assert parent.isupdate, "What are you trying to do here?" + + backer: Spdataset = parent.backer + + assert backer is not None, "Backer isn't there, what did you do?" + + # Need to do a couple of things before we go do stuff. + # 1. Remove all version info (duh) + # 2. Check if corresponding was a deleted cell. If it was, replace the main with null id. + + ncols = len(parent.columns) + current = [get_batch_edit_pack_from_row(ncols, row) for row in parent.data] + + assert len(backer.columns) == ncols # C'mon, we handle so much. they deserve it. + + inserted_records = [] + + assert parent.rowresults is not None + row_results = json.loads(parent.rowresults) + + def look_up_in_backer(_id): + row = parent.data[_id] + pack = get_batch_edit_pack_from_row(ncols, row) + # Could have added a new row. This handles future use cases. + # we could literally crash here right now, bc this won't happen currently. + if pack is None: + return None + result = row_results[_id] + upload_result = UploadResult.from_json(result) + # impossible to get stop iteration. + is_match = lambda _backer_pack: (_backer_pack is not None) and (_backer_pack['self']['id'] == pack['self']['id']) + _filter = filter(lambda row: Func.maybe(get_batch_edit_pack_from_row(ncols, row), is_match), backer.data) + gen = next(_filter) + return gen, adjust_pack(get_batch_edit_pack_from_row(ncols, row), upload_result, _commit_uploader) + + def _commit_uploader(result): + inserted_records.append(result) + + rows_to_backup = [] + packs = [] + # Yes, we don't care about reverse here. + for row in range(len(parent.data)): + r, be = look_up_in_backer(row) + rows_to_backup.append(dict(zip(parent.columns, r))) + packs.append(be) + + # Don't use parent's plan... + base_table, upload_plan = get_raw_ds_upload_plan(backer) + results = do_upload(collection, rows_to_backup, upload_plan, agent, None, False, False, progress, packs) + + success = not any(r.contains_failure() for r in results) + + if not success: + raise RollbackFailure("Unable to roll back") + + unupload_dataset(parent, agent, progress) + + parent.rowresults = json.dumps([r.to_json() for r in results]) + parent.save(update_fields=['rowresults']) + + + + + + diff --git a/specifyweb/workbench/upload/upload_result.py b/specifyweb/workbench/upload/upload_result.py index bda62905e41..cd7d9cc72bf 100644 --- a/specifyweb/workbench/upload/upload_result.py +++ b/specifyweb/workbench/upload/upload_result.py @@ -53,15 +53,39 @@ def to_json(self) -> Dict: info=self.info.to_json(), picklistAdditions=[a.to_json() for a in self.picklistAdditions] )} + + @staticmethod + def from_json(json: Dict) -> "Uploaded": + uploaded = json['Uploaded'] + return Uploaded( + id=uploaded['id'], + info=json_to_ReportInfo(uploaded['info']), + picklistAdditions=[json_to_PicklistAddition(i) for i in uploaded['picklistAdditions']] + ) -def json_to_Uploaded(json: Dict) -> Uploaded: - uploaded = json['Uploaded'] - return Uploaded( - id=uploaded['id'], - info=json_to_ReportInfo(uploaded['info']), - picklistAdditions=[json_to_PicklistAddition(i) for i in uploaded['picklistAdditions']] - ) +class Updated(NamedTuple): + id: int + info: ReportInfo + picklistAdditions: List[PicklistAddition] + def get_id(self) -> int: + return self.id + + def to_json(self) -> Dict: + return { 'Updated': dict( + id=self.id, + info=self.info.to_json(), + picklistAdditions=[a.to_json() for a in self.picklistAdditions] + )} + + @staticmethod + def from_json(json: Dict) -> "Updated": + uploaded = json['Updated'] + return Updated( + id=uploaded['id'], + info=json_to_ReportInfo(uploaded['info']), + picklistAdditions=[json_to_PicklistAddition(i) for i in uploaded['picklistAdditions']] + ) class Matched(NamedTuple): id: int @@ -76,14 +100,26 @@ def to_json(self) -> Dict: info=self.info.to_json() )} -def json_to_Matched(json: Dict) -> Matched: - matched = json['Matched'] - return Matched( - id=matched['id'], - info=json_to_ReportInfo(matched['info']) - ) - + @staticmethod + def from_json(json: Dict) -> "Matched": + matched = json['Matched'] + return Matched( + id=matched['id'], + info=json_to_ReportInfo(matched['info']) + ) +class MatchedAndChanged(Matched): + def to_json(self) -> Dict: + return {'MatchedAndChanged': super().to_json()['Matched']} + + @staticmethod + def from_json(json: Dict) -> Matched: + matchedAndChanged = json['MatchedAndChanged'] + return MatchedAndChanged( + id=matchedAndChanged['id'], + info=json_to_ReportInfo(matchedAndChanged['info']) + ) + class MatchedMultiple(NamedTuple): ids: List[int] key: str @@ -98,14 +134,15 @@ def to_json(self): key=self.key, info=self.info.to_json() )} - -def json_to_MatchedMultiple(json: Dict) -> MatchedMultiple: - matchedMultiple = json['MatchedMultiple'] - return MatchedMultiple( - ids=matchedMultiple['ids'], - key=matchedMultiple.get('key', ''), - info=json_to_ReportInfo(matchedMultiple['info']) - ) + + @staticmethod + def from_json(json: Dict) -> "MatchedMultiple": + matchedMultiple = json['MatchedMultiple'] + return MatchedMultiple( + ids=matchedMultiple['ids'], + key=matchedMultiple.get('key', ''), + info=json_to_ReportInfo(matchedMultiple['info']) + ) class NullRecord(NamedTuple): info: ReportInfo @@ -115,10 +152,48 @@ def get_id(self) -> None: def to_json(self): return { 'NullRecord': dict(info=self.info.to_json()) } + + @staticmethod + def from_json(json: Dict) -> "NullRecord": + nullRecord = json['NullRecord'] + return NullRecord(info=json_to_ReportInfo(nullRecord['info'])) -def json_to_NullRecord(json: Dict) -> NullRecord: - nullRecord = json['NullRecord'] - return NullRecord(info=json_to_ReportInfo(nullRecord['info'])) +class NoChange(NamedTuple): + id: int + info: ReportInfo + + def get_id(self) -> int: + return self.id + + def to_json(self): + return { + 'NoChange': dict( + id=self.id, + info=self.info.to_json() + ) + } + + @staticmethod + def from_json(json: Dict) -> 'NoChange': + noChange = json['NoChange'] + return NoChange(id=noChange['id'], info=json_to_ReportInfo(noChange['info'])) + +class Deleted(NamedTuple): + id: int + info: ReportInfo + + def get_id(self) -> None: + return None + + def to_json(self): + assert self.id is not None + return {'Deleted': dict(id=self.id, info=self.info.to_json())} + + @staticmethod + def from_json(json: Dict) -> 'Deleted': + deleted = json['Deleted'] + return Deleted(id=deleted['id'], info=json_to_ReportInfo(deleted['info'])) + class FailedBusinessRule(NamedTuple): message: str @@ -129,15 +204,16 @@ def get_id(self) -> Failure: return "Failure" def to_json(self): - return { self.__class__.__name__: dict(message=self.message, payload=self.payload, info=self.info.to_json()) } - -def json_to_FailedBusinessRule(json: Dict) -> FailedBusinessRule: - r = json['FailedBusinessRule'] - return FailedBusinessRule( - message=r['message'], - payload=r['payload'], - info=json_to_ReportInfo(r['info']) - ) + return { "FailedBusinessRule": dict(message=self.message, payload=self.payload, info=self.info.to_json()) } + + @staticmethod + def from_json(json: Dict) -> "FailedBusinessRule": + r = json['FailedBusinessRule'] + return FailedBusinessRule( + message=r['message'], + payload=r['payload'], + info=json_to_ReportInfo(r['info']) + ) class NoMatch(NamedTuple): info: ReportInfo @@ -146,24 +222,26 @@ def get_id(self) -> Failure: return "Failure" def to_json(self): - return { self.__class__.__name__: dict(info=self.info.to_json()) } - -def json_to_NoMatch(json: Dict) -> NoMatch: - r = json['NoMatch'] - return NoMatch(info=json_to_ReportInfo(r['info'])) + return { "NoMatch": dict(info=self.info.to_json()) } + + @staticmethod + def from_json(json: Dict) -> "NoMatch": + r = json['NoMatch'] + return NoMatch(info=json_to_ReportInfo(r['info'])) class ParseFailures(NamedTuple): failures: List[WorkBenchParseFailure] def get_id(self) -> Failure: return "Failure" - + def to_json(self): return { self.__class__.__name__: dict(failures=[f.to_json() for f in self.failures]) } -def json_to_ParseFailures(json: Dict) -> ParseFailures: - r = json['ParseFailures'] - return ParseFailures(failures=[WorkBenchParseFailure(*i) for i in r['failures']]) + @staticmethod + def from_json(json: Dict) -> "ParseFailures": + r = json['ParseFailures'] + return ParseFailures(failures=[WorkBenchParseFailure(*i) for i in r['failures']]) class PropagatedFailure(NamedTuple): def get_id(self) -> Failure: @@ -172,11 +250,11 @@ def get_id(self) -> Failure: def to_json(self): return { 'PropagatedFailure': {} } -def json_to_PropagatedFailure(json: Dict) -> PropagatedFailure: - return PropagatedFailure() - -RecordResult = Union[Uploaded, NoMatch, Matched, MatchedMultiple, NullRecord, FailedBusinessRule, ParseFailures, PropagatedFailure] + @staticmethod + def from_json(json: Dict) -> "PropagatedFailure": + return PropagatedFailure() +RecordResult = Union[Uploaded, NoMatch, Matched, MatchedMultiple, NullRecord, FailedBusinessRule, ParseFailures, PropagatedFailure, NoChange, Updated, Deleted, MatchedAndChanged] class UploadResult(NamedTuple): record_result: RecordResult @@ -198,31 +276,41 @@ def to_json(self) -> Dict: 'toOne': {k: v.to_json() for k,v in self.toOne.items()}, 'toMany': {k: [v.to_json() for v in vs] for k,vs in self.toMany.items()}, }} + + @staticmethod + def from_json(json: Dict) -> "UploadResult": + return UploadResult( + record_result=json_to_record_result(json['UploadResult']['record_result']), + toOne={k: UploadResult.from_json(v) for k,v in json['UploadResult']['toOne'].items()}, + toMany={k: [UploadResult.from_json(v) for v in vs] for k,vs in json['UploadResult']['toMany'].items()} + ) -def json_to_UploadResult(json: Dict) -> UploadResult: - return UploadResult( - record_result=json_to_record_result(json['UploadResult']['record_result']), - toOne={k: json_to_UploadResult(v) for k,v in json['UploadResult']['toOne'].items()}, - toMany={k: [json_to_UploadResult(v) for v in vs] for k,vs in json['UploadResult']['toMany'].items()} - ) def json_to_record_result(json: Dict) -> RecordResult: for record_type in json: if record_type == "Uploaded": - return json_to_Uploaded(json) + return Uploaded.from_json(json) elif record_type == "NoMatch": - return json_to_NoMatch(json) + return NoMatch.from_json(json) elif record_type == "Matched": - return json_to_Matched(json) + return Matched.from_json(json) elif record_type == "MatchedMultiple": - return json_to_MatchedMultiple(json) + return MatchedMultiple.from_json(json) elif record_type == "NullRecord": - return json_to_NullRecord(json) + return NullRecord.from_json(json) elif record_type == "FailedBusinessRule": - return json_to_FailedBusinessRule(json) + return FailedBusinessRule.from_json(json) elif record_type == "ParseFailures": - return json_to_ParseFailures(json) + return ParseFailures.from_json(json) elif record_type == "PropagatedFailure": - return json_to_PropagatedFailure(json) + return PropagatedFailure.from_json(json) + elif record_type == "NoChange": + return NoChange.from_json(json) + elif record_type == 'Updated': + return Updated.from_json(json) + elif record_type == 'Deleted': + return Deleted.from_json(json) + elif record_type == 'MatchedAndChanged': + return MatchedAndChanged.from_json(json) assert False, f"record_result is unknown type: {record_type}" assert False, f"record_result contains no data: {json}" diff --git a/specifyweb/workbench/upload/upload_results_schema.py b/specifyweb/workbench/upload/upload_results_schema.py index 9ce67cfd67a..5e58f9afbd1 100644 --- a/specifyweb/workbench/upload/upload_results_schema.py +++ b/specifyweb/workbench/upload/upload_results_schema.py @@ -47,6 +47,10 @@ { '$ref': '#/definitions/Matched' }, { '$ref': '#/definitions/Uploaded' }, { '$ref': '#/definitions/PropagatedFailure' }, + { '$ref': '#/definitions/NoChange' }, + { '$ref': '#/definitions/Updated' }, + { '$ref': '#/definitions/Deleted' }, + { '$ref': '#/definitions/MatchedAndChanged' }, ] }, @@ -62,7 +66,6 @@ 'required': ['PropagatedFailure'], 'additionalProperties': False }, - 'ParseFailures': { 'type': 'object', 'desciption': 'Indicates one or more values were invalid, preventing a record from uploading.', @@ -93,7 +96,6 @@ 'required': ['ParseFailures'], 'additionalProperties': False }, - 'NoMatch': { 'type': 'object', 'desciption': 'Indicates failure due to inability to find an expected existing matching record.', @@ -110,7 +112,6 @@ 'required': ['NoMatch'], 'additionalProperties': False }, - 'FailedBusinessRule': { 'type': 'object', 'desciption': 'Indicates a record failed to upload due to a business rule violation.', @@ -133,7 +134,6 @@ 'required': ['FailedBusinessRule'], 'additionalProperties': False }, - 'NullRecord': { 'type': 'object', 'desciption': 'Indicates that no record was uploaded because all relevant columns in the data set are empty.', @@ -150,7 +150,6 @@ 'required': ['NullRecord'], 'additionalProperties': False }, - 'MatchedMultiple': { 'type': 'object', 'desciption': 'Indicates failure due to finding multiple matches to existing records.', @@ -169,7 +168,6 @@ 'required': ['MatchedMultiple'], 'additionalProperties': False }, - 'Matched': { 'type': 'object', 'desciption': 'Indicates that an existing record in the database was matched.', @@ -187,7 +185,6 @@ 'required': ['Matched'], 'additionalProperties': False }, - 'Uploaded': { 'type': 'object', 'desciption': 'Indicates that a new row was added to the database.', @@ -209,7 +206,6 @@ 'required': ['Uploaded'], 'additionalProperties': False }, - 'PicklistAddition': { 'type': 'object', 'desciption': 'Indicates that a value had to be added to a picklist in the course of uploading a record.', @@ -222,7 +218,6 @@ 'required': ['id', 'name', 'value', 'caption'], 'additionalProperties': False }, - 'ReportInfo': { 'type': 'object', 'desciption': 'Records metadata about an UploadResult indicating the tables, data set columns, and any tree information involved.', @@ -244,6 +239,78 @@ }, 'required': ['rank', 'name'], 'additionalProperties': False - } + }, + 'NoChange': { + 'type': 'object', + 'desciption': "Indicates that there was no change to the record.", + 'properties': { + 'NoChange': { + "type": "object", + "properties": { + "id": {"type": "integer", "description": "The id of the database row"}, + "info": { '$ref': '#/definitions/ReportInfo' } + }, + "required": ["id", "info"], + "additionalProperties": False + } + }, + "required": ["NoChange"], + "additionalProperties": False + }, + "Updated": { + 'type': "object", + "desciption": "Indicates that were updates to the record", + "properties": { + "Updated" : { + 'type': 'object', + 'properties': { + 'id': { 'type': 'integer', 'description': 'The database id of the updated row.' }, + 'picklistAdditions': { + 'type': 'array', + 'items': { '$ref': '#definitions/PicklistAddition' } + }, + 'info': { '$ref': '#/definitions/ReportInfo' }, + }, + 'required': ['id', 'info', 'picklistAdditions'], + 'additionalProperties': False + } + }, + "required": ["Updated"], + "additionalProperties": False + }, + "Deleted": { + 'type': 'object', + 'desciption': "Indicates that record was deleted.", + 'properties': { + 'Deleted': { + "type": "object", + "properties": { + "id": {"type": "integer", "description": "The id of the database row"}, + "info": { '$ref': '#/definitions/ReportInfo' } + }, + "required": ["id", "info"], + "additionalProperties": False + } + }, + "required": ["Deleted"], + "additionalProperties": False + }, + "MatchedAndChanged": { + "type": "object", + "desciption": "Indicates a related record was matched, different than current related record", + 'properties': { + 'MatchedAndChanged': { + 'type': 'object', + 'properties': { + 'id': { 'type': 'integer', 'description': 'The id of the new matched record' }, + 'info': { '$ref': '#/definitions/ReportInfo' } + }, + 'required': ['id', 'info'], + 'additionalProperties': False + } + }, + 'required': ['MatchedAndChanged'], + 'additionalProperties': False + } } } diff --git a/specifyweb/workbench/upload/upload_table.py b/specifyweb/workbench/upload/upload_table.py index f4059a23798..cb9a92c4b49 100644 --- a/specifyweb/workbench/upload/upload_table.py +++ b/specifyweb/workbench/upload/upload_table.py @@ -1,30 +1,33 @@ import logging -from functools import reduce -from typing import List, Dict, Any, NamedTuple, Union, Optional, Set, Callable, Literal, cast, Tuple +from typing import List, Dict, Any, NamedTuple, Union, Optional, Set, Literal, Tuple from django.db import transaction, IntegrityError +from django.db.models import Model from specifyweb.businessrules.exceptions import BusinessRuleException from specifyweb.specify import models -from specifyweb.specify.datamodel import datamodel -from specifyweb.specify.load_datamodel import Field, Relationship -import specifyweb.stored_queries.models as sql_models +from specifyweb.specify.api import delete_obj +from specifyweb.specify.func import Func +from specifyweb.specify.field_change_info import FieldChangeInfo +from specifyweb.workbench.upload.clone import clone_record +from specifyweb.workbench.upload.predicates import ContetRef, DjangoPredicates, SkippablePredicate, ToRemove, resolve_reference_attributes, safe_fetch +from specifyweb.workbench.upload.preferences import should_defer_fields from .column_options import ColumnOptions, ExtendedColumnOptions from .parsing import parse_many, ParseResult, WorkBenchParseFailure -from .upload_result import UploadResult, Uploaded, NoMatch, Matched, \ +from .upload_result import Deleted, MatchedAndChanged, NoChange, Updated, UploadResult, Uploaded, NoMatch, Matched, \ MatchedMultiple, NullRecord, FailedBusinessRule, ReportInfo, \ PicklistAddition, ParseFailures, PropagatedFailure -from .uploadable import FilterPredicate, Predicate, PredicateWithQuery, Row, Uploadable, ScopedUploadable, \ - BoundUploadable, Disambiguation, Auditor, Filter +from .uploadable import NULL_RECORD, Row, ScopeGenerator, Uploadable, ScopedUploadable, \ + BoundUploadable, Disambiguation, Auditor -from sqlalchemy.orm import Query, aliased, Session # type: ignore -from sqlalchemy import sql, Table as SQLTable # type: ignore -from sqlalchemy.sql.expression import ColumnElement # type: ignore -from sqlalchemy.exc import OperationalError # type: ignore logger = logging.getLogger(__name__) +# This doesn't cause race conditions, since the cache itself is local to a dataset. +# Even if you've another validation on the same thread, this won't cause an issue +REFERENCE_KEY = object() + class UploadTable(NamedTuple): name: str wbcols: Dict[str, ColumnOptions] @@ -34,9 +37,9 @@ class UploadTable(NamedTuple): overrideScope: Optional[Dict[Literal['collection'], Optional[int]]] = None - def apply_scoping(self, collection, row=None) -> Tuple[bool, "ScopedUploadTable"]: + def apply_scoping(self, collection, generator: ScopeGenerator = None, row=None) -> "ScopedUploadTable": from .scoping import apply_scoping_to_uploadtable - return apply_scoping_to_uploadtable(self, collection, row) + return apply_scoping_to_uploadtable(self, collection, generator, row) def get_cols(self) -> Set[str]: return set(cd.column for cd in self.wbcols.values()) \ @@ -53,7 +56,7 @@ def _to_json(self) -> Dict: for key, uploadable in self.toOne.items() } result['toMany'] = { - # legacy behaviour + # legacy behaviour, don't know a better way without migrations key: [to_many.to_json()['uploadTable'] for to_many in to_manys] for key, to_manys in self.toMany.items() } @@ -73,7 +76,9 @@ class ScopedUploadTable(NamedTuple): toMany: Dict[str, List['ScopedUploadable']] # type: ignore scopingAttrs: Dict[str, int] disambiguation: Optional[int] - + to_one_fields: Dict[str, List[str]] # TODO: Consider making this a payload.. + match_payload: Optional[Dict[str, Any]] + def disambiguate(self, disambiguation: Disambiguation) -> "ScopedUploadable": if disambiguation is None: return self @@ -92,6 +97,24 @@ def disambiguate(self, disambiguation: Disambiguation) -> "ScopedUploadable": for fieldname, records in self.toMany.items() } ) + + def apply_batch_edit_pack(self, batch_edit_pack: Optional[Dict[str, Any]]) -> "ScopedUploadable": + if batch_edit_pack is None: + return self + return self._replace( + match_payload=batch_edit_pack['self'], + toOne={ + fieldname: uploadable.apply_batch_edit_pack(batch_edit_pack['to_one'].get(fieldname)) + for fieldname, uploadable in self.toOne.items() + }, + toMany={ + fieldname: [ + record.apply_batch_edit_pack(batch_edit_pack['to_many'].get(fieldname)[_id]) + for (_id, record) in enumerate(records) + ] + for fieldname, records in self.toMany.items() + } + ) def get_treedefs(self) -> Set: return ( @@ -99,14 +122,20 @@ def get_treedefs(self) -> Set: set(td for toMany in self.toMany.values() for tmr in toMany for td in tmr.get_treedefs()) ) + def bind(self, row: Row, uploadingAgentId: int, auditor: Auditor, cache: Optional[Dict]=None) -> Union["BoundUploadTable", ParseFailures]: + + current_id = None if self.match_payload is None else self.match_payload.get('id') - def bind(self, row: Row, uploadingAgentId: int, auditor: Auditor, sql_alchemy_session, cache: Optional[Dict]=None - ) -> Union["BoundUploadTable", ParseFailures]: - parsedFields, parseFails = parse_many(self.name, self.wbcols, row) + if current_id == NULL_RECORD: + parsedFields: List[ParseResult] = [] + parseFails: List[WorkBenchParseFailure] = [] + current_id = None + else: + parsedFields, parseFails = parse_many(self.name, self.wbcols, row) toOne: Dict[str, BoundUploadable] = {} for fieldname, uploadable in self.toOne.items(): - result = uploadable.bind(row, uploadingAgentId, auditor, sql_alchemy_session, cache) + result = uploadable.bind(row, uploadingAgentId, auditor, cache) if isinstance(result, ParseFailures): parseFails += result.failures else: @@ -116,7 +145,7 @@ def bind(self, row: Row, uploadingAgentId: int, auditor: Auditor, sql_alchemy_se for fieldname, records in self.toMany.items(): boundRecords: List[BoundUploadable] = [] for record in records: - result_ = record.bind(row, uploadingAgentId, auditor, sql_alchemy_session, cache) + result_ = record.bind(row, uploadingAgentId, auditor, cache) if isinstance(result_, ParseFailures): parseFails += result_.failures else: @@ -125,7 +154,7 @@ def bind(self, row: Row, uploadingAgentId: int, auditor: Auditor, sql_alchemy_se if parseFails: return ParseFailures(parseFails) - + return BoundUploadTable( name=self.name, static=self.static, @@ -137,38 +166,37 @@ def bind(self, row: Row, uploadingAgentId: int, auditor: Auditor, sql_alchemy_se uploadingAgentId=uploadingAgentId, auditor=auditor, cache=cache, - session=sql_alchemy_session + to_one_fields=self.to_one_fields, + match_payload=self.match_payload ) class OneToOneTable(UploadTable): - def apply_scoping(self, collection, row=None) -> Tuple[bool, "ScopedOneToOneTable"]: - cache, s = super().apply_scoping(collection, row) - return cache, ScopedOneToOneTable(*s) + def apply_scoping(self, collection, generator: ScopeGenerator = None, row=None) -> "ScopedOneToOneTable": + s = super().apply_scoping(collection, generator, row) + return ScopedOneToOneTable(*s) def to_json(self) -> Dict: return { 'oneToOneTable': self._to_json() } class ScopedOneToOneTable(ScopedUploadTable): - def bind(self, row: Row, uploadingAgentId: int, auditor: Auditor, sql_alchemy_session, cache: Optional[Dict]=None - ) -> Union["BoundOneToOneTable", ParseFailures]: - b = super().bind(row, uploadingAgentId, auditor, sql_alchemy_session, cache) + def bind(self, row: Row, uploadingAgentId: int, auditor: Auditor, cache: Optional[Dict]=None) -> Union["BoundOneToOneTable", ParseFailures]: + b = super().bind(row, uploadingAgentId, auditor, cache) return BoundOneToOneTable(*b) if isinstance(b, BoundUploadTable) else b class MustMatchTable(UploadTable): - def apply_scoping(self, collection, row=None) -> Tuple[bool, "ScopedMustMatchTable"]: - cache, s = super().apply_scoping(collection, row) - return cache, ScopedMustMatchTable(*s) + def apply_scoping(self, collection, generator: ScopeGenerator = None, row=None) -> "ScopedMustMatchTable": + s = super().apply_scoping(collection, generator, row) + return ScopedMustMatchTable(*s) def to_json(self) -> Dict: return { 'mustMatchTable': self._to_json() } class ScopedMustMatchTable(ScopedUploadTable): - def bind(self,row: Row, uploadingAgentId: int, auditor: Auditor, sql_alchemy_session, cache: Optional[Dict]=None + def bind(self,row: Row, uploadingAgentId: int, auditor: Auditor, cache: Optional[Dict]=None ) -> Union["BoundMustMatchTable", ParseFailures]: - b = super().bind(row, uploadingAgentId, auditor, sql_alchemy_session, cache) + b = super().bind(row, uploadingAgentId, auditor, cache) return BoundMustMatchTable(*b) if isinstance(b, BoundUploadTable) else b -from django.db.utils import OperationalError class BoundUploadTable(NamedTuple): name: str static: Dict[str, Any] @@ -180,191 +208,220 @@ class BoundUploadTable(NamedTuple): uploadingAgentId: Optional[int] auditor: Auditor cache: Optional[Dict] - session: Any # TODO: Improve typing - + to_one_fields: Dict[str, List[str]] + match_payload: Optional[Dict[str, Any]] + + @property + def current_id(self): + return None if self.match_payload is None else self.match_payload.get('id') + + @property + def current_version(self): + return None if self.match_payload is None else self.match_payload.get('version', None) + def is_one_to_one(self) -> bool: return False def must_match(self) -> bool: return False + + def can_save(self) -> bool: + return isinstance(self.current_id, int) + + @property + def django_model(self) -> Model: + return getattr(models, self.name.capitalize()) + + @property + def _reference_cache_key(self): + # Caching NEVER changes the logic of the uploads. Only makes things faster. + current_id = self.current_id + assert isinstance(current_id, int), "Attempting to lookup a null record in cache!" + return (REFERENCE_KEY, self.name, current_id) + + @property + def _should_defer_match(self): + return should_defer_fields('match') + + def get_django_predicates(self, should_defer_match: bool, to_one_override: Dict[str, UploadResult] = {}) -> DjangoPredicates: + + model = self.django_model - def get_predicates(self, query: Query, sql_table: SQLTable, to_one_override: Dict[str, UploadResult] = {}, path: List[str] = []) -> PredicateWithQuery: if self.disambiguation is not None: - if getattr(models, self.name.capitalize()).objects.filter(id=self.disambiguation).exists(): - return query, FilterPredicate([Predicate(getattr(sql_table, sql_table._id), self.disambiguation)]) - - specify_table = datamodel.get_table_strict(self.name) + if model.objects.filter(id=self.disambiguation).exists(): + return DjangoPredicates(filters={ + 'id': self.disambiguation + }) - direct_field_pack = FilterPredicate.from_simple_dict( - sql_table, - ((specify_table.get_field_strict(fieldname).name, value) - for parsedField in self.parsedFields - for fieldname, value in parsedField.filter_on.items()), - path=path - ) - - def _reduce( - accumulated: PredicateWithQuery, - # to-ones are converted to a list of one element to simplify handling to-manys - current: Tuple[str, Union[List[BoundUploadable], BoundUploadable]], - # to-one and to-many handle return filter packs differently - specialize_callback: Callable[[FilterPredicate, BoundUploadable, Relationship, SQLTable, List[str]], Optional[FilterPredicate]] - ) -> PredicateWithQuery: - current_query, current_predicates = accumulated - relationship_name, upload_tables = current - if not isinstance(upload_tables, list): - upload_tables = [upload_tables] - relationship = specify_table.get_relationship(relationship_name) - related_model_name = relationship.relatedModelName - - def _uploadables_reduce(accum: Tuple[PredicateWithQuery, List[ColumnElement], int], uploadable: BoundUploadable) -> Tuple[PredicateWithQuery, List[ColumnElement], int]: - next_sql_model: SQLTable = aliased(getattr(sql_models, related_model_name)) - (query, previous_predicate), to_ignore, index = accum - _id = getattr(next_sql_model, next_sql_model._id) - extended_criterions = [_id != previous_id for previous_id in to_ignore] - criterion = sql.and_(*extended_criterions) - - joined = query.join( - next_sql_model, - getattr(sql_table, relationship.name), - ) - if len(extended_criterions): - # to make sure matches are record-aligned - # disable this, and see what unit test fails to figure out what it does - joined = joined.filter(criterion) - next_query, _raw_field_pack = uploadable.get_predicates(joined, next_sql_model, path=[*path, repr((index, relationship_name))]) - to_merge = specialize_callback(_raw_field_pack, uploadable, relationship, sql_table, path) - if to_merge is not None: - next_query = query - else: - to_ignore = [*to_ignore, _id] - to_merge = _raw_field_pack - return (next_query, previous_predicate.merge(to_merge)), to_ignore, index + 1 - - reduced, _, __ = reduce(_uploadables_reduce, upload_tables, ((current_query, current_predicates), [], 0)) - return reduced + if self.current_id == NULL_RECORD: + return SkippablePredicate() - to_one_reduce = lambda accum, curr: _reduce(accum, curr, FilterPredicate.to_one_augment) - to_many_reduce = lambda accum, curr: _reduce(accum, curr, FilterPredicate.to_many_augment) - - # this is handled here to make the matching query simple for the root table - if to_one_override: - to_one_override_pack = FilterPredicate.from_simple_dict( - sql_table, - ((FilterPredicate.rel_to_fk(specify_table.get_relationship(rel)), value.get_id()) for (rel, value) in to_one_override.items()), - path - ) - else: - to_one_override_pack = FilterPredicate() + # This is always the first hit, for both the updates/deletes and uploads. + record_ref = self._get_reference() + attrs = {} if (record_ref is None or should_defer_match) else self._resolve_reference_attributes(model, record_ref) - query, to_one_pack = reduce( - to_one_reduce, - # useful for one-to-ones - [(key, value) for (key, value) in self.toOne.items() if key not in to_one_override], - (query, to_one_override_pack) - ) + direct_filters = { + fieldname: value + for parsedField in self.parsedFields + for fieldname, value in parsedField.filter_on.items() + } - query, to_many_pack = reduce(to_many_reduce, self.toMany.items(), (query, FilterPredicate())) - accumulated_pack = direct_field_pack.merge(to_many_pack).merge(to_one_pack) + to_ones = { + key: to_one_override[key].get_id() + if key in to_one_override + # For simplicity in typing, to-ones are also considered as a list + else value.get_django_predicates(should_defer_match=should_defer_match).reduce_for_to_one() + for key, value in self.toOne.items() + } - is_reducible = not (any(value[1] is not None for value in accumulated_pack.filter)) - if is_reducible: - # don't care about excludes anymore - return query, FilterPredicate() + to_many = { + key: [value.get_django_predicates(should_defer_match=should_defer_match).reduce_for_to_many(value) for value in values] + for key, values in self.toMany.items() + } - static_predicate = FilterPredicate.from_simple_dict(sql_table, iter(self.map_static_to_db().items()), path) + combined_filters = DjangoPredicates(filters={**attrs, **direct_filters, **to_ones, **to_many}) - return query, static_predicate.merge(accumulated_pack) - - def map_static_to_db(self) -> Filter: - model = getattr(models, self.name.capitalize()) - table = datamodel.get_table_strict(self.name) - raw_attrs = {**self.scopingAttrs, **self.static} - - return { - FilterPredicate.rel_to_fk(table.get_field_strict(model._meta.get_field(direct_field).name)): value - for (direct_field, value) in raw_attrs.items() - } + if combined_filters.is_reducible(): + return DjangoPredicates() + combined_filters = combined_filters._replace(filters={**combined_filters.filters, **self.scopingAttrs, **self.static}) + return combined_filters + + def get_to_remove(self) -> ToRemove: + return ToRemove(model_name=self.name, filter_on={**self.scopingAttrs, **self.static}) + def process_row(self) -> UploadResult: - return self._handle_row(force_upload=False) + return self._handle_row(skip_match=False, allow_null=True) def force_upload_row(self) -> UploadResult: - return self._handle_row(force_upload=True) + return self._handle_row(skip_match=True, allow_null=True) def match_row(self) -> UploadResult: return BoundMustMatchTable(*self).process_row() + + def save_row(self, force=False) -> UploadResult: + current_id = self.current_id + if current_id is None: + return self.force_upload_row() + update_table = BoundUpdateTable(*self) + return update_table.process_row() if force else update_table.process_row_with_null() + + def _get_reference(self, should_cache=True) -> Optional[Model]: + model: Model = self.django_model + current_id = self.current_id + + if current_id is None: + return None + + cache_key = self._reference_cache_key + cache_hit = None if self.cache is None else self.cache.get(cache_key, None) + + if cache_hit is not None: + if not should_cache: + # As an optimization, for the first update, return the cached one, but immediately evict it. + # Currently, it is not possible for more than 1 successive write-intent access to _get_reference so this is very good for it. + # If somewhere, somehow, we do have more than that, this algorithm still works, since the read/write table evicts it. + # Eample: If we do have more than 1, the first one will evict it, and then the second one will refetch it (won't get a cache hit) -- cache coherency not broken + # Using pop as a _different_ memory optimization. + assert self.cache is not None + self.cache.pop(cache_key) + return cache_hit + + reference_record = safe_fetch(model, {'id':current_id}, self.current_version) + + if should_cache and self.cache is not None: + self.cache[cache_key] = reference_record + + return reference_record + + + def _resolve_reference_attributes(self, model, reference_record) -> Dict[str, Any]: + + return resolve_reference_attributes(self.scopingAttrs.keys(), model, reference_record) - def _handle_row(self, force_upload: bool) -> UploadResult: - model = getattr(models, self.name.capitalize()) + def _handle_row(self, skip_match: bool, allow_null: bool) -> UploadResult: + model = self.django_model if self.disambiguation is not None: if model.objects.filter(id=self.disambiguation).exists(): return UploadResult(Matched(id=self.disambiguation, info=ReportInfo(self.name, [], None)), {}, {}) info = ReportInfo(tableName=self.name, columns=[pr.column for pr in self.parsedFields], treeInfo=None) - toOneResults_ = self._process_to_ones() + current_id = self.current_id - toOneResults = { - field: result - for field, result in toOneResults_.items() - } + assert current_id != NULL_RECORD, "found handling a NULL record!" - if any(result.get_id() == "Failure" for result in toOneResults.values()): - return UploadResult(PropagatedFailure(), toOneResults, {}) + to_one_results = self._process_to_ones() + if any(result.get_id() == "Failure" for result in to_one_results.values()): + return UploadResult(PropagatedFailure(), to_one_results, {}) + attrs = { fieldname_: value for parsedField in self.parsedFields for fieldname_, value in parsedField.upload.items() } - base_sql_table = getattr(sql_models, datamodel.get_table_strict(self.name).name) - query, filter_predicate = self.get_predicates(self.session.query(getattr(base_sql_table, base_sql_table._id)), base_sql_table, toOneResults) + # This is very handy to check for whether the entire record needs to be skipped or not. + # This also returns predicates for to-many, we if this is empty, we really are a null record + try: + filter_predicate = self.get_django_predicates(should_defer_match=self._should_defer_match, to_one_override=to_one_results) + except ContetRef as e: + # Not sure if there is a better way for this. Consider moving this to binding. + return UploadResult(FailedBusinessRule(str(e), {}, info), to_one_results, {}) - if all(v is None for v in attrs.values()) and not filter_predicate.filter: + attrs = { + **({} if should_defer_fields('null_check') else self._resolve_reference_attributes(model, self._get_reference())), + **attrs + } + + if (all(v is None for v in attrs.values()) and not filter_predicate.filters) and allow_null: # nothing to upload - return UploadResult(NullRecord(info), toOneResults, {}) + return UploadResult(NullRecord(info), to_one_results, {}) - if not force_upload: - match = self._match(query, filter_predicate, info) + if not skip_match: + match = self._match(filter_predicate, info) if match: - return UploadResult(match, toOneResults, {}) + return UploadResult(match, to_one_results, {}) - return self._do_upload(model, toOneResults, info) + return self._do_upload(model, to_one_results, info) def _process_to_ones(self) -> Dict[str, UploadResult]: return { fieldname: to_one_def.process_row() for fieldname, to_one_def in - sorted(self.toOne.items(), key=lambda kv: kv[0]) # make the upload order deterministic + Func.sort_by_key(self.toOne) # make the upload order deterministic # we don't care about being able to process one-to-one. Instead, we include them in the matching predicates. # this allows handing "MatchedMultiple" case of one-to-ones more gracefully, while allowing us to include them - # in the matching. See "test_ambiguous_one_to_one_match" in testuploading.py + # in the matching. See "test_ambiguous_one_to_one_match" in testuploading.py. + # BUT, we need to still perform a save incase we are updating. if not to_one_def.is_one_to_one() } - def _match(self, query: Query, predicate: FilterPredicate, info: ReportInfo) -> Union[Matched, MatchedMultiple, None]: - assert predicate.filter or predicate.exclude, "Attempting to match a null record!" - cache_key = predicate.cache_key() + def _match(self, predicates: DjangoPredicates, info: ReportInfo) -> Union[Matched, MatchedMultiple, None]: + + cache_key = predicates.get_cache_key(self.name) cache_hit: Optional[List[int]] = self.cache.get(cache_key, None) if self.cache is not None else None if cache_hit is not None: ids = cache_hit else: - query = predicate.apply_to_query(query) - try: - query = query.distinct().limit(10) - raw_ids: List[Tuple[int, Any]] = list(query) - ids = [_id[0] for _id in raw_ids] - except OperationalError as e: - if e.args[0] == "(MySQLdb.OperationalError) (1065, 'Query was empty')": - ids = [] - else: - raise - if self.cache is not None and ids: - self.cache[cache_key] = ids + query = predicates.apply_to_query(self.name).values_list('id', flat=True) + current_id = self.current_id + ids = [] + if current_id is not None: + # Consider user added a column in query which is not unique. We'll always get more than one match in that case. That is, very likely, not the intended + # behaviour is. To handle that case, run the query twice. First, using the id we have, then without it, if we don't find a match. + # I don't want to cache this, since we got lucky. we can't naively compare the attributes though, we'll incorrectly ignore + # filters on to-many in that case. can't get more than one match though. I guess we could cache this if we add id to predicates... + query_with_self = query.filter(id=current_id) + ids = list(query_with_self) + if not ids: + query = query[:10] + ids = list(query.values_list('id', flat=True)) + if self.cache is not None and ids: + self.cache[cache_key] = ids n_matched = len(ids) if n_matched > 1: @@ -374,7 +431,7 @@ def _match(self, query: Query, predicate: FilterPredicate, info: ReportInfo) -> else: return None - def _do_upload(self, model, toOneResults: Dict[str, UploadResult], info: ReportInfo) -> UploadResult: + def _check_missing_required(self) -> Optional[ParseFailures]: missing_requireds = [ # TODO: there should probably be a different structure for # missing required fields than ParseFailure @@ -384,66 +441,126 @@ def _do_upload(self, model, toOneResults: Dict[str, UploadResult], info: ReportI ] if missing_requireds: - return UploadResult(ParseFailures(missing_requireds), toOneResults, {}) + return ParseFailures(missing_requireds) + + return None + + def _do_upload(self, model, to_one_results: Dict[str, UploadResult], info: ReportInfo) -> UploadResult: + missing_required = self._check_missing_required() + + if missing_required is not None: + return UploadResult(missing_required, to_one_results, {}) + attrs = { fieldname_: value for parsedField in self.parsedFields for fieldname_, value in parsedField.upload.items() } - # replace any one-to-one records that matched with forced uploads - toOneResults = {**toOneResults, **{ + # by the time we get here, we know we need to so something. + to_one_results = {**to_one_results, **{ fieldname: to_one_def.force_upload_row() for fieldname, to_one_def in # Make the upload order deterministic (maybe? depends on if it matched I guess) # But because the records can't be shared, the unupload order shouldn't matter anyways... - sorted(self.toOne.items(), key=lambda kv: kv[0]) + Func.sort_by_key(self.toOne) if to_one_def.is_one_to_one() }} - toOneIds: Dict[str, Optional[int]] = {} - for field, result in toOneResults.items(): + to_one_ids: Dict[str, Optional[int]] = {} + for field, result in to_one_results.items(): id = result.get_id() if id == "Failure": - return UploadResult(PropagatedFailure(), toOneResults, {}) - toOneIds[field] = id + return UploadResult(PropagatedFailure(), to_one_results, {}) + to_one_ids[field] = id - with transaction.atomic(): - try: - uploaded = self._do_insert(model, **{ - **({'createdbyagent_id': self.uploadingAgentId} if model.specify_model.get_field('createdbyagent') else {}), + new_attrs = { **attrs, **self.scopingAttrs, **self.static, - **{ model._meta.get_field(fieldname).attname: id for fieldname, id in toOneIds.items() }, - }) + **{ model._meta.get_field(fieldname).attname: id for fieldname, id in to_one_ids.items() }, + **({'createdbyagent_id': self.uploadingAgentId} if model.specify_model.get_field('createdbyagent') else {}) + } + + with transaction.atomic(): + try: + if self.current_id is None: + uploaded = self._do_insert(model, new_attrs) + else: + uploaded = self._do_clone(new_attrs) picklist_additions = self._do_picklist_additions() except (BusinessRuleException, IntegrityError) as e: - return UploadResult(FailedBusinessRule(str(e), {}, info), toOneResults, {}) + return UploadResult(FailedBusinessRule(str(e), {}, info), to_one_results, {}) + + record = Uploaded(uploaded.id, info, picklist_additions) - self.auditor.insert(uploaded, self.uploadingAgentId, None) + to_many_results = self._handle_to_many(False, record.get_id(), model) - toManyResults = { - fieldname: _upload_to_manys(model, uploaded.id, fieldname, self.uploadingAgentId, self.auditor, self.cache, records, self.session) + return UploadResult(record, to_one_results, to_many_results) + + def _handle_to_many(self, update: bool, parent_id: int, model: Model): + return { + fieldname: _upload_to_manys(model, parent_id, fieldname, update, records, self._relationship_is_dependent(fieldname)) for fieldname, records in - sorted(self.toMany.items(), key=lambda kv: kv[0]) # make the upload order deterministic + Func.sort_by_key(self.toMany) } - return UploadResult(Uploaded(uploaded.id, info, picklist_additions), toOneResults, toManyResults) - - def _do_insert(self, model, **attrs) -> Any: - return model.objects.create(**attrs) + def _do_insert(self, model, attrs) -> Any: + inserter = self._get_inserter() + return inserter(model, attrs) + + def _do_clone(self, attrs) -> Any: + inserter = self._get_inserter() + to_ignore = [ + *self.toOne.keys(), # Don't touch mapped to-ones + *self.toMany.keys(), # Don't touch mapped to-manys + ] + return clone_record(self._get_reference(), inserter, self.to_one_fields, to_ignore, attrs) + + def _get_inserter(self): + def _inserter(model, attrs): + uploaded = model.objects.create(**attrs) + self.auditor.insert(uploaded, None) + return uploaded + return _inserter + def _do_picklist_additions(self) -> List[PicklistAddition]: added_picklist_items = [] for parsedField in self.parsedFields: if parsedField.add_to_picklist is not None: a = parsedField.add_to_picklist pli = a.picklist.picklistitems.create(value=a.value, title=a.value, createdbyagent_id=self.uploadingAgentId) - self.auditor.insert(pli, self.uploadingAgentId, None) + self.auditor.insert(pli, None) added_picklist_items.append(PicklistAddition(name=a.picklist.name, caption=a.column, value=a.value, id=pli.id)) return added_picklist_items - + + def delete_row(self, info, parent_obj=None) -> UploadResult: + if self.current_id is None: + return UploadResult(NullRecord(info), {}, {}) + # By the time we are here, we know if we can't have a not null to-one or to-many mapping. + # So, we can just go ahead and follow the general delete protocol. Don't need version-control here. + # Also caching still works (we'll, always, get a hit) because updates and deletes are independent (update wouldn't have been called). + reference_record = self._get_reference( + should_cache=False # Need to evict the last copy, in case someone tries accessing it, we'll then get a stale record + ) + result: Optional[Union[Deleted, FailedBusinessRule]] = None + with transaction.atomic(): + try: + delete_obj(reference_record, parent_obj=parent_obj, deleter=self.auditor.delete) + result = Deleted(self.current_id, info) + except (BusinessRuleException, IntegrityError) as e: + result = FailedBusinessRule(str(e), {}, info) + assert result is not None + return UploadResult(result, {}, {}) + + def _relationship_is_dependent(self, field_name) -> bool: + django_model = self.django_model + # We could check to_one_fields, but we are not going to, because that is just redundant with is_one_to_one. + if field_name in self.toOne: + return self.toOne[field_name].is_one_to_one() + return django_model.specify_model.get_relationship(field_name).dependent # type: ignore + class BoundOneToOneTable(BoundUploadTable): def is_one_to_one(self) -> bool: return True @@ -465,23 +582,170 @@ def _process_to_ones(self) -> Dict[str, UploadResult]: def _do_upload(self, model, toOneResults: Dict[str, UploadResult], info: ReportInfo) -> UploadResult: return UploadResult(NoMatch(info), toOneResults, {}) - -def _upload_to_manys(parent_model, parent_id, parent_field, uploadingAgentId: Optional[int], auditor: Auditor, cache: Optional[Dict], records, session) -> List[UploadResult]: +def _upload_to_manys(parent_model, parent_id, parent_field, is_update, records, is_dependent) -> List[UploadResult]: fk_field = parent_model._meta.get_field(parent_field).remote_field.attname + bound_tables = [record._replace(disambiguation=None, static={**record.static, fk_field: parent_id}) for record in records] + return [(record.force_upload_row() if not is_update else record.save_row(force=(not is_dependent))) for record in bound_tables] + + +class BoundUpdateTable(BoundUploadTable): + + def process_row(self): + return self._handle_row(skip_match=True, allow_null=False) + + def process_row_with_null(self): + return self._handle_row(skip_match=True, allow_null=True) + + @property + def _should_defer_match(self): + # Complicated. consider the case where deferForMatch is true. In that case, we can't always just defer fields, + # because during updates, we'd wrongly skip to-manys -- and possibly delete them -- if they contain field values (not visible) BUT get skipped due to above. + # So, we handle it by always going by null_check ONLY IF we know we are doing an update, which we know at this point. + return should_defer_fields('null_check') + + def _handle_row(self, skip_match: bool, allow_null: bool): + assert self.disambiguation is None, "Did not epect disambigution for update tables!" + assert self.match_payload is not None, "Trying to perform a save on unhandled type of payload!" + assert self.current_id is not None, "Did not find any identifier to go by. You likely meant to upload instead of save" + + current_id = self.current_id - return [ - BoundUploadTable( - name=record.name, - scopingAttrs=record.scopingAttrs, - disambiguation=None, - parsedFields=record.parsedFields, - toOne=record.toOne, - static={**record.static, fk_field: parent_id}, - toMany=record.toMany, - uploadingAgentId=uploadingAgentId, - auditor=auditor, - cache=cache, - session=session - ).force_upload_row() - for record in records - ] \ No newline at end of file + info = ReportInfo(tableName=self.name, columns=[pr.column for pr in self.parsedFields], treeInfo=None) + + if current_id == NULL_RECORD: + return UploadResult(NoChange(current_id, info), {}, {}) + + return super()._handle_row(skip_match=True, allow_null=allow_null) + + def _process_to_ones(self) -> Dict[str, UploadResult]: + return { + field_name: (to_one_def.save_row() if to_one_def.is_one_to_one() else to_one_def.process_row()) + for field_name, to_one_def in + Func.sort_by_key(self.toOne) + } + + def _do_upload(self, model, to_one_results: Dict[str, UploadResult], info: ReportInfo) -> UploadResult: + + missing_required = self._check_missing_required() + + if missing_required is not None: + return UploadResult(missing_required, to_one_results, {}) + + attrs = { + **{ + fieldname_: value + for parsedField in self.parsedFields + for fieldname_, value in parsedField.upload.items() + }, + **self.scopingAttrs, + **self.static + } + + to_one_ids = { + model._meta.get_field(fieldname).attname: result.get_id() for fieldname, result in to_one_results.items() + } + + # Should also always get a cache hit at this point, evict the hit. + reference_record = self._get_reference(should_cache=False) + + assert reference_record is not None + + concrete_field_changes = BoundUpdateTable._field_changed(reference_record, attrs) + + if any(scoping_attr in concrete_field_changes for scoping_attr in self.scopingAttrs.keys()): + # I don't know what else to do. I don't think this will ever get raised. I don't know what I'll need to debug this, so showing everything. + raise Exception(f"Attempting to change the scope of the record: {reference_record} at {self}") + + to_one_changes = BoundUpdateTable._field_changed(reference_record, to_one_ids) + + to_one_matched_and_changed = { + related: result._replace(record_result=MatchedAndChanged(*result.record_result)) + for related, result in to_one_results.items() + if isinstance(result.record_result, Matched) + and model._meta.get_field(related).attname in to_one_changes + } + + to_one_results = { + **to_one_results, + **to_one_matched_and_changed + } + + changed = len(concrete_field_changes) != 0 + + record: Optional[Union[NoChange, Updated]] = None + if not changed: + # We aren't done here. That is, we can't just return from here. This is because we'll need to still look at + # to-manys. There can be changes there that we'd need to catch. Yuk. + record = NoChange(reference_record.pk, info) + + else: + + modified_columns = [parsed.column for parsed in self.parsedFields if (any(fieldname in concrete_field_changes for fieldname in parsed.upload.keys()))] + # Only report modified columns + info = info._replace(columns=modified_columns) + + attrs = { + **attrs, + **to_one_ids, + **({'modifiedbyagent_id': self.uploadingAgentId} if hasattr(reference_record, 'modifiedbyagent_id') else {}) + } + + with transaction.atomic(): + try: + updated = self._do_update(reference_record, [*to_one_changes.values(), *concrete_field_changes.values()], **attrs) + picklist_additions = self._do_picklist_additions() + except (BusinessRuleException, IntegrityError) as e: + return UploadResult(FailedBusinessRule(str(e), {}, info), to_one_results, {}) + + record = record or Updated(updated.pk, info, picklist_additions) + + to_many_results = self._handle_to_many(True, record.get_id(), model) + + to_one_adjusted, to_many_adjusted = self._clean_up_fks(to_one_results, to_many_results) + + return UploadResult(record, to_one_adjusted, to_many_adjusted) + + def _do_update(self, reference_obj, dirty_fields, **attrs): + # TODO: Try handling parent_obj. Quite complicated and ugly. + self.auditor.update(reference_obj, None, dirty_fields) + for (key, value) in attrs.items(): + setattr(reference_obj, key, value) + if hasattr(reference_obj, 'version'): + # Consider using bump_version here. + # I'm not doing it for performance reasons -- we already checked our version at this point, and have a lock, so can just increment the version. + setattr(reference_obj, 'version', getattr(reference_obj, 'version') + 1) + reference_obj.save() + return reference_obj + + def _do_insert(self): + raise Exception("Attempting to insert into a save table directly!") + + def force_upload_row(self) -> UploadResult: + raise Exception("Attempting to force upload! Can't force upload to a save table") + + def _clean_up_fks(self, to_one_results: Dict[str, UploadResult], to_many_results: Dict[str, List[UploadResult]]) -> Tuple[Dict[str, UploadResult], Dict[str, List[UploadResult]]]: + + to_one_deleted = { + key: uploadable.delete_row(to_one_results[key].record_result.info) # type: ignore + for (key, uploadable) in self.toOne.items() + if self._relationship_is_dependent(key) and isinstance(to_one_results[key].record_result, NullRecord) + } + + to_many_deleted = { + key: [ + (uploadable.delete_row(result.record_result.info) if isinstance(result.record_result, NullRecord) else result) + for (result, uploadable) in zip(to_many_results[key], uploadables)] + for (key, uploadables) in self.toMany.items() + if self._relationship_is_dependent(key) + } + + return {**to_one_results, **to_one_deleted}, {**to_many_results, **to_many_deleted} + + @staticmethod + def _field_changed(reference_record, attrs: Dict[str, Any]): + return { + key: FieldChangeInfo(field_name=key, old_value=getattr(reference_record, key), new_value=new_value) # type: ignore + for (key, new_value) in attrs.items() + if getattr(reference_record, key) != new_value + } + \ No newline at end of file diff --git a/specifyweb/workbench/upload/uploadable.py b/specifyweb/workbench/upload/uploadable.py index 8eac777ef8f..2705a85f191 100644 --- a/specifyweb/workbench/upload/uploadable.py +++ b/specifyweb/workbench/upload/uploadable.py @@ -1,26 +1,31 @@ -from typing import Iterator, List, Dict, Tuple, Any, NamedTuple, Optional, TypedDict, Union, Set +from contextlib import contextmanager +import re +from typing import Dict, Generator, Callable, Literal, NamedTuple, Tuple, Any, Optional, TypedDict, Union, Set from typing_extensions import Protocol -from functools import reduce -from sqlalchemy import Table as SQLTable # type: ignore -import sqlalchemy as db # type: ignore -from sqlalchemy.orm import Query # type: ignore -from sqlalchemy.sql.expression import ColumnElement # type: ignore - -from specifyweb.specify.load_datamodel import Field, Relationship -from specifyweb.specify.models import datamodel +from specifyweb.context.remote_prefs import get_remote_prefs +from specifyweb.workbench.upload.predicates import DjangoPredicates, ToRemove from .upload_result import UploadResult, ParseFailures from .auditor import Auditor -import specifyweb.stored_queries.models as sql_models + +NULL_RECORD = 'null_record' + +ScopeGenerator = Optional[Generator[int, None, None]] + +Progress = Callable[[int, Optional[int]], None] + +Row = Dict[str, str] + +Filter = Dict[str, Any] class Uploadable(Protocol): # also returns if the scoped table returned can be cached or not. # depends on whether scope depends on other columns. if any definition is found, # we cannot cache. well, we can make this more complicated by recursviely caching # static parts of even a non-entirely-cachable uploadable. - def apply_scoping(self, collection, row=None) -> Tuple[bool, "ScopedUploadable"]: + def apply_scoping(self, collection, generator: ScopeGenerator = None, row=None) -> "ScopedUploadable": ... def get_cols(self) -> Set[str]: @@ -32,8 +37,6 @@ def to_json(self) -> Dict: def unparse(self) -> Dict: ... -Row = Dict[str, str] - class DisambiguationInfo(Protocol): def disambiguate(self) -> Optional[int]: ... @@ -54,122 +57,14 @@ class ScopedUploadable(Protocol): def disambiguate(self, disambiguation: Disambiguation) -> "ScopedUploadable": ... - def bind(self, row: Row, uploadingAgentId: int, auditor: Auditor, sql_alchemy_session, cache: Optional[Dict]=None) -> Union["BoundUploadable", ParseFailures]: + def bind(self, row: Row, uploadingAgentId: int, auditor: Auditor, cache: Optional[Dict]=None) -> Union["BoundUploadable", ParseFailures]: ... def get_treedefs(self) -> Set: ... - -Filter = Dict[str, Any] - -def filter_match_key(f: Filter) -> str: - return repr(sorted(f.items())) - -class Matchee(TypedDict): - # need to reference the fk in the penultimate table in join to to-many - ref: ColumnElement - # which column to use in the table (== the otherside of ref) - backref: str - filters: Filter - path: List[str] - -def matchee_to_key(matchee: Matchee): - return (matchee['backref'], matchee['path'], filter_match_key(matchee['filters'])) - -class Predicate(NamedTuple): - ref: ColumnElement - value: Any = None - path: List[str] = [] - -class FilterPredicate(NamedTuple): - # gets flatenned into ANDs - filter: List[Predicate] = [] - # basically the entire exclude can be flatenned into ORs. (NOT A and NOT B -> NOT (A or B)) - # significantly reduces the tables needed in the look-up query (vs Django's default) - exclude: Dict[ - str, # the model name - List[Matchee] # list of found references - ] = {} - - def merge(self, other: 'FilterPredicate') -> 'FilterPredicate': - filters = [*self.filter, *other.filter] - exclude = reduce( - lambda accum, current: {**accum, current[0]: [*accum.get(current[0], []), *current[1]]}, - other.exclude.items(), - self.exclude - ) - return FilterPredicate(filters, exclude) - def to_one_augment(self, uploadable: 'BoundUploadable', relationship: Relationship, sql_table: SQLTable, path: List[str]) -> Optional['FilterPredicate']: - if self.filter or self.exclude: - return None - return FilterPredicate([Predicate(getattr(sql_table, relationship.column), None, [*path, relationship.name])]) - - def to_many_augment(self, uploadable: 'BoundUploadable', relationship: Relationship, sql_table: SQLTable, path: List[str]) -> Optional['FilterPredicate']: - if self.filter: - return None - - # nested excludes don't make sense and complicates everything. this avoids it (while keeping semantics same) - return FilterPredicate(exclude={ - relationship.relatedModelName: [{ - 'ref': getattr(sql_table, sql_table._id), - 'backref': relationship.otherSideName, - 'filters': uploadable.map_static_to_db(), - 'path': path - }] - }) - - @staticmethod - def from_simple_dict(sql_table: SQLTable, iterator: Iterator[Tuple[str, Any]], path:List[str]=[]): - # REFACTOR: make this inline? - return FilterPredicate( - [Predicate(getattr(sql_table, fieldname), value, [*path, fieldname]) - for fieldname, value in iterator] - ) - - def apply_to_query(self, query: Query) -> Query: - direct = db.and_(*[(field == value) for field, value, _ in self.filter]) - excludes = db.or_(*[FilterPredicate._map_exclude(items) for items in self.exclude.items()]) - filter_by = direct if not self.exclude else db.and_( - direct, - db.not_(excludes) - ) - return query.filter(filter_by) - - @staticmethod - def _map_exclude(current: Tuple[str, List[Matchee]]): - model_name, matches = current - sql_table = getattr(sql_models, model_name) - table = datamodel.get_table_strict(model_name) - assert len(matches) > 0, "got nothing to exclude" - - criterion = [ - db.and_( - getattr(sql_table, table.get_relationship(matchee['backref']).column) == matchee['ref'], - db.and_( - *[ - getattr(sql_table, field) == value - for field, value in matchee['filters'].items() - ] - ) - ) - for matchee in matches - ] - - # dbs limit 1 anyways... - return (db.exists(db.select([1])).where(db.or_(*criterion))) - - @staticmethod - def rel_to_fk(field: Field): - return field.column if field.is_relationship else field.name - - def cache_key(self) -> str: - filters = sorted((repr(_filter.path), _filter.value) for _filter in self.filter) - excludes = sorted((key, sorted(matchee_to_key(value) for value in values)) for (key, values) in self.exclude.items()) - return repr((filters, excludes)) - -PredicateWithQuery = Tuple[Query, FilterPredicate] - + def apply_batch_edit_pack(self, batch_edit_pack: Optional[Dict[str, Any]]) -> "ScopedUploadable": + ... class BoundUploadable(Protocol): def is_one_to_one(self) -> bool: @@ -178,7 +73,10 @@ def is_one_to_one(self) -> bool: def must_match(self) -> bool: ... - def get_predicates(self, query: Query, sql_table: SQLTable, to_one_override: Dict[str, UploadResult]={}, path: List[str] = []) -> PredicateWithQuery: + def get_django_predicates(self, should_defer_match: bool, to_one_override: Dict[str, UploadResult] = {}) -> DjangoPredicates: + ... + + def get_to_remove(self) -> ToRemove: ... def match_row(self) -> UploadResult: @@ -189,6 +87,13 @@ def process_row(self) -> UploadResult: def force_upload_row(self) -> UploadResult: ... + + def save_row(self, force=False) -> UploadResult: + ... - def map_static_to_db(self) -> Filter: + # I don't want to use dataset's isupdate=True, so using this. That is, the entire "batch edit" can work perfectly fine using workbench. + def can_save(self) -> bool: ... + + def delete_row(self, info, parent_obj=None) -> UploadResult: + ... \ No newline at end of file diff --git a/specifyweb/workbench/views.py b/specifyweb/workbench/views.py index ef325e0ef93..cc517062ce5 100644 --- a/specifyweb/workbench/views.py +++ b/specifyweb/workbench/views.py @@ -21,7 +21,7 @@ check_permission_targets, check_table_permissions from . import models, tasks from .upload import upload as uploader, upload_plan_schema -from .upload.upload import do_upload_dataset +from .upload.upload import do_upload_dataset, rollback_batch_edit logger = logging.getLogger(__name__) @@ -329,7 +329,13 @@ def datasets(request) -> http.HttpResponse: return http.JsonResponse(models.Spdataset.get_meta_fields( request, ["uploadresult"], - {'uploadplan__isnull':False} if 'with_plan' in request.GET else None + { + **({'uploadplan__isnull':False} if request.GET.get('with_plan', 0) else {}), + # Defaults to false, to not have funny behaviour if frontend omits isupdate. + # That is, assume normal dataset is needed unless specifically told otherwise. + **({'isupdate': request.GET.get('isupdate', False)}), + **({'parent_id': None}) + } ), safe=False) @openapi(schema={ @@ -626,8 +632,6 @@ def upload(request, ds, no_commit: bool, allow_partial: bool) -> http.HttpRespon return http.HttpResponse('dataset has already been uploaded.', status=400) taskid = str(uuid4()) - do_upload_dataset(request.specify_collection, request.specify_user_agent.id, ds, no_commit, allow_partial, None) - """ async_result = tasks.upload.apply_async([ request.specify_collection.id, request.specify_user_agent.id, @@ -640,8 +644,7 @@ def upload(request, ds, no_commit: bool, allow_partial: bool) -> http.HttpRespon 'taskid': taskid } ds.save(update_fields=['uploaderstatus']) - """ - return http.JsonResponse('ok', safe=False) + return http.JsonResponse(taskid, safe=False) @openapi(schema={ @@ -680,14 +683,14 @@ def unupload(request, ds) -> http.HttpResponse: return http.HttpResponse('dataset has not been uploaded.', status=400) taskid = str(uuid4()) - async_result = tasks.unupload.apply_async([ds.id, request.specify_user_agent.id], task_id=taskid) + async_result = tasks.unupload.apply_async([ds.id, request.specify_collection.id, request.specify_user_agent.id], task_id=taskid) ds.uploaderstatus = { 'operation': "unuploading", 'taskid': taskid } ds.save(update_fields=['uploaderstatus']) - return http.JsonResponse(async_result.id, safe=False) + return http.JsonResponse('w', safe=False) # @login_maybe_required From 4ad5b11cecc36e77bccd864bf98cecb9099c87d6 Mon Sep 17 00:00:00 2001 From: realVinayak Date: Fri, 16 Aug 2024 16:50:10 -0500 Subject: [PATCH 27/63] (bug): mypy + .env --- .env | 9 +++++---- .../workbench/upload/tests/test_upload_results_json.py | 7 +------ 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/.env b/.env index ac6f25ca36b..4c2e7531890 100644 --- a/.env +++ b/.env @@ -1,7 +1,8 @@ + DATABASE_HOST=mariadb DATABASE_PORT=3306 -MYSQL_ROOT_PASSWORD=root -DATABASE_NAME=nbm_mnb_8_5 +MYSQL_ROOT_PASSWORD=password +DATABASE_NAME=specify # When running Specify 7 for the first time or during updates that # require migrations, ensure that the MASTER_NAME and MASTER_PASSWORD @@ -10,7 +11,7 @@ DATABASE_NAME=nbm_mnb_8_5 # After launching Specify and verifying the update is complete, you can # safely replace these credentials with the master SQL user name and password. MASTER_NAME=root -MASTER_PASSWORD=root +MASTER_PASSWORD=password # Make sure to set the `SECRET_KEY` to a unique value SECRET_KEY=change_this_to_some_unique_random_string @@ -39,4 +40,4 @@ LOG_LEVEL=WARNING # should only be used during development and troubleshooting and not # during general use. Django applications leak memory when operated # continuously in debug mode. -SP7_DEBUG=true +SP7_DEBUG=true \ No newline at end of file diff --git a/specifyweb/workbench/upload/tests/test_upload_results_json.py b/specifyweb/workbench/upload/tests/test_upload_results_json.py index f6a38405589..68d82ac7d3f 100644 --- a/specifyweb/workbench/upload/tests/test_upload_results_json.py +++ b/specifyweb/workbench/upload/tests/test_upload_results_json.py @@ -47,12 +47,7 @@ def testParseFailures(self, parseFailures: ParseFailures): self.assertEqual(parseFailures, ParseFailures.from_json(json.loads(j))) @given(noChange=infer) - def testParseFailures(self, noChange: NoChange): - j = json.dumps(noChange.to_json()) - self.assertEqual(noChange, NoChange.from_json(json.loads(j))) - - @given(noChange=infer) - def testParseFailures(self, noChange: NoChange): + def testNoChange(self, noChange: NoChange): j = json.dumps(noChange.to_json()) self.assertEqual(noChange, NoChange.from_json(json.loads(j))) From 755eba245459bdc7f9b223020f5327e44924cb59 Mon Sep 17 00:00:00 2001 From: realVinayak Date: Fri, 16 Aug 2024 16:54:03 -0500 Subject: [PATCH 28/63] (bug): mypy resolve --- .../workbench/upload/tests/test_upload_results_json.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/specifyweb/workbench/upload/tests/test_upload_results_json.py b/specifyweb/workbench/upload/tests/test_upload_results_json.py index 68d82ac7d3f..2004cf1bafd 100644 --- a/specifyweb/workbench/upload/tests/test_upload_results_json.py +++ b/specifyweb/workbench/upload/tests/test_upload_results_json.py @@ -52,17 +52,17 @@ def testNoChange(self, noChange: NoChange): self.assertEqual(noChange, NoChange.from_json(json.loads(j))) @given(updated=infer) - def testUploaded(self, updated: Updated): + def testUpdated(self, updated: Updated): j = json.dumps(updated.to_json()) self.assertEqual(updated, Updated.from_json(json.loads(j))) @given(deleted=infer) - def testUploaded(self, deleted: Deleted): + def testDeleted(self, deleted: Deleted): j = json.dumps(deleted.to_json()) self.assertEqual(deleted, Deleted.from_json(json.loads(j))) @given(matchedAndChanged=infer) - def testUploaded(self, matchedAndChanged: MatchedAndChanged): + def testMatchedAndChanged(self, matchedAndChanged: MatchedAndChanged): j = json.dumps(matchedAndChanged.to_json()) self.assertEqual(matchedAndChanged, MatchedAndChanged.from_json(json.loads(j))) From 1e8e90be9b893b965fff3beaece1a770b690450b Mon Sep 17 00:00:00 2001 From: realVinayak Date: Mon, 19 Aug 2024 08:29:18 -0500 Subject: [PATCH 29/63] (batch-edit): unit tests + bug resolves --- .../js_src/lib/components/BatchEdit/index.tsx | 105 +- .../lib/components/QueryBuilder/Wrapped.tsx | 4 +- .../js_src/lib/components/Router/Routes.tsx | 3 +- .../lib/components/Toolbar/WbsDialog.tsx | 321 ++- .../js_src/lib/components/WbImport/helpers.ts | 11 +- .../lib/components/WbPlanView/State.tsx | 6 +- .../lib/components/WbPlanView/index.tsx | 19 +- .../lib/components/WbPlanView/navigator.ts | 14 +- .../components/WbPlanView/navigatorSpecs.ts | 7 - .../lib/components/WorkBench/DataSetMeta.tsx | 7 +- .../components/WorkBench/batchEditHelpers.ts | 17 +- .../js_src/lib/components/WorkBench/index.tsx | 27 +- .../js_src/lib/localization/batchEdit.ts | 11 +- .../js_src/lib/localization/workbench.ts | 2 +- .../js_src/lib/utils/cache/definitions.ts | 1 + specifyweb/specify/api.py | 4 +- specifyweb/specify/func.py | 43 +- specifyweb/specify/tests/test_api.py | 727 +++-- specifyweb/specify/tests/test_trees.py | 2 +- specifyweb/specify/tree_views.py | 405 +-- specifyweb/stored_queries/batch_edit.py | 898 ++++-- specifyweb/stored_queries/execution.py | 705 +++-- specifyweb/stored_queries/models.py | 11 +- .../stored_queries/tests/static/test_plan.py | 229 ++ .../stored_queries/tests/test_batch_edit.py | 2410 +++++++++++++++++ .../stored_queries/tests/test_format.py | 370 +++ specifyweb/stored_queries/tests/tests.py | 295 ++ .../{tests.py => tests/tests_legacy.py} | 649 +---- specifyweb/workbench/models.py | 3 +- specifyweb/workbench/tests.py | 94 +- specifyweb/workbench/upload/clone.py | 6 +- specifyweb/workbench/upload/preferences.py | 1 + specifyweb/workbench/upload/scoping.py | 22 +- specifyweb/workbench/upload/tests/base.py | 3 +- .../upload/tests/test_batch_edit_table.py | 640 +++++ .../workbench/upload/tests/test_bugs.py | 6 +- .../workbench/upload/tests/testuploading.py | 1 - specifyweb/workbench/upload/treerecord.py | 14 +- specifyweb/workbench/upload/upload.py | 4 +- specifyweb/workbench/upload/upload_table.py | 684 +++-- specifyweb/workbench/views.py | 6 +- 41 files changed, 6661 insertions(+), 2126 deletions(-) create mode 100644 specifyweb/stored_queries/tests/static/test_plan.py create mode 100644 specifyweb/stored_queries/tests/test_batch_edit.py create mode 100644 specifyweb/stored_queries/tests/test_format.py create mode 100644 specifyweb/stored_queries/tests/tests.py rename specifyweb/stored_queries/{tests.py => tests/tests_legacy.py} (60%) create mode 100644 specifyweb/workbench/upload/tests/test_batch_edit_table.py diff --git a/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx b/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx index 55a070b1f4b..923181b398e 100644 --- a/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx @@ -3,30 +3,29 @@ import { SpecifyResource } from '../DataModel/legacyTypes'; import { GeographyTreeDefItem, SpQuery, Tables } from '../DataModel/types'; import { Button } from '../Atoms/Button'; import { useNavigate } from 'react-router-dom'; -import { keysToLowerCase } from '../../utils/utils'; +import { keysToLowerCase, sortFunction, group } from '../../utils/utils'; import { serializeResource } from '../DataModel/serializers'; import { ajax } from '../../utils/ajax'; import { QueryField } from '../QueryBuilder/helpers'; -import { defined, filterArray, localized, RA } from '../../utils/types'; +import { defined, filterArray, RA } from '../../utils/types'; import { generateMappingPathPreview } from '../WbPlanView/mappingPreview'; import { batchEditText } from '../../localization/batchEdit'; import { uniquifyDataSetName } from '../WbImport/helpers'; import { QueryFieldSpec } from '../QueryBuilder/fieldSpec'; -import { State } from 'typesafe-reducer'; import { isNestedToMany } from '../WbPlanView/modelHelpers'; -import { LocalizedString } from 'typesafe-i18n'; -import {strictGetTreeDefinitionItems, treeRanksPromise } from '../InitialContext/treeRanks'; -import { SerializedResource } from '../DataModel/helperTypes'; +import {isTreeTable, strictGetTreeDefinitionItems, treeRanksPromise } from '../InitialContext/treeRanks'; +import { AnyTree, SerializedResource } from '../DataModel/helperTypes'; import { f } from '../../utils/functools'; import { LoadingContext } from '../Core/Contexts'; import { commonText } from '../../localization/common'; import { Dialog } from '../Molecules/Dialog'; -import { formatConjunction } from '../Atoms/Internationalization'; import { dialogIcons } from '../Atoms/Icons'; import { userPreferences } from '../Preferences/userPreferences'; +import { SpecifyTable } from '../DataModel/specifyTable'; +import { H2, H3 } from '../Atoms'; +import { strictGetTable } from '../DataModel/tables'; - -export function BatchEdit({ +export function BatchEditFromQuery({ query, fields, baseTableName, @@ -49,7 +48,7 @@ export function BatchEdit({ limit: userPreferences.get('batchEdit', 'query', 'limit') }), }); - const [errors, setErrors] = React.useState>([]); + const [errors, setErrors] = React.useState(undefined); const loading = React.useContext(LoadingContext); return ( <> @@ -57,14 +56,17 @@ export function BatchEdit({ onClick={() => { loading( treeRanksPromise.then(()=>{ - const queryFieldSpecs = fields.map((field)=>[field.isDisplay, QueryFieldSpec.fromPath(baseTableName, field.mappingPath)] as const); - const visibleSpecs = filterArray(queryFieldSpecs.map((item)=>item[0] ? item[1] : undefined)); - // Need to only perform checks on display fields, but need to use line numbers from the original query. - const newErrors = filterArray(queryFieldSpecs.flatMap(([isDisplay, fieldSpec], index)=>isDisplay ? validators.map((callback)=>f.maybe(callback(fieldSpec, visibleSpecs), (reason)=>({type: 'Invalid', reason, line: index} as const))) : [])); - if (newErrors.length > 0){ - setErrors(newErrors); - return; - } + const queryFieldSpecs = filterArray(fields.map((field)=>field.isDisplay ? QueryFieldSpec.fromPath(baseTableName, field.mappingPath) : undefined)); + const missingRanks = findAllMissing(queryFieldSpecs); + const invalidFields = filterArray(queryFieldSpecs.map(containsFaultyNestedToMany)); + + const hasErrors = (Object.values(missingRanks).some((ranks)=>ranks.length > 0) || (invalidFields.length > 0)); + + if (hasErrors) { + setErrors({missingRanks, invalidFields}); + return + } + const newName = batchEditText.datasetName({queryName: query.get('name'), datePart: new Date().toDateString()}); return uniquifyDataSetName(newName, undefined, 'batchEdit').then((name)=>post(name).then(({ data }) => navigate(`/specify/workbench/${data.id}`))) }) @@ -74,45 +76,66 @@ export function BatchEdit({ > <>{batchEditText.batchEdit()} - {errors.length > 0 && setErrors([])}/>} + {errors !== undefined && setErrors(undefined)}/>} ); } +type QueryError = { + readonly missingRanks: { + // Query can contain relationship to multiple trees + readonly [KEY in AnyTree['tableName']]: RA + }, + readonly invalidFields: RA +}; -type Invalid = State<"Invalid", {readonly line: number, readonly reason: LocalizedString}>; - -type ValidatorItem = (queryField: QueryFieldSpec, allQueryFields: RA) => undefined | LocalizedString; - -const validators: RA = [containsFaultyNestedToMany, containsFaultyTreeRelationships] - -function containsFaultyNestedToMany(queryField: QueryFieldSpec, allQueryFields: RA) : undefined | LocalizedString; -function containsFaultyNestedToMany(queryField: QueryFieldSpec) : undefined | LocalizedString { - const joinPath = queryField.joinPath +function containsFaultyNestedToMany(queryFieldSpec: QueryFieldSpec) : undefined | string { + const joinPath = queryFieldSpec.joinPath if (joinPath.length <= 1) return undefined; const hasNestedToMany = joinPath.some((currentField, id)=>{ const nextField = joinPath[id+1]; return nextField !== undefined && currentField.isRelationship && nextField.isRelationship && isNestedToMany(currentField, nextField); }); - return hasNestedToMany ? batchEditText.containsNestedToMany() : undefined + return hasNestedToMany ? (generateMappingPathPreview(queryFieldSpec.baseTable.name, queryFieldSpec.toMappingPath())) : undefined } const getTreeDefFromName = (rankName: string, treeDefItems: RA>)=>defined(treeDefItems.find((treeRank)=>treeRank.name.toLowerCase() === rankName.toLowerCase())); -function containsFaultyTreeRelationships(queryField: QueryFieldSpec, allQueryFields: RA) : undefined | LocalizedString { - if (queryField.treeRank === undefined) return undefined; // no treee ranks, nothing to check. - const otherTreeRanks = filterArray(allQueryFields.map((fieldSpec)=>fieldSpec.treeRank)); - const treeDefItems = strictGetTreeDefinitionItems(queryField.table.name as "Geography", false); - const currentRank = defined(getTreeDefFromName(queryField.treeRank, treeDefItems)); - const isHighest = otherTreeRanks.every((item)=>getTreeDefFromName(item, treeDefItems).rankId >= currentRank.rankId); - if (!isHighest) return undefined; // To not possibly duplicate the error multiple times - const missingRanks = treeDefItems.filter((item)=>item.rankId > currentRank.rankId); - if (missingRanks.length !== 0) return - return batchEditText.missingRanks({rankJoined: formatConjunction(missingRanks.map((item)=>localized(item.title ?? '')))}) +function findAllMissing(queryFieldSpecs: RA): QueryError['missingRanks'] { + const treeFieldSpecs = group(queryFieldSpecs.filter((fieldSpec)=>isTreeTable(fieldSpec.table.name)).map((spec)=>[spec.table, spec.treeRank])); + return Object.fromEntries(treeFieldSpecs.map(([treeTable, treeRanks])=>[treeTable.name, findMissingRanks(treeTable, treeRanks)])) +} + +function findMissingRanks(treeTable: SpecifyTable, treeRanks: RA) { + if (treeRanks.every((rank)=>(rank === undefined))) {}; + + const allTreeDefItems = strictGetTreeDefinitionItems(treeTable.name as "Geography", false); + + // Duplicates don't affect any logic here + const currentTreeRanks = filterArray(treeRanks.map((treeRank)=>f.maybe(treeRank, (name)=>getTreeDefFromName(name, allTreeDefItems)))); + + const currentRanksSorted = [...currentTreeRanks].sort(sortFunction(({rankId})=>rankId, true)); + + const highestRank = currentRanksSorted[0]; + + const ranksBelow = allTreeDefItems.filter(({rankId, name})=>rankId > highestRank.rankId && !currentTreeRanks.find((rank)=>rank.name === name)); + + return ranksBelow.map((rank)=>rank.name) } -function ErrorsDialog({errors, onClose: handleClose}:{readonly errors: RA; readonly onClose: ()=>void }): JSX.Element { +function ErrorsDialog({errors, onClose: handleClose}:{readonly errors: QueryError; readonly onClose: ()=>void }): JSX.Element { return - {errors.map((error, index)=>
    {error.line+1} {error.reason}
    )} + +
    +} + +function ShowInvalidFields({error}: {readonly error: QueryError['invalidFields']}){ + const hasErrors = error.length > 0; + return hasErrors ?

    {batchEditText.removeField()}

    {error.map((singleError)=>

    {singleError}

    )}
    : null +} + +function ShowMissingRanks({error}: {readonly error: QueryError['missingRanks']}) { + const hasMissing = Object.values(error).some((rank)=>rank.length > 0); + return hasMissing ?

    {batchEditText.addTreeRank()}

    {Object.entries(error).map(([treeTable, ranks])=>
    {strictGetTable(treeTable).label}
    {ranks.map((rank)=>

    {rank}

    )}
    )}
    : null } \ No newline at end of file diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx index 4230abaa12b..289392fc39d 100644 --- a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx +++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx @@ -52,7 +52,7 @@ import { getInitialState, reducer } from './reducer'; import type { QueryResultRow } from './Results'; import { QueryResultsWrapper } from './ResultsWrapper'; import { QueryToolbar } from './Toolbar'; -import { BatchEdit } from '../BatchEdit'; +import { BatchEditFromQuery } from '../BatchEdit'; const fetchTreeRanks = async (): Promise => treeRanksPromise.then(f.true); @@ -590,7 +590,7 @@ function Wrapped({ } extraButtons={ <> - + {query.countOnly ? undefined : ( = [ /* eslint-enable @typescript-eslint/promise-function-async */ -export const inRouterContext = {}; +export const inRouterContext = {}; \ No newline at end of file diff --git a/specifyweb/frontend/js_src/lib/components/Toolbar/WbsDialog.tsx b/specifyweb/frontend/js_src/lib/components/Toolbar/WbsDialog.tsx index a4b2a9853c5..63c7c4d5b6e 100644 --- a/specifyweb/frontend/js_src/lib/components/Toolbar/WbsDialog.tsx +++ b/specifyweb/frontend/js_src/lib/components/Toolbar/WbsDialog.tsx @@ -29,10 +29,12 @@ import { SortIndicator, useSortConfig } from '../Molecules/Sorting'; import { TableIcon } from '../Molecules/TableIcon'; import { hasPermission } from '../Permissions/helpers'; import { OverlayContext } from '../Router/Router'; -import { DatasetVariants, uniquifyDataSetName } from '../WbImport/helpers'; +import { uniquifyDataSetName } from '../WbImport/helpers'; import type { Dataset, DatasetBriefPlan } from '../WbPlanView/Wrapped'; import { WbDataSetMeta } from '../WorkBench/DataSetMeta'; import { formatUrl } from '../Router/queryString'; +import { f } from '../../utils/functools'; +import { batchEditText } from '../../localization/batchEdit'; const createWorkbenchDataSet = async () => createEmptyDataSet( @@ -47,11 +49,11 @@ const createWorkbenchDataSet = async () => export const createEmptyDataSet = async < DATASET extends AttachmentDataSet | Dataset >( - datasetVariant: keyof typeof DatasetVariants, + datasetVariant: keyof typeof datasetVariants, name: LocalizedString, props?: Partial ): Promise => - ajax(DatasetVariants[datasetVariant], { + ajax(datasetVariants[datasetVariant].fetchUrl, { method: 'POST', body: { name: await uniquifyDataSetName(name, undefined, datasetVariant), @@ -134,171 +136,200 @@ type DataSetFilter = { readonly with_plan: number; readonly isupdate: number } -/** Render a dialog for choosing a data set */ -export function DataSetsDialog({ + +type WB_VARIANT = keyof Omit; + +export function GenericDataSetsDialog({ onClose: handleClose, - showTemplates, onDataSetSelect: handleDataSetSelect, - filterByBatchEdit=false + wbVariant }: { - readonly showTemplates: boolean; + readonly wbVariant: WB_VARIANT; readonly onClose: () => void; readonly onDataSetSelect?: (id: number) => void; - readonly filterByBatchEdit?: boolean; }): JSX.Element | null { - const datasetFilter: DataSetFilter = { - with_plan: showTemplates ? 1 : 0, - isupdate: filterByBatchEdit ? 1 : 0 - } + const variant = datasetVariants[wbVariant]; const [unsortedDatasets] = useAsyncState( React.useCallback( - async () => - ajax>( - formatUrl('/api/workbench/dataset/', datasetFilter), - { headers: { Accept: 'application/json' } } - ).then(({ data }) => data), - [showTemplates] + async () => ajax>(formatUrl(variant.fetchUrl, {}), { headers: { Accept: 'application/json' } }).then(({data})=>data), + [wbVariant] ), true ); + const [sortConfig, handleSort, applySortConfig] = useSortConfig(variant.sortConfig.key, variant.sortConfig.field, false); - const [sortConfig, handleSort, applySortConfig] = useSortConfig( - 'listOfDataSets', - 'dateCreated', - false - ); - - const datasets = Array.isArray(unsortedDatasets) - ? applySortConfig( - unsortedDatasets, - ({ name, timestampcreated, uploadresult }) => - sortConfig.sortField === 'name' - ? name - : sortConfig.sortField === 'dateCreated' - ? timestampcreated - : uploadresult?.timestamp ?? '' - ) - : undefined; - - // While being granular in permissions is nice, it is redudant here, since batch-edit datasets cannot be created. - const canImport = - hasPermission('/workbench/dataset', 'create') && !showTemplates && !filterByBatchEdit; + const datasets = Array.isArray(unsortedDatasets) ? applySortConfig( + unsortedDatasets, ({ name, timestampcreated, uploadresult }) => + sortConfig.sortField === 'name' + ? name + : sortConfig.sortField === 'dateCreated' + ? timestampcreated + : uploadresult?.timestamp ?? '') : undefined; + const navigate = useNavigate(); const loading = React.useContext(LoadingContext); - return Array.isArray(datasets) ? ( - + {commonText.cancel()} + {variant.canImport() && ( <> - {commonText.cancel()} - {canImport && ( - <> - - {wbText.importFile()} - - - loading( - createWorkbenchDataSet().then(({ id }) => - navigate(`/specify/workbench/plan/${id}/`) - ) - ) - } - > - {wbText.createNew()} - - - )} + + {wbText.importFile()} + + + loading( + createWorkbenchDataSet().then(({ id }) => + navigate(`/specify/workbench/plan/${id}/`) + ) + ) + } + > + {wbText.createNew()} + - } - className={{ - container: dialogClassNames.wideContainer, - }} - dimensionsKey="DataSetsDialog" - header={ - showTemplates - ? wbPlanText.copyPlan() - : commonText.countLine({ - resource: wbText.dataSets(), - count: datasets.length, - }) - } - icon={icons.table} - onClose={handleClose} - > - {datasets.length === 0 ? ( -

    - {showTemplates - ? wbPlanText.noPlansToCopyFrom() - : `${wbText.wbsDialogEmpty()} ${ - canImport ? wbText.createDataSetInstructions() : '' - }`} -

    - ) : ( - )} -
    - ) : null; + + } + className={{ + container: dialogClassNames.wideContainer, + }} + dimensionsKey="DataSetsDialog" + header={header(datasets.length)} + icon={icons.table} + onClose={handleClose} +> + {datasets.length === 0 ? ( +

    + {onEmpty(canImport())} +

    + ) : ( + + )} + + : null; + } +const baseWbVariant = { + fetchUrl: '/api/workbench/dataset/', + sortConfig: { + key: 'listOfDataSets', + field: 'name' + }, + canImport: ()=>hasPermission('/workbench/dataset', 'create'), + header: (count: number)=>commonText.countLine({resource: wbText.dataSets({variant: wbText.workBench()}), count}), + onEmpty: (canImport: boolean)=>`${wbText.wbsDialogEmpty()} ${canImport ? wbText.createDataSetInstructions() : ''}`, + canEdit: ()=>hasPermission('/workbench/dataset', 'update'), + route: (id: number)=>`/specify/workbench/${id}`, + metaRoute: (id: number)=>`/specify/overlay/workbench/${id}/meta/`, +} as const; + +export const datasetVariants = { + 'workbench': baseWbVariant, + 'workbenchChoosePlan': { + fetchUrl: '/api/workbench/dataset/?with_plan', + sortConfig: baseWbVariant.sortConfig, + canImport: ()=>false, + header: ()=>wbPlanText.copyPlan(), + onEmpty: ()=>wbPlanText.noPlansToCopyFrom(), + canEdit: ()=>false, + route: baseWbVariant.route, + metaRoute: baseWbVariant.metaRoute + }, + 'batchEdit': { + fetchUrl: '/api/workbench/dataset/?isupdate=1', + sortConfig: { + key: 'listOfBatchEditDataSets', + field: 'name' + }, + // Cannot import via the header + canImport: ()=>false, + header: (count: number)=>commonText.countLine({resource: wbText.dataSets({variant: batchEditText.batchEdit()}), count}), + onEmpty: ()=>`${wbText.wbsDialogEmpty()} ${hasPermission('/workbench/dataset', 'create') ? batchEditText.createDataSetInstructions() : ''}`, + canEdit: ()=>hasPermission('/workbench/dataset', 'update'), + route: baseWbVariant.route, + metaRoute: baseWbVariant.metaRoute + }, + 'bulkAttachment': { + fetchUrl: '/attachment_gw/dataset/', + sortConfig: { + key: 'attachmentDatasets', + field: 'name' + }, + canImport: ()=>hasPermission('/attachment_import/dataset', 'create'), + header: f.never, + onEmpty:f.never, + canEdit: ()=>hasPermission('/attachment_import/dataset', 'update'), + route: (id: number)=>`/specify/attachments/import/${id}`, + // Actually, in retrorespect, this would be a nice feature + metaRoute: f.never + } +} as const; + export function DataSetsOverlay(): JSX.Element { const handleClose = React.useContext(OverlayContext); - return ; + return ; } export function BatchEditDataSetsOverlay(): JSX.Element { const handleClose = React.useContext(OverlayContext); - return ; + return ; } \ No newline at end of file diff --git a/specifyweb/frontend/js_src/lib/components/WbImport/helpers.ts b/specifyweb/frontend/js_src/lib/components/WbImport/helpers.ts index 710bd1b2ec7..d7db60ffd5f 100644 --- a/specifyweb/frontend/js_src/lib/components/WbImport/helpers.ts +++ b/specifyweb/frontend/js_src/lib/components/WbImport/helpers.ts @@ -13,6 +13,7 @@ import { tables } from '../DataModel/tables'; import { fileToText } from '../Molecules/FilePicker'; import { uniquifyHeaders } from '../WbPlanView/headerHelper'; import type { Dataset, DatasetBrief } from '../WbPlanView/Wrapped'; +import { datasetVariants } from '../Toolbar/WbsDialog'; /** * REFACTOR: add this ESLint rule: @@ -157,18 +158,12 @@ function guessDelimiter(text: string): string { const MAX_NAME_LENGTH = 64; -export const DatasetVariants = { - 'workbench': '/api/workbench/dataset/', - 'batchEdit': '/api/workbench/dataset/?isupdate=1', - 'bulkAttachment': '/attachment_gw/dataset/' -} as const; - export async function uniquifyDataSetName( name: string, currentDataSetId?: number, - datasetsUrl: keyof typeof DatasetVariants = 'workbench' + datasetsUrl: keyof typeof datasetVariants = 'workbench' ): Promise { - return ajax>(DatasetVariants[datasetsUrl], { + return ajax>(datasetVariants[datasetsUrl].fetchUrl, { headers: { Accept: 'application/json' }, }).then(({ data: datasets }) => getUniqueName( diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/State.tsx b/specifyweb/frontend/js_src/lib/components/WbPlanView/State.tsx index 4839b212ff7..792f0759dbf 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/State.tsx +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/State.tsx @@ -9,7 +9,7 @@ import { Button } from '../Atoms/Button'; import { LoadingContext } from '../Core/Contexts'; import type { Tables } from '../DataModel/types'; import { Dialog, dialogClassNames } from '../Molecules/Dialog'; -import { DataSetsDialog } from '../Toolbar/WbsDialog'; +import { GenericDataSetsDialog } from '../Toolbar/WbsDialog'; import { ListOfBaseTables } from './Components'; import type { UploadPlan } from './uploadPlanParser'; import type { Dataset } from './Wrapped'; @@ -38,8 +38,8 @@ function TemplateSelection({ {wbPlanText.invalidTemplatePlan()} )} - loading( diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/index.tsx b/specifyweb/frontend/js_src/lib/components/WbPlanView/index.tsx index 3e77ae42fb1..f5972b1ac09 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/index.tsx @@ -22,14 +22,8 @@ import { WbPlanView } from './Wrapped'; const fetchTreeRanks = async (): Promise => treeRanksPromise.then(f.true); -/** - * Entrypoint React component for the workbench mapper - */ export function WbPlanViewWrapper(): JSX.Element | null { const { id = '' } = useParams(); - const [treeRanksLoaded = false] = useAsyncState(fetchTreeRanks, true); - useMenuItem('workBench'); - const [dataSet] = useAsyncState( React.useCallback(async () => { const dataSetId = f.parseInt(id); @@ -41,6 +35,15 @@ export function WbPlanViewWrapper(): JSX.Element | null { }, [id]), true ); + return dataSet === false ? : dataSet === undefined ? null : +} + +/** + * Entrypoint React component for the workbench mapper + */ +function WbPlanViewSafe({dataSet}:{readonly dataSet: Dataset}): JSX.Element | null { + const [treeRanksLoaded = false] = useAsyncState(fetchTreeRanks, true); + useMenuItem(dataSet.isupdate ? 'batchEdit' : 'workBench'); useErrorContext('dataSet', dataSet); const isReadOnly = @@ -51,9 +54,7 @@ export function WbPlanViewWrapper(): JSX.Element | null { // FEATURE: Remove this dataSet.isupdate; - return dataSet === false ? ( - - ) : treeRanksLoaded && typeof dataSet === 'object' ? ( + return treeRanksLoaded && typeof dataSet === 'object' ? ( typeof tableActions[number] | undefined; // Whether can execute query/do workbench upload @@ -46,7 +44,6 @@ const wbPlanView: NavigatorSpec = { includeRootFormattedAggregated: false, allowTransientToMany: true, useSchemaOverrides: true, - includeAllTreeFields: true, /* * Hide nested -to-many relationships as they are not * supported by the WorkBench @@ -87,8 +84,6 @@ const queryBuilder: NavigatorSpec = { includeRootFormattedAggregated: true, allowTransientToMany: true, useSchemaOverrides: false, - // All tree fields are only available for "any rank" - includeAllTreeFields: true, allowNestedToMany: true, ensurePermission: () => userPreferences.get('queryBuilder', 'general', 'showNoReadTables') @@ -117,7 +112,6 @@ const formatterEditor: NavigatorSpec = { includeRootFormattedAggregated: false, allowTransientToMany: false, useSchemaOverrides: false, - includeAllTreeFields: false, allowNestedToMany: false, ensurePermission: () => undefined, hasActionPermission: () => true, @@ -139,7 +133,6 @@ const permissive: NavigatorSpec = { includeRootFormattedAggregated: true, allowTransientToMany: true, useSchemaOverrides: false, - includeAllTreeFields: true, allowNestedToMany: true, ensurePermission: () => undefined, hasActionPermission: () => true, diff --git a/specifyweb/frontend/js_src/lib/components/WorkBench/DataSetMeta.tsx b/specifyweb/frontend/js_src/lib/components/WorkBench/DataSetMeta.tsx index afc2cdeceeb..d6cf20ab21e 100644 --- a/specifyweb/frontend/js_src/lib/components/WorkBench/DataSetMeta.tsx +++ b/specifyweb/frontend/js_src/lib/components/WorkBench/DataSetMeta.tsx @@ -27,8 +27,9 @@ import { FormattedResourceUrl } from '../Molecules/FormattedResource'; import { TableIcon } from '../Molecules/TableIcon'; import { hasPermission } from '../Permissions/helpers'; import { unsafeNavigate } from '../Router/Router'; -import { DatasetVariants, getMaxDataSetLength, uniquifyDataSetName } from '../WbImport/helpers'; +import { getMaxDataSetLength, uniquifyDataSetName } from '../WbImport/helpers'; import type { Dataset } from '../WbPlanView/Wrapped'; +import { datasetVariants } from '../Toolbar/WbsDialog'; const syncNameAndRemarks = async ( name: LocalizedString, @@ -43,7 +44,7 @@ const syncNameAndRemarks = async ( type DataSetMetaProps = { readonly dataset: Dataset | EagerDataSet; - readonly datasetVariant: keyof typeof DatasetVariants; + readonly datasetVariant: keyof typeof datasetVariants; readonly getRowCount?: () => number; readonly permissionResource: | '/attachment_import/dataset' @@ -118,7 +119,7 @@ export function DataSetMeta({ const [showDeleteConfirm, setShowDeleteConfirm] = React.useState(false); - const datasetUrl = DatasetVariants[datasetVariant]; + const datasetUrl = datasetVariants[datasetVariant]; return isDeleted ? ( , - readonly to_many: R> + readonly self?: BatchEditRecord, + readonly to_one?: R, + readonly to_many?: R> } -export const isBatchEditNullRecord = (batchEditPack: BatchEditPack, currentTable: SpecifyTable, mappingPath: MappingPath): boolean => { - if (mappingPath.length <= 1) return batchEditPack.self.id === NULL_RECORD; +export const isBatchEditNullRecord = (batchEditPack: BatchEditPack | undefined, currentTable: SpecifyTable, mappingPath: MappingPath): boolean => { + if (batchEditPack == undefined) return false; + if (mappingPath.length <= 1) return batchEditPack?.self?.id === NULL_RECORD; const [node, ...rest] = mappingPath; if (isTreeTable(currentTable.name)) return false; const relationship = defined(currentTable.getRelationship(node)); @@ -31,8 +32,8 @@ export const isBatchEditNullRecord = (batchEditPack: BatchEditPack, currentTable if (relationshipIsToMany(relationship)){ // id starts with 1... const toManyId = getNumberFromToManyIndex(rest[0]) - 1; - const toMany = batchEditPack.to_many[name][toManyId]; - return toMany && isBatchEditNullRecord(toMany, relatedTable, rest.slice(1)); + const toMany = batchEditPack?.to_many?.[name][toManyId]; + return toMany !== undefined && isBatchEditNullRecord(toMany, relatedTable, rest.slice(1)); } - return isBatchEditNullRecord(batchEditPack.to_one[name], relatedTable, rest); + return isBatchEditNullRecord(batchEditPack?.to_one?.[name], relatedTable, rest); } \ No newline at end of file diff --git a/specifyweb/frontend/js_src/lib/components/WorkBench/index.tsx b/specifyweb/frontend/js_src/lib/components/WorkBench/index.tsx index 159597668a7..225a03119b5 100644 --- a/specifyweb/frontend/js_src/lib/components/WorkBench/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/WorkBench/index.tsx @@ -16,38 +16,41 @@ import { NotFoundView } from '../Router/NotFoundView'; import type { Dataset } from '../WbPlanView/Wrapped'; import { WbView } from './WbView'; -export function WorkBench(): JSX.Element { - - const [treeRanksLoaded = false] = useAsyncState(fetchTreeRanks, true); +export function WorkBench(): JSX.Element | undefined { const { id } = useParams(); const datasetId = f.parseInt(id); const [dataset, setDataset] = useDataset(datasetId); + return datasetId === undefined ? + : + dataset === undefined ? undefined : ; +} + +export function WorkBenchSafe({getSetDataset}: {readonly getSetDataset: GetSet}): JSX.Element { + const [dataset, setDataset] = getSetDataset; + const [treeRanksLoaded = false] = useAsyncState(fetchTreeRanks, true); + useErrorContext('dataSet', dataset); - useMenuItem(dataset?.isupdate ? 'batchEdit' : 'workBench'); + useMenuItem(dataset.isupdate ? 'batchEdit' : 'workBench'); - const loading = React.useContext(LoadingContext); const [isDeleted, handleDeleted] = useBooleanState(); - if (dataset === undefined || !treeRanksLoaded || dataset.id !== datasetId) - return ; + const loading = React.useContext(LoadingContext); const triggerDatasetRefresh = () => loading(fetchDataset(dataset.id).then(setDataset)); - return datasetId === undefined ? ( - - ) : isDeleted ? ( + return isDeleted ? ( <>{wbText.dataSetDeletedOrNotFound()} - ) : ( + ) : treeRanksLoaded ? ( - ); + ) : ; } const fetchTreeRanks = async (): Promise => treeRanksPromise.then(f.true); diff --git a/specifyweb/frontend/js_src/lib/localization/batchEdit.ts b/specifyweb/frontend/js_src/lib/localization/batchEdit.ts index 96210e9d4e2..3fa411a86ec 100644 --- a/specifyweb/frontend/js_src/lib/localization/batchEdit.ts +++ b/specifyweb/frontend/js_src/lib/localization/batchEdit.ts @@ -13,16 +13,19 @@ export const batchEditText = createDictionary({ numberOfRecords: { 'en-us': "Number of records selected from the query" }, - containsNestedToMany: { - 'en-us': "The query contains non-hidden nested-to-many relationships. Either remove the field, or make the field hidden." + removeField: { + 'en-us': "Field not supported for batch edit. Either remove the field, or make it hidden." }, - missingRanks: { - 'en-us': "The following tree ranks need to be added to the query: {rankJoined:string}" + addTreeRank: { + 'en-us': "Please add the following missing rank to the query", }, datasetName: { 'en-us': "{queryName:string} {datePart:string}" }, errorInQuery: { 'en-us': "Following errors were found in the query" + }, + createDataSetInstructions: { + 'en-us': "Use the query builder to make a new batch edit dataset" } } as const) \ No newline at end of file diff --git a/specifyweb/frontend/js_src/lib/localization/workbench.ts b/specifyweb/frontend/js_src/lib/localization/workbench.ts index 720ef5be65a..4eadf29ffc4 100644 --- a/specifyweb/frontend/js_src/lib/localization/workbench.ts +++ b/specifyweb/frontend/js_src/lib/localization/workbench.ts @@ -1263,7 +1263,7 @@ export const wbText = createDictionary({ 'de-ch': 'Neuer Datensatz {date}', }, dataSets: { - 'en-us': 'WorkBench Data Sets', + 'en-us': '{variant:string} Data Sets', 'ru-ru': 'Наборы данных', 'es-es': 'Conjuntos de datos de WorkBench', 'fr-fr': 'Ensembles de données WorkBench', diff --git a/specifyweb/frontend/js_src/lib/utils/cache/definitions.ts b/specifyweb/frontend/js_src/lib/utils/cache/definitions.ts index c0485d2548c..21fc018a6e4 100644 --- a/specifyweb/frontend/js_src/lib/utils/cache/definitions.ts +++ b/specifyweb/frontend/js_src/lib/utils/cache/definitions.ts @@ -211,6 +211,7 @@ export type SortConfigs = { | 'name' | 'timestampCreated' | 'timestampModified'; + readonly listOfBatchEditDataSets: 'dateCreated' | 'dateUploaded' | 'name'; }; // Some circular types can't be expressed without interfaces diff --git a/specifyweb/specify/api.py b/specifyweb/specify/api.py index a04aaff01b5..6147fbea16d 100644 --- a/specifyweb/specify/api.py +++ b/specifyweb/specify/api.py @@ -636,7 +636,7 @@ def delete_resource(collection, agent, name, id, version) -> None: return delete_obj(obj, (make_default_deleter(collection, agent)), version) def make_default_deleter(collection=None, agent=None): - def _deleter(parent_obj, obj): + def _deleter(obj, parent_obj): if collection and agent: check_table_permissions(collection, agent, obj, "delete") auditlog.remove(obj, agent, parent_obj) @@ -662,7 +662,7 @@ def delete_obj(obj, deleter: Optional[Callable[[Any, Any], None]]=None, version= obj.pre_constraints_delete() if deleter: - deleter(parent_obj, obj) + deleter(obj, parent_obj) obj.delete() diff --git a/specifyweb/specify/func.py b/specifyweb/specify/func.py index 32224eed7f4..0cdcc2b56b8 100644 --- a/specifyweb/specify/func.py +++ b/specifyweb/specify/func.py @@ -1,23 +1,24 @@ - from functools import reduce -from typing import Callable, Dict, Generator, List, Optional, Tuple, TypeVar +from typing import Callable, Dict, Generator, List, Optional, Tuple, TypeVar from django.db.models import Q # made as a class to encapsulate type variables and prevent pollution of export + + class Func: - I = TypeVar('I') - O = TypeVar('O') + I = TypeVar("I") + O = TypeVar("O") @staticmethod def maybe(value: Optional[I], callback: Callable[[I], O]): if value is None: return None return callback(value) - + @staticmethod def sort_by_key(to_sort: Dict[I, O], reverse=False) -> List[Tuple[I, O]]: return sorted(to_sort.items(), key=lambda t: t[0], reverse=reverse) - + @staticmethod def make_ors(eprns: List[Q]) -> Q: assert len(eprns) > 0 @@ -30,24 +31,36 @@ def _generator(step=step): while True: yield i i += step + return _generator(step) - + @staticmethod - def tap_call(callback: Callable[[], O], generator: Generator[int, None, None]) -> Tuple[bool, O]: + def tap_call( + callback: Callable[[], O], generator: Generator[int, None, None] + ) -> Tuple[bool, O]: init_1 = next(generator) init_2 = next(generator) step = init_2 - init_1 to_return = callback() post = next(generator) called = (post - init_2) != step - assert (post - init_2) % step == 0, "(sanity check failed): made irregular generator" + assert ( + post - init_2 + ) % step == 0, "(sanity check failed): made irregular generator" return called, to_return - @staticmethod def remove_keys(source: Dict[I, O], callback: Callable[[O], bool]) -> Dict[I, O]: - return { - key: value - for key, value in source.items() - if callback(value) - } \ No newline at end of file + return {key: value for key, value in source.items() if callback(value)} + + @staticmethod + def is_not_empty(val): + return val + + @staticmethod + def first(source: List[Tuple[I, O]]) -> List[I]: + return [first for (first, _) in source] + + @staticmethod + def second(source: List[Tuple[I, O]]) -> List[O]: + return [second for (_, second) in source] diff --git a/specifyweb/specify/tests/test_api.py b/specifyweb/specify/tests/test_api.py index bdec47e896d..b882429993b 100644 --- a/specifyweb/specify/tests/test_api.py +++ b/specifyweb/specify/tests/test_api.py @@ -2,7 +2,6 @@ Tests for api.py """ - import json from unittest import skip from datetime import datetime @@ -11,65 +10,76 @@ from specifyweb.permissions.models import UserPolicy from specifyweb.specify import api, models, scoping -from specifyweb.businessrules.uniqueness_rules import UNIQUENESS_DISPATCH_UID, check_unique, apply_default_uniqueness_rules -from specifyweb.businessrules.orm_signal_handler import connect_signal, disconnect_signal +from specifyweb.businessrules.uniqueness_rules import ( + UNIQUENESS_DISPATCH_UID, + check_unique, + apply_default_uniqueness_rules, +) +from specifyweb.businessrules.orm_signal_handler import ( + connect_signal, + disconnect_signal, +) def get_table(name: str): return getattr(models, name.capitalize()) class MainSetupTearDown: def setUp(self): - disconnect_signal('pre_save', None, dispatch_uid=UNIQUENESS_DISPATCH_UID) - connect_signal('pre_save', check_unique, None, dispatch_uid=UNIQUENESS_DISPATCH_UID) + disconnect_signal("pre_save", None, dispatch_uid=UNIQUENESS_DISPATCH_UID) + connect_signal( + "pre_save", check_unique, None, dispatch_uid=UNIQUENESS_DISPATCH_UID + ) self.institution = models.Institution.objects.create( - name='Test Institution', + name="Test Institution", isaccessionsglobal=True, issecurityon=False, isserverbased=False, issharinglocalities=True, issinglegeographytree=True, - ) + ) self.division = models.Division.objects.create( - institution=self.institution, - name='Test Division') + institution=self.institution, name="Test Division" + ) - self.geologictimeperiodtreedef = models.Geologictimeperiodtreedef.objects.create( - name='Test gtptd') + self.geologictimeperiodtreedef = ( + models.Geologictimeperiodtreedef.objects.create(name="Test gtptd") + ) - self.geographytreedef = models.Geographytreedef.objects.create( - name='Test gtd') + self.geographytreedef = models.Geographytreedef.objects.create(name="Test gtd") self.geographytreedef.treedefitems.create(name="Planet", rankid="0") - self.datatype = models.Datatype.objects.create( - name='Test datatype') + self.datatype = models.Datatype.objects.create(name="Test datatype") self.discipline = models.Discipline.objects.create( geologictimeperiodtreedef=self.geologictimeperiodtreedef, geographytreedef=self.geographytreedef, division=self.division, - datatype=self.datatype) + datatype=self.datatype, + ) apply_default_uniqueness_rules(self.discipline) self.collection = models.Collection.objects.create( - catalognumformatname='test', - collectionname='TestCollection', + catalognumformatname="test", + collectionname="TestCollection", isembeddedcollectingevent=False, - discipline=self.discipline) + discipline=self.discipline, + ) self.specifyuser = models.Specifyuser.objects.create( isloggedin=False, isloggedinreport=False, name="testuser", - password="205C0D906445E1C71CA77C6D714109EB6D582B03A5493E4C") # testuser + password="205C0D906445E1C71CA77C6D714109EB6D582B03A5493E4C", + ) # testuser UserPolicy.objects.create( collection=None, specifyuser=self.specifyuser, - resource='%', - action='%', + resource="%", + action="%", ) self.agent = models.Agent.objects.create( @@ -77,61 +87,95 @@ def setUp(self): firstname="Test", lastname="User", division=self.division, - specifyuser=self.specifyuser) + specifyuser=self.specifyuser, + ) self.collectingevent = models.Collectingevent.objects.create( - discipline=self.discipline) + discipline=self.discipline + ) + + def make_co(num: int): + return [ + models.Collectionobject.objects.create( + collection=self.collection, catalognumber="num-%d" % i + ) + for i in range(num) + ] + + self.collectionobjects = make_co(5) + self.make_co = make_co - self.collectionobjects = [ - models.Collectionobject.objects.create( - collection=self.collection, - catalognumber="num-%d" % i) - for i in range(5)] + def _update(obj, kwargs): + for key, value in kwargs.items(): + setattr(obj, key, value) + obj.save() + + self._update = _update + + +class ApiTests(MainSetupTearDown, TestCase): + pass -class ApiTests(MainSetupTearDown, TestCase): pass skip_perms_check = lambda x: None + class SimpleApiTests(ApiTests): def test_get_collection(self): - data = api.get_collection(self.collection, 'collectionobject', skip_perms_check) - self.assertEqual(data['meta']['total_count'], len(self.collectionobjects)) - self.assertEqual(len(data['objects']), len(self.collectionobjects)) - ids = [obj['id'] for obj in data['objects']] + data = api.get_collection(self.collection, "collectionobject", skip_perms_check) + self.assertEqual(data["meta"]["total_count"], len(self.collectionobjects)) + self.assertEqual(len(data["objects"]), len(self.collectionobjects)) + ids = [obj["id"] for obj in data["objects"]] for co in self.collectionobjects: self.assertTrue(co.id in ids) def test_get_resouce(self): - data = api.get_resource('institution', self.institution.id, skip_perms_check) - self.assertEqual(data['id'], self.institution.id) - self.assertEqual(data['name'], self.institution.name) + data = api.get_resource("institution", self.institution.id, skip_perms_check) + self.assertEqual(data["id"], self.institution.id) + self.assertEqual(data["name"], self.institution.name) def test_create_object(self): - obj = api.create_obj(self.collection, self.agent, 'collectionobject', { - 'collection': api.uri_for_model('collection', self.collection.id), - 'catalognumber': 'foobar'}) + obj = api.create_obj( + self.collection, + self.agent, + "collectionobject", + { + "collection": api.uri_for_model("collection", self.collection.id), + "catalognumber": "foobar", + }, + ) obj = models.Collectionobject.objects.get(id=obj.id) self.assertTrue(obj.id is not None) self.assertEqual(obj.collection, self.collection) - self.assertEqual(obj.catalognumber, 'foobar') + self.assertEqual(obj.catalognumber, "foobar") self.assertEqual(obj.createdbyagent, self.agent) def test_update_object(self): - data = api.get_resource('collection', self.collection.id, skip_perms_check) - data['collectionname'] = 'New Name' - api.update_obj(self.collection, self.agent, 'collection', - data['id'], data['version'], data) + data = api.get_resource("collection", self.collection.id, skip_perms_check) + data["collectionname"] = "New Name" + api.update_obj( + self.collection, self.agent, "collection", data["id"], data["version"], data + ) obj = models.Collection.objects.get(id=self.collection.id) - self.assertEqual(obj.collectionname, 'New Name') + self.assertEqual(obj.collectionname, "New Name") def test_delete_object(self): - obj = api.create_obj(self.collection, self.agent, 'collectionobject', { - 'collection': api.uri_for_model('collection', self.collection.id), - 'catalognumber': 'foobar'}) - api.delete_resource(self.collection, self.agent, 'collectionobject', obj.id, obj.version) + obj = api.create_obj( + self.collection, + self.agent, + "collectionobject", + { + "collection": api.uri_for_model("collection", self.collection.id), + "catalognumber": "foobar", + }, + ) + api.delete_resource( + self.collection, self.agent, "collectionobject", obj.id, obj.version + ) self.assertEqual(models.Collectionobject.objects.filter(id=obj.id).count(), 0) + class RecordSetTests(ApiTests): def setUp(self): super(RecordSetTests, self).setUp() @@ -140,34 +184,62 @@ def setUp(self): dbtableid=models.Collectionobject.specify_model.tableId, name="Test recordset", type=0, - specifyuser=self.specifyuser) + specifyuser=self.specifyuser, + ) def test_post_resource(self): - obj = api.post_resource(self.collection, self.agent, 'collectionobject', { - 'collection': api.uri_for_model('collection', self.collection.id), - 'catalognumber': 'foobar'}, recordsetid=self.recordset.id) - self.assertEqual(self.recordset.recordsetitems.filter(recordid=obj.id).count(), 1) + obj = api.post_resource( + self.collection, + self.agent, + "collectionobject", + { + "collection": api.uri_for_model("collection", self.collection.id), + "catalognumber": "foobar", + }, + recordsetid=self.recordset.id, + ) + self.assertEqual( + self.recordset.recordsetitems.filter(recordid=obj.id).count(), 1 + ) - @skip("errors because of many-to-many stuff checking if Agent is admin. should test with different model.") + @skip( + "errors because of many-to-many stuff checking if Agent is admin. should test with different model." + ) def test_post_bad_resource(self): with self.assertRaises(api.RecordSetException) as cm: - obj = api.post_resource(self.collection, self.agent, 'Agent', - {'agenttype': 0, - 'lastname': 'MonkeyWrench', - 'division': api.uri_for_model('division', self.division.id)}, - recordsetid=self.recordset.id) - self.assertEqual(models.Agent.objects.filter(lastname='MonkeyWrench').count(), 0) - - @skip("errors because of many-to-many stuff checking if Agent is admin. should test with different model.") + obj = api.post_resource( + self.collection, + self.agent, + "Agent", + { + "agenttype": 0, + "lastname": "MonkeyWrench", + "division": api.uri_for_model("division", self.division.id), + }, + recordsetid=self.recordset.id, + ) + self.assertEqual( + models.Agent.objects.filter(lastname="MonkeyWrench").count(), 0 + ) + + @skip( + "errors because of many-to-many stuff checking if Agent is admin. should test with different model." + ) def test_post_resource_to_bad_recordset(self): - max_id = models.Recordset.objects.aggregate(Max('id'))['id__max'] + max_id = models.Recordset.objects.aggregate(Max("id"))["id__max"] with self.assertRaises(api.RecordSetException) as cm: - obj = api.post_resource(self.collection, self.agent, 'Agent', - {'agenttype': 0, - 'lastname': 'Pitts', - 'division': api.uri_for_model('division', self.division.id)}, - recordsetid=max_id + 100) - self.assertEqual(models.Agent.objects.filter(lastname='Pitts').count(), 0) + obj = api.post_resource( + self.collection, + self.agent, + "Agent", + { + "agenttype": 0, + "lastname": "Pitts", + "division": api.uri_for_model("division", self.division.id), + }, + recordsetid=max_id + 100, + ) + self.assertEqual(models.Agent.objects.filter(lastname="Pitts").count(), 0) def test_remove_from_recordset_on_delete(self): ids = [co.id for co in self.collectionobjects] @@ -175,35 +247,60 @@ def test_remove_from_recordset_on_delete(self): for id in ids: self.recordset.recordsetitems.create(recordid=id) - counts = set((self.recordset.recordsetitems.filter(recordid=id).count() for id in ids)) + counts = set( + (self.recordset.recordsetitems.filter(recordid=id).count() for id in ids) + ) self.assertEqual(counts, set([1])) for co in self.collectionobjects: co.delete() - counts = set((self.recordset.recordsetitems.filter(recordid=id).count() for id in ids)) + counts = set( + (self.recordset.recordsetitems.filter(recordid=id).count() for id in ids) + ) self.assertEqual(counts, set([0])) def test_get_resource_with_recordset_info(self): - data = api.get_resource('collectionobject', self.collectionobjects[0].id, skip_perms_check) - self.assertFalse(hasattr(data, 'recordset_info')) + data = api.get_resource( + "collectionobject", self.collectionobjects[0].id, skip_perms_check + ) + self.assertFalse(hasattr(data, "recordset_info")) - data = api.get_resource('collectionobject', self.collectionobjects[0].id, skip_perms_check, self.recordset.id) - self.assertEqual(data['recordset_info'], None) + data = api.get_resource( + "collectionobject", + self.collectionobjects[0].id, + skip_perms_check, + self.recordset.id, + ) + self.assertEqual(data["recordset_info"], None) self.recordset.recordsetitems.create(recordid=self.collectionobjects[0].id) - data = api.get_resource('collectionobject', self.collectionobjects[0].id, skip_perms_check, self.recordset.id) - self.assertEqual(data['recordset_info']['recordsetid'], self.recordset.id) - + data = api.get_resource( + "collectionobject", + self.collectionobjects[0].id, + skip_perms_check, + self.recordset.id, + ) + self.assertEqual(data["recordset_info"]["recordsetid"], self.recordset.id) def test_update_object(self): - data = api.get_resource('collectionobject', self.collectionobjects[0].id, skip_perms_check, self.recordset.id) - self.assertEqual(data['recordset_info'], None) - - obj = api.update_obj(self.collection, self.agent, 'collectionobject', - data['id'], data['version'], data) - + data = api.get_resource( + "collectionobject", + self.collectionobjects[0].id, + skip_perms_check, + self.recordset.id, + ) + self.assertEqual(data["recordset_info"], None) + + obj = api.update_obj( + self.collection, + self.agent, + "collectionobject", + data["id"], + data["version"], + data, + ) def test_get_recordset_info(self): ids = [co.id for co in self.collectionobjects] @@ -213,14 +310,30 @@ def test_get_recordset_info(self): for i, co in enumerate(self.collectionobjects): info = api.get_recordset_info(co, self.recordset.id) - self.assertEqual(info['recordsetid'], self.recordset.id) - self.assertEqual(info['total_count'], len(self.collectionobjects)) - self.assertEqual(info['index'], i) - self.assertEqual(info['previous'], None if i == 0 else \ - api.uri_for_model('collectionobject', self.collectionobjects[i-1].id)) + self.assertEqual(info["recordsetid"], self.recordset.id) + self.assertEqual(info["total_count"], len(self.collectionobjects)) + self.assertEqual(info["index"], i) + self.assertEqual( + info["previous"], + ( + None + if i == 0 + else api.uri_for_model( + "collectionobject", self.collectionobjects[i - 1].id + ) + ), + ) - self.assertEqual(info['next'], None if i == len(self.collectionobjects) - 1 else \ - api.uri_for_model('collectionobject', self.collectionobjects[i+1].id)) + self.assertEqual( + info["next"], + ( + None + if i == len(self.collectionobjects) - 1 + else api.uri_for_model( + "collectionobject", self.collectionobjects[i + 1].id + ) + ), + ) def test_no_recordset_info(self): info = api.get_recordset_info(self.collectionobjects[0], self.recordset.id) @@ -234,10 +347,14 @@ def test_recordsetitem_ordering(self): for id in ids: self.recordset.recordsetitems.create(recordid=id) - rsis = api.get_collection(self.collection, 'recordsetitem', skip_perms_check, params={ - 'recordset': self.recordset.id}) + rsis = api.get_collection( + self.collection, + "recordsetitem", + skip_perms_check, + params={"recordset": self.recordset.id}, + ) - result_ids = [rsi['recordid'] for rsi in rsis['objects']] + result_ids = [rsi["recordid"] for rsi in rsis["objects"]] ids.sort() self.assertEqual(result_ids, ids) @@ -256,209 +373,271 @@ def test_deleting_recordset_deletes_items(self): with self.assertRaises(models.Recordset.DoesNotExist) as cm: recordset = models.Recordset.objects.get(id=self.recordset.id) + class ApiRelatedFieldsTests(ApiTests): def test_get_to_many_uris_with_regular_othersidename(self): - data = api.get_resource('collectingevent', self.collectingevent.id, skip_perms_check) - self.assertEqual(data['collectionobjects'], - api.uri_for_model('collectionobject') + - '?collectingevent=%d' % self.collectingevent.id) + data = api.get_resource( + "collectingevent", self.collectingevent.id, skip_perms_check + ) + self.assertEqual( + data["collectionobjects"], + api.uri_for_model("collectionobject") + + "?collectingevent=%d" % self.collectingevent.id, + ) def test_get_to_many_uris_with_special_othersidename(self): - data = api.get_resource('agent', self.agent.id, skip_perms_check) + data = api.get_resource("agent", self.agent.id, skip_perms_check) # This one is actually a regular othersidename - self.assertEqual(data['collectors'], - api.uri_for_model('collector') + - '?agent=%d' % self.agent.id) + self.assertEqual( + data["collectors"], + api.uri_for_model("collector") + "?agent=%d" % self.agent.id, + ) # This one is the special otherside name ("organization" instead of "agent") - self.assertEqual(data['orgmembers'], - api.uri_for_model('agent') + - '?organization=%d' % self.agent.id) + self.assertEqual( + data["orgmembers"], + api.uri_for_model("agent") + "?organization=%d" % self.agent.id, + ) class VersionCtrlApiTests(ApiTests): def test_bump_version(self): - data = api.get_resource('collection', self.collection.id, skip_perms_check) - data['collectionname'] = 'New Name' - obj = api.update_obj(self.collection, self.agent, 'collection', - data['id'], data['version'], data) - self.assertEqual(obj.version, data['version'] + 1) + data = api.get_resource("collection", self.collection.id, skip_perms_check) + data["collectionname"] = "New Name" + obj = api.update_obj( + self.collection, self.agent, "collection", data["id"], data["version"], data + ) + self.assertEqual(obj.version, data["version"] + 1) def test_update_object(self): - data = api.get_resource('collection', self.collection.id, skip_perms_check) - data['collectionname'] = 'New Name' + data = api.get_resource("collection", self.collection.id, skip_perms_check) + data["collectionname"] = "New Name" self.collection.version += 1 self.collection.save() with self.assertRaises(api.StaleObjectException) as cm: - api.update_obj(self.collection, self.agent, 'collection', - data['id'], data['version'], data) - data = api.get_resource('collection', self.collection.id, skip_perms_check) - self.assertNotEqual(data['collectionname'], 'New Name') + api.update_obj( + self.collection, + self.agent, + "collection", + data["id"], + data["version"], + data, + ) + data = api.get_resource("collection", self.collection.id, skip_perms_check) + self.assertNotEqual(data["collectionname"], "New Name") def test_delete_object(self): - obj = api.create_obj(self.collection, self.agent, 'collectionobject', { - 'collection': api.uri_for_model('collection', self.collection.id), - 'catalognumber': 'foobar'}) - data = api.get_resource('collectionobject', obj.id, skip_perms_check) + obj = api.create_obj( + self.collection, + self.agent, + "collectionobject", + { + "collection": api.uri_for_model("collection", self.collection.id), + "catalognumber": "foobar", + }, + ) + data = api.get_resource("collectionobject", obj.id, skip_perms_check) obj.version += 1 obj.save() with self.assertRaises(api.StaleObjectException) as cm: - api.delete_resource(self.collection, self.agent, 'collectionobject', data['id'], data['version']) + api.delete_resource( + self.collection, + self.agent, + "collectionobject", + data["id"], + data["version"], + ) self.assertEqual(models.Collectionobject.objects.filter(id=obj.id).count(), 1) def test_missing_version(self): - data = api.get_resource('collection', self.collection.id, skip_perms_check) - data['collectionname'] = 'New Name' + data = api.get_resource("collection", self.collection.id, skip_perms_check) + data["collectionname"] = "New Name" self.collection.version += 1 self.collection.save() with self.assertRaises(api.MissingVersionException) as cm: - api.update_obj(self.collection, self.agent, 'collection', - data['id'], None, data) + api.update_obj( + self.collection, self.agent, "collection", data["id"], None, data + ) + class InlineApiTests(ApiTests): def test_get_resource_with_to_many_inlines(self): for i in range(3): - self.collectionobjects[0].determinations.create( - iscurrent=False, number1=i) - data = api.get_resource('collectionobject', self.collectionobjects[0].id, skip_perms_check) - self.assertTrue(isinstance(data['determinations'], list)) - self.assertEqual(len(data['determinations']), 3) - ids = [d['id'] for d in data['determinations']] + self.collectionobjects[0].determinations.create(iscurrent=False, number1=i) + data = api.get_resource( + "collectionobject", self.collectionobjects[0].id, skip_perms_check + ) + self.assertTrue(isinstance(data["determinations"], list)) + self.assertEqual(len(data["determinations"]), 3) + ids = [d["id"] for d in data["determinations"]] for det in self.collectionobjects[0].determinations.all(): self.assertTrue(det.id in ids) def test_inlined_in_collection(self): - dets = [self.collectionobjects[0].determinations.create(iscurrent=False, number1=i) - for i in range(3)] - - data = api.get_collection(self.collection, 'collectionobject', skip_perms_check) - for obj in data['objects']: - self.assertTrue(isinstance(obj['determinations'], list)) - if obj['id'] == self.collectionobjects[0].id: - serialized_dets = obj['determinations'] - self.assertEqual(len(obj['determinations']), 3) + dets = [ + self.collectionobjects[0].determinations.create(iscurrent=False, number1=i) + for i in range(3) + ] + + data = api.get_collection(self.collection, "collectionobject", skip_perms_check) + for obj in data["objects"]: + self.assertTrue(isinstance(obj["determinations"], list)) + if obj["id"] == self.collectionobjects[0].id: + serialized_dets = obj["determinations"] + self.assertEqual(len(obj["determinations"]), 3) else: - self.assertEqual(len(obj['determinations']), 0) + self.assertEqual(len(obj["determinations"]), 0) - ids = {d['id'] for d in serialized_dets} + ids = {d["id"] for d in serialized_dets} for det in dets: self.assertTrue(det.id in ids) def test_inlined_inlines(self): - preptype = models.Preptype.objects.create( - collection=self.collection) + preptype = models.Preptype.objects.create(collection=self.collection) for i in range(3): self.collectionobjects[0].preparations.create( - collectionmemberid=self.collection.id, - preptype=preptype) - data = api.get_collection(self.collection, 'collectionobject', skip_perms_check) - co = next(obj for obj in data['objects'] if obj['id'] == self.collectionobjects[0].id) - self.assertTrue(isinstance(co['preparations'], list)) - self.assertEqual(co['preparations'][0]['preparationattachments'], []) + collectionmemberid=self.collection.id, preptype=preptype + ) + data = api.get_collection(self.collection, "collectionobject", skip_perms_check) + co = next( + obj for obj in data["objects"] if obj["id"] == self.collectionobjects[0].id + ) + self.assertTrue(isinstance(co["preparations"], list)) + self.assertEqual(co["preparations"][0]["preparationattachments"], []) def test_get_resource_with_to_one_inlines(self): - self.collectionobjects[0].collectionobjectattribute = \ - models.Collectionobjectattribute.objects.create(collectionmemberid=self.collection.id) + self.collectionobjects[0].collectionobjectattribute = ( + models.Collectionobjectattribute.objects.create( + collectionmemberid=self.collection.id + ) + ) self.collectionobjects[0].save() - data = api.get_resource('collectionobject', self.collectionobjects[0].id, skip_perms_check) - self.assertTrue(isinstance(data['collectionobjectattribute'], dict)) - self.assertEqual(data['collectionobjectattribute']['id'], - self.collectionobjects[0].collectionobjectattribute.id) + data = api.get_resource( + "collectionobject", self.collectionobjects[0].id, skip_perms_check + ) + self.assertTrue(isinstance(data["collectionobjectattribute"], dict)) + self.assertEqual( + data["collectionobjectattribute"]["id"], + self.collectionobjects[0].collectionobjectattribute.id, + ) def test_create_object_with_inlines(self): - data = { - 'collection': api.uri_for_model('collection', self.collection.id), - 'catalognumber': 'foobar', - 'determinations': [{ - 'iscurrent': False, - 'number1': 1 - }, { - 'iscurrent': False, - 'number1': 2 - }], - 'collectionobjectattribute': { - 'text1': 'some text'}} - - obj = api.create_obj(self.collection, self.agent, 'collectionobject', data) + data = { + "collection": api.uri_for_model("collection", self.collection.id), + "catalognumber": "foobar", + "determinations": [ + {"iscurrent": False, "number1": 1}, + {"iscurrent": False, "number1": 2}, + ], + "collectionobjectattribute": {"text1": "some text"}, + } + + obj = api.create_obj(self.collection, self.agent, "collectionobject", data) co = models.Collectionobject.objects.get(id=obj.id) - self.assertEqual(set(co.determinations.values_list('number1', flat=True)), - set((1, 2))) - self.assertEqual(co.collectionobjectattribute.text1, 'some text') + self.assertEqual( + set(co.determinations.values_list("number1", flat=True)), set((1, 2)) + ) + self.assertEqual(co.collectionobjectattribute.text1, "some text") def test_create_object_with_inlined_existing_resource(self): coa = models.Collectionobjectattribute.objects.create( - collectionmemberid=self.collection.id) + collectionmemberid=self.collection.id + ) - coa_data = api.get_resource('collectionobjectattribute', coa.id, skip_perms_check) + coa_data = api.get_resource( + "collectionobjectattribute", coa.id, skip_perms_check + ) co_data = { - 'collection': api.uri_for_model('collection', self.collection.id), - 'collectionobjectattribute': coa_data, - 'catalognumber': 'foobar'} - obj = api.create_obj(self.collection, self.agent, 'collectionobject', co_data) + "collection": api.uri_for_model("collection", self.collection.id), + "collectionobjectattribute": coa_data, + "catalognumber": "foobar", + } + obj = api.create_obj(self.collection, self.agent, "collectionobject", co_data) co = models.Collectionobject.objects.get(id=obj.id) self.assertEqual(co.collectionobjectattribute, coa) def test_create_recordset_with_inlined_items(self): - obj = api.create_obj(self.collection, self.agent, 'recordset', { - 'name': "Test", - 'dbtableid': 1, - 'specifyuser': f'/api/specify/specifyuser/{self.specifyuser.id}/', - 'type': 0, - 'recordsetitems': [ - {'recordid': 123}, - {'recordid': 124}, - ] - }) + obj = api.create_obj( + self.collection, + self.agent, + "recordset", + { + "name": "Test", + "dbtableid": 1, + "specifyuser": f"/api/specify/specifyuser/{self.specifyuser.id}/", + "type": 0, + "recordsetitems": [ + {"recordid": 123}, + {"recordid": 124}, + ], + }, + ) rs = models.Recordset.objects.get(pk=obj.id) - self.assertEqual(set([123, 124]), set(rs.recordsetitems.values_list('recordid', flat=True))) + self.assertEqual( + set([123, 124]), set(rs.recordsetitems.values_list("recordid", flat=True)) + ) def test_update_object_with_inlines(self): self.collectionobjects[0].determinations.create( - collectionmemberid=self.collection.id, - number1=1, - remarks='original value') - - data = api.get_resource('collectionobject', self.collectionobjects[0].id, skip_perms_check) - data['determinations'][0]['remarks'] = 'changed value' - data['determinations'].append({ - 'number1': 2, - 'remarks': 'a new determination'}) - data['collectionobjectattribute'] = { - 'text1': 'added an attribute'} + collectionmemberid=self.collection.id, number1=1, remarks="original value" + ) - api.update_obj(self.collection, self.agent, 'collectionobject', - data['id'], data['version'], data) + data = api.get_resource( + "collectionobject", self.collectionobjects[0].id, skip_perms_check + ) + data["determinations"][0]["remarks"] = "changed value" + data["determinations"].append({"number1": 2, "remarks": "a new determination"}) + data["collectionobjectattribute"] = {"text1": "added an attribute"} + + api.update_obj( + self.collection, + self.agent, + "collectionobject", + data["id"], + data["version"], + data, + ) obj = models.Collectionobject.objects.get(id=self.collectionobjects[0].id) self.assertEqual(obj.determinations.count(), 2) - self.assertEqual(obj.determinations.get(number1=1).remarks, 'changed value') - self.assertEqual(obj.determinations.get(number1=2).remarks, 'a new determination') - self.assertEqual(obj.collectionobjectattribute.text1, 'added an attribute') + self.assertEqual(obj.determinations.get(number1=1).remarks, "changed value") + self.assertEqual( + obj.determinations.get(number1=2).remarks, "a new determination" + ) + self.assertEqual(obj.collectionobjectattribute.text1, "added an attribute") def test_update_object_with_more_inlines(self): for i in range(6): self.collectionobjects[0].determinations.create( - collectionmemberid=self.collection.id, - number1=i) - - data = api.get_resource('collectionobject', self.collectionobjects[0].id, skip_perms_check) - even_dets = [d for d in data['determinations'] if d['number1'] % 2 == 0] - for d in even_dets: data['determinations'].remove(d) - - data['collectionobjectattribute'] = {'text1': 'look! an attribute'} + collectionmemberid=self.collection.id, number1=i + ) - api.update_obj(self.collection, self.agent, 'collectionobject', - data['id'], data['version'], data) + data = api.get_resource( + "collectionobject", self.collectionobjects[0].id, skip_perms_check + ) + even_dets = [d for d in data["determinations"] if d["number1"] % 2 == 0] + for d in even_dets: + data["determinations"].remove(d) + + data["collectionobjectattribute"] = {"text1": "look! an attribute"} + + api.update_obj( + self.collection, + self.agent, + "collectionobject", + data["id"], + data["version"], + data, + ) obj = models.Collectionobject.objects.get(id=self.collectionobjects[0].id) self.assertEqual(obj.determinations.count(), 3) for d in obj.determinations.all(): self.assertFalse(d.number1 % 2 == 0) - self.assertEqual(obj.collectionobjectattribute.text1, 'look! an attribute') - + self.assertEqual(obj.collectionobjectattribute.text1, "look! an attribute") # version control on inlined resources should be tested @@ -470,13 +649,14 @@ def setUp(self): # Because the test database doesn't have specifyuser_spprincipal from specifyweb.context import views + views.users_collections_for_sp6 = lambda cursor, userid: [] def test_set_user_agents(self): c = Client() c.force_login(self.specifyuser) response = c.post( - f'/api/set_agents/{self.specifyuser.id}/', + f"/api/set_agents/{self.specifyuser.id}/", data=[self.agent.id], content_type="application/json", ) @@ -484,34 +664,37 @@ def test_set_user_agents(self): def test_set_user_agents_missing_exception(self): collection2 = models.Collection.objects.create( - catalognumformatname='test2', - collectionname='TestCollection2', + catalognumformatname="test2", + collectionname="TestCollection2", isembeddedcollectingevent=False, - discipline=self.discipline) - + discipline=self.discipline, + ) + UserPolicy.objects.create( collection_id=collection2.id, specifyuser_id=self.specifyuser.id, - resource='%', - action='%', + resource="%", + action="%", ) c = Client() c.force_login(self.specifyuser) response = c.post( - f'/api/set_agents/{self.specifyuser.id}/', + f"/api/set_agents/{self.specifyuser.id}/", data=[], content_type="application/json", ) self.assertEqual(response.status_code, 400) self.assertEqual( json.loads(response.content), - {'MissingAgentForAccessibleCollection': { - 'all_accessible_divisions': [self.division.id], - 'missing_for_6': [], - 'missing_for_7': [self.collection.id, collection2.id] - }} + { + "MissingAgentForAccessibleCollection": { + "all_accessible_divisions": [self.division.id], + "missing_for_6": [], + "missing_for_7": [self.collection.id, collection2.id], + } + }, ) def test_set_user_agents_multiple_exception(self): @@ -520,19 +703,28 @@ def test_set_user_agents_multiple_exception(self): firstname="Test2", lastname="User2", division=self.division, - specifyuser=None) + specifyuser=None, + ) c = Client() c.force_login(self.specifyuser) response = c.post( - f'/api/set_agents/{self.specifyuser.id}/', + f"/api/set_agents/{self.specifyuser.id}/", data=[self.agent.id, agent2.id], content_type="application/json", ) self.assertEqual(response.status_code, 400) self.assertEqual( json.loads(response.content), - {'MultipleAgentsException': [{'agentid1': self.agent.id, 'agentid2': agent2.id, 'divisonid': self.division.id}]} + { + "MultipleAgentsException": [ + { + "agentid1": self.agent.id, + "agentid2": agent2.id, + "divisonid": self.division.id, + } + ] + }, ) def test_set_user_agents_in_use_exception(self): @@ -540,88 +732,93 @@ def test_set_user_agents_in_use_exception(self): isloggedin=False, isloggedinreport=False, name="testuser2", - password="205C0D906445E1C71CA77C6D714109EB6D582B03A5493E4C") # testuser + password="205C0D906445E1C71CA77C6D714109EB6D582B03A5493E4C", + ) # testuser c = Client() c.force_login(self.specifyuser) response = c.post( - f'/api/set_agents/{user2.id}/', + f"/api/set_agents/{user2.id}/", data=[self.agent.id], content_type="application/json", ) self.assertEqual(response.status_code, 400) self.assertEqual( - json.loads(response.content), - {'AgentInUseException': [self.agent.id]} + json.loads(response.content), {"AgentInUseException": [self.agent.id]} ) + class ScopingTests(ApiTests): def setUp(self): super(ScopingTests, self).setUp() self.other_division = models.Division.objects.create( - institution=self.institution, - name='Other Division') + institution=self.institution, name="Other Division" + ) self.other_discipline = models.Discipline.objects.create( geologictimeperiodtreedef=self.geologictimeperiodtreedef, geographytreedef=self.geographytreedef, division=self.other_division, - datatype=self.datatype) + datatype=self.datatype, + ) self.other_collection = models.Collection.objects.create( - catalognumformatname='test', - collectionname='OtherCollection', + catalognumformatname="test", + collectionname="OtherCollection", isembeddedcollectingevent=False, - discipline=self.other_discipline) + discipline=self.other_discipline, + ) def test_explicitly_defined_scope(self): accession = models.Accession.objects.create( - accessionnumber="ACC_Test", - division=self.division + accessionnumber="ACC_Test", division=self.division ) accession_scope = scoping.Scoping(accession).get_scope_model() self.assertEqual(accession_scope.id, self.institution.id) loan = models.Loan.objects.create( - loannumber = "LOAN_Test", - discipline=self.other_discipline + loannumber="LOAN_Test", discipline=self.other_discipline ) loan_scope = scoping.Scoping(loan).get_scope_model() self.assertEqual(loan_scope.id, self.other_discipline.id) def test_infered_scope(self): - disposal = models.Disposal.objects.create( - disposalnumber = "DISPOSAL_TEST" - ) + disposal = models.Disposal.objects.create(disposalnumber="DISPOSAL_TEST") disposal_scope = scoping.Scoping(disposal).get_scope_model() self.assertEqual(disposal_scope.id, self.institution.id) loan = models.Loan.objects.create( - loannumber = "Inerred_Loan", + loannumber="Inerred_Loan", division=self.other_division, - discipline=self.other_discipline + discipline=self.other_discipline, ) inferred_loan_scope = scoping.Scoping(loan)._infer_scope()[1] self.assertEqual(inferred_loan_scope.id, self.other_division.id) - collection_object_scope = scoping.Scoping(self.collectionobjects[0]).get_scope_model() + collection_object_scope = scoping.Scoping( + self.collectionobjects[0] + ).get_scope_model() self.assertEqual(collection_object_scope.id, self.collection.id) def test_in_same_scope(self): - collection_objects_same_collection = (self.collectionobjects[0], self.collectionobjects[1]) - self.assertEqual(scoping.in_same_scope(*collection_objects_same_collection), True) + collection_objects_same_collection = ( + self.collectionobjects[0], + self.collectionobjects[1], + ) + self.assertEqual( + scoping.in_same_scope(*collection_objects_same_collection), True + ) other_collectionobject = models.Collectionobject.objects.create( - catalognumber="other-co", - collection=self.other_collection + catalognumber="other-co", collection=self.other_collection ) - self.assertEqual(scoping.in_same_scope(other_collectionobject, self.collectionobjects[0]), False) - - agent = models.Agent.objects.create( - agenttype=1, - division=self.other_division + self.assertEqual( + scoping.in_same_scope(other_collectionobject, self.collectionobjects[0]), + False, ) + + agent = models.Agent.objects.create(agenttype=1, division=self.other_division) self.assertEqual(scoping.in_same_scope(agent, other_collectionobject), True) self.assertEqual(scoping.in_same_scope(self.collectionobjects[0], agent), False) diff --git a/specifyweb/specify/tests/test_trees.py b/specifyweb/specify/tests/test_trees.py index 598c53e83c1..aa880c2fe6b 100644 --- a/specifyweb/specify/tests/test_trees.py +++ b/specifyweb/specify/tests/test_trees.py @@ -6,7 +6,7 @@ from specifyweb.specify.tree_extras import set_fullnames from specifyweb.specify.tree_views import get_tree_rows from specifyweb.stored_queries.execution import set_group_concat_max_len -from specifyweb.stored_queries.tests import SQLAlchemySetup +from specifyweb.stored_queries.tests.tests import SQLAlchemySetup from contextlib import contextmanager from django.db import connection diff --git a/specifyweb/specify/tree_views.py b/specifyweb/specify/tree_views.py index cda822ac56d..13d47f59609 100644 --- a/specifyweb/specify/tree_views.py +++ b/specifyweb/specify/tree_views.py @@ -8,8 +8,11 @@ from specifyweb.middleware.general import require_GET from specifyweb.businessrules.exceptions import BusinessRuleException -from specifyweb.permissions.permissions import PermissionTarget, \ - PermissionTargetAction, check_permission_targets +from specifyweb.permissions.permissions import ( + PermissionTarget, + PermissionTargetAction, + check_permission_targets, +) from specifyweb.specify.tree_ranks import tree_rank_count from specifyweb.specify.field_change_info import FieldChangeInfo from specifyweb.stored_queries import models @@ -23,9 +26,13 @@ from .views import login_maybe_required, openapi import logging + logger = logging.getLogger(__name__) -TREE_TABLE = Literal['Taxon', 'Storage', 'Geography', 'Geologictimeperiod', 'Lithostrat'] +TREE_TABLE = Literal[ + "Taxon", "Storage", "Geography", "Geologictimeperiod", "Lithostrat" +] + def tree_mutation(mutation): @login_maybe_required @@ -35,98 +42,93 @@ def tree_mutation(mutation): def wrapper(*args, **kwargs): try: mutation(*args, **kwargs) - result = {'success': True} + result = {"success": True} except BusinessRuleException as e: - result = {'success': False, 'error': str(e)} + result = {"success": False, "error": str(e)} return HttpResponse(toJson(result), content_type="application/json") return wrapper -@openapi(schema={ - "get": { - "parameters": [ - { - "name": "includeauthor", - "in": "query", - "required": False, - "schema": { - "type": "number" - }, - "description": "If parameter is present, include the author of the requested node in the response \ - if the tree is taxon and node's rankid >= paramter value." - } - ], - "responses": { - "200": { - "description": "Returns a list of nodes with parent restricted to the tree defined by . \ +@openapi( + schema={ + "get": { + "parameters": [ + { + "name": "includeauthor", + "in": "query", + "required": False, + "schema": {"type": "number"}, + "description": "If parameter is present, include the author of the requested node in the response \ + if the tree is taxon and node's rankid >= paramter value.", + } + ], + "responses": { + "200": { + "description": "Returns a list of nodes with parent restricted to the tree defined by . \ Nodes are sorted by ", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { + "content": { + "application/json": { + "schema": { "type": "array", - "prefixItems": [ - { - - "type" : "integer", - "description" : "The id of the child node" - - }, - { - "type": "string", - "description": "The name of the child node" - }, - { - "type": "string", - "description": "The fullName of the child node" - }, - { - - "type" : "integer", - "description" : "The nodenumber of the child node" - }, - { - "type" : "integer", - "description" : "The highestChildNodeNumber of the child node" - }, - { - "type" : "integer", - "description" : "The rankId of the child node" - - }, - { - "type": "number", - "description": "The acceptedId of the child node. Returns null if the node has no acceptedId" - }, - { - "type": "string", - "description": "The fullName of the child node's accepted node. Returns null if the node has no acceptedId" - }, - { - "type": "string", - "description": "The author of the child node. \ - Returns null if is not taxon or the rankId of the node is less than paramter" - }, - { - - "type" : "integer", - "description" : "The number of children the child node has" - }, - { - "type": "string", - "description": "Concat of fullname of syonyms" - } - ], + "items": { + "type": "array", + "prefixItems": [ + { + "type": "integer", + "description": "The id of the child node", + }, + { + "type": "string", + "description": "The name of the child node", + }, + { + "type": "string", + "description": "The fullName of the child node", + }, + { + "type": "integer", + "description": "The nodenumber of the child node", + }, + { + "type": "integer", + "description": "The highestChildNodeNumber of the child node", + }, + { + "type": "integer", + "description": "The rankId of the child node", + }, + { + "type": "number", + "description": "The acceptedId of the child node. Returns null if the node has no acceptedId", + }, + { + "type": "string", + "description": "The fullName of the child node's accepted node. Returns null if the node has no acceptedId", + }, + { + "type": "string", + "description": "The author of the child node. \ + Returns null if is not taxon or the rankId of the node is less than paramter", + }, + { + "type": "integer", + "description": "The number of children the child node has", + }, + { + "type": "string", + "description": "Concat of fullname of syonyms", + }, + ], + }, } } - } + }, } - } + }, } } -}) +) @login_maybe_required @require_GET def tree_view(request, treedef, tree, parentid, sortfield): @@ -141,16 +143,18 @@ def tree_view(request, treedef, tree, parentid, sortfield): See https://github.com/specify/specify7/pull/2818 for more context and a breakdown regarding implementation/design decisions """ - include_author = request.GET.get('includeauthor', False) and tree == 'taxon' + include_author = request.GET.get("includeauthor", False) and tree == "taxon" with models.session_context() as session: set_group_concat_max_len(session.connection()) - results = get_tree_rows(treedef, tree, parentid, sortfield, include_author, session) - return HttpResponse(toJson(results), content_type='application/json') + results = get_tree_rows( + treedef, tree, parentid, sortfield, include_author, session + ) + return HttpResponse(toJson(results), content_type="application/json") def get_tree_rows(treedef, tree, parentid, sortfield, include_author, session): tree_table = datamodel.get_table(tree) - parentid = None if parentid == 'null' else int(parentid) + parentid = None if parentid == "null" else int(parentid) node = getattr(models, tree_table.name) child = aliased(node) @@ -159,8 +163,8 @@ def get_tree_rows(treedef, tree, parentid, sortfield, include_author, session): id_col = getattr(node, node._id) child_id = getattr(child, node._id) treedef_col = getattr(node, tree_table.name + "TreeDefID") - orderby = tree_table.name.lower() + '.' + sortfield - + orderby = tree_table.name.lower() + "." + sortfield + col_args = [ node.name, node.fullName, @@ -174,44 +178,55 @@ def get_tree_rows(treedef, tree, parentid, sortfield, include_author, session): apply_min = [ # for some reason, SQL is rejecting the group_by in some dbs - # due to "only_full_group_by". It is somehow not smart enough to see + # due to "only_full_group_by". It is somehow not smart enough to see # that there is no dependency in the columns going from main table to the to-manys (child, and syns) # I want to use ANY_VALUE() but that's not supported by MySQL 5.6- and MariaDB. # I don't want to disable "only_full_group_by" in case someone misuses it... # applying min to fool into thinking it is aggregated. # these values are guarenteed to be the same - sql.func.min(arg) for arg in col_args - ] - + sql.func.min(arg) + for arg in col_args + ] + grouped = [ - *apply_min, + *apply_min, # syns are to-many, so child can be duplicated sql.func.count(distinct(child_id)), # child are to-many, so syn's full name can be duplicated # FEATURE: Allow users to select a separator?? Maybe that's too nice - group_concat(distinct(synonym.fullName), separator=', ') + group_concat(distinct(synonym.fullName), separator=", "), ] - query = session.query(id_col, *grouped) \ - .outerjoin(child, child.ParentID == id_col) \ - .outerjoin(accepted, node.AcceptedID == getattr(accepted, node._id)) \ - .outerjoin(synonym, synonym.AcceptedID == id_col) \ - .group_by(id_col) \ - .filter(treedef_col == int(treedef)) \ - .filter(node.ParentID == parentid) \ + query = ( + session.query(id_col, *grouped) + .outerjoin(child, child.ParentID == id_col) + .outerjoin(accepted, node.AcceptedID == getattr(accepted, node._id)) + .outerjoin(synonym, synonym.AcceptedID == id_col) + .group_by(id_col) + .filter(treedef_col == int(treedef)) + .filter(node.ParentID == parentid) .order_by(orderby) + ) results = list(query) return results + @login_maybe_required @require_GET def tree_stats(request, treedef, tree, parentid): "Returns tree stats (collection object count) for tree nodes parented by ." - using_cte = (tree in ['geography', 'taxon', 'storage']) - results = get_tree_stats(treedef, tree, parentid, request.specify_collection, models.session_context, using_cte) + using_cte = tree in ["geography", "taxon", "storage"] + results = get_tree_stats( + treedef, + tree, + parentid, + request.specify_collection, + models.session_context, + using_cte, + ) - return HttpResponse(toJson(results), content_type='application/json') + return HttpResponse(toJson(results), content_type="application/json") @login_maybe_required @@ -221,12 +236,13 @@ def path(request, tree, id): id = int(id) tree_node = get_object_or_404(tree, id=id) - data = {node.definitionitem.name: obj_to_data(node) - for node in get_tree_path(tree_node)} + data = { + node.definitionitem.name: obj_to_data(node) for node in get_tree_path(tree_node) + } - data['resource_uri'] = '/api/specify_tree/%s/%d/path/' % (tree, id) + data["resource_uri"] = "/api/specify_tree/%s/%d/path/" % (tree, id) - return HttpResponse(toJson(data), content_type='application/json') + return HttpResponse(toJson(data), content_type="application/json") def get_tree_path(tree_node): @@ -246,22 +262,25 @@ def predict_fullname(request, tree, parentid): parent = get_object_or_404(tree, id=parentid) depth = parent.definition.treedefitems.count() reverse = parent.definition.fullnamedirection == -1 - defitemid = int(request.GET['treedefitemid']) - name = request.GET['name'] + defitemid = int(request.GET["treedefitemid"]) + name = request.GET["name"] fullname = tree_extras.predict_fullname( parent._meta.db_table, depth, parent.id, defitemid, name, reverse ) - return HttpResponse(fullname, content_type='text/plain') + return HttpResponse(fullname, content_type="text/plain") @tree_mutation def merge(request, tree, id): """Merges node into the node with id indicated by the 'target' POST parameter.""" - check_permission_targets(request.specify_collection.id, - request.specify_user.id, [perm_target(tree).merge]) + check_permission_targets( + request.specify_collection.id, + request.specify_user.id, + [perm_target(tree).merge], + ) node = get_object_or_404(tree, id=id) - target = get_object_or_404(tree, id=request.POST['target']) + target = get_object_or_404(tree, id=request.POST["target"]) tree_extras.merge(node, target, request.specify_user_agent) @@ -270,10 +289,11 @@ def move(request, tree, id): """Reparents the node to be a child of the node indicated by the 'target' POST parameter. """ - check_permission_targets(request.specify_collection.id, - request.specify_user.id, [perm_target(tree).move]) + check_permission_targets( + request.specify_collection.id, request.specify_user.id, [perm_target(tree).move] + ) node = get_object_or_404(tree, id=id) - target = get_object_or_404(tree, id=request.POST['target']) + target = get_object_or_404(tree, id=request.POST["target"]) old_parent = node.parent old_parentid = old_parent.id old_fullname = node.fullname @@ -283,88 +303,101 @@ def move(request, tree, id): node = get_object_or_404(tree, id=id) if old_stamp is None or (node.timestampmodified > old_stamp): field_change_infos = [ - FieldChangeInfo(field_name='parentid', old_value=old_parentid, new_value=target.id), - FieldChangeInfo(field_name='fullname', old_value=old_fullname, new_value=node.fullname) - ] - tree_extras.mutation_log(TREE_MOVE, node, request.specify_user_agent, - node.parent, field_change_infos) - -@openapi(schema={ - "post": { - "parameters": [{ - "name": "tree", - "in": "path", - "required": True, - "schema": { - "enum": ['Storage'] - } - }, - { - "name": "id", - "in": "path", - "description": "The id of the node from which to bulk move from.", - "required": True, - "schema": { - "type": "integer", - "minimum": 0 - } - }], - "requestBody": { - "required": True, - "content": { - "application/x-www-form-urlencoded": { - "schema": { - "type": "object", - "properties": { - "target": { - "type": "integer", - "description": "The ID of the storage tree node to which the preparations should be moved." + FieldChangeInfo( + field_name="parentid", old_value=old_parentid, new_value=target.id + ), + FieldChangeInfo( + field_name="fullname", old_value=old_fullname, new_value=node.fullname + ), + ] + tree_extras.mutation_log( + TREE_MOVE, node, request.specify_user_agent, node.parent, field_change_infos + ) + + +@openapi( + schema={ + "post": { + "parameters": [ + { + "name": "tree", + "in": "path", + "required": True, + "schema": {"enum": ["Storage"]}, + }, + { + "name": "id", + "in": "path", + "description": "The id of the node from which to bulk move from.", + "required": True, + "schema": {"type": "integer", "minimum": 0}, + }, + ], + "requestBody": { + "required": True, + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "type": "object", + "properties": { + "target": { + "type": "integer", + "description": "The ID of the storage tree node to which the preparations should be moved.", + }, }, - }, - 'required': ['target'], - 'additionalProperties': False + "required": ["target"], + "additionalProperties": False, + } } + }, + }, + "responses": { + "200": { + "description": "Success message indicating the bulk move operation was successful." } - } - }, - "responses": { - "200": { - "description": "Success message indicating the bulk move operation was successful." - } + }, } } -}) +) @tree_mutation def bulk_move(request, tree: TREE_TABLE, id: int): """Bulk move the preparations under the node to have as new location storage the node indicated by the 'target' POST parameter. """ - check_permission_targets(request.specify_collection.id, - request.specify_user.id, [perm_target(tree).bulk_move]) + check_permission_targets( + request.specify_collection.id, + request.specify_user.id, + [perm_target(tree).bulk_move], + ) node = get_object_or_404(tree, id=id) - target = get_object_or_404(tree, id=request.POST['target']) + target = get_object_or_404(tree, id=request.POST["target"]) tree_extras.bulk_move(node, target, request.specify_user_agent) + @tree_mutation def synonymize(request, tree, id): """Synonymizes the node to be a synonym of the node indicated by the 'target' POST parameter. """ - check_permission_targets(request.specify_collection.id, - request.specify_user.id, - [perm_target(tree).synonymize]) + check_permission_targets( + request.specify_collection.id, + request.specify_user.id, + [perm_target(tree).synonymize], + ) node = get_object_or_404(tree, id=id) - target = get_object_or_404(tree, id=request.POST['target']) + target = get_object_or_404(tree, id=request.POST["target"]) tree_extras.synonymize(node, target, request.specify_user_agent) @tree_mutation def desynonymize(request, tree, id): "Causes the node to no longer be a synonym of another node." - check_permission_targets(request.specify_collection.id, - request.specify_user.id, - [perm_target(tree).desynonymize]) + check_permission_targets( + request.specify_collection.id, + request.specify_user.id, + [perm_target(tree).desynonymize], + ) node = get_object_or_404(tree, id=id) tree_extras.desynonymize(node, request.specify_user_agent) @@ -372,22 +405,28 @@ def desynonymize(request, tree, id): @tree_mutation def repair_tree(request, tree): "Repairs the indicated ." - check_permission_targets(request.specify_collection.id, - request.specify_user.id, - [perm_target(tree).repair]) + check_permission_targets( + request.specify_collection.id, + request.specify_user.id, + [perm_target(tree).repair], + ) tree_model = datamodel.get_table(tree) table = tree_model.name.lower() tree_extras.renumber_tree(table) tree_extras.validate_tree_numbering(table) + @login_maybe_required @require_GET def tree_rank_item_count(request, tree, rankid): """Returns the number of items in the tree rank with id .""" - tree_rank_model_name = tree if tree.endswith('treedefitem') else tree + 'treedefitem' + tree_rank_model_name = ( + tree if tree.endswith("treedefitem") else tree + "treedefitem" + ) rank = get_object_or_404(tree_rank_model_name, id=rankid) count = tree_rank_count(tree, rank.id) - return HttpResponse(toJson(count), content_type='application/json') + return HttpResponse(toJson(count), content_type="application/json") + class TaxonMutationPT(PermissionTarget): resource = "/tree/edit/taxon" @@ -437,9 +476,9 @@ class LithostratMutationPT(PermissionTarget): def perm_target(tree): return { - 'taxon': TaxonMutationPT, - 'geography': GeographyMutationPT, - 'storage': StorageMutationPT, - 'geologictimeperiod': GeologictimeperiodMutationPT, - 'lithostrat': LithostratMutationPT, + "taxon": TaxonMutationPT, + "geography": GeographyMutationPT, + "storage": StorageMutationPT, + "geologictimeperiod": GeologictimeperiodMutationPT, + "lithostrat": LithostratMutationPT, }[tree] diff --git a/specifyweb/stored_queries/batch_edit.py b/specifyweb/stored_queries/batch_edit.py index e494ba60ea3..d28c417311a 100644 --- a/specifyweb/stored_queries/batch_edit.py +++ b/specifyweb/stored_queries/batch_edit.py @@ -1,10 +1,14 @@ from functools import reduce -from typing import Any, Callable, Dict, List, NamedTuple, Optional, Tuple, TypeVar +from typing import Any, Callable, Dict, List, NamedTuple, Optional, Tuple, TypedDict from specifyweb.specify.models import datamodel from specifyweb.specify.load_datamodel import Field, Table from specifyweb.stored_queries.execution import execute from specifyweb.stored_queries.queryfield import QueryField, fields_from_json -from specifyweb.stored_queries.queryfieldspec import FieldSpecJoinPath, QueryFieldSpec, TreeRankQuery +from specifyweb.stored_queries.queryfieldspec import ( + FieldSpecJoinPath, + QueryFieldSpec, + TreeRankQuery, +) from specifyweb.workbench.models import Spdataset from specifyweb.workbench.upload.treerecord import TreeRecord from specifyweb.workbench.upload.upload_plan_schema import parse_column_options @@ -13,18 +17,18 @@ from specifyweb.workbench.views import regularize_rows from specifyweb.specify.func import Func from . import models +import json from django.db import transaction - + MaybeField = Callable[[QueryFieldSpec], Optional[Field]] -# TODO: -# Investigate if any/some/most of the logic for making an upload plan could be moved to frontend and reused. -# - does generation of upload plan in the backend bc upload plan is not known (we don't know count of to-many). +# TODO: +# Investigate if any/some/most of the logic for making an upload plan could be moved to frontend and reused. +# - does generation of upload plan in the backend bc upload plan is not known (we don't know count of to-many). # - seemed complicated to merge upload plan from the frontend -# - need to place id markers at correct level, so need to follow upload plan anyways. - +# - need to place id markers at correct level, so need to follow upload plan anyways. def _get_nested_order(field_spec: QueryFieldSpec): @@ -32,24 +36,26 @@ def _get_nested_order(field_spec: QueryFieldSpec): # won't affect logic, just data being saved. if len(field_spec.join_path) == 0: return None - return field_spec.table.get_field('ordernumber') + return field_spec.table.get_field("ordernumber") batch_edit_fields: Dict[str, Tuple[MaybeField, int]] = { # technically, if version updates are correct, this is useless beyond base tables - # and to-manys. TODO: Do just that. remove it. sorts asc. using sort, the optimized + # and to-manys. TODO: Do just that. remove it. sorts asc. using sort, the optimized # dataset construction takes place. - 'id': (lambda field_spec: field_spec.table.idField, 1), + "id": (lambda field_spec: field_spec.table.idField, 1), # version control gets added here. no sort. - 'version': (lambda field_spec: field_spec.table.get_field('version'), 0), + "version": (lambda field_spec: field_spec.table.get_field("version"), 0), # ordernumber. no sort (actually adding a sort here is useless) - 'order': (_get_nested_order, 1) + "order": (_get_nested_order, 1), } + class BatchEditFieldPack(NamedTuple): field: Optional[QueryField] = None idx: Optional[int] = None - value: Any = None # stricten this? + value: Any = None # stricten this? + class BatchEditPack(NamedTuple): id: BatchEditFieldPack @@ -58,15 +64,23 @@ class BatchEditPack(NamedTuple): # extends a path to contain the last field + for a defined fields @staticmethod - def from_field_spec(field_spec: QueryFieldSpec) -> 'BatchEditPack': - # don't care about which way. bad things will happen if not sorted. + def from_field_spec(field_spec: QueryFieldSpec) -> "BatchEditPack": + # don't care about which way. bad things will happen if not sorted. # not using assert () since it can be optimised out. - if ( batch_edit_fields['id'][1] == 0 or batch_edit_fields['order'][1] == 0 ): raise Exception("the ID field should always be sorted!") - extend_callback = lambda field: field_spec._replace(join_path=(*field_spec.join_path, field), date_part=None) + if batch_edit_fields["id"][1] == 0 or batch_edit_fields["order"][1] == 0: + raise Exception("the ID field should always be sorted!") + + def extend_callback(field): + return field_spec._replace( + join_path=(*field_spec.join_path, field), date_part=None + ) + new_field_specs = { key: Func.maybe( Func.maybe(callback(field_spec), extend_callback), - lambda field_spec: BatchEditFieldPack(field=BatchEditPack._query_field(field_spec, sort_type)) + lambda field_spec: BatchEditFieldPack( + field=BatchEditPack._query_field(field_spec, sort_type) + ), ) for key, (callback, sort_type) in batch_edit_fields.items() } @@ -82,44 +96,56 @@ def _query_field(field_spec: QueryFieldSpec, sort_type: int): negate=False, display=True, format_name=None, - sort_type=sort_type + sort_type=sort_type, ) - + def _index( - self, - start_idx: int, - current: Tuple[Dict[str, Optional[BatchEditFieldPack]], List[QueryField]], - next: Tuple[int, Tuple[str, Tuple[MaybeField, int]]]): + self, + start_idx: int, + current: Tuple[Dict[str, Optional[BatchEditFieldPack]], List[QueryField]], + next: Tuple[int, Tuple[str, Tuple[MaybeField, int]]], + ): current_dict, fields = current field_idx, (field_name, _) = next value: Optional[BatchEditFieldPack] = getattr(self, field_name) - new_dict = {**current_dict, field_name: None if value is None else value._replace(idx=(field_idx + start_idx))} + new_dict = { + **current_dict, + field_name: ( + None + if value is None + else value._replace(idx=(field_idx + start_idx), field=None) + ), + } new_fields = fields if value is None else [*fields, value.field] return new_dict, new_fields - - def index_plan(self, start_index=0) -> Tuple['BatchEditPack', List[QueryField]]: + def index_plan(self, start_index=0) -> Tuple["BatchEditPack", List[QueryField]]: _dict, fields = reduce( - lambda accum, next: self._index(start_idx=start_index, current=accum, next=next), - enumerate(batch_edit_fields.items()), - ({}, []) - ) + lambda accum, next: self._index( + start_idx=start_index, current=accum, next=next + ), + enumerate(batch_edit_fields.items()), + ({}, []), + ) return BatchEditPack(**_dict), fields - + def bind(self, row: Tuple[Any]): - return BatchEditPack(**{ - key: Func.maybe( - getattr(self, key), - lambda pack: pack._replace(value=row[pack.idx])) for key in batch_edit_fields.keys() - }) - + return BatchEditPack( + **{ + key: Func.maybe( + getattr(self, key), lambda pack: pack._replace(value=row[pack.idx]) + ) + for key in batch_edit_fields.keys() + } + ) + def to_json(self) -> Dict[str, Any]: return { - 'id': self.id.value, - 'ordernumber': self.order.value if self.order is not None else None, - 'version': self.version.value if self.version is not None else None + "id": self.id.value, + "ordernumber": self.order.value if self.order is not None else None, + "version": self.version.value if self.version is not None else None, } - + # we not only care that it is part of tree, but also care that there is rank to tree def is_part_of_tree(self, query_fields: List[QueryField]) -> bool: if self.id is None or self.id.idx is None: @@ -130,60 +156,101 @@ def is_part_of_tree(self, query_fields: List[QueryField]) -> bool: if len(join_path) < 2: return False return isinstance(join_path[-2], TreeRankQuery) - - + + # FUTURE: this already supports nested-to-many for most part # wb plan, but contains query fields along with indexes to look-up in a result row. # TODO: see if it can be moved + combined with front-end logic. I kept all parsing on backend, but there might be possible beneft in doing this # on the frontend (it already has code from mapping path -> upload plan) class RowPlanMap(NamedTuple): columns: List[BatchEditFieldPack] = [] - to_one: Dict[str, 'RowPlanMap'] = {} - to_many: Dict[str, 'RowPlanMap'] = {} + to_one: Dict[str, "RowPlanMap"] = {} + to_many: Dict[str, "RowPlanMap"] = {} batch_edit_pack: Optional[BatchEditPack] = None - has_filters: bool = True + has_filters: bool = False @staticmethod def _merge(has_filters: bool): - def _merger(current: Dict[str, 'RowPlanMap'], other: Tuple[str, 'RowPlanMap']) -> Dict[str, 'RowPlanMap']: + def _merger( + current: Dict[str, "RowPlanMap"], other: Tuple[str, "RowPlanMap"] + ) -> Dict[str, "RowPlanMap"]: key, other_plan = other return { - **current, + **current, # merge if other is also found in ours - key: other_plan if key not in current else current[key].merge(other_plan, has_filter_on_parent=has_filters) - } + key: ( + other_plan + if key not in current + else current[key].merge( + other_plan, has_filter_on_parent=has_filters + ) + ), + } + return _merger - + # takes two row plans, combines them together. Adjusts has_filters. - def merge(self: 'RowPlanMap', other: 'RowPlanMap', has_filter_on_parent=False) -> 'RowPlanMap': + def merge( + self: "RowPlanMap", other: "RowPlanMap", has_filter_on_parent=False + ) -> "RowPlanMap": new_columns = [*self.columns, *other.columns] batch_edit_pack = self.batch_edit_pack or other.batch_edit_pack has_self_filters = has_filter_on_parent or self.has_filters or other.has_filters - to_one = reduce(RowPlanMap._merge(has_self_filters), other.to_one.items(), self.to_one) + to_one = reduce( + RowPlanMap._merge(has_self_filters), other.to_one.items(), self.to_one + ) to_many = reduce(RowPlanMap._merge(False), other.to_many.items(), self.to_many) - return RowPlanMap(new_columns, to_one, to_many, batch_edit_pack, has_filters=has_self_filters) - - def _index(current: Tuple[int, Dict[str, 'RowPlanMap'], List[QueryField]], other: Tuple[str, 'RowPlanMap']): + return RowPlanMap( + new_columns, to_one, to_many, batch_edit_pack, has_filters=has_self_filters + ) + + def _index( + current: Tuple[int, Dict[str, "RowPlanMap"], List[QueryField]], + other: Tuple[str, "RowPlanMap"], + ): next_start_index = current[0] other_indexed, fields = other[1].index_plan(start_index=next_start_index) - to_return = ((next_start_index + len(fields)), {**current[1], other[0]: other_indexed}, [*current[2], *fields]) + to_return = ( + (next_start_index + len(fields)), + {**current[1], other[0]: other_indexed}, + [*current[2], *fields], + ) return to_return - + # to make things simpler, returns the QueryFields along with indexed plan, which are expected to be used together - def index_plan(self, start_index=1) -> Tuple['RowPlanMap', List[QueryField]]: + def index_plan(self, start_index=1) -> Tuple["RowPlanMap", List[QueryField]]: next_index = len(self.columns) + start_index # For optimization, and sanity, we remove the field from columns, as they are now completely redundant (we always know what they are using the id) - _columns = [column._replace(idx=index, field=None) for index, column in zip(range(start_index, next_index), self.columns)] - _batch_indexed, _batch_fields = self.batch_edit_pack.index_plan(start_index=next_index) if self.batch_edit_pack else (None, []) + _columns = [ + column._replace(idx=index, field=None) + for index, column in zip(range(start_index, next_index), self.columns) + ] + _batch_indexed, _batch_fields = ( + self.batch_edit_pack.index_plan(start_index=next_index) + if self.batch_edit_pack + else (None, []) + ) next_index += len(_batch_fields) next_index, _to_one, to_one_fields = reduce( - RowPlanMap._index, + RowPlanMap._index, # makes the order deterministic, would be funny otherwise Func.sort_by_key(self.to_one), - (next_index, {}, [])) - next_index, _to_many, to_many_fields = reduce(RowPlanMap._index, Func.sort_by_key(self.to_many), (next_index, {}, [])) + (next_index, {}, []), + ) + next_index, _to_many, to_many_fields = reduce( + RowPlanMap._index, Func.sort_by_key(self.to_many), (next_index, {}, []) + ) column_fields = [column.field for column in self.columns] - return (RowPlanMap(columns=_columns, to_one=_to_one, to_many=_to_many, batch_edit_pack=_batch_indexed, has_filters=self.has_filters), [*column_fields, *_batch_fields, *to_one_fields, *to_many_fields]) + return ( + RowPlanMap( + columns=_columns, + to_one=_to_one, + to_many=_to_many, + batch_edit_pack=_batch_indexed, + has_filters=self.has_filters, + ), + [*column_fields, *_batch_fields, *to_one_fields, *to_many_fields], + ) # helper for generating an row plan for a single query field # handles formatted/aggregated self or relationships correctly (places them in upload-plan at correct level) @@ -192,272 +259,491 @@ def index_plan(self, start_index=1) -> Tuple['RowPlanMap', List[QueryField]]: # on the colletors table (as a column). Instead, we put it as a column in collectingevent. This has no visual difference (it is unmapped) anyways. @staticmethod def _recur_row_plan( - running_path: FieldSpecJoinPath, - next_path: FieldSpecJoinPath, - next_table: Table, # bc queryfieldspecs will be terminated early on - original_field: QueryField, - ) -> 'RowPlanMap': - + running_path: FieldSpecJoinPath, + next_path: FieldSpecJoinPath, + next_table: Table, # bc queryfieldspecs will be terminated early on + original_field: QueryField, + ) -> "RowPlanMap": + original_field_spec = original_field.fieldspec # contains partial path - partial_field_spec = original_field_spec._replace(join_path=running_path, table=next_table) + partial_field_spec = original_field_spec._replace( + join_path=running_path, table=next_table + ) # to handle CO->(formatted), that's it. this function will never be called with empty path other than top-level formatted/aggregated node, *rest = (None,) if not next_path else next_path # we can't edit relationships's formatted/aggregated anyways. - batch_edit_pack = None if original_field_spec.needs_formatted() else BatchEditPack.from_field_spec(partial_field_spec) + batch_edit_pack = ( + None + if original_field_spec.needs_formatted() + else BatchEditPack.from_field_spec(partial_field_spec) + ) if node is None or (len(rest) == 0): # we are at the end - return RowPlanMap(columns=[BatchEditFieldPack(field=original_field)], batch_edit_pack=batch_edit_pack, has_filters=(original_field.op_num != 8)) - + return RowPlanMap( + columns=[BatchEditFieldPack(field=original_field)], + batch_edit_pack=batch_edit_pack, + has_filters=(original_field.op_num != 8), + ) + assert node.is_relationship, "using a non-relationship as a pass through!" - rel_type = 'to_many' if node.type.endswith('to-many') or node.type == 'zero-to-one' else 'to_one' + rel_type = ( + "to_many" + if node.type.endswith("to-many") or node.type == "zero-to-one" + else "to_one" + ) - rel_name = node.name.lower() if not isinstance(node, TreeRankQuery) else node.name + rel_name = ( + node.name.lower() if not isinstance(node, TreeRankQuery) else node.name + ) return RowPlanMap( - **{rel_type: { - rel_name: RowPlanMap._recur_row_plan( - (*running_path, node), - rest, - datamodel.get_table(node.relatedModelName), - original_field + **{ + rel_type: { + rel_name: RowPlanMap._recur_row_plan( + (*running_path, node), + rest, + datamodel.get_table(node.relatedModelName), + original_field, ) }, - 'batch_edit_pack': batch_edit_pack - } + "batch_edit_pack": batch_edit_pack, + } ) - + # generates multiple row plan maps, and merges them into one # this doesn't index the row plan, bc that is complicated. # instead, see usage of index_plan() which indexes the plan in one go. @staticmethod - def get_row_plan(fields: List[QueryField]) -> 'RowPlanMap': + def get_row_plan(fields: List[QueryField]) -> "RowPlanMap": iter = [ - RowPlanMap._recur_row_plan((), field.fieldspec.join_path, field.fieldspec.root_table, field) + RowPlanMap._recur_row_plan( + (), field.fieldspec.join_path, field.fieldspec.root_table, field + ) for field in fields - ] - return reduce(lambda current, other: current.merge( - other, - has_filter_on_parent=False - ), iter, RowPlanMap()) + ] + return reduce( + lambda current, other: current.merge(other, has_filter_on_parent=False), + iter, + RowPlanMap(), + ) @staticmethod - def _bind_null(value: 'RowPlanCanonical') -> List['RowPlanCanonical']: + def _bind_null(value: "RowPlanCanonical") -> List["RowPlanCanonical"]: if value.batch_edit_pack.id.value is None: return [] return [value] - - def bind(self, row: Tuple[Any]) -> 'RowPlanCanonical': - columns = [column._replace(value=row[column.idx], field=None) for column in self.columns] + + def bind(self, row: Tuple[Any]) -> "RowPlanCanonical": + columns = [ + column._replace(value=row[column.idx], field=None) + for column in self.columns + ] to_ones = {key: value.bind(row) for (key, value) in self.to_one.items()} to_many = { key: RowPlanMap._bind_null(value.bind(row)) for (key, value) in self.to_many.items() - } + } pack = self.batch_edit_pack.bind(row) if self.batch_edit_pack else None return RowPlanCanonical(columns, to_ones, to_many, pack) - + # gets a null record to fill-out empty space # doesn't support nested-to-many's yet - complicated - def nullify(self) -> 'RowPlanCanonical': - columns = [pack._replace(value='(Not included in results)' if self.has_filters else None) for pack in self.columns] + def nullify(self) -> "RowPlanCanonical": + columns = [ + pack._replace( + value="(Not included in results)" if self.has_filters else None + ) + for pack in self.columns + ] to_ones = {key: value.nullify() for (key, value) in self.to_one.items()} - batch_edit_pack = BatchEditPack(id=BatchEditFieldPack(value=NULL_RECORD)) if self.has_filters else None - return RowPlanCanonical(columns, to_ones, batch_edit_pack=batch_edit_pack) + batch_edit_pack = ( + BatchEditPack(id=BatchEditFieldPack(value=NULL_RECORD)) + if self.has_filters + else None + ) + return RowPlanCanonical(columns, to_ones, batch_edit_pack=batch_edit_pack) - # a fake upload plan that keeps track of the maximum ids / ord er numbrs seen in to-manys - def to_many_planner(self) -> 'RowPlanMap': + # a fake upload plan that keeps track of the maximum ids / order numbrs seen in to-manys + def to_many_planner(self) -> "RowPlanMap": to_one = {key: value.to_many_planner() for (key, value) in self.to_one.items()} to_many = { key: RowPlanMap( - batch_edit_pack=BatchEditPack(order=BatchEditFieldPack(value=0), id=BatchEditFieldPack()) - if value.batch_edit_pack.order - # only use id if order field is not present - else BatchEditPack(id=BatchEditFieldPack(value=0))) for (key, value) in self.to_many.items() - } + batch_edit_pack=( + BatchEditPack( + order=BatchEditFieldPack(value=0), id=BatchEditFieldPack() + ) + if value.batch_edit_pack.order + # only use id if order field is not present + else BatchEditPack(id=BatchEditFieldPack(value=0)) + ) + ) + for (key, value) in self.to_many.items() + } return RowPlanMap(to_one=to_one, to_many=to_many) + # the main data-structure which stores the data # RowPlanMap is just a map, this stores actual data (to many is a dict of list, rather than just a dict) # maybe unify that with RowPlanMap? + + class RowPlanCanonical(NamedTuple): columns: List[BatchEditFieldPack] = [] - to_one: Dict[str, 'RowPlanCanonical'] = {} - to_many: Dict[str, List[Optional['RowPlanCanonical']]] = {} + to_one: Dict[str, "RowPlanCanonical"] = {} + to_many: Dict[str, List[Optional["RowPlanCanonical"]]] = {} batch_edit_pack: Optional[BatchEditPack] = None @staticmethod - def _maybe_extend(values: List[Optional['RowPlanCanonical']], result:Tuple[bool, 'RowPlanCanonical']): + def _maybe_extend( + values: List[Optional["RowPlanCanonical"]], + result: Tuple[bool, "RowPlanCanonical"], + ): is_new = result[0] new_values = (is_new, [*values, result[1]] if is_new else values) return new_values # FUTURE: already handles nested to-many. - def merge(self, row: Tuple[Any], indexed_plan: RowPlanMap) -> Tuple[bool, 'RowPlanCanonical']: + def merge( + self, row: Tuple[Any], indexed_plan: RowPlanMap + ) -> Tuple[bool, "RowPlanCanonical"]: # nothing to compare against. useful for recursion + handing default null as default value for reduce if self.batch_edit_pack is None: - return True, indexed_plan.bind(row) - + return False, indexed_plan.bind(row) + # trying to defer actual bind to later batch_fields = indexed_plan.batch_edit_pack.bind(row) if batch_fields.id.value != self.batch_edit_pack.id.value: # if the id itself is different, we are on a different record. just bind and return return True, indexed_plan.bind(row) - # now, ids are the same. no reason to bind other's to one. + # now, ids are the same. no reason to bind other's to one. # however, still need to handle to-manys inside to-ones (this will happen when a row gets duplicated due to to-many) - # in that case, to-one wouldn't change. but, need to recur down till either new to-many gets found or we are in a dup chain. - # don't need a new flag here. why? - to_one = { - key: value.merge(row, indexed_plan.to_one.get(key))[1] - for (key, value) in self.to_one.items() - } + def _reduce_to_one( + accum: Tuple[bool, Dict[str, "RowPlanCanonical"]], + current: Tuple[str, RowPlanCanonical], + ): + key, value = current + is_stalled, previous_chain = accum + new_stalled, result = ( + (True, value) + if is_stalled + else value.merge(row, indexed_plan.to_one.get(key)) + ) + return (is_stalled or new_stalled, {**previous_chain, key: result}) + + to_one_stalled, to_one = reduce( + _reduce_to_one, Func.sort_by_key(self.to_one), (False, {}) + ) # the most tricky lines in this file - def _reduce_to_many(accum: Tuple[int, List[Tuple[str, bool, List['RowPlanCanonical']]]], current: Tuple[str, List[RowPlanCanonical]]): + def _reduce_to_many( + accum: Tuple[int, List[Tuple[str, bool, List["RowPlanCanonical"]]]], + current: Tuple[str, List[RowPlanCanonical]], + ): key, values = current previous_length, previous_chain = accum - stalled = (previous_length > 1) or len(values) == 0 - is_new, new_values = (False, values) if stalled else RowPlanCanonical._maybe_extend(values, values[-1].merge(row, indexed_plan.to_many.get(key))) - return (max(len(new_values), previous_length), [*previous_chain, (key, is_new, new_values)]) - - _, to_many_result = reduce(_reduce_to_many, self.to_many.items(), (0, [])) - - to_many_new = any(results[1] for results in to_many_result) - if to_many_new: - # a "meh" optimization - to_many = { - key: values - for (key, _, values) in to_many_result - } - else: + is_stalled = previous_length > 1 + if len(values) == 0: + new_values = [] + new_stalled = False + else: + new_stalled, new_values = ( + (True, values) + if is_stalled + else RowPlanCanonical._maybe_extend( + values, values[-1].merge(row, indexed_plan.to_many.get(key)) + ) + ) + return ( + max(len(new_values), previous_length), + [*previous_chain, (key, is_stalled or new_stalled, new_values)], + ) + + if to_one_stalled: to_many = self.to_many - - # TODO: explain why those arguments - return False, RowPlanCanonical( - self.columns, - to_one, - to_many, - self.batch_edit_pack + to_many_stalled = True + else: + # We got stalled early on. + most_length, to_many_result = reduce( + _reduce_to_many, Func.sort_by_key(self.to_many), (0, []) ) + to_many_stalled = ( + any(results[1] for results in to_many_result) or most_length > 1 + ) + to_many = {key: values for (key, _, values) in to_many_result} + + # TODO: explain why those arguments + stalled = to_one_stalled or to_many_stalled + return stalled, RowPlanCanonical( + self.columns, to_one, to_many, self.batch_edit_pack + ) + @staticmethod - def _update_id_order(values: List['RowPlanCanonical'], plan: RowPlanMap): + def _update_id_order(values: List["RowPlanCanonical"], plan: RowPlanMap): is_id = plan.batch_edit_pack.order is None - new_value = len(values) if is_id else max([value.batch_edit_pack.order.value for value in values]) - current_value = plan.batch_edit_pack.order.value if not is_id else plan.batch_edit_pack.id.value - return RowPlanMap(batch_edit_pack=plan.batch_edit_pack._replace(**{('id' if is_id else 'order'): BatchEditFieldPack(value=max(new_value, current_value))})) - + new_value = ( + len(values) + if is_id + else ( + 0 + if len(values) == 0 + else max([value.batch_edit_pack.order.value for value in values]) + ) + ) + current_value = ( + plan.batch_edit_pack.order.value + if not is_id + else plan.batch_edit_pack.id.value + ) + return RowPlanMap( + batch_edit_pack=plan.batch_edit_pack._replace( + **{ + ("id" if is_id else "order"): BatchEditFieldPack( + value=max(new_value, current_value) + ) + } + ) + ) + # as we iterate through rows, need to update the to-many stats (number of ids or maximum order we saw) # this is done to expand the rows at the end def update_to_manys(self, to_many_planner: RowPlanMap) -> RowPlanMap: - to_one = {key: value.update_to_manys(to_many_planner.to_one.get(key)) for (key, value) in self.to_one.items()} - to_many = {key: RowPlanCanonical._update_id_order(values, to_many_planner.to_many.get(key)) for key, values in self.to_many.items()} + to_one = { + key: value.update_to_manys(to_many_planner.to_one.get(key)) + for (key, value) in self.to_one.items() + } + to_many = { + key: RowPlanCanonical._update_id_order( + values, to_many_planner.to_many.get(key) + ) + for key, values in self.to_many.items() + } return RowPlanMap(to_one=to_one, to_many=to_many) - + @staticmethod - def _extend_id_order(values: List['RowPlanCanonical'], to_many_planner: RowPlanMap, indexed_plan: RowPlanMap) -> List['RowPlanCanonical']: + def _extend_id_order( + values: List["RowPlanCanonical"], + to_many_planner: RowPlanMap, + indexed_plan: RowPlanMap, + ) -> List["RowPlanCanonical"]: is_id = to_many_planner.batch_edit_pack.order is None fill_out = None # minor memoization, hehe null_record = indexed_plan.nullify() - if not is_id: # if order is present, things are more complex - max_order = max([value.batch_edit_pack.order.value for value in values]) + if not is_id: # if order is present, things are more complex + max_order = ( + 0 + if len(values) == 0 + else max([value.batch_edit_pack.order.value for value in values]) + ) # this might be useless - assert len(set([value.batch_edit_pack.order.value for value in values])) == len(values) + assert len(values) == 0 or ( + len(set([value.batch_edit_pack.order.value for value in values])) + == len(values) + ) # fill-in before, out happens later anyways - fill_in_range = range(min(max_order, to_many_planner.batch_edit_pack.order.value)+1) + fill_in_range = range( + min(max_order, to_many_planner.batch_edit_pack.order.value) + 1 + ) # TODO: this is generic and doesn't assume items aren't sorted by order. maybe we can optimize, knowing that. - filled_in = [next(filter(lambda pack: pack.batch_edit_pack.order.value == fill_in, values), null_record) for fill_in in fill_in_range] + filled_in = [ + next( + filter( + lambda pack: pack.batch_edit_pack.order.value == fill_in, values + ), + null_record, + ) + for fill_in in fill_in_range + ] values = filled_in fill_out = to_many_planner.batch_edit_pack.order.value - max_order - + if fill_out is None: fill_out = to_many_planner.batch_edit_pack.id.value - len(values) - + assert fill_out >= 0, "filling out in opposite directon!" rest = range(fill_out) values = [*values, *(null_record for _ in rest)] + _ids = [ + value.batch_edit_pack.id.value + for value in values + if value + and value.batch_edit_pack + and value.batch_edit_pack.id.value != NULL_RECORD + ] + if len(_ids) != len(set(_ids)): + raise Exception("Inserted duplicate ids") return values - def extend(self, to_many_planner: RowPlanMap, plan: RowPlanMap) -> 'RowPlanCanonical': - to_ones = {key: value.extend(to_many_planner.to_one.get(key), plan.to_one.get(key)) for (key, value) in self.to_one.items()} - to_many = {key: RowPlanCanonical._extend_id_order(values, to_many_planner.to_many.get(key), plan.to_many.get(key)) for (key, values) in self.to_many.items()} + def extend( + self, to_many_planner: RowPlanMap, plan: RowPlanMap + ) -> "RowPlanCanonical": + to_ones = { + key: value.extend(to_many_planner.to_one.get(key), plan.to_one.get(key)) + for (key, value) in self.to_one.items() + } + to_many = { + key: RowPlanCanonical._extend_id_order( + values, to_many_planner.to_many.get(key), plan.to_many.get(key) + ) + for (key, values) in self.to_many.items() + } return self._replace(to_one=to_ones, to_many=to_many) @staticmethod def _make_to_one_flat(callback: Callable[[str, Func.I], Func.O]): - def _flat(accum: Tuple[List[Any], Dict[str, Func.O]], current: Tuple[str, Func.I]): + def _flat( + accum: Tuple[List[Any], Dict[str, Func.O]], current: Tuple[str, Func.I] + ): to_one_fields, to_one_pack = callback(*current) return [*accum[0], *to_one_fields], {**accum[1], current[0]: to_one_pack} + return _flat - + @staticmethod def _make_to_many_flat(callback: Callable[[str, Func.I], Func.O]): - def _flat(accum: Tuple[List[Any], Dict[str, Any]], current: Tuple[str, List['RowPlanCanonical']]): + def _flat( + accum: Tuple[List[Any], Dict[str, Any]], + current: Tuple[str, List["RowPlanCanonical"]], + ): rel_name, to_many = current to_many_flattened = [callback(rel_name, canonical) for canonical in to_many] row_data = [cell for row in to_many_flattened for cell in row[0]] to_many_pack = [cell[1] for cell in to_many_flattened] return [*accum[0], *row_data], {**accum[1], rel_name: to_many_pack} + return _flat - + def flatten(self) -> Tuple[List[Any], Dict[str, Any]]: cols = [col.value for col in self.columns] - base_pack = self.batch_edit_pack.to_json() if self.batch_edit_pack is not None else None - def _flatten(_: str, _self: 'RowPlanCanonical'): + base_pack = ( + self.batch_edit_pack.to_json() + if self.batch_edit_pack is not None + and self.batch_edit_pack.id.value is not None + else None + ) + + def _flatten(_: str, _self: "RowPlanCanonical"): return _self.flatten() + _to_one_reducer = RowPlanCanonical._make_to_one_flat(_flatten) _to_many_reducer = RowPlanCanonical._make_to_many_flat(_flatten) to_ones = reduce(_to_one_reducer, Func.sort_by_key(self.to_one), ([], {})) to_many = reduce(_to_many_reducer, Func.sort_by_key(self.to_many), ([], {})) all_data = [*cols, *to_ones[0], *to_many[0]] - return all_data, {'self': base_pack, 'to_one': to_ones[1], 'to_many': to_many[1]} if base_pack else None - def to_upload_plan(self, base_table: Table, localization_dump: Dict[str, str], query_fields: List[QueryField], fields_added: Dict[str, int], get_column_id: Callable[[str], int]) -> Tuple[List[Tuple[Tuple[int, int], str]], Uploadable]: - # Yuk, finally. - - # Whether we are something like [det-> (T -- what we are) -> tree] - intermediary_to_tree = any(canonical.batch_edit_pack is not None and canonical.batch_edit_pack.is_part_of_tree(query_fields) for canonical in self.to_one.values()) + # Removing all the unnecceary keys to save up on the size of the dataset + return all_data, ( + Func.remove_keys( + { + "self": base_pack, + "to_one": Func.remove_keys(to_ones[1], Func.is_not_empty), + "to_many": Func.remove_keys( + to_many[1], + lambda records: any( + Func.is_not_empty(record) for record in records + ), + ), + }, + Func.is_not_empty, + ) + if base_pack + else None + ) + + def to_upload_plan( + self, + base_table: Table, + localization_dump: Dict[str, str], + query_fields: List[QueryField], + fields_added: Dict[str, int], + get_column_id: Callable[[str], int], + ) -> Tuple[List[Tuple[Tuple[int, int], str]], Uploadable]: + # Yuk, finally. + + # Whether we are something like [det-> (T -- what we are) -> tree]. Set break points in handle_tree_field in query_construct.py to figure out what this means. + intermediary_to_tree = any( + canonical.batch_edit_pack is not None + and canonical.batch_edit_pack.is_part_of_tree(query_fields) + for canonical in self.to_one.values() + ) def _lookup_in_fields(_id: Optional[int]): assert _id is not None, "invalid lookup used!" - field = query_fields[_id - 1] # Need to go off by 1, bc we added 1 to account for distinct + field = query_fields[ + _id - 1 + ] # Need to go off by 1, bc we added 1 to account for distinct string_id = field.fieldspec.to_stringid() - localized_label = localization_dump.get(string_id, naive_field_format(field.fieldspec)) + localized_label = localization_dump.get( + string_id, naive_field_format(field.fieldspec) + ) fields_added[localized_label] = fields_added.get(localized_label, 0) + 1 _count = fields_added[localized_label] if _count > 1: - localized_label += f' #{_count}' + localized_label += f" #{_count}" fieldspec = field.fieldspec - is_null = fieldspec.needs_formatted() or intermediary_to_tree or (fieldspec.is_temporal() and fieldspec.date_part != 'Full Date') or fieldspec.get_field().name.lower() == 'fullname' + # Couple of special fields are not editable. TODO: See if more needs to be added here. + # 1. Partial dates + # 2. Formatted/Aggregated + # three. Fullname in trees + is_null = ( + fieldspec.needs_formatted() + or intermediary_to_tree + or (fieldspec.is_temporal() and fieldspec.date_part != "Full Date") + or fieldspec.get_field().name.lower() == "fullname" + ) id_in_original_fields = get_column_id(string_id) - # bc we can't edit formatted and others. we also can't have intermediary-to-trees as editable... - return (id_in_original_fields, _count), (None if is_null else fieldspec.get_field().name.lower()), localized_label - - key_and_fields_and_headers = [_lookup_in_fields(column.idx) for column in self.columns] + return ( + (id_in_original_fields, _count), + (None if is_null else fieldspec.get_field().name.lower()), + localized_label, + ) + + key_and_fields_and_headers = [ + _lookup_in_fields(column.idx) for column in self.columns + ] wb_cols = { key: parse_column_options(value) for _, key, value in key_and_fields_and_headers - if key is not None # will happen for formatters/aggregators + if key is not None # will happen for not-editable fields. } - def _to_upload_plan(rel_name: str, _self: 'RowPlanCanonical'): - related_model = base_table if intermediary_to_tree else datamodel.get_table_strict(base_table.get_relationship(rel_name).relatedModelName) - return _self.to_upload_plan(related_model, localization_dump, query_fields, fields_added, get_column_id) - + def _to_upload_plan(rel_name: str, _self: "RowPlanCanonical"): + related_model = ( + base_table + if intermediary_to_tree + else datamodel.get_table_strict( + base_table.get_relationship(rel_name).relatedModelName + ) + ) + return _self.to_upload_plan( + related_model, + localization_dump, + query_fields, + fields_added, + get_column_id, + ) + _to_one_reducer = RowPlanCanonical._make_to_one_flat(_to_upload_plan) _to_many_reducer = RowPlanCanonical._make_to_many_flat(_to_upload_plan) - to_one_headers, to_one_upload_tables = reduce(_to_one_reducer, Func.sort_by_key(self.to_one), ([], {})) - to_many_headers, to_many_upload_tables = reduce(_to_many_reducer, Func.sort_by_key(self.to_many), ([], {})) + to_one_headers, to_one_upload_tables = reduce( + _to_one_reducer, Func.sort_by_key(self.to_one), ([], {}) + ) + to_many_headers, to_many_upload_tables = reduce( + _to_many_reducer, Func.sort_by_key(self.to_many), ([], {}) + ) - raw_headers = [(key, header) for (key, __, header) in key_and_fields_and_headers] + raw_headers = [ + (key, header) for (key, __, header) in key_and_fields_and_headers + ] all_headers = [*raw_headers, *to_one_headers, *to_many_headers] if intermediary_to_tree: @@ -466,24 +752,24 @@ def _to_upload_plan(rel_name: str, _self: 'RowPlanCanonical'): name=base_table.django_name, ranks={ key: upload_table.wbcols - for (key, upload_table) - in to_one_upload_tables.items() - } - ) + for (key, upload_table) in to_one_upload_tables.items() + }, + ) else: upload_plan = UploadTable( - name=base_table.django_name, - overrideScope=None, - wbcols=wb_cols, - static={}, - toOne=to_one_upload_tables, - toMany=to_many_upload_tables - ) - + name=base_table.django_name, + overrideScope=None, + wbcols=wb_cols, + static={}, + toOne=Func.remove_keys(to_one_upload_tables, Func.is_not_empty), + toMany=Func.remove_keys(to_many_upload_tables, Func.is_not_empty), + ) + return all_headers, upload_plan - + + # TODO: This really only belongs on the front-end. -# Using this as a last resort to show fields +# Using this as a last resort to show fields, for unit tests def naive_field_format(fieldspec: QueryFieldSpec): field = fieldspec.get_field() if field is None: @@ -491,106 +777,198 @@ def naive_field_format(fieldspec: QueryFieldSpec): if field.is_relationship: return f"{fieldspec.table.name} ({'formatted' if field.type.endswith('to-one') else 'aggregatd'})" return f"{fieldspec.table.name} {field.name}" - -import json + + def run_batch_edit(collection, user, spquery, agent): + props = BatchEditProps( + collection=collection, + user=user, + contexttableid=int(spquery["contexttableid"]), + captions=spquery.get("captions", None), + limit=spquery.get("limit", 0), + recordsetid=spquery.get("recordsetid", None), + fields=fields_from_json(spquery["fields"]), + session_maker=models.session_context, + ) + (headers, rows, packs, json_upload_plan, visual_order) = run_batch_edit_query(props) + mapped_raws = [ + [*row, json.dumps({"batch_edit": pack})] for (row, pack) in zip(rows, packs) + ] + regularized_rows = regularize_rows(len(headers), mapped_raws) + return make_dataset( + user=user, + collection=collection, + name=spquery["name"], + headers=headers, + regularized_rows=regularized_rows, + agent=agent, + json_upload_plan=json_upload_plan, + visual_order=visual_order, + ) + + +# @transaction.atomic <--- we DONT do this because the query logic could take up possibly multiple minutes +class BatchEditProps(TypedDict): + collection: Any + user: Any + contexttableid: int = None + captions: Any = None + limit: Optional[int] = 0 + recordsetid: Optional[int] = None + session_maker: Any = models.session_context + fields: List[QueryField] + + +def run_batch_edit_query(props: BatchEditProps): offset = 0 - tableid = int(spquery['contexttableid']) - captions = spquery['captions'] - limit = int(spquery['limit']) + tableid = int(props["contexttableid"]) + captions = props["captions"] + limit = props["limit"] - recordsetid = spquery.get('recordsetid', None) - fields = fields_from_json(spquery['fields']) + recordsetid = props["recordsetid"] + fields = props["fields"] visible_fields = [field for field in fields if field.display] - assert len(visible_fields) == len(captions), "Got misaligned captions!" + assert captions is None or ( + len(visible_fields) == len(captions) + ), "Got misaligned captions!" - localization_dump: Dict[str, str] = { - # we cannot use numbers since they can very off - field.fieldspec.to_stringid(): caption - for field, caption in zip(visible_fields, captions) - } + localization_dump: Dict[str, str] = ( + { + # we cannot use numbers since they can very off + field.fieldspec.to_stringid(): caption + for field, caption in zip(visible_fields, captions) + } + if captions is not None + else {} + ) row_plan = RowPlanMap.get_row_plan(visible_fields) indexed, query_fields = row_plan.index_plan() - - # we don't really care about these fields, since we'have already done the numbering (and it won't break with + # we don't really care about these fields, since we'have already done the numbering (and it won't break with # more fields). We also don't caree about their sort, since their sort is guaranteed to be after ours - query_with_hidden = [*query_fields, *[field for field in fields if not field.display]] + query_with_hidden = [ + *query_fields, + *[field for field in fields if not field.display], + ] - with models.session_context() as session: + with props["session_maker"]() as session: rows = execute( - session, collection, user, tableid, True, False, query_with_hidden, limit, offset, True, recordsetid, False + session, + props["collection"], + props["user"], + tableid, + True, + False, + query_with_hidden, + limit, + offset, + True, + recordsetid, + False, ) - - current_row = None + to_many_planner = indexed.to_many_planner() visited_rows: List[RowPlanCanonical] = [] - row_to_commit = RowPlanCanonical() - for row in rows['results']: - is_new, current_row = row_to_commit.merge(row, indexed) - to_many_planner = current_row.update_to_manys(to_many_planner) - if is_new: - visited_rows.append(row_to_commit) - row_to_commit = current_row + previous_id = None + previous_row = RowPlanCanonical() + for row in rows["results"]: + _, new_row = previous_row.merge(row, indexed) + to_many_planner = new_row.update_to_manys(to_many_planner) + if previous_id != new_row.batch_edit_pack.id.value: + visited_rows.append(previous_row) + previous_id = new_row.batch_edit_pack.id.value + previous_row = new_row # The very last row will not have anybody to commit by, so we need to add it. # At this point though, we _know_ we need to commit it - visited_rows.append(row_to_commit) + visited_rows.append(previous_row) visited_rows = visited_rows[1:] assert len(visited_rows) > 0, "nothing to return!" - raw_rows: List[RowPlanCanonical] = [] + raw_rows: List[Tuple[List[Any], Dict[str, Any]]] = [] for visited_row in visited_rows: extend_row = visited_row.extend(to_many_planner, indexed) row_data, row_batch_edit_pack = extend_row.flatten() - row_data = [*row_data, json.dumps({'batch_edit': row_batch_edit_pack})] - raw_rows.append(row_data) - - assert len(set([len(raw_row) for raw_row in raw_rows])) == 1, "Made irregular rows somewhere!" + raw_rows.append((row_data, row_batch_edit_pack)) + + assert ( + len(set([len(raw_row[0]) for raw_row in raw_rows])) == 1 + ), "Made irregular rows somewhere!" def _get_orig_column(string_id: str): - return next(filter(lambda field: field[1].fieldspec.to_stringid() == string_id, enumerate(visible_fields)))[0] - + return next( + filter( + lambda field: field[1].fieldspec.to_stringid() == string_id, + enumerate(visible_fields), + ) + )[0] + # The keys are lookups into original query field (not modified by us). Used to get ids in the original one. - key_and_headers, upload_plan = extend_row.to_upload_plan(datamodel.get_table_by_id(tableid), localization_dump, query_fields, {}, _get_orig_column) + key_and_headers, upload_plan = extend_row.to_upload_plan( + datamodel.get_table_by_id(tableid), + localization_dump, + query_fields, + {}, + _get_orig_column, + ) headers_enumerated = enumerate(key_and_headers) - visual_order = [_id for (_id, _) in sorted(headers_enumerated, key=lambda tup: tup[1])] + # We would have arbitarily sorted the columns, so our columns will not be correct. + # Rather than sifting the data, we just add a default visual order. + visual_order = Func.first(sorted(headers_enumerated, key=lambda tup: tup[1][0])) - headers = [header for (_, header) in key_and_headers] - - regularized_rows = regularize_rows(len(headers), raw_rows) + headers = Func.second(key_and_headers) json_upload_plan = upload_plan.unparse() + return ( + headers, + Func.first(raw_rows), + Func.second(raw_rows), + json_upload_plan, + visual_order, + ) + + +def make_dataset( + user, + collection, + name, + headers, + regularized_rows, + agent, + json_upload_plan, + visual_order, +): # We are _finally_ ready to make a new dataset - + with transaction.atomic(): ds = Spdataset.objects.create( specifyuser=user, collection=collection, - name=spquery['name'], + name=name, columns=headers, data=regularized_rows, - importedfilename=spquery['name'], + importedfilename=name, createdbyagent=agent, modifiedbyagent=agent, uploadplan=json.dumps(json_upload_plan), visualorder=visual_order, - isupdate=True + isupdate=True, ) - - ds_id, ds_name = (ds.id, ds.name) - ds.id = None - ds.name = f"Backs - {ds.name}" - ds.parent_id = ds_id - # Create the backer. - ds.save() - return (ds_id, ds_name) \ No newline at end of file + + ds_id, ds_name = (ds.id, ds.name) + ds.id = None + ds.name = f"Backs - {ds.name}" + ds.parent_id = ds_id + # Create the backer. + ds.save() + + return (ds_id, ds_name) diff --git a/specifyweb/stored_queries/execution.py b/specifyweb/stored_queries/execution.py index da4be4e3479..afddadecfd5 100644 --- a/specifyweb/stored_queries/execution.py +++ b/specifyweb/stored_queries/execution.py @@ -4,7 +4,7 @@ import os import re -from typing import List, NamedTuple, Optional, TypedDict +from typing import List, Literal, NamedTuple, Optional, Union import xml.dom.minidom from collections import namedtuple, defaultdict from datetime import datetime, timedelta @@ -33,7 +33,20 @@ logger = logging.getLogger(__name__) -SORT_TYPES = [None, asc, desc] +SORT_LITERAL: Union[Literal["asc"], Literal["desc"], None] + + +class QuerySort: + SORT_TYPES = [None, asc, desc] + + NONE: 0 + ASC: 1 + DESC: 2 + + @staticmethod + def by_id(sort_id: int): + return QuerySort.SORT_TYPES[sort_id] + class BuildQueryProps(NamedTuple): recordsetid: Optional[int] = None @@ -43,12 +56,14 @@ class BuildQueryProps(NamedTuple): implicit_or: bool = True format_agent_type: bool = False + def set_group_concat_max_len(connection): """The default limit on MySQL group concat function is quite small. This function increases it for the database connection for the given session. """ - connection.execute('SET group_concat_max_len = 1024 * 1024 * 1024') + connection.execute("SET group_concat_max_len = 1024 * 1024 * 1024") + def filter_by_collection(model, query, collection): """Add predicates to the given query to filter result to items scoped @@ -58,69 +73,118 @@ def filter_by_collection(model, query, collection): discipline as the given collection since collecting events are scoped to the discipline level. """ - if (model is models.Accession and - collection.discipline.division.institution.isaccessionsglobal): + if ( + model is models.Accession + and collection.discipline.division.institution.isaccessionsglobal + ): logger.info("not filtering query b/c accessions are global in this database") return query if model is models.Taxon: logger.info("filtering taxon to discipline: %s", collection.discipline.name) - return query.filter(model.TaxonTreeDefID == collection.discipline.taxontreedef_id) + return query.filter( + model.TaxonTreeDefID == collection.discipline.taxontreedef_id + ) if model is models.TaxonTreeDefItem: - logger.info("filtering taxon rank to discipline: %s", collection.discipline.name) - return query.filter(model.TaxonTreeDefID == collection.discipline.taxontreedef_id) + logger.info( + "filtering taxon rank to discipline: %s", collection.discipline.name + ) + return query.filter( + model.TaxonTreeDefID == collection.discipline.taxontreedef_id + ) if model is models.Geography: logger.info("filtering geography to discipline: %s", collection.discipline.name) - return query.filter(model.GeographyTreeDefID == collection.discipline.geographytreedef_id) + return query.filter( + model.GeographyTreeDefID == collection.discipline.geographytreedef_id + ) if model is models.GeographyTreeDefItem: - logger.info("filtering geography rank to discipline: %s", collection.discipline.name) - return query.filter(model.GeographyTreeDefID == collection.discipline.geographytreedef_id) + logger.info( + "filtering geography rank to discipline: %s", collection.discipline.name + ) + return query.filter( + model.GeographyTreeDefID == collection.discipline.geographytreedef_id + ) if model is models.LithoStrat: - logger.info("filtering lithostrat to discipline: %s", collection.discipline.name) - return query.filter(model.LithoStratTreeDefID == collection.discipline.lithostrattreedef_id) + logger.info( + "filtering lithostrat to discipline: %s", collection.discipline.name + ) + return query.filter( + model.LithoStratTreeDefID == collection.discipline.lithostrattreedef_id + ) if model is models.LithoStratTreeDefItem: - logger.info("filtering lithostrat rank to discipline: %s", collection.discipline.name) - return query.filter(model.LithoStratTreeDefID == collection.discipline.lithostrattreedef_id) + logger.info( + "filtering lithostrat rank to discipline: %s", collection.discipline.name + ) + return query.filter( + model.LithoStratTreeDefID == collection.discipline.lithostrattreedef_id + ) if model is models.GeologicTimePeriod: - logger.info("filtering geologic time period to discipline: %s", collection.discipline.name) - return query.filter(model.GeologicTimePeriodTreeDefID == collection.discipline.geologictimeperiodtreedef_id) + logger.info( + "filtering geologic time period to discipline: %s", + collection.discipline.name, + ) + return query.filter( + model.GeologicTimePeriodTreeDefID + == collection.discipline.geologictimeperiodtreedef_id + ) if model is models.GeologicTimePeriodTreeDefItem: - logger.info("filtering geologic time period rank to discipline: %s", collection.discipline.name) - return query.filter(model.GeologicTimePeriodTreeDefID == collection.discipline.geologictimeperiodtreedef_id) + logger.info( + "filtering geologic time period rank to discipline: %s", + collection.discipline.name, + ) + return query.filter( + model.GeologicTimePeriodTreeDefID + == collection.discipline.geologictimeperiodtreedef_id + ) if model is models.Storage: - logger.info("filtering storage to institution: %s", collection.discipline.division.institution.name) - return query.filter(model.StorageTreeDefID == collection.discipline.division.institution.storagetreedef_id) + logger.info( + "filtering storage to institution: %s", + collection.discipline.division.institution.name, + ) + return query.filter( + model.StorageTreeDefID + == collection.discipline.division.institution.storagetreedef_id + ) if model is models.StorageTreeDefItem: - logger.info("filtering storage rank to institution: %s", collection.discipline.division.institution.name) - return query.filter(model.StorageTreeDefID == collection.discipline.division.institution.storagetreedef_id) + logger.info( + "filtering storage rank to institution: %s", + collection.discipline.division.institution.name, + ) + return query.filter( + model.StorageTreeDefID + == collection.discipline.division.institution.storagetreedef_id + ) if model in ( - models.Agent, - models.Accession, - models.RepositoryAgreement, - models.ExchangeIn, - models.ExchangeOut, - models.ConservDescription, + models.Agent, + models.Accession, + models.RepositoryAgreement, + models.ExchangeIn, + models.ExchangeOut, + models.ConservDescription, ): return query.filter(model.DivisionID == collection.discipline.division_id) for filter_col, scope, scope_name in ( - ('CollectionID' , lambda collection: collection, lambda o: o.collectionname), - ('collectionMemberId' , lambda collection: collection, lambda o: o.collectionname), - ('DisciplineID' , lambda collection: collection.discipline, lambda o: o.name), - + ("CollectionID", lambda collection: collection, lambda o: o.collectionname), + ( + "collectionMemberId", + lambda collection: collection, + lambda o: o.collectionname, + ), + ("DisciplineID", lambda collection: collection.discipline, lambda o: o.name), # The below are disabled to match Specify 6 behavior. - # ('DivisionID' , lambda collection: collection.discipline.division, lambda o: o.name), - # ('InstitutionID' , lambda collection: collection.discipline.division.institution, lambda o: o.name), + # ('DivisionID' , lambda collection: collection.discipline.division, lambda o: o.name), + # ('InstitutionID' , lambda collection: collection.discipline.division.institution, lambda o: o.name), ): if hasattr(model, filter_col): @@ -132,37 +196,61 @@ def filter_by_collection(model, query, collection): return query - def do_export(spquery, collection, user, filename, exporttype, host): """Executes the given deserialized query definition, sending the to a file, and creates "export completed" message when finished. See query_to_csv for details of the other accepted arguments. """ - recordsetid = spquery.get('recordsetid', None) + recordsetid = spquery.get("recordsetid", None) - distinct = spquery['selectdistinct'] - tableid = spquery['contexttableid'] + distinct = spquery["selectdistinct"] + tableid = spquery["contexttableid"] path = os.path.join(settings.DEPOSITORY_DIR, filename) - message_type = 'query-export-to-csv-complete' + message_type = "query-export-to-csv-complete" with models.session_context() as session: - field_specs = fields_from_json(spquery['fields']) - if exporttype == 'csv': - query_to_csv(session, collection, user, tableid, field_specs, path, - recordsetid=recordsetid, - captions=spquery['captions'], strip_id=True, - distinct=spquery['selectdistinct'], delimiter=spquery['delimiter'],) - elif exporttype == 'kml': - query_to_kml(session, collection, user, tableid, field_specs, path, spquery['captions'], host, - recordsetid=recordsetid, strip_id=False) - message_type = 'query-export-to-kml-complete' - - Message.objects.create(user=user, content=json.dumps({ - 'type': message_type, - 'file': filename, - })) + field_specs = fields_from_json(spquery["fields"]) + if exporttype == "csv": + query_to_csv( + session, + collection, + user, + tableid, + field_specs, + path, + recordsetid=recordsetid, + captions=spquery["captions"], + strip_id=True, + distinct=spquery["selectdistinct"], + delimiter=spquery["delimiter"], + ) + elif exporttype == "kml": + query_to_kml( + session, + collection, + user, + tableid, + field_specs, + path, + spquery["captions"], + host, + recordsetid=recordsetid, + strip_id=False, + ) + message_type = "query-export-to-kml-complete" + + Message.objects.create( + user=user, + content=json.dumps( + { + "type": message_type, + "file": filename, + } + ), + ) + def stored_query_to_csv(query_id, collection, user, path): """Executes a query from the Spquery table with the given id and send @@ -174,14 +262,36 @@ def stored_query_to_csv(query_id, collection, user, path): sp_query = session.query(models.SpQuery).get(query_id) tableid = sp_query.contextTableId - field_specs = [QueryField.from_spqueryfield(field) - for field in sorted(sp_query.fields, key=lambda field: field.position)] - - query_to_csv(session, collection, user, tableid, field_specs, path, distinct=spquery['selectdistinct']) # bug? + field_specs = [ + QueryField.from_spqueryfield(field) + for field in sorted(sp_query.fields, key=lambda field: field.position) + ] -def query_to_csv(session, collection, user, tableid, field_specs, path, - recordsetid=None, captions=False, strip_id=False, row_filter=None, - distinct=False, delimiter=','): + query_to_csv( + session, + collection, + user, + tableid, + field_specs, + path, + distinct=spquery["selectdistinct"], + ) # bug? + + +def query_to_csv( + session, + collection, + user, + tableid, + field_specs, + path, + recordsetid=None, + captions=False, + strip_id=False, + row_filter=None, + distinct=False, + delimiter=",", +): """Build a sqlalchemy query using the QueryField objects given by field_specs and send the results to a CSV file at the given file path. @@ -189,36 +299,59 @@ def query_to_csv(session, collection, user, tableid, field_specs, path, See build_query for details of the other accepted arguments. """ set_group_concat_max_len(session.connection()) - query, __ = build_query(session, collection, user, tableid, field_specs, BuildQueryProps(recordsetid=recordsetid, replace_nulls=True, distinct=distinct)) + query, __ = build_query( + session, + collection, + user, + tableid, + field_specs, + BuildQueryProps(recordsetid=recordsetid, replace_nulls=True, distinct=distinct), + ) - logger.debug('query_to_csv starting') + logger.debug("query_to_csv starting") - with open(path, 'w', newline='', encoding='utf-8') as f: + with open(path, "w", newline="", encoding="utf-8") as f: csv_writer = csv.writer(f, delimiter=delimiter) if captions: header = captions if not strip_id and not distinct: - header = ['id'] + header + header = ["id"] + header csv_writer.writerow(header) for row in query.yield_per(1): - if row_filter is not None and not row_filter(row): continue + if row_filter is not None and not row_filter(row): + continue encoded = [ - re.sub('\r|\n', ' ', str(f)) + re.sub("\r|\n", " ", str(f)) for f in (row[1:] if strip_id or distinct else row) ] csv_writer.writerow(encoded) - logger.debug('query_to_csv finished') + logger.debug("query_to_csv finished") + def row_has_geocoords(coord_cols, row): - """Assuming single point - """ - return row[coord_cols[0]] != None and row[coord_cols[0]] != '' and row[coord_cols[1]] != None and row[coord_cols[1]] != '' + """Assuming single point""" + return ( + row[coord_cols[0]] != None + and row[coord_cols[0]] != "" + and row[coord_cols[1]] != None + and row[coord_cols[1]] != "" + ) -def query_to_kml(session, collection, user, tableid, field_specs, path, captions, host, - recordsetid=None, strip_id=False): +def query_to_kml( + session, + collection, + user, + tableid, + field_specs, + path, + captions, + host, + recordsetid=None, + strip_id=False, +): """Build a sqlalchemy query using the QueryField objects given by field_specs and send the results to a kml file at the given file path. @@ -226,21 +359,28 @@ def query_to_kml(session, collection, user, tableid, field_specs, path, captions See build_query for details of the other accepted arguments. """ set_group_concat_max_len(session.connection()) - query, __ = build_query(session, collection, user, tableid, field_specs, BuildQueryProps(recordsetid=recordsetid, replace_nulls=True)) + query, __ = build_query( + session, + collection, + user, + tableid, + field_specs, + BuildQueryProps(recordsetid=recordsetid, replace_nulls=True), + ) - logger.debug('query_to_kml starting') + logger.debug("query_to_kml starting") kmlDoc = xml.dom.minidom.Document() - kmlElement = kmlDoc.createElementNS('http://earth.google.com/kml/2.2', 'kml') - kmlElement.setAttribute('xmlns','http://earth.google.com/kml/2.2') + kmlElement = kmlDoc.createElementNS("http://earth.google.com/kml/2.2", "kml") + kmlElement.setAttribute("xmlns", "http://earth.google.com/kml/2.2") kmlElement = kmlDoc.appendChild(kmlElement) - documentElement = kmlDoc.createElement('Document') + documentElement = kmlDoc.createElement("Document") documentElement = kmlElement.appendChild(documentElement) if not strip_id: model = models.models_by_tableid[tableid] - table = str(getattr(model, model._id)).split('.')[0].lower() #wtfiw + table = str(getattr(model, model._id)).split(".")[0].lower() # wtfiw else: table = None @@ -248,19 +388,28 @@ def query_to_kml(session, collection, user, tableid, field_specs, path, captions for row in query.yield_per(1): if row_has_geocoords(coord_cols, row): - placemarkElement = createPlacemark(kmlDoc, row, coord_cols, table, captions, host) + placemarkElement = createPlacemark( + kmlDoc, row, coord_cols, table, captions, host + ) documentElement.appendChild(placemarkElement) - with open(path, 'wb') as kmlFile: - kmlFile.write(kmlDoc.toprettyxml(' ', newl = '\n', encoding = 'utf-8')) + with open(path, "wb") as kmlFile: + kmlFile.write(kmlDoc.toprettyxml(" ", newl="\n", encoding="utf-8")) + + logger.debug("query_to_kml finished") - logger.debug('query_to_kml finished') def getCoordinateColumns(field_specs, hasId): - coords = {'longitude1': -1, 'latitude1': -1, 'longitude2': -1, 'latitude2': -1, 'latlongtype': -1} + coords = { + "longitude1": -1, + "latitude1": -1, + "longitude2": -1, + "latitude2": -1, + "latlongtype": -1, + } f = 1 if hasId else 0 for fld in field_specs: - if fld.fieldspec.table.name == 'Locality': + if fld.fieldspec.table.name == "Locality": jp = fld.fieldspec.join_path if not jp: continue @@ -270,103 +419,111 @@ def getCoordinateColumns(field_specs, hasId): if fld.display: f = f + 1 - result = [coords['longitude1'], coords['latitude1']] - if coords['longitude2'] != -1 and coords['latitude2'] != -1: - result.extend([coords['longitude2'], coords['latitude2']]) - if coords['latlongtype'] != -1: - result.append(coords['latlongtype']) + result = [coords["longitude1"], coords["latitude1"]] + if coords["longitude2"] != -1 and coords["latitude2"] != -1: + result.extend([coords["longitude2"], coords["latitude2"]]) + if coords["latlongtype"] != -1: + result.append(coords["latlongtype"]) return result + def createPlacemark(kmlDoc, row, coord_cols, table, captions, host): - # This creates a element for a row of data. - #print row - placemarkElement = kmlDoc.createElement('Placemark') - extElement = kmlDoc.createElement('ExtendedData') + # This creates a element for a row of data. + # print row + placemarkElement = kmlDoc.createElement("Placemark") + extElement = kmlDoc.createElement("ExtendedData") placemarkElement.appendChild(extElement) # Loop through the columns and create a element for every field that has a value. adj = 0 if table == None else 1 - nameElement = kmlDoc.createElement('name') + nameElement = kmlDoc.createElement("name") nameText = kmlDoc.createTextNode(row[adj]) nameElement.appendChild(nameText) placemarkElement.appendChild(nameElement) for f in range(adj, len(row)): if f not in coord_cols: - dataElement = kmlDoc.createElement('Data') - dataElement.setAttribute('name', captions[f-adj]) - valueElement = kmlDoc.createElement('value') + dataElement = kmlDoc.createElement("Data") + dataElement.setAttribute("name", captions[f - adj]) + valueElement = kmlDoc.createElement("value") dataElement.appendChild(valueElement) valueText = kmlDoc.createTextNode(row[f]) valueElement.appendChild(valueText) extElement.appendChild(dataElement) - - - #display coords - crdElement = kmlDoc.createElement('Data') - crdElement.setAttribute('name', 'coordinates') - crdValue = kmlDoc.createElement('value') + # display coords + crdElement = kmlDoc.createElement("Data") + crdElement.setAttribute("name", "coordinates") + crdValue = kmlDoc.createElement("value") crdElement.appendChild(crdValue) - crdStr = row[coord_cols[1]] + ', ' + row[coord_cols[0]] + crdStr = row[coord_cols[1]] + ", " + row[coord_cols[0]] if len(coord_cols) >= 4: - crdStr += ' : ' + row[coord_cols[3]] + ', ' + row[coord_cols[2]] + crdStr += " : " + row[coord_cols[3]] + ", " + row[coord_cols[2]] if len(coord_cols) == 5: - crdStr += ' (' + row[coord_cols[4]] + ')' + crdStr += " (" + row[coord_cols[4]] + ")" crdValue.appendChild(kmlDoc.createTextNode(crdStr)) extElement.appendChild(crdElement) - #add the url + # add the url if table != None: - urlElement = kmlDoc.createElement('Data') - urlElement.setAttribute('name', 'go to') - urlValue = kmlDoc.createElement('value') + urlElement = kmlDoc.createElement("Data") + urlElement.setAttribute("name", "go to") + urlValue = kmlDoc.createElement("value") urlElement.appendChild(urlValue) - urlText = kmlDoc.createTextNode(host + '/specify/view/' + table + '/' + str(row[0]) + '/') + urlText = kmlDoc.createTextNode( + host + "/specify/view/" + table + "/" + str(row[0]) + "/" + ) urlValue.appendChild(urlText) extElement.appendChild(urlElement) - #add coords + # add coords if len(coord_cols) == 5: coord_type = row[coord_cols[4]].lower() elif len(coord_cols) == 4: - coord_type = 'line' + coord_type = "line" else: - coord_type = 'point' + coord_type = "point" - - pointElement = kmlDoc.createElement('Point') - coordinates = row[coord_cols[0]] + ',' + row[coord_cols[1]] - coorElement = kmlDoc.createElement('coordinates') + pointElement = kmlDoc.createElement("Point") + coordinates = row[coord_cols[0]] + "," + row[coord_cols[1]] + coorElement = kmlDoc.createElement("coordinates") coorElement.appendChild(kmlDoc.createTextNode(coordinates)) pointElement.appendChild(coorElement) - if coord_type == 'point': + if coord_type == "point": placemarkElement.appendChild(pointElement) else: - multiElement = kmlDoc.createElement('MultiGeometry') + multiElement = kmlDoc.createElement("MultiGeometry") multiElement.appendChild(pointElement) - if coord_type == 'line': - lineElement = kmlDoc.createElement('LineString') - tessElement = kmlDoc.createElement('tessellate') - tessElement.appendChild(kmlDoc.createTextNode('1')) + if coord_type == "line": + lineElement = kmlDoc.createElement("LineString") + tessElement = kmlDoc.createElement("tessellate") + tessElement.appendChild(kmlDoc.createTextNode("1")) lineElement.appendChild(tessElement) - coordinates = row[coord_cols[0]] + ',' + row[coord_cols[1]] + ' ' + row[coord_cols[2]] + ',' + row[coord_cols[3]] - coorElement = kmlDoc.createElement('coordinates') + coordinates = ( + row[coord_cols[0]] + + "," + + row[coord_cols[1]] + + " " + + row[coord_cols[2]] + + "," + + row[coord_cols[3]] + ) + coorElement = kmlDoc.createElement("coordinates") coorElement.appendChild(kmlDoc.createTextNode(coordinates)) lineElement.appendChild(coorElement) multiElement.appendChild(lineElement) else: - ringElement = kmlDoc.createElement('LinearRing') - tessElement = kmlDoc.createElement('tessellate') - tessElement.appendChild(kmlDoc.createTextNode('1')) + ringElement = kmlDoc.createElement("LinearRing") + tessElement = kmlDoc.createElement("tessellate") + tessElement.appendChild(kmlDoc.createTextNode("1")) ringElement.appendChild(tessElement) - coordinates = row[coord_cols[0]] + ',' + row[coord_cols[1]] - coordinates += ' ' + row[coord_cols[2]] + ',' + row[coord_cols[1]] - coordinates += ' ' + row[coord_cols[2]] + ',' + row[coord_cols[3]] - coordinates += ' ' + row[coord_cols[0]] + ',' + row[coord_cols[3]] - coordinates += ' ' + row[coord_cols[0]] + ',' + row[coord_cols[1]] - coorElement = kmlDoc.createElement('coordinates') + coordinates = row[coord_cols[0]] + "," + row[coord_cols[1]] + coordinates += " " + row[coord_cols[2]] + "," + row[coord_cols[1]] + coordinates += " " + row[coord_cols[2]] + "," + row[coord_cols[3]] + coordinates += " " + row[coord_cols[0]] + "," + row[coord_cols[3]] + coordinates += " " + row[coord_cols[0]] + "," + row[coord_cols[1]] + coorElement = kmlDoc.createElement("coordinates") coorElement.appendChild(kmlDoc.createTextNode(coordinates)) ringElement.appendChild(coorElement) multiElement.appendChild(ringElement) @@ -375,23 +532,36 @@ def createPlacemark(kmlDoc, row, coord_cols, table, captions, host): return placemarkElement + def run_ephemeral_query(collection, user, spquery): """Execute a Specify query from deserialized json and return the results as an array for json serialization to the web app. """ - logger.info('ephemeral query: %s', spquery) - limit = spquery.get('limit', 20) - offset = spquery.get('offset', 0) - recordsetid = spquery.get('recordsetid', None) - distinct = spquery['selectdistinct'] - tableid = spquery['contexttableid'] - count_only = spquery['countonly'] - format_audits = spquery.get('formatauditrecids', False) + logger.info("ephemeral query: %s", spquery) + limit = spquery.get("limit", 20) + offset = spquery.get("offset", 0) + recordsetid = spquery.get("recordsetid", None) + distinct = spquery["selectdistinct"] + tableid = spquery["contexttableid"] + count_only = spquery["countonly"] + format_audits = spquery.get("formatauditrecids", False) with models.session_context() as session: - field_specs = fields_from_json(spquery['fields']) - return execute(session, collection, user, tableid, distinct, count_only, - field_specs, limit, offset, recordsetid, formatauditobjs=format_audits) + field_specs = fields_from_json(spquery["fields"]) + return execute( + session, + collection, + user, + tableid, + distinct, + count_only, + field_specs, + limit, + offset, + recordsetid, + formatauditobjs=format_audits, + ) + def augment_field_specs(field_specs: List[QueryField], formatauditobjs=False): print("augment_field_specs ######################################") @@ -401,27 +571,35 @@ def augment_field_specs(field_specs: List[QueryField], formatauditobjs=False): print(fs.fieldspec.table.tableId) field = fs.fieldspec.join_path[-1] model = models.models_by_tableid[fs.fieldspec.table.tableId] - if field.type == 'java.util.Calendar': + if field.type == "java.util.Calendar": precision_field = field.name + "Precision" has_precision = hasattr(model, precision_field) if has_precision: - new_field_specs.append(make_augmented_field_spec(fs, model, precision_field)) - elif formatauditobjs and model.name.lower().startswith('spauditlog'): - if field.name.lower() in 'newvalue, oldvalue': - log_model = models.models_by_tableid[530]; - new_field_specs.append(make_augmented_field_spec(fs, log_model, 'TableNum')) - new_field_specs.append(make_augmented_field_spec(fs, model, 'FieldName')) - elif field.name.lower() == 'recordid': - new_field_specs.append(make_augmented_field_spec(fs, model, 'TableNum')) + new_field_specs.append( + make_augmented_field_spec(fs, model, precision_field) + ) + elif formatauditobjs and model.name.lower().startswith("spauditlog"): + if field.name.lower() in "newvalue, oldvalue": + log_model = models.models_by_tableid[530] + new_field_specs.append( + make_augmented_field_spec(fs, log_model, "TableNum") + ) + new_field_specs.append( + make_augmented_field_spec(fs, model, "FieldName") + ) + elif field.name.lower() == "recordid": + new_field_specs.append(make_augmented_field_spec(fs, model, "TableNum")) print("################################ sceps_dleif_tnemgua") + def make_augmented_field_spec(field_spec, model, field_name): print("make_augmented_field_spec ######################################") + def recordset(collection, user, user_agent, recordset_info): "Create a record set from the records matched by a query." - spquery = recordset_info['fromquery'] - tableid = spquery['contexttableid'] + spquery = recordset_info["fromquery"] + tableid = spquery["contexttableid"] with models.session_context() as session: recordset = models.RecordSet() @@ -429,9 +607,9 @@ def recordset(collection, user, user_agent, recordset_info): recordset.version = 0 recordset.collectionMemberId = collection.id recordset.dbTableId = tableid - recordset.name = recordset_info['name'] - if 'remarks' in recordset_info: - recordset.remarks = recordset_info['remarks'] + recordset.name = recordset_info["name"] + if "remarks" in recordset_info: + recordset.remarks = recordset_info["remarks"] recordset.type = 0 recordset.createdByAgentID = user_agent.id recordset.SpecifyUserID = user.id @@ -442,7 +620,7 @@ def recordset(collection, user, user_agent, recordset_info): model = models.models_by_tableid[tableid] id_field = getattr(model, model._id) - field_specs = fields_from_json(spquery['fields']) + field_specs = fields_from_json(spquery["fields"]) query, __ = build_query(session, collection, user, tableid, field_specs) query = query.with_entities(id_field, literal(new_rs_id)).distinct() @@ -452,29 +630,39 @@ def recordset(collection, user, user_agent, recordset_info): return new_rs_id -def return_loan_preps(collection, user, agent, data): - spquery = data['query'] - commit = data['commit'] - tableid = spquery['contexttableid'] - if not (tableid == Loanpreparation.specify_model.tableId): raise AssertionError( - f"Unexpected tableId '{tableid}' in request. Expected {Loanpreparation.specify_model.tableId}", - {"tableId" : tableid, - "expectedTableId": Loanpreparation.specify_model.tableId, - "localizationKey" : "unexpectedTableId"}) +def return_loan_preps(collection, user, agent, data): + spquery = data["query"] + commit = data["commit"] + + tableid = spquery["contexttableid"] + if not (tableid == Loanpreparation.specify_model.tableId): + raise AssertionError( + f"Unexpected tableId '{tableid}' in request. Expected {Loanpreparation.specify_model.tableId}", + { + "tableId": tableid, + "expectedTableId": Loanpreparation.specify_model.tableId, + "localizationKey": "unexpectedTableId", + }, + ) with models.session_context() as session: model = models.models_by_tableid[tableid] id_field = getattr(model, model._id) - field_specs = fields_from_json(spquery['fields']) + field_specs = fields_from_json(spquery["fields"]) query, __ = build_query(session, collection, user, tableid, field_specs) lrp = orm.aliased(models.LoanReturnPreparation) loan = orm.aliased(models.Loan) query = query.join(loan).outerjoin(lrp) - unresolved = (model.quantity - sql.functions.coalesce(sql.functions.sum(lrp.quantityResolved), 0)).label('unresolved') - query = query.with_entities(id_field, unresolved, loan.loanId, loan.loanNumber).group_by(id_field) + unresolved = ( + model.quantity + - sql.functions.coalesce(sql.functions.sum(lrp.quantityResolved), 0) + ).label("unresolved") + query = query.with_entities( + id_field, unresolved, loan.loanId, loan.loanNumber + ).group_by(id_field) to_return = [ (lp_id, quantity, loan_id, loan_no) for lp_id, quantity, loan_id, loan_no in query @@ -491,53 +679,115 @@ def return_loan_preps(collection, user, agent, data): lp.isresolved = True lp.save() - auditlog.update(lp, agent, None, [ - FieldChangeInfo(field_name='quantityresolved', old_value=lp.quantityresolved - quantity, new_value=lp.quantityresolved), - FieldChangeInfo(field_name='quantityreturned', old_value=lp.quantityreturned - quantity, new_value=lp.quantityreturned), - FieldChangeInfo(field_name='isresolved', old_value=was_resolved, new_value=True) - ]) + auditlog.update( + lp, + agent, + None, + [ + FieldChangeInfo( + field_name="quantityresolved", + old_value=lp.quantityresolved - quantity, + new_value=lp.quantityresolved, + ), + FieldChangeInfo( + field_name="quantityreturned", + old_value=lp.quantityreturned - quantity, + new_value=lp.quantityreturned, + ), + FieldChangeInfo( + field_name="isresolved", + old_value=was_resolved, + new_value=True, + ), + ], + ) new_lrp = Loanreturnpreparation.objects.create( quantityresolved=quantity, quantityreturned=quantity, loanpreparation_id=lp_id, - returneddate=data.get('returneddate', None), - receivedby_id=data.get('receivedby', None), + returneddate=data.get("returneddate", None), + receivedby_id=data.get("receivedby", None), createdbyagent=agent, discipline=collection.discipline, ) auditlog.insert(new_lrp, agent) - loans_to_close = Loan.objects.select_for_update().filter( - pk__in=set((loan_id for _, _, loan_id, _ in to_return)), - isclosed=False, - ).exclude( - loanpreparations__isresolved=False + loans_to_close = ( + Loan.objects.select_for_update() + .filter( + pk__in=set((loan_id for _, _, loan_id, _ in to_return)), + isclosed=False, + ) + .exclude(loanpreparations__isresolved=False) ) for loan in loans_to_close: loan.isclosed = True loan.save() - auditlog.update(loan, agent, None, [ - {'field_name': 'isclosed', 'old_value': False, 'new_value': True}, - ]) + auditlog.update( + loan, + agent, + None, + [ + { + "field_name": "isclosed", + "old_value": False, + "new_value": True, + }, + ], + ) return to_return -def execute(session, collection, user, tableid, distinct, count_only, field_specs, limit, offset, format_agent_type=False, recordsetid=None, formatauditobjs=False): + +def execute( + session, + collection, + user, + tableid, + distinct, + count_only, + field_specs, + limit, + offset, + format_agent_type=False, + recordsetid=None, + formatauditobjs=False, +): "Build and execute a query, returning the results as a data structure for json serialization" - set_group_concat_max_len(session.connection()) - query, order_by_exprs = build_query(session, collection, user, tableid, field_specs, BuildQueryProps(recordsetid=recordsetid, formatauditobjs=formatauditobjs, distinct=distinct, format_agent_type=format_agent_type)) + set_group_concat_max_len(session.info["connection"]) + query, order_by_exprs = build_query( + session, + collection, + user, + tableid, + field_specs, + BuildQueryProps( + recordsetid=recordsetid, + formatauditobjs=formatauditobjs, + distinct=distinct, + format_agent_type=format_agent_type, + ), + ) if count_only: - return {'count': query.count()} + return {"count": query.count()} else: logger.debug("order by: %s", order_by_exprs) query = query.order_by(*order_by_exprs).offset(offset) if limit: query = query.limit(limit) - return {'results': list(query)} + return {"results": list(query)} -def build_query(session, collection, user, tableid, field_specs, props: BuildQueryProps = BuildQueryProps()): + +def build_query( + session, + collection, + user, + tableid, + field_specs, + props: BuildQueryProps = BuildQueryProps(), +): """Build a sqlalchemy query using the QueryField objects given by field_specs. @@ -567,20 +817,34 @@ def build_query(session, collection, user, tableid, field_specs, props: BuildQue id_field = getattr(model, model._id) field_specs = [apply_absolute_date(field_spec) for field_spec in field_specs] - field_specs = [apply_specify_user_name(field_spec, user) for field_spec in field_specs] - + field_specs = [ + apply_specify_user_name(field_spec, user) for field_spec in field_specs + ] query = QueryConstruct( collection=collection, - objectformatter=ObjectFormatter(collection, user, props.replace_nulls, format_agent_type=props.format_agent_type), - query=session.query(func.group_concat(id_field.distinct(), separator=',')) if props.distinct else session.query(id_field), + objectformatter=ObjectFormatter( + collection, + user, + props.replace_nulls, + format_agent_type=props.format_agent_type, + ), + query=( + session.query(func.group_concat(id_field.distinct(), separator=",")) + if props.distinct + else session.query(id_field) + ), ) - tables_to_read = set([ - table - for fs in field_specs - for table in query.tables_in_path(fs.fieldspec.root_table, fs.fieldspec.join_path) - ]) + tables_to_read = set( + [ + table + for fs in field_specs + for table in query.tables_in_path( + fs.fieldspec.root_table, fs.fieldspec.join_path + ) + ] + ) for table in tables_to_read: check_table_permissions(collection, user, table, "read") @@ -590,22 +854,29 @@ def build_query(session, collection, user, tableid, field_specs, props: BuildQue if props.recordsetid is not None: logger.debug("joining query to recordset: %s", props.recordsetid) recordset = session.query(models.RecordSet).get(props.recordsetid) - if not (recordset.dbTableId == tableid): raise AssertionError( - f"Unexpected tableId '{tableid}' in request. Expected '{recordset.dbTableId}'", - {"tableId" : tableid, - "expectedTableId" : recordset.dbTableId, - "localizationKey" : "unexpectedTableId"}) - query = query.join(models.RecordSetItem, models.RecordSetItem.recordId == id_field) \ - .filter(models.RecordSetItem.recordSet == recordset) + if not (recordset.dbTableId == tableid): + raise AssertionError( + f"Unexpected tableId '{tableid}' in request. Expected '{recordset.dbTableId}'", + { + "tableId": tableid, + "expectedTableId": recordset.dbTableId, + "localizationKey": "unexpectedTableId", + }, + ) + query = query.join( + models.RecordSetItem, models.RecordSetItem.recordId == id_field + ).filter(models.RecordSetItem.recordSet == recordset) order_by_exprs = [] selected_fields = [] predicates_by_field = defaultdict(list) - #augment_field_specs(field_specs, formatauditobjs) + # augment_field_specs(field_specs, formatauditobjs) for fs in field_specs: - sort_type = SORT_TYPES[fs.sort_type] + sort_type = QuerySort.by_id(fs.sort_type) - query, field, predicate = fs.add_to_query(query, formatauditobjs=props.formatauditobjs) + query, field, predicate = fs.add_to_query( + query, formatauditobjs=props.formatauditobjs + ) if fs.display: formatted_field = query.objectformatter.fieldformat(fs, field) query = query.add_columns(formatted_field) @@ -619,9 +890,7 @@ def build_query(session, collection, user, tableid, field_specs, props: BuildQue if props.implicit_or: implicit_ors = [ - reduce(sql.or_, ps) - for ps in predicates_by_field.values() - if ps + reduce(sql.or_, ps) for ps in predicates_by_field.values() if ps ] if implicit_ors: diff --git a/specifyweb/stored_queries/models.py b/specifyweb/stored_queries/models.py index df9982bf4c3..fc8c7c30298 100644 --- a/specifyweb/stored_queries/models.py +++ b/specifyweb/stored_queries/models.py @@ -15,11 +15,15 @@ connect_args={'cursorclass': SSCursor}) Session = sessionmaker(bind=engine) + def make_session_context(session_maker): @contextmanager def _session_context(): - session = session_maker() + session, connection = session_maker() try: + if connection is None: + connection = session.connection() + session.info['connection'] = connection yield session session.commit() except: @@ -29,7 +33,9 @@ def _session_context(): session.close() return _session_context -session_context = make_session_context(Session) + +session_context = make_session_context(lambda: (Session(), None)) + def generate_models(): tables = build_models.make_tables(datamodel) @@ -37,6 +43,7 @@ def generate_models(): build_models.map_classes(datamodel, tables, classes) return tables, classes + tables, classes = generate_models() models_by_tableid: Dict[int, build_models.Table] = dict((cls.tableid, cls) for cls in list(classes.values())) diff --git a/specifyweb/stored_queries/tests/static/test_plan.py b/specifyweb/stored_queries/tests/static/test_plan.py new file mode 100644 index 00000000000..451b192cea9 --- /dev/null +++ b/specifyweb/stored_queries/tests/static/test_plan.py @@ -0,0 +1,229 @@ +plan = { + "baseTableName": "Collectionobject", + "uploadable": { + "uploadTable": { + "wbcols": { + "catalognumber": "CollectionObject catalogNumber", + "integer1": "CollectionObject integer1", + }, + "static": {}, + "toOne": { + "cataloger": { + "uploadTable": { + "wbcols": { + "firstname": "Agent firstName", + "lastname": "Agent lastName", + }, + "static": {}, + "toOne": {}, + "toMany": { + "agentspecialties": [ + { + "wbcols": { + "specialtyname": "AgentSpecialty specialtyName" + }, + "static": {}, + "toOne": {}, + "toMany": {}, + }, + { + "wbcols": { + "specialtyname": "AgentSpecialty specialtyName #2" + }, + "static": {}, + "toOne": {}, + "toMany": {}, + }, + { + "wbcols": { + "specialtyname": "AgentSpecialty specialtyName #3" + }, + "static": {}, + "toOne": {}, + "toMany": {}, + }, + { + "wbcols": { + "specialtyname": "AgentSpecialty specialtyName #4" + }, + "static": {}, + "toOne": {}, + "toMany": {}, + }, + ], + "collectors": [ + { + "wbcols": {"remarks": "Collector remarks"}, + "static": {}, + "toOne": { + "collectingevent": { + "uploadTable": { + "wbcols": { + "stationfieldnumber": "CollectingEvent stationFieldNumber" + }, + "static": {}, + "toOne": {}, + "toMany": {}, + } + } + }, + "toMany": {}, + }, + { + "wbcols": {"remarks": "Collector remarks #2"}, + "static": {}, + "toOne": { + "collectingevent": { + "uploadTable": { + "wbcols": { + "stationfieldnumber": "CollectingEvent stationFieldNumber #2" + }, + "static": {}, + "toOne": {}, + "toMany": {}, + } + } + }, + "toMany": {}, + }, + { + "wbcols": {"remarks": "Collector remarks #3"}, + "static": {}, + "toOne": { + "collectingevent": { + "uploadTable": { + "wbcols": { + "stationfieldnumber": "CollectingEvent stationFieldNumber #3" + }, + "static": {}, + "toOne": {}, + "toMany": {}, + } + } + }, + "toMany": {}, + }, + { + "wbcols": {"remarks": "Collector remarks #4"}, + "static": {}, + "toOne": { + "collectingevent": { + "uploadTable": { + "wbcols": { + "stationfieldnumber": "CollectingEvent stationFieldNumber #4" + }, + "static": {}, + "toOne": {}, + "toMany": {}, + } + } + }, + "toMany": {}, + }, + { + "wbcols": {"remarks": "Collector remarks #5"}, + "static": {}, + "toOne": { + "collectingevent": { + "uploadTable": { + "wbcols": { + "stationfieldnumber": "CollectingEvent stationFieldNumber #5" + }, + "static": {}, + "toOne": {}, + "toMany": {}, + } + } + }, + "toMany": {}, + }, + { + "wbcols": {"remarks": "Collector remarks #6"}, + "static": {}, + "toOne": { + "collectingevent": { + "uploadTable": { + "wbcols": { + "stationfieldnumber": "CollectingEvent stationFieldNumber #6" + }, + "static": {}, + "toOne": {}, + "toMany": {}, + } + } + }, + "toMany": {}, + }, + { + "wbcols": {"remarks": "Collector remarks #7"}, + "static": {}, + "toOne": { + "collectingevent": { + "uploadTable": { + "wbcols": { + "stationfieldnumber": "CollectingEvent stationFieldNumber #7" + }, + "static": {}, + "toOne": {}, + "toMany": {}, + } + } + }, + "toMany": {}, + }, + { + "wbcols": {"remarks": "Collector remarks #8"}, + "static": {}, + "toOne": { + "collectingevent": { + "uploadTable": { + "wbcols": { + "stationfieldnumber": "CollectingEvent stationFieldNumber #8" + }, + "static": {}, + "toOne": {}, + "toMany": {}, + } + } + }, + "toMany": {}, + }, + ], + }, + } + } + }, + "toMany": { + "determinations": [ + { + "wbcols": { + "integer1": "Determination integer1", + "remarks": "Determination remarks", + }, + "static": {}, + "toOne": {}, + "toMany": {}, + }, + { + "wbcols": { + "integer1": "Determination integer1 #2", + "remarks": "Determination remarks #2", + }, + "static": {}, + "toOne": {}, + "toMany": {}, + }, + { + "wbcols": { + "integer1": "Determination integer1 #3", + "remarks": "Determination remarks #3", + }, + "static": {}, + "toOne": {}, + "toMany": {}, + }, + ] + }, + } + }, +} diff --git a/specifyweb/stored_queries/tests/test_batch_edit.py b/specifyweb/stored_queries/tests/test_batch_edit.py new file mode 100644 index 00000000000..1dd33d6ee0e --- /dev/null +++ b/specifyweb/stored_queries/tests/test_batch_edit.py @@ -0,0 +1,2410 @@ +import json + +from specifyweb.stored_queries.batch_edit import ( + BatchEditFieldPack, + BatchEditPack, + BatchEditProps, + RowPlanMap, + run_batch_edit_query, +) + +from specifyweb.stored_queries.queryfield import fields_from_json +from specifyweb.stored_queries.queryfieldspec import QueryFieldSpec +from specifyweb.stored_queries.tests.tests import SQLAlchemySetup +from specifyweb.stored_queries.tests.static import test_plan + +from specifyweb.specify.datamodel import datamodel +import specifyweb.specify.models as models + +from specifyweb.workbench.upload.upload_plan_schema import schema +from jsonschema import validate + + +def apply_visual_order(headers, order): + return [headers[col] for col in order] + + +# NOTES: Yes, it is more convenient to hard code ids (instead of defining variables.). +# But, using variables can make bugs apparent +# what if a object doesn't appear in the resulsts? Using the variables will trigger IDEs unused variable warning, making things more safer +class QueryConstructionTests(SQLAlchemySetup): + def setUp(self): + super().setUp() + agents = [ + {"firstname": "Test1", "lastname": "LastName"}, + {"firstname": "Test2", "lastname": "LastNameAsTest"}, + {"firstname": "Test4", "lastname": "LastNameTest4"}, + ] + + self.agents_created = [ + models.Agent.objects.create(agenttype=0, division=self.division, **kwargs) + for kwargs in agents + ] + + def _create(model, kwargs): + return model.objects.create(**kwargs) + + self._create = _create + + self.preptype = models.Preptype.objects.create( + name="testPrepType", + isloanable=False, + collection=self.collection, + ) + + self.build_props = lambda query_fields, base_table: BatchEditProps( + collection=self.collection, + user=self.specifyuser, + contexttableid=datamodel.get_table_strict(base_table).tableId, + fields=query_fields, + session_maker=QueryConstructionTests.test_session_context, + captions=None, + limit=None, + recordsetid=None, + ) + + def test_query_construction(self): + query = json.load( + open("specifyweb/stored_queries/tests/static/test_co_query.json") + ) + query_fields = fields_from_json(query["fields"]) + visible_fields = [field for field in query_fields if field.display] + row_plan = RowPlanMap.get_row_plan(visible_fields) + plan, fields = row_plan.index_plan() + + prev_plan = RowPlanMap( + columns=[ + BatchEditFieldPack(field=None, idx=1, value=None), + BatchEditFieldPack(field=None, idx=2, value=None), + BatchEditFieldPack(field=None, idx=3, value=None), + BatchEditFieldPack(field=None, idx=4, value=None), + BatchEditFieldPack(field=None, idx=5, value=None), + BatchEditFieldPack(field=None, idx=6, value=None), + ], + to_one={ + "cataloger": RowPlanMap( + columns=[ + BatchEditFieldPack(field=None, idx=9, value=None), + BatchEditFieldPack(field=None, idx=10, value=None), + BatchEditFieldPack(field=None, idx=11, value=None), + ], + to_one={}, + to_many={ + "collectors": RowPlanMap( + columns=[ + BatchEditFieldPack(field=None, idx=14, value=None), + BatchEditFieldPack(field=None, idx=15, value=None), + ], + to_one={ + "collectingevent": RowPlanMap( + columns=[ + BatchEditFieldPack( + field=None, idx=19, value=None + ) + ], + to_one={}, + to_many={}, + batch_edit_pack=BatchEditPack( + id=BatchEditFieldPack( + field=None, idx=20, value=None + ), + order=None, + version=BatchEditFieldPack( + field=None, idx=21, value=None + ), + ), + has_filters=False, + ) + }, + to_many={}, + batch_edit_pack=BatchEditPack( + id=BatchEditFieldPack(field=None, idx=16, value=None), + order=BatchEditFieldPack( + field=None, idx=18, value=None + ), + version=BatchEditFieldPack( + field=None, idx=17, value=None + ), + ), + has_filters=False, + ) + }, + batch_edit_pack=BatchEditPack( + id=BatchEditFieldPack(field=None, idx=12, value=None), + order=None, + version=BatchEditFieldPack(field=None, idx=13, value=None), + ), + has_filters=False, + ), + "collectingevent": RowPlanMap( + columns=[], + to_one={ + "locality": RowPlanMap( + columns=[ + BatchEditFieldPack(field=None, idx=24, value=None), + BatchEditFieldPack(field=None, idx=25, value=None), + BatchEditFieldPack(field=None, idx=26, value=None), + BatchEditFieldPack(field=None, idx=27, value=None), + BatchEditFieldPack(field=None, idx=28, value=None), + BatchEditFieldPack(field=None, idx=29, value=None), + BatchEditFieldPack(field=None, idx=30, value=None), + BatchEditFieldPack(field=None, idx=31, value=None), + BatchEditFieldPack(field=None, idx=32, value=None), + ], + to_one={}, + to_many={}, + batch_edit_pack=BatchEditPack( + id=BatchEditFieldPack(field=None, idx=33, value=None), + order=None, + version=BatchEditFieldPack( + field=None, idx=34, value=None + ), + ), + has_filters=False, + ) + }, + to_many={}, + batch_edit_pack=BatchEditPack( + id=BatchEditFieldPack(field=None, idx=22, value=None), + order=None, + version=BatchEditFieldPack(field=None, idx=23, value=None), + ), + has_filters=False, + ), + }, + to_many={ + "determinations": RowPlanMap( + columns=[ + BatchEditFieldPack(field=None, idx=35, value=None), + BatchEditFieldPack(field=None, idx=36, value=None), + ], + to_one={}, + to_many={}, + batch_edit_pack=BatchEditPack( + id=BatchEditFieldPack(field=None, idx=37, value=None), + order=None, + version=BatchEditFieldPack(field=None, idx=38, value=None), + ), + has_filters=False, + ), + "preparations": RowPlanMap( + columns=[ + BatchEditFieldPack(field=None, idx=39, value=None), + BatchEditFieldPack(field=None, idx=40, value=None), + ], + to_one={}, + to_many={}, + batch_edit_pack=BatchEditPack( + id=BatchEditFieldPack(field=None, idx=41, value=None), + order=None, + version=BatchEditFieldPack(field=None, idx=42, value=None), + ), + has_filters=False, + ), + }, + batch_edit_pack=BatchEditPack( + id=BatchEditFieldPack(field=None, idx=7, value=None), + order=None, + version=BatchEditFieldPack(field=None, idx=8, value=None), + ), + has_filters=False, + ) + + self.assertEqual(plan, prev_plan) + + def test_basic_run(self): + base_table = "collectionobject" + query_paths = [ + ["catalognumber"], + ["integer1"], + ["cataloger"], + ["cataloger", "firstname"], + ["cataloger", "lastname"], + ["collectingevent"], + ["collectingevent", "locality", "localityname"], + ] + added = [(base_table, *path) for path in query_paths] + + query_fields = [ + BatchEditPack._query_field(QueryFieldSpec.from_path(path), 0) + for path in added + ] + + [ + self._update(obj, {"cataloger_id": self.agents_created[0]}) + for obj in self.collectionobjects[:2] + ] + + self._update(self.collectionobjects[2], {"integer1": 99}) + self._update(self.collectionobjects[4], {"integer1": 229}) + + [ + self._update(obj, {"cataloger_id": self.agents_created[1]}) + for obj in self.collectionobjects[2:] + ] + + [ + self._update(obj, {"collectingevent_id": None}) + for obj in self.collectionobjects + ] + + props = self.build_props(query_fields, base_table) + + (headers, rows, packs, plan, order) = run_batch_edit_query(props) + + self.assertEqual( + headers, + [ + "CollectionObject catalogNumber", + "CollectionObject integer1", + "Agent (formatted)", + "CollectingEvent (formatted)", + "Agent firstName", + "Agent lastName", + "Locality localityName", + ], + ) + + self.assertEqual( + apply_visual_order(headers, order), + [ + "CollectionObject catalogNumber", + "CollectionObject integer1", + "Agent (formatted)", + "Agent firstName", + "Agent lastName", + "CollectingEvent (formatted)", + "Locality localityName", + ], + ) + + correct_rows = [ + ["num-0", None, "", "", "Test1", "LastName", None], + ["num-1", None, "", "", "Test1", "LastName", None], + ["num-2", 99, "", "", "Test2", "LastNameAsTest", None], + ["num-3", None, "", "", "Test2", "LastNameAsTest", None], + ["num-4", 229, "", "", "Test2", "LastNameAsTest", None], + ] + + self.assertEqual(correct_rows, rows) + + correct_packs = [ + { + "self": { + "id": self.collectionobjects[0].id, + "ordernumber": None, + "version": 0, + }, + "to_one": { + "cataloger": { + "self": { + "id": self.agents_created[0].id, + "ordernumber": None, + "version": 0, + } + } + }, + }, + { + "self": { + "id": self.collectionobjects[1].id, + "ordernumber": None, + "version": 0, + }, + "to_one": { + "cataloger": { + "self": { + "id": self.agents_created[0].id, + "ordernumber": None, + "version": 0, + } + } + }, + }, + { + "self": { + "id": self.collectionobjects[2].id, + "ordernumber": None, + "version": 0, + }, + "to_one": { + "cataloger": { + "self": { + "id": self.agents_created[1].id, + "ordernumber": None, + "version": 0, + } + } + }, + }, + { + "self": { + "id": self.collectionobjects[3].id, + "ordernumber": None, + "version": 0, + }, + "to_one": { + "cataloger": { + "self": { + "id": self.agents_created[1].id, + "ordernumber": None, + "version": 0, + } + } + }, + }, + { + "self": { + "id": self.collectionobjects[4].id, + "ordernumber": None, + "version": 0, + }, + "to_one": { + "cataloger": { + "self": { + "id": self.agents_created[1].id, + "ordernumber": None, + "version": 0, + } + } + }, + }, + ] + + self.assertEqual(correct_packs, packs) + + plan = { + "baseTableName": "Collectionobject", + "uploadable": { + "uploadTable": { + "wbcols": { + "catalognumber": "CollectionObject catalogNumber", + "integer1": "CollectionObject integer1", + }, + "static": {}, + "toOne": { + "cataloger": { + "uploadTable": { + "wbcols": { + "firstname": "Agent firstName", + "lastname": "Agent lastName", + }, + "static": {}, + "toOne": {}, + "toMany": {}, + } + }, + "collectingevent": { + "uploadTable": { + "wbcols": {}, + "static": {}, + "toOne": { + "locality": { + "uploadTable": { + "wbcols": { + "localityname": "Locality localityName" + }, + "static": {}, + "toOne": {}, + "toMany": {}, + } + } + }, + "toMany": {}, + } + }, + }, + "toMany": {}, + } + }, + } + + validate(plan, schema) + + def test_duplicates_flattened(self): + base_table = "collectionobject" + query_paths = [ + ["catalognumber"], + ["integer1"], + ["cataloger"], + ["determinations", "integer1"], + ["determinations", "remarks"], + ["preparations", "countAmt"], + ["preparations", "text1"], + ["cataloger", "firstname"], + ["cataloger", "lastname"], + ["cataloger", "agentSpecialties", "specialtyName"], + ["cataloger", "collectors", "remarks"], + ["cataloger", "collectors", "collectingevent", "stationfieldnumber"], + ] + + added = [(base_table, *path) for path in query_paths] + + query_fields = [ + BatchEditPack._query_field(QueryFieldSpec.from_path(path), 0) + for path in added + ] + + sp1 = models.Agentspecialty.objects.create( + agent=self.agents_created[0], specialtyname="agent1-testspecialty" + ) + sp2 = models.Agentspecialty.objects.create( + agent=self.agents_created[0], specialtyname="agent1-testspecialty2" + ) + sp3 = models.Agentspecialty.objects.create( + agent=self.agents_created[0], specialtyname="agent1-testspecialty4" + ) + sp4 = models.Agentspecialty.objects.create( + agent=self.agents_created[0], specialtyname="agent1-testspecialty5" + ) + + sp5 = models.Agentspecialty.objects.create( + agent=self.agents_created[1], specialtyname="agent2-testspecialty" + ) + sp6 = models.Agentspecialty.objects.create( + agent=self.agents_created[1], specialtyname="agent2-testspecialty2" + ) + sp7 = models.Agentspecialty.objects.create( + agent=self.agents_created[1], specialtyname="agent2-testspecialty4" + ) + + ce1 = models.Collectingevent.objects.create( + discipline=self.discipline, stationfieldnumber="sfn1" + ) + + ce2 = models.Collectingevent.objects.create( + discipline=self.discipline, stationfieldnumber="sfn2" + ) + ce3 = models.Collectingevent.objects.create( + discipline=self.discipline, stationfieldnumber="sfn3" + ) + ce4 = models.Collectingevent.objects.create( + discipline=self.discipline, stationfieldnumber="sfn4" + ) + ce5 = models.Collectingevent.objects.create( + discipline=self.discipline, stationfieldnumber="sfn5" + ) + ce6 = models.Collectingevent.objects.create( + discipline=self.discipline, stationfieldnumber="sfn6" + ) + + col1 = models.Collector.objects.create( + collectingevent=ce1, + agent=self.agents_created[0], + remarks="ce1-agt1", + ordernumber=1, + ) + + col2 = models.Collector.objects.create( + collectingevent=ce2, + agent=self.agents_created[0], + remarks="ce2-agt1", + ordernumber=2, + ) + + col3 = models.Collector.objects.create( + collectingevent=ce3, + agent=self.agents_created[0], + remarks="ce3-agt1", + ordernumber=4, + ) + + col4 = models.Collector.objects.create( + collectingevent=ce4, + agent=self.agents_created[0], + remarks="ce4-agt1", + ordernumber=5, + ) + + col5 = models.Collector.objects.create( + collectingevent=ce5, + agent=self.agents_created[0], + remarks="ce5-agt1", + ordernumber=6, + ) + + col6 = models.Collector.objects.create( + collectingevent=ce6, + agent=self.agents_created[0], + remarks="ce6-agt1", + ordernumber=7, + ) + + col8 = models.Collector.objects.create( + collectingevent=ce1, + agent=self.agents_created[2], + remarks="ce1-agt2", + ordernumber=1, + ) + + col9 = models.Collector.objects.create( + collectingevent=ce2, + agent=self.agents_created[2], + remarks="ce2-agt2", + ordernumber=2, + ) + + col10 = models.Collector.objects.create( + collectingevent=ce3, + agent=self.agents_created[2], + remarks="ce4-agt2", + ordernumber=4, + ) + + col11 = models.Collector.objects.create( + collectingevent=ce4, + agent=self.agents_created[2], + remarks="ce5-agt2", + ordernumber=5, + ) + + col12 = models.Collector.objects.create( + collectingevent=ce5, + agent=self.agents_created[2], + remarks="ce6-agt2", + ordernumber=6, + ) + + col13 = models.Collector.objects.create( + collectingevent=ce6, + agent=self.agents_created[2], + remarks="ce7-agt2", + ordernumber=7, + ) + + col14 = models.Collector.objects.create( + collectingevent=ce1, + agent=self.agents_created[1], + remarks="ce1-agt", + ordernumber=1, + ) + + col15 = models.Collector.objects.create( + collectingevent=ce2, + agent=self.agents_created[1], + remarks="ce2-agt", + ordernumber=2, + ) + + col16 = models.Collector.objects.create( + collectingevent=ce3, + agent=self.agents_created[1], + remarks="ce4-agt", + ordernumber=4, + ) + + col17 = models.Collector.objects.create( + collectingevent=ce4, + agent=self.agents_created[1], + remarks="ce5-agt", + ordernumber=5, + ) + + co_1_det_1 = models.Determination.objects.create( + collectionobject=self.collectionobjects[0], + integer1=10, + remarks="Some remarks", + ) + + co_1_det_2 = models.Determination.objects.create( + collectionobject=self.collectionobjects[0], + integer1=929, + ) + + co_1_det_3 = models.Determination.objects.create( + collectionobject=self.collectionobjects[0], + ) + + co_2_det_1 = models.Determination.objects.create( + collectionobject=self.collectionobjects[1], + integer1=224, + remarks="Some remarks unique", + ) + + co_2_det_2 = models.Determination.objects.create( + collectionobject=self.collectionobjects[1], + integer1=2222, + ) + + co_4_det_1 = models.Determination.objects.create( + collectionobject=self.collectionobjects[4], + integer1=1212, + remarks="Some remarks for determination testing", + ) + + co_4_det_2 = models.Determination.objects.create( + collectionobject=self.collectionobjects[4], + integer1=8729, + remarks="test remarks", + ) + + co_4_det_3 = models.Determination.objects.create( + collectionobject=self.collectionobjects[4] + ) + + self._update( + self.collectionobjects[0], + {"integer1": 99, "cataloger": self.agents_created[0]}, + ) + + self._update( + self.collectionobjects[2], + {"integer1": 412, "cataloger": self.agents_created[2]}, + ) + + self._update( + self.collectionobjects[3], + {"integer1": 322, "cataloger": self.agents_created[1]}, + ) + + props = self.build_props(query_fields, base_table) + + (headers, rows, packs, plan, order) = run_batch_edit_query(props) + + visual = apply_visual_order(headers, order) + self.assertEqual( + visual, + [ + "CollectionObject catalogNumber", + "CollectionObject integer1", + "Agent (formatted)", + "Determination integer1", + "Determination integer1 #2", + "Determination integer1 #3", + "Determination remarks", + "Determination remarks #2", + "Determination remarks #3", + "Agent firstName", + "Agent lastName", + "AgentSpecialty specialtyName", + "AgentSpecialty specialtyName #2", + "AgentSpecialty specialtyName #3", + "AgentSpecialty specialtyName #4", + "Collector remarks", + "Collector remarks #2", + "Collector remarks #3", + "Collector remarks #4", + "Collector remarks #5", + "Collector remarks #6", + "Collector remarks #7", + "Collector remarks #8", + "CollectingEvent stationFieldNumber", + "CollectingEvent stationFieldNumber #2", + "CollectingEvent stationFieldNumber #3", + "CollectingEvent stationFieldNumber #4", + "CollectingEvent stationFieldNumber #5", + "CollectingEvent stationFieldNumber #6", + "CollectingEvent stationFieldNumber #7", + "CollectingEvent stationFieldNumber #8", + ], + ) + + correct_rows = [ + { + "CollectionObject catalogNumber": "num-0", + "CollectionObject integer1": 99, + "Agent (formatted)": "", + "Agent firstName": "Test1", + "Agent lastName": "LastName", + "AgentSpecialty specialtyName": "agent1-testspecialty", + "AgentSpecialty specialtyName #2": "agent1-testspecialty2", + "AgentSpecialty specialtyName #3": "agent1-testspecialty4", + "AgentSpecialty specialtyName #4": "agent1-testspecialty5", + "Collector remarks": None, + "CollectingEvent stationFieldNumber": None, + "Collector remarks #2": "ce1-agt1", + "CollectingEvent stationFieldNumber #2": "sfn1", + "Collector remarks #3": "ce2-agt1", + "CollectingEvent stationFieldNumber #3": "sfn2", + "Collector remarks #4": None, + "CollectingEvent stationFieldNumber #4": None, + "Collector remarks #5": "ce3-agt1", + "CollectingEvent stationFieldNumber #5": "sfn3", + "Collector remarks #6": "ce4-agt1", + "CollectingEvent stationFieldNumber #6": "sfn4", + "Collector remarks #7": "ce5-agt1", + "CollectingEvent stationFieldNumber #7": "sfn5", + "Collector remarks #8": "ce6-agt1", + "CollectingEvent stationFieldNumber #8": "sfn6", + "Determination integer1": 10, + "Determination remarks": "Some remarks", + "Determination integer1 #2": 929, + "Determination remarks #2": None, + "Determination integer1 #3": None, + "Determination remarks #3": None, + }, + { + "CollectionObject catalogNumber": "num-1", + "CollectionObject integer1": None, + "Agent (formatted)": "", + "Agent firstName": None, + "Agent lastName": None, + "AgentSpecialty specialtyName": None, + "AgentSpecialty specialtyName #2": None, + "AgentSpecialty specialtyName #3": None, + "AgentSpecialty specialtyName #4": None, + "Collector remarks": None, + "CollectingEvent stationFieldNumber": None, + "Collector remarks #2": None, + "CollectingEvent stationFieldNumber #2": None, + "Collector remarks #3": None, + "CollectingEvent stationFieldNumber #3": None, + "Collector remarks #4": None, + "CollectingEvent stationFieldNumber #4": None, + "Collector remarks #5": None, + "CollectingEvent stationFieldNumber #5": None, + "Collector remarks #6": None, + "CollectingEvent stationFieldNumber #6": None, + "Collector remarks #7": None, + "CollectingEvent stationFieldNumber #7": None, + "Collector remarks #8": None, + "CollectingEvent stationFieldNumber #8": None, + "Determination integer1": 224, + "Determination remarks": "Some remarks unique", + "Determination integer1 #2": 2222, + "Determination remarks #2": None, + "Determination integer1 #3": None, + "Determination remarks #3": None, + }, + { + "CollectionObject catalogNumber": "num-2", + "CollectionObject integer1": 412, + "Agent (formatted)": "", + "Agent firstName": "Test4", + "Agent lastName": "LastNameTest4", + "AgentSpecialty specialtyName": None, + "AgentSpecialty specialtyName #2": None, + "AgentSpecialty specialtyName #3": None, + "AgentSpecialty specialtyName #4": None, + "Collector remarks": None, + "CollectingEvent stationFieldNumber": None, + "Collector remarks #2": "ce1-agt2", + "CollectingEvent stationFieldNumber #2": "sfn1", + "Collector remarks #3": "ce2-agt2", + "CollectingEvent stationFieldNumber #3": "sfn2", + "Collector remarks #4": None, + "CollectingEvent stationFieldNumber #4": None, + "Collector remarks #5": "ce4-agt2", + "CollectingEvent stationFieldNumber #5": "sfn3", + "Collector remarks #6": "ce5-agt2", + "CollectingEvent stationFieldNumber #6": "sfn4", + "Collector remarks #7": "ce6-agt2", + "CollectingEvent stationFieldNumber #7": "sfn5", + "Collector remarks #8": "ce7-agt2", + "CollectingEvent stationFieldNumber #8": "sfn6", + "Determination integer1": None, + "Determination remarks": None, + "Determination integer1 #2": None, + "Determination remarks #2": None, + "Determination integer1 #3": None, + "Determination remarks #3": None, + }, + { + "CollectionObject catalogNumber": "num-3", + "CollectionObject integer1": 322, + "Agent (formatted)": "", + "Agent firstName": "Test2", + "Agent lastName": "LastNameAsTest", + "AgentSpecialty specialtyName": "agent2-testspecialty", + "AgentSpecialty specialtyName #2": "agent2-testspecialty2", + "AgentSpecialty specialtyName #3": "agent2-testspecialty4", + "AgentSpecialty specialtyName #4": None, + "Collector remarks": None, + "CollectingEvent stationFieldNumber": None, + "Collector remarks #2": "ce1-agt", + "CollectingEvent stationFieldNumber #2": "sfn1", + "Collector remarks #3": "ce2-agt", + "CollectingEvent stationFieldNumber #3": "sfn2", + "Collector remarks #4": None, + "CollectingEvent stationFieldNumber #4": None, + "Collector remarks #5": "ce4-agt", + "CollectingEvent stationFieldNumber #5": "sfn3", + "Collector remarks #6": "ce5-agt", + "CollectingEvent stationFieldNumber #6": "sfn4", + "Collector remarks #7": None, + "CollectingEvent stationFieldNumber #7": None, + "Collector remarks #8": None, + "CollectingEvent stationFieldNumber #8": None, + "Determination integer1": None, + "Determination remarks": None, + "Determination integer1 #2": None, + "Determination remarks #2": None, + "Determination integer1 #3": None, + "Determination remarks #3": None, + }, + { + "CollectionObject catalogNumber": "num-4", + "CollectionObject integer1": None, + "Agent (formatted)": "", + "Agent firstName": None, + "Agent lastName": None, + "AgentSpecialty specialtyName": None, + "AgentSpecialty specialtyName #2": None, + "AgentSpecialty specialtyName #3": None, + "AgentSpecialty specialtyName #4": None, + "Collector remarks": None, + "CollectingEvent stationFieldNumber": None, + "Collector remarks #2": None, + "CollectingEvent stationFieldNumber #2": None, + "Collector remarks #3": None, + "CollectingEvent stationFieldNumber #3": None, + "Collector remarks #4": None, + "CollectingEvent stationFieldNumber #4": None, + "Collector remarks #5": None, + "CollectingEvent stationFieldNumber #5": None, + "Collector remarks #6": None, + "CollectingEvent stationFieldNumber #6": None, + "Collector remarks #7": None, + "CollectingEvent stationFieldNumber #7": None, + "Collector remarks #8": None, + "CollectingEvent stationFieldNumber #8": None, + "Determination integer1": 1212, + "Determination remarks": "Some remarks for determination testing", + "Determination integer1 #2": 8729, + "Determination remarks #2": "test remarks", + "Determination integer1 #3": None, + "Determination remarks #3": None, + }, + ] + + to_dict = [dict(zip(headers, row)) for row in rows] + + self.assertEqual(to_dict, correct_rows) + + correct_packs = [ + { + "self": { + "id": self.collectionobjects[0].id, + "ordernumber": None, + "version": 0, + }, + "to_one": { + "cataloger": { + "self": { + "id": self.agents_created[0].id, + "ordernumber": None, + "version": 0, + }, + "to_many": { + "agentspecialties": [ + { + "self": { + "id": sp1.id, + "ordernumber": 0, + "version": 0, + } + }, + { + "self": { + "id": sp2.id, + "ordernumber": 1, + "version": 0, + } + }, + { + "self": { + "id": sp3.id, + "ordernumber": 2, + "version": 0, + } + }, + { + "self": { + "id": sp4.id, + "ordernumber": 3, + "version": 0, + } + }, + ], + "collectors": [ + None, + { + "self": { + "id": col1.id, + "ordernumber": 1, + "version": 0, + }, + "to_one": { + "collectingevent": { + "self": { + "id": ce1.id, + "ordernumber": None, + "version": 0, + } + } + }, + }, + { + "self": { + "id": col2.id, + "ordernumber": 2, + "version": 0, + }, + "to_one": { + "collectingevent": { + "self": { + "id": ce2.id, + "ordernumber": None, + "version": 0, + } + } + }, + }, + None, + { + "self": { + "id": col3.id, + "ordernumber": 4, + "version": 0, + }, + "to_one": { + "collectingevent": { + "self": { + "id": ce3.id, + "ordernumber": None, + "version": 0, + } + } + }, + }, + { + "self": { + "id": col4.id, + "ordernumber": 5, + "version": 0, + }, + "to_one": { + "collectingevent": { + "self": { + "id": ce4.id, + "ordernumber": None, + "version": 0, + } + } + }, + }, + { + "self": { + "id": col5.id, + "ordernumber": 6, + "version": 0, + }, + "to_one": { + "collectingevent": { + "self": { + "id": ce5.id, + "ordernumber": None, + "version": 0, + } + } + }, + }, + { + "self": { + "id": col6.id, + "ordernumber": 7, + "version": 0, + }, + "to_one": { + "collectingevent": { + "self": { + "id": ce6.id, + "ordernumber": None, + "version": 0, + } + } + }, + }, + ], + }, + } + }, + "to_many": { + "determinations": [ + { + "self": { + "id": co_1_det_1.id, + "ordernumber": None, + "version": 0, + } + }, + { + "self": { + "id": co_1_det_2.id, + "ordernumber": None, + "version": 0, + } + }, + { + "self": { + "id": co_1_det_3.id, + "ordernumber": None, + "version": 0, + } + }, + ] + }, + }, + { + "self": { + "id": self.collectionobjects[1].id, + "ordernumber": None, + "version": 0, + }, + "to_many": { + "determinations": [ + { + "self": { + "id": co_2_det_1.id, + "ordernumber": None, + "version": 0, + } + }, + { + "self": { + "id": co_2_det_2.id, + "ordernumber": None, + "version": 0, + } + }, + None, + ] + }, + }, + { + "self": { + "id": self.collectionobjects[2].id, + "ordernumber": None, + "version": 0, + }, + "to_one": { + "cataloger": { + "self": { + "id": self.agents_created[2].id, + "ordernumber": None, + "version": 0, + }, + "to_many": { + "collectors": [ + None, + { + "self": { + "id": col8.id, + "ordernumber": 1, + "version": 0, + }, + "to_one": { + "collectingevent": { + "self": { + "id": ce1.id, + "ordernumber": None, + "version": 0, + } + } + }, + }, + { + "self": { + "id": col9.id, + "ordernumber": 2, + "version": 0, + }, + "to_one": { + "collectingevent": { + "self": { + "id": ce2.id, + "ordernumber": None, + "version": 0, + } + } + }, + }, + None, + { + "self": { + "id": col10.id, + "ordernumber": 4, + "version": 0, + }, + "to_one": { + "collectingevent": { + "self": { + "id": ce3.id, + "ordernumber": None, + "version": 0, + } + } + }, + }, + { + "self": { + "id": col11.id, + "ordernumber": 5, + "version": 0, + }, + "to_one": { + "collectingevent": { + "self": { + "id": ce4.id, + "ordernumber": None, + "version": 0, + } + } + }, + }, + { + "self": { + "id": col12.id, + "ordernumber": 6, + "version": 0, + }, + "to_one": { + "collectingevent": { + "self": { + "id": ce5.id, + "ordernumber": None, + "version": 0, + } + } + }, + }, + { + "self": { + "id": col13.id, + "ordernumber": 7, + "version": 0, + }, + "to_one": { + "collectingevent": { + "self": { + "id": ce6.id, + "ordernumber": None, + "version": 0, + } + } + }, + }, + ] + }, + } + }, + }, + { + "self": { + "id": self.collectionobjects[3].id, + "ordernumber": None, + "version": 0, + }, + "to_one": { + "cataloger": { + "self": { + "id": self.agents_created[1].id, + "ordernumber": None, + "version": 0, + }, + "to_many": { + "agentspecialties": [ + { + "self": { + "id": sp5.id, + "ordernumber": 0, + "version": 0, + } + }, + { + "self": { + "id": sp6.id, + "ordernumber": 1, + "version": 0, + } + }, + { + "self": { + "id": sp7.id, + "ordernumber": 2, + "version": 0, + } + }, + None, + ], + "collectors": [ + None, + { + "self": { + "id": col14.id, + "ordernumber": 1, + "version": 0, + }, + "to_one": { + "collectingevent": { + "self": { + "id": ce1.id, + "ordernumber": None, + "version": 0, + } + } + }, + }, + { + "self": { + "id": col15.id, + "ordernumber": 2, + "version": 0, + }, + "to_one": { + "collectingevent": { + "self": { + "id": ce2.id, + "ordernumber": None, + "version": 0, + } + } + }, + }, + None, + { + "self": { + "id": col16.id, + "ordernumber": 4, + "version": 0, + }, + "to_one": { + "collectingevent": { + "self": { + "id": ce3.id, + "ordernumber": None, + "version": 0, + } + } + }, + }, + { + "self": { + "id": col17.id, + "ordernumber": 5, + "version": 0, + }, + "to_one": { + "collectingevent": { + "self": { + "id": ce4.id, + "ordernumber": None, + "version": 0, + } + } + }, + }, + None, + None, + ], + }, + } + }, + }, + { + "self": { + "id": self.collectionobjects[4].id, + "ordernumber": None, + "version": 0, + }, + "to_many": { + "determinations": [ + { + "self": { + "id": co_4_det_1.id, + "ordernumber": None, + "version": 0, + } + }, + { + "self": { + "id": co_4_det_2.id, + "ordernumber": None, + "version": 0, + } + }, + { + "self": { + "id": co_4_det_3.id, + "ordernumber": None, + "version": 0, + } + }, + ] + }, + }, + ] + + self.assertEqual(packs, correct_packs) + self.assertDictEqual(plan, test_plan.plan) + + def test_stalls_within_to_many(self): + base_table = "collectionobject" + query_paths = [ + ["catalognumber"], + ["determinations", "remarks"], + ["preparations", "countamt"], + ] + + added = [(base_table, *path) for path in query_paths] + + query_fields = [ + BatchEditPack._query_field(QueryFieldSpec.from_path(path), 0) + for path in added + ] + + co_1_det_1 = models.Determination.objects.create( + collectionobject=self.collectionobjects[0], + remarks="Remarks for collection object 1, det 1", + ) + + co_1_det_2 = models.Determination.objects.create( + collectionobject=self.collectionobjects[0], + remarks="Some remarks for collection object 1, det 2", + ) + + co_3_det_1 = models.Determination.objects.create( + collectionobject=self.collectionobjects[2], + remarks="Some remarks for collection object 3 det 1", + ) + + co_1_prep_1 = models.Preparation.objects.create( + collectionobject=self.collectionobjects[0], + countamt=90, + preptype=self.preptype, + ) + + co_1_prep_2 = models.Preparation.objects.create( + collectionobject=self.collectionobjects[0], + countamt=890, + preptype=self.preptype, + ) + + co_1_prep_3 = models.Preparation.objects.create( + collectionobject=self.collectionobjects[0], + countamt=12, + preptype=self.preptype, + ) + + co_2_prep_1 = models.Preparation.objects.create( + collectionobject=self.collectionobjects[1], + countamt=84, + preptype=self.preptype, + ) + + co_2_prep_2 = models.Preparation.objects.create( + collectionobject=self.collectionobjects[1], + countamt=982, + preptype=self.preptype, + ) + + co_3_prep_1 = models.Preparation.objects.create( + collectionobject=self.collectionobjects[2], + countamt=690, + preptype=self.preptype, + ) + + co_3_prep_2 = models.Preparation.objects.create( + collectionobject=self.collectionobjects[2], + countamt=6890, + preptype=self.preptype, + ) + + co_3_prep_3 = models.Preparation.objects.create( + collectionobject=self.collectionobjects[2], + countamt=612, + preptype=self.preptype, + ) + + props = self.build_props(query_fields, base_table) + + (headers, rows, packs, plan, order) = run_batch_edit_query(props) + + correct_rows = [ + [ + "num-0", + "Remarks for collection object 1, det 1", + "Some remarks for collection object 1, det 2", + 90, + 890, + 12, + ], + ["num-1", None, None, 84, 982, None], + [ + "num-2", + "Some remarks for collection object 3 det 1", + None, + 690, + 6890, + 612, + ], + ["num-3", None, None, None, None, None], + ["num-4", None, None, None, None, None], + ] + + correct_packs = [ + { + "self": { + "id": self.collectionobjects[0].id, + "ordernumber": None, + "version": 0, + }, + "to_many": { + "determinations": [ + { + "self": { + "id": co_1_det_1.id, + "ordernumber": None, + "version": 0, + } + }, + { + "self": { + "id": co_1_det_2.id, + "ordernumber": None, + "version": 0, + } + }, + ], + "preparations": [ + { + "self": { + "id": co_1_prep_1.id, + "ordernumber": None, + "version": 0, + } + }, + { + "self": { + "id": co_1_prep_2.id, + "ordernumber": None, + "version": 0, + } + }, + { + "self": { + "id": co_1_prep_3.id, + "ordernumber": None, + "version": 0, + } + }, + ], + }, + }, + { + "self": { + "id": self.collectionobjects[1].id, + "ordernumber": None, + "version": 0, + }, + "to_many": { + "preparations": [ + { + "self": { + "id": co_2_prep_1.id, + "ordernumber": None, + "version": 0, + } + }, + { + "self": { + "id": co_2_prep_2.id, + "ordernumber": None, + "version": 0, + } + }, + None, + ] + }, + }, + { + "self": { + "id": self.collectionobjects[2].id, + "ordernumber": None, + "version": 0, + }, + "to_many": { + "determinations": [ + { + "self": { + "id": co_3_det_1.id, + "ordernumber": None, + "version": 0, + } + }, + None, + ], + "preparations": [ + { + "self": { + "id": co_3_prep_1.id, + "ordernumber": None, + "version": 0, + } + }, + { + "self": { + "id": co_3_prep_2.id, + "ordernumber": None, + "version": 0, + } + }, + { + "self": { + "id": co_3_prep_3.id, + "ordernumber": None, + "version": 0, + } + }, + ], + }, + }, + { + "self": { + "id": self.collectionobjects[3].id, + "ordernumber": None, + "version": 0, + } + }, + { + "self": { + "id": self.collectionobjects[4].id, + "ordernumber": None, + "version": 0, + } + }, + ] + + self.assertEqual(correct_rows, rows) + + self.assertEqual( + headers, + [ + "CollectionObject catalogNumber", + "Determination remarks", + "Determination remarks #2", + "Preparation countAmt", + "Preparation countAmt #2", + "Preparation countAmt #3", + ], + ) + + self.assertEqual(packs, correct_packs) + + def test_to_one_does_not_stall_if_not_to_many(self): + base_table = "collectionobject" + query_paths = [ + ["catalognumber"], + ["cataloger", "firstname"], + ["determinations", "remarks"], + ] + added = [(base_table, *path) for path in query_paths] + + query_fields = [ + BatchEditPack._query_field(QueryFieldSpec.from_path(path), 0) + for path in added + ] + + co_1_det_1 = models.Determination.objects.create( + collectionobject=self.collectionobjects[0], + remarks="Remarks for collection object 1 det1", + ) + + co_1_det_2 = models.Determination.objects.create( + collectionobject=self.collectionobjects[0], + remarks="Remarks for collection object 1 det2", + ) + + co_2_det_1 = models.Determination.objects.create( + collectionobject=self.collectionobjects[1], + remarks="Remarks for collection object 2 det1", + ) + + co_2_det_2 = models.Determination.objects.create( + collectionobject=self.collectionobjects[1], + remarks="Remarks for collection object 2 det2", + ) + + co_2_det_3 = models.Determination.objects.create( + collectionobject=self.collectionobjects[1], + remarks="Remarks for collection object 2 det-three", + ) + + self._update(self.collectionobjects[0], {"cataloger": self.agents_created[0]}) + + props = self.build_props(query_fields, base_table) + (headers, rows, packs, plan, order) = run_batch_edit_query(props) + + self.assertEqual( + headers, + [ + "CollectionObject catalogNumber", + "Agent firstName", + "Determination remarks", + "Determination remarks #2", + "Determination remarks #3", + ], + ) + + self.assertEqual( + rows, + [ + [ + "num-0", + "Test1", + "Remarks for collection object 1 det1", + "Remarks for collection object 1 det2", + None, + ], + [ + "num-1", + None, + "Remarks for collection object 2 det1", + "Remarks for collection object 2 det2", + "Remarks for collection object 2 det-three", + ], + ["num-2", None, None, None, None], + ["num-3", None, None, None, None], + ["num-4", None, None, None, None], + ], + ) + + self.assertEqual( + packs, + [ + { + "self": { + "id": self.collectionobjects[0].id, + "ordernumber": None, + "version": 0, + }, + "to_one": { + "cataloger": { + "self": { + "id": self.agents_created[0].id, + "ordernumber": None, + "version": 0, + } + } + }, + "to_many": { + "determinations": [ + { + "self": { + "id": co_1_det_1.id, + "ordernumber": None, + "version": 0, + } + }, + { + "self": { + "id": co_1_det_2.id, + "ordernumber": None, + "version": 0, + } + }, + None, + ] + }, + }, + { + "self": { + "id": self.collectionobjects[1].id, + "ordernumber": None, + "version": 0, + }, + "to_many": { + "determinations": [ + { + "self": { + "id": co_2_det_1.id, + "ordernumber": None, + "version": 0, + } + }, + { + "self": { + "id": co_2_det_2.id, + "ordernumber": None, + "version": 0, + } + }, + { + "self": { + "id": co_2_det_3.id, + "ordernumber": None, + "version": 0, + } + }, + ] + }, + }, + { + "self": { + "id": self.collectionobjects[2].id, + "ordernumber": None, + "version": 0, + } + }, + { + "self": { + "id": self.collectionobjects[3].id, + "ordernumber": None, + "version": 0, + } + }, + { + "self": { + "id": self.collectionobjects[4].id, + "ordernumber": None, + "version": 0, + } + }, + ], + ) + + def test_to_one_stalls_within(self): + # Something like collectionobject -> cataloger -> agent specialty and agent -> agent address self stalls + base_table = "collectionobject" + query_paths = [ + ["catalognumber"], + ["cataloger", "firstname"], + ["cataloger", "addresses", "address"], + ["cataloger", "addresses", "address2"], + ["cataloger", "agentSpecialties", "specialtyName"], + ["cataloger", "variants", "name"], + ] + added = [(base_table, *path) for path in query_paths] + + query_fields = [ + BatchEditPack._query_field(QueryFieldSpec.from_path(path), 0) + for path in added + ] + + agt1_sp1 = models.Agentspecialty.objects.create( + agent=self.agents_created[0], specialtyname="agent1-testspecialty" + ) + agt1_sp2 = models.Agentspecialty.objects.create( + agent=self.agents_created[0], specialtyname="agent1-testspecialty2" + ) + + agt2_sp1 = models.Agentspecialty.objects.create( + agent=self.agents_created[1], specialtyname="agent2-testspecialty1" + ) + + agt1_add1 = models.Address.objects.create( + address="1234 Main St.", + address2="Second line", + agent=self.agents_created[0], + ) + + agt1_add2 = models.Address.objects.create( + address="5678 Pleo St.", + address2="Second line -- right below tree", + agent=self.agents_created[0], + ) + + agt2_add1 = models.Address.objects.create( + address="8765 Oreo St.", address2="not sure", agent=self.agents_created[1] + ) + + agt2_add2 = models.Address.objects.create( + address="Plamer road", address2="Room 202", agent=self.agents_created[1] + ) + + agt2_add3 = models.Address.objects.create( + address="Reo road", + address2="Room 208, Apt. 101", + agent=self.agents_created[1], + ) + + agt2_var1 = models.Agentvariant.objects.create( + agent=self.agents_created[1], name="variant for agent2", vartype=0 + ) + + agt2_var2 = models.Agentvariant.objects.create( + agent=self.agents_created[1], name="variant 2 for agent2", vartype=1 + ) + + agt3_var2 = models.Agentvariant.objects.create( + agent=self.agents_created[1], name="variant 3 for agent2", vartype=2 + ) + + self._update(self.collectionobjects[0], {"cataloger": self.agents_created[0]}) + self._update(self.collectionobjects[1], {"cataloger": self.agents_created[1]}) + self._update(self.collectionobjects[2], {"cataloger": self.agents_created[1]}) + self._update(self.collectionobjects[3], {"cataloger": self.agents_created[0]}) + self._update(self.collectionobjects[4], {"cataloger": self.agents_created[0]}) + + props = self.build_props(query_fields, base_table) + + (headers, rows, packs, plan, order) = run_batch_edit_query(props) + + ordered_headers = apply_visual_order(headers, order) + + self.assertEqual( + headers, + [ + "CollectionObject catalogNumber", + "Agent firstName", + "Address address", + "Address address2", + "Address address #2", + "Address address2 #2", + "Address address #3", + "Address address2 #3", + "AgentSpecialty specialtyName", + "AgentSpecialty specialtyName #2", + "AgentVariant name", + "AgentVariant name #2", + "AgentVariant name #3", + ], + ) + + correct_rows = [ + [ + "num-0", + "Test1", + "1234 Main St.", + "Second line", + "5678 Pleo St.", + "Second line -- right below tree", + None, + None, + "agent1-testspecialty", + "agent1-testspecialty2", + None, + None, + None, + ], + [ + "num-1", + "Test2", + "8765 Oreo St.", + "not sure", + "Plamer road", + "Room 202", + "Reo road", + "Room 208, Apt. 101", + "agent2-testspecialty1", + None, + "variant for agent2", + "variant 2 for agent2", + "variant 3 for agent2", + ], + [ + "num-2", + "Test2", + "8765 Oreo St.", + "not sure", + "Plamer road", + "Room 202", + "Reo road", + "Room 208, Apt. 101", + "agent2-testspecialty1", + None, + "variant for agent2", + "variant 2 for agent2", + "variant 3 for agent2", + ], + [ + "num-3", + "Test1", + "1234 Main St.", + "Second line", + "5678 Pleo St.", + "Second line -- right below tree", + None, + None, + "agent1-testspecialty", + "agent1-testspecialty2", + None, + None, + None, + ], + [ + "num-4", + "Test1", + "1234 Main St.", + "Second line", + "5678 Pleo St.", + "Second line -- right below tree", + None, + None, + "agent1-testspecialty", + "agent1-testspecialty2", + None, + None, + None, + ], + ] + + self.assertEqual(rows, correct_rows) + + agent_1_pack = { + "self": { + "id": self.agents_created[0].id, + "ordernumber": None, + "version": 0, + }, + "to_many": { + "addresses": [ + {"self": {"id": agt1_add1.id, "ordernumber": None, "version": 0}}, + {"self": {"id": agt1_add2.id, "ordernumber": None, "version": 0}}, + None, + ], + "agentspecialties": [ + {"self": {"id": agt1_sp1.id, "ordernumber": 0, "version": 0}}, + {"self": {"id": agt1_sp2.id, "ordernumber": 1, "version": 0}}, + ], + }, + } + + agent_2_pack = { + "self": { + "id": self.agents_created[1].id, + "ordernumber": None, + "version": 0, + }, + "to_many": { + "addresses": [ + {"self": {"id": agt2_add1.id, "ordernumber": None, "version": 0}}, + {"self": {"id": agt2_add2.id, "ordernumber": None, "version": 0}}, + {"self": {"id": agt2_add3.id, "ordernumber": None, "version": 0}}, + ], + "agentspecialties": [ + {"self": {"id": agt2_sp1.id, "ordernumber": 0, "version": 0}}, + None, + ], + "variants": [ + {"self": {"id": agt2_var1.id, "ordernumber": None, "version": 0}}, + {"self": {"id": agt2_var2.id, "ordernumber": None, "version": 0}}, + {"self": {"id": agt3_var2.id, "ordernumber": None, "version": 0}}, + ], + }, + } + correct_packs = [ + { + "self": { + "id": self.collectionobjects[0].id, + "ordernumber": None, + "version": 0, + }, + "to_one": {"cataloger": agent_1_pack}, + }, + { + "self": { + "id": self.collectionobjects[1].id, + "ordernumber": None, + "version": 0, + }, + "to_one": {"cataloger": agent_2_pack}, + }, + { + "self": { + "id": self.collectionobjects[2].id, + "ordernumber": None, + "version": 0, + }, + "to_one": {"cataloger": agent_2_pack}, + }, + { + "self": { + "id": self.collectionobjects[3].id, + "ordernumber": None, + "version": 0, + }, + "to_one": {"cataloger": agent_1_pack}, + }, + { + "self": { + "id": self.collectionobjects[4].id, + "ordernumber": None, + "version": 0, + }, + "to_one": {"cataloger": agent_1_pack}, + }, + ] + self.assertEqual(correct_packs, packs) + + def test_to_one_stalls_to_many(self): + # To ensure that to-many on to-one side stalls naive to-manys + + # Test conditions: + # 1. Only one on to-one's to-many side does not stall + # 2. Multiple on to-one's to-many side stalls + # 3. Test none on to-one does not stall + + base_table = "collectionobject" + query_paths = [ + ["catalognumber"], + ["cataloger", "firstname"], + ["cataloger", "addresses", "address"], + ["cataloger", "agentSpecialties", "specialtyName"], + ["determinations", "remarks"], + ["preparations", "countamt"], + ] + added = [(base_table, *path) for path in query_paths] + + query_fields = [ + BatchEditPack._query_field(QueryFieldSpec.from_path(path), 0) + for path in added + ] + + # 1. Only one on to-one's to-many side does not stall + agt1_sp1 = models.Agentspecialty.objects.create( + agent=self.agents_created[0], specialtyname="agent1-testspeciality - one" + ) + + co_1_det_1 = models.Determination.objects.create( + collectionobject=self.collectionobjects[0], + remarks="Remarks for collection object 1 det 1", + ) + + co_1_det_2 = models.Determination.objects.create( + collectionobject=self.collectionobjects[0], + remarks="Remarks for collection object 1 det 2", + ) + + co_1_prep_1 = models.Preparation.objects.create( + collectionobject=self.collectionobjects[0], + countamt=90, + preptype=self.preptype, + ) + + # 2. Multiple on to-one's to-many side stalls + ag2_sp1 = models.Agentspecialty.objects.create( + agent=self.agents_created[1], specialtyname="agent2-testspecialty - one" + ) + + ag2_sp2 = models.Agentspecialty.objects.create( + agent=self.agents_created[1], specialtyname="agent2-testspecialty - two" + ) + + ag2_add1 = models.Address.objects.create( + address="1234 Main St.", + address2="Second line", + agent=self.agents_created[1], + ) + + ag2_add2 = models.Address.objects.create( + address="6789 Main St.", + address2="Non-primary address", + agent=self.agents_created[1], + ) + + ag2_add3 = models.Address.objects.create( + address="1420 Alumni Place", + address2="Address at aardwark", + agent=self.agents_created[1], + ) + + co_2_det_1 = models.Determination.objects.create( + collectionobject=self.collectionobjects[1], + remarks="Remarks for collection object 2 det 1", + ) + + co_2_det_2 = models.Determination.objects.create( + collectionobject=self.collectionobjects[1], + remarks="Remarks for collection object 2 det 2", + ) + + co_2_prep_1 = models.Preparation.objects.create( + collectionobject=self.collectionobjects[1], + countamt=102, + preptype=self.preptype, + ) + + # 3. Test none on to-one does not stall + co_3_det_1 = models.Determination.objects.create( + collectionobject=self.collectionobjects[2], + remarks="Remarks for collection object 3 det 1", + ) + + co_3_det_2 = models.Determination.objects.create( + collectionobject=self.collectionobjects[2], + remarks="Remarks for collection object 3 det 2", + ) + + co_3_prep_1 = models.Preparation.objects.create( + collectionobject=self.collectionobjects[2], + countamt=21, + preptype=self.preptype, + ) + + co_3_prep_2 = models.Preparation.objects.create( + collectionobject=self.collectionobjects[2], + countamt=12, + preptype=self.preptype, + ) + + self._update(self.collectionobjects[0], {"cataloger": self.agents_created[0]}) + self._update(self.collectionobjects[1], {"cataloger": self.agents_created[1]}) + + props = self.build_props(query_fields, base_table) + + (headers, rows, packs, plan, order) = run_batch_edit_query(props) + + self.assertEqual( + headers, + [ + "CollectionObject catalogNumber", + "Agent firstName", + "Address address", + "Address address #2", + "Address address #3", + "AgentSpecialty specialtyName", + "AgentSpecialty specialtyName #2", + "Determination remarks", + "Determination remarks #2", + "Preparation countAmt", + "Preparation countAmt #2", + ], + ) + + correct_rows = [ + [ + "num-0", + "Test1", + None, + None, + None, + "agent1-testspeciality - one", + None, + "Remarks for collection object 1 det 1", + "Remarks for collection object 1 det 2", + 90, + None, + ], + [ + "num-1", + "Test2", + "1234 Main St.", + "6789 Main St.", + "1420 Alumni Place", + "agent2-testspecialty - one", + "agent2-testspecialty - two", + "Remarks for collection object 2 det 1", + "Remarks for collection object 2 det 2", + 102, + None, + ], + [ + "num-2", + None, + None, + None, + None, + None, + None, + "Remarks for collection object 3 det 1", + "Remarks for collection object 3 det 2", + 21, + 12, + ], + ["num-3", None, None, None, None, None, None, None, None, None, None], + ["num-4", None, None, None, None, None, None, None, None, None, None], + ] + + self.assertEqual(rows, correct_rows) + + correct_packs = [ + { + "self": { + "id": self.collectionobjects[0].id, + "ordernumber": None, + "version": 0, + }, + "to_one": { + "cataloger": { + "self": { + "id": self.agents_created[0].id, + "ordernumber": None, + "version": 0, + }, + "to_many": { + "agentspecialties": [ + { + "self": { + "id": agt1_sp1.id, + "ordernumber": 0, + "version": 0, + } + }, + None, + ] + }, + } + }, + "to_many": { + "determinations": [ + { + "self": { + "id": co_1_det_1.id, + "ordernumber": None, + "version": 0, + } + }, + { + "self": { + "id": co_1_det_2.id, + "ordernumber": None, + "version": 0, + } + }, + ], + "preparations": [ + { + "self": { + "id": co_1_prep_1.id, + "ordernumber": None, + "version": 0, + } + }, + None, + ], + }, + }, + { + "self": { + "id": self.collectionobjects[1].id, + "ordernumber": None, + "version": 0, + }, + "to_one": { + "cataloger": { + "self": { + "id": self.agents_created[1].id, + "ordernumber": None, + "version": 0, + }, + "to_many": { + "addresses": [ + { + "self": { + "id": ag2_add1.id, + "ordernumber": None, + "version": 0, + } + }, + { + "self": { + "id": ag2_add2.id, + "ordernumber": None, + "version": 0, + } + }, + { + "self": { + "id": ag2_add3.id, + "ordernumber": None, + "version": 0, + } + }, + ], + "agentspecialties": [ + { + "self": { + "id": ag2_sp1.id, + "ordernumber": 0, + "version": 0, + } + }, + { + "self": { + "id": ag2_sp2.id, + "ordernumber": 1, + "version": 0, + } + }, + ], + }, + } + }, + "to_many": { + "determinations": [ + { + "self": { + "id": co_2_det_1.id, + "ordernumber": None, + "version": 0, + } + }, + { + "self": { + "id": co_2_det_2.id, + "ordernumber": None, + "version": 0, + } + }, + ], + "preparations": [ + { + "self": { + "id": co_2_prep_1.id, + "ordernumber": None, + "version": 0, + } + }, + None, + ], + }, + }, + { + "self": { + "id": self.collectionobjects[2].id, + "ordernumber": None, + "version": 0, + }, + "to_many": { + "determinations": [ + { + "self": { + "id": co_3_det_1.id, + "ordernumber": None, + "version": 0, + } + }, + { + "self": { + "id": co_3_det_2.id, + "ordernumber": None, + "version": 0, + } + }, + ], + "preparations": [ + { + "self": { + "id": co_3_prep_1.id, + "ordernumber": None, + "version": 0, + } + }, + { + "self": { + "id": co_3_prep_2.id, + "ordernumber": None, + "version": 0, + } + }, + ], + }, + }, + { + "self": { + "id": self.collectionobjects[3].id, + "ordernumber": None, + "version": 0, + } + }, + { + "self": { + "id": self.collectionobjects[4].id, + "ordernumber": None, + "version": 0, + } + }, + ] + + self.assertEqual(correct_packs, packs) diff --git a/specifyweb/stored_queries/tests/test_format.py b/specifyweb/stored_queries/tests/test_format.py new file mode 100644 index 00000000000..3cd8c3f2498 --- /dev/null +++ b/specifyweb/stored_queries/tests/test_format.py @@ -0,0 +1,370 @@ +from specifyweb.stored_queries.format import ObjectFormatter +from specifyweb.stored_queries.query_construct import QueryConstruct +from specifyweb.stored_queries.tests.tests import SQLAlchemySetup +from xml.etree import ElementTree +import specifyweb.specify.models as spmodels +import specifyweb.stored_queries.models as models + +# Used for pretty-formatting sql code for testing +import sqlparse + + +class FormatterAggregatorTests(SQLAlchemySetup): + + def setUp(self): + super().setUp() + object_formatter = ObjectFormatter(self.collection, self.specifyuser, False) + def _get_formatter(formatter_def): + object_formatter.formattersDom = ElementTree.fromstring(formatter_def) + return object_formatter + self.get_formatter = _get_formatter + + def test_basic_formatters(self): + + formatter_def = """ + + + + + accessionNumber + + + + + + + agent + role + + + + + + + lastName + + + lastName + firstName + middleInitial + + + lastName + + + lastName + + + + + + + + """ + + object_formatter = self.get_formatter(formatter_def) + + accession_1 = spmodels.Accession.objects.create( + accessionnumber='1', + division=self.division + ) + + agent_2 = spmodels.Agent.objects.create( + agenttype=1, + firstname="Test", + lastname="User", + middleinitial="MiddleInitial", + division=self.division, + specifyuser=self.specifyuser + ) + + accession_agent_1 = spmodels.Accessionagent.objects.create( + agent=self.agent, + role='role1', + accession=accession_1 + ) + accession_agent_2 = spmodels.Accessionagent.objects.create( + agent=agent_2, + role='role2', + accession=accession_1 + ) + + with FormatterAggregatorTests.test_session_context() as session: + query = QueryConstruct( + collection=self.collection, + objectformatter=object_formatter, + query=session.query() + ) + _, accession_expr = object_formatter.objformat(query, models.Accession, None) + self.assertEqual(str(accession_expr), 'IFNULL(accession."AccessionNumber", \'\')') + _, agent_expr = object_formatter.objformat(query, models.Agent, None) + self.assertEqual(str(agent_expr), + 'IFNULL(CASE IFNULL(agent."AgentType", \'\') ' + 'WHEN :param_1 THEN IFNULL(agent."LastName", \'\') ' + 'WHEN :param_2 THEN concat(IFNULL(agent."LastName", \'\'), IFNULL(concat(:concat_1, agent."FirstName"), \'\'), IFNULL(concat(:concat_2, agent."MiddleInitial"), \'\')) ' + 'WHEN :param_3 THEN IFNULL(agent."LastName", \'\') ' + 'WHEN :param_4 THEN IFNULL(agent."LastName", \'\') END, \'\')') + orm_field = object_formatter.aggregate(query, spmodels.datamodel.get_table('Accession').get_relationship('accessionagents'), models.Accession, None, []) + self.assertEqual(sqlparse.format(str(orm_field), reindent=True), + '\n ' + '(SELECT IFNULL(GROUP_CONCAT(IFNULL(concat(IFNULL(CASE IFNULL(agent_1."AgentType", \'\')' + '\n WHEN :param_1 THEN IFNULL(agent_1."LastName", \'\')' + '\n WHEN :param_2 THEN concat(IFNULL(agent_1."LastName", \'\'), IFNULL(concat(:concat_1, agent_1."FirstName"), \'\'), IFNULL(concat(:concat_2, agent_1."MiddleInitial"), \'\'))' + '\n WHEN :param_3 THEN IFNULL(agent_1."LastName", \'\')' + '\n WHEN :param_4 THEN IFNULL(agent_1."LastName", \'\')' + '\n END, \'\'), IFNULL(concat(:concat_3, accessionagent."Role"), \'\')), \'\') SEPARATOR :sep), \'\') AS blank_nulls_1' + '\n FROM accession,' + '\n accessionagent' + '\n LEFT OUTER JOIN agent AS agent_1 ON agent_1."AgentID" = accessionagent."AgentID"' + '\n WHERE accessionagent."AccessionID" = accession."AccessionID"' + '\n LIMIT :param_5)') + query, expr = object_formatter.objformat(query, models.AccessionAgent, None) + query = query.query.add_column(expr) + self.assertCountEqual(list(query), [('User - role1',), ('User, Test MiddleInitial - role2',)]) + + + def test_aggregation_in_formatters(self): + formatter_def = """ + + + + + accessionAgents + + + + + + + role + + + + + + + + """ + object_formatter = self.get_formatter(formatter_def) + accession_1 = spmodels.Accession.objects.create( + accessionnumber='a', + division=self.division) + accession_2 = spmodels.Accession.objects.create( + accessionnumber='b', + division=self.division) + accession_agent_1 = spmodels.Accessionagent.objects.create( + agent=self.agent, + role='role2', + accession=accession_1, + ) + accession_agent_2 = spmodels.Accessionagent.objects.create( + agent=self.agent, + role='role1', + accession=accession_1, + ) + accession_agent_3 = spmodels.Accessionagent.objects.create( + agent=self.agent, + role='role3', + accession=accession_2, + + ) + accession_agent_4 = spmodels.Accessionagent.objects.create( + agent=self.agent, + role='role4', + accession=accession_2, + ) + with FormatterAggregatorTests.test_session_context() as session: + query = QueryConstruct( + collection=self.collection, + objectformatter=object_formatter, + query=session.query() + ) + query, expr = object_formatter.objformat(query, models.Accession, None) + self.assertEqual(sqlparse.format(str(expr), reindent=True), + 'IFNULL(' + '\n (SELECT IFNULL(GROUP_CONCAT(IFNULL(accessionagent."Role", \'\')' + '\n ORDER BY accessionagent."TimestampCreated" SEPARATOR :sep), \'\') AS blank_nulls_1' + '\n FROM accessionagent, accession' + '\n WHERE accessionagent."AccessionID" = accession."AccessionID"), \'\')' + ) + query = query.query.add_columns(models.Accession.accessionNumber, expr) + self.assertCountEqual(list(query), [('a', 'role2; role1'), ('b', 'role3; role4')]) + + def test_detect_cycles(self): + formatter_def = """ + + + + + accessionAgents + + + + + + + role + accession + accession.accessionnumber + + + + + + + + """ + object_formatter = self.get_formatter(formatter_def) + accession_1 = spmodels.Accession.objects.create( + accessionnumber='Some_number', + division=self.division) + accession_agent_1 = spmodels.Accessionagent.objects.create( + agent=self.agent, + role='roleA', + accession=accession_1, + ) + with FormatterAggregatorTests.test_session_context() as session: + query = QueryConstruct( + collection=self.collection, + objectformatter=object_formatter, + query=session.query() + ) + query, expr = object_formatter.objformat(query, models.Accession, None) + query = query.query.add_column(expr) + self.assertCountEqual(list(query), + [( + "roleASome_number",)] + ) + + def test_relationships_in_switch_fields(self): + formatter_def = """ + + + + + text1 + + + + + + + accession + + + accession.text2 + role + + + + + """ + object_formatter = self.get_formatter(formatter_def) + accession_1 = spmodels.Accession.objects.create( + accessionnumber='1', + division=self.division, + text1='text 1 value for this accession', + text2='this should never be seen' + ) + accession_2 = spmodels.Accession.objects.create( + accessionnumber='2', + division=self.division, + text1='this should never be seen', + text2='text 2 value for this accession' + ) + + accession_agent_1 = spmodels.Accessionagent.objects.create( + agent=self.agent, + role='role', + accession=accession_1, + + ) + accession_agent_2 = spmodels.Accessionagent.objects.create( + agent=self.agent, + role='role2', + accession=accession_2, + ) + with FormatterAggregatorTests.test_session_context() as session: + query = QueryConstruct( + collection=self.collection, + objectformatter=object_formatter, + query=session.query() + ) + query, expr = object_formatter.objformat(query, models.AccessionAgent, None) + query = query.query.add_columns(expr) + self.assertCountEqual(list(query), [('text 1 value for this accession',), (' text 2 value for this accession role2',)]) diff --git a/specifyweb/stored_queries/tests/tests.py b/specifyweb/stored_queries/tests/tests.py new file mode 100644 index 00000000000..68ed4496699 --- /dev/null +++ b/specifyweb/stored_queries/tests/tests.py @@ -0,0 +1,295 @@ +from sqlalchemy import orm, inspect +from unittest import expectedFailure + +from django.test import TestCase +import specifyweb.specify.models as spmodels +from specifyweb.specify.tests.test_api import ApiTests +from MySQLdb.cursors import SSCursor +from django.conf import settings +import sqlalchemy +from sqlalchemy.dialects import mysql +from django.db import connection +from sqlalchemy import event +from .. import models + +"""" +Provides a gateway to test sqlalchemy queries, while +using django models. The idea is to use django.db's connection.cursor() +to execute the query, and make a literal query for it. This is done because django's unit test +will run inside a nested transaction, and any other transaction will never see the changes +so SqlAlchemy queries will not see the changes. + +Multiple-session context safe. So, the following usage is valid + +with session.context() as session: + query_1 = session.query(...) + query_2 = session.query(...) + +""" + + +def setup_sqlalchemy(url: str): + engine = sqlalchemy.create_engine(url, pool_recycle=settings.SA_POOL_RECYCLE, + connect_args={'cursorclass': SSCursor}) + + # BUG: Raise 0-row exception somewhere here. + @event.listens_for(engine, 'before_cursor_execute', retval=True) + # Listen to low-level cursor execution events. Just before query is executed by SQLAlchemy, run it instead + # by Django, and then return a wrapped sql statement which will return the same result set. + def run_django_query(conn, cursor, statement, parameters, context, executemany): + django_cursor = connection.cursor() + # Get MySQL Compatible compiled query. + # print('##################################################################') + # print(statement % parameters) + # print('##################################################################') + django_cursor.execute(statement, parameters) + result_set = django_cursor.fetchall() + columns = django_cursor.description + # SqlAlchemy needs to find columns back in the rows, hence adding label to columns + selects = [ + sqlalchemy.select( + [(sqlalchemy.sql.null() if column is None else sqlalchemy.literal(column)).label(columns[idx][0]) + for idx, column in enumerate(row)]) for row in result_set] + # union all instead of union because rows can be duplicated in the original query, + # but still need to preserve the duplication + unioned = sqlalchemy.union_all(*selects) + # Tests will fail when migrated to different background. TODO: Auto-detect dialects + final_query = str(unioned.compile(compile_kwargs={"literal_binds": True, }, dialect=mysql.dialect())) + + return final_query, () + + Session = orm.sessionmaker(bind=engine) + return engine, models.make_session_context(lambda: (Session(), connection.cursor())) + + +class SQLAlchemySetup(ApiTests): + + test_sa_url = None + engine = None + test_session_context = None + + @classmethod + def setUpClass(cls): + # Django creates a new database for testing. SQLAlchemy needs to connect to the test database + super().setUpClass() + + engine, session_context = setup_sqlalchemy(settings.SA_TEST_DB_URL) + cls.engine = engine + cls.test_session_context = session_context + + +class SQLAlchemySetupTest(SQLAlchemySetup): + + def test_collection_object_count(self): + + with SQLAlchemySetupTest.test_session_context() as session: + + co_aliased = orm.aliased(models.CollectionObject) + sa_collection_objects = list(session.query(co_aliased._id).filter( + co_aliased.collectionMemberId == self.collection.id)) + sa_ids = [_id for (_id, ) in sa_collection_objects] + ids = [co.id for co in self.collectionobjects] + + self.assertEqual(sa_ids, ids) + min_co_id, = session.query( + sqlalchemy.sql.func.min(co_aliased.collectionObjectId)).filter( + co_aliased.collectionMemberId == self.collection.id).first() + + self.assertEqual(min_co_id, min(ids)) + + max_co_id, = session.query( + sqlalchemy.sql.func.max(co_aliased.collectionObjectId)).filter( + co_aliased.collectionMemberId == self.collection.id).first() + + self.assertEqual(max_co_id, max(ids)) + + +class SQLAlchemyModelTest(TestCase): + + @staticmethod + def test_sqlalchemy_model(datamodel_table): + table_errors = { + 'not_found': [], # Fields / Relationships not found + 'incorrect_direction': {}, # Relationship direct not correct + 'incorrect_columns': {}, # Relationship columns not correct + 'incorrect_table': {} # Relationship related model not correct + } + orm_table = orm.aliased(getattr(models, datamodel_table.name)) + known_fields = datamodel_table.all_fields + + for field in known_fields: + + in_sql = getattr(orm_table, field.name, None) or getattr(orm_table, field.name.lower(), None) + + if in_sql is None: + table_errors['not_found'].append(field.name) + continue + + if not field.is_relationship: + continue + + sa_relationship = inspect(in_sql).property + + sa_direction = sa_relationship.direction.name.lower() + datamodel_direction = field.type.replace('-', '').lower() + + if sa_direction != datamodel_direction: + table_errors['incorrect_direction'][field.name] = [sa_direction, datamodel_direction] + print(f"Incorrect direction: {field.name} {sa_direction} {datamodel_direction}") + + remote_sql_table = sa_relationship.target.name.lower() + remote_datamodel_table = field.relatedModelName.lower() + + if remote_sql_table.lower() != remote_datamodel_table: + # Check case where the relation model's name is different from the DB table name + remote_sql_table = sa_relationship.mapper._log_desc.split('(')[1].split('|')[0].lower() + if remote_sql_table.lower() != remote_datamodel_table: + table_errors['incorrect_table'][field.name] = [remote_sql_table, remote_datamodel_table] + print(f"Incorrect table: {field.name} {remote_sql_table} {remote_datamodel_table}") + + sa_column = list(sa_relationship.local_columns)[0].name + if sa_column.lower() != ( + datamodel_table.idColumn.lower() if not getattr(field, 'column', None) else field.column.lower()): + table_errors['incorrect_columns'][field.name] = [sa_column, datamodel_table.idColumn.lower(), + getattr(field, 'column', None)] + print( + f"Incorrect columns: {field.name} {sa_column} {datamodel_table.idColumn.lower()} {getattr(field, 'column', None)}") + + 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: + table_errors = SQLAlchemyModelTest.test_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: + table_errors['not_found'] = sorted(table_errors['not_found']) + if table_errors: + self.assertDictEqual(table_errors, expected_errors[table.name]) + + +expected_errors = { + "Attachment": { + "incorrect_table": { + "dnaSequencingRunAttachments": [ + "dnasequencerunattachment", + "dnasequencingrunattachment" + ] + } + }, + "AutoNumberingScheme": { + "not_found": [ + "collections", + "disciplines", + "divisions" + ] + }, + "Collection": { + "not_found": [ + "numberingSchemes", + "userGroups" + ] + }, + "CollectionObject": { + "not_found": [ + "projects" + ] + }, + "DNASequencingRun": { + "incorrect_table": { + "attachments": [ + "dnasequencerunattachment", + "dnasequencingrunattachment" + ] + } + }, + "Discipline": { + "not_found": [ + "numberingSchemes", + "userGroups" + ], + "incorrect_direction": { + "taxonTreeDef": [ + "manytoone", + "onetoone" + ] + } + }, + "Division": { + "not_found": [ + "numberingSchemes", + "userGroups" + ] + }, + "Institution": { + "not_found": [ + "userGroups" + ] + }, + "InstitutionNetwork": { + "not_found": [ + "collections", + "contacts" + ] + }, + "Locality": { + "incorrect_direction": { + "geoCoordDetails": [ + "onetomany", + "zerotoone" + ], + "localityDetails": [ + "onetomany", + "zerotoone" + ] + } + }, + "Project": { + "not_found": [ + "collectionObjects" + ] + }, + "SpExportSchema": { + "not_found": [ + "spExportSchemaMappings" + ] + }, + "SpExportSchemaMapping": { + "not_found": [ + "spExportSchemas" + ] + }, + "SpPermission": { + "not_found": [ + "principals" + ] + }, + "SpPrincipal": { + "not_found": [ + "permissions", + "scope", + "specifyUsers" + ] + }, + "SpReport": { + "incorrect_direction": { + "workbenchTemplate": [ + "manytoone", + "onetoone" + ] + } + }, + "SpecifyUser": { + "not_found": [ + "spPrincipals" + ] + }, + "TaxonTreeDef": { + "incorrect_direction": { + "discipline": [ + "onetomany", + "onetoone" + ] + } + } +} diff --git a/specifyweb/stored_queries/tests.py b/specifyweb/stored_queries/tests/tests_legacy.py similarity index 60% rename from specifyweb/stored_queries/tests.py rename to specifyweb/stored_queries/tests/tests_legacy.py index 9db4034c780..39fc01816ab 100644 --- a/specifyweb/stored_queries/tests.py +++ b/specifyweb/stored_queries/tests/tests_legacy.py @@ -1,22 +1,7 @@ -from sqlalchemy import orm, inspect -from unittest import skip, expectedFailure +from unittest import TestCase, expectedFailure, skip -from django.test import TestCase -import specifyweb.specify.models as spmodels from specifyweb.specify.tests.test_api import ApiTests -from .format import ObjectFormatter -from .query_construct import QueryConstruct -from .queryfieldspec import QueryFieldSpec -from MySQLdb.cursors import SSCursor -from django.conf import settings -import sqlalchemy -from sqlalchemy.dialects import mysql -from django.db import connection -from sqlalchemy import event -from . import models -from xml.etree import ElementTree -# Used for pretty-formatting sql code for testing -import sqlparse +from specifyweb.stored_queries.queryfieldspec import QueryFieldSpec class QueryFieldTests(TestCase): def test_stringid_roundtrip_from_bug(self) -> None: @@ -30,449 +15,6 @@ def test_stringid_roundtrip_en_masse(self) -> None: self.assertEqual(relfld == 1, fs.is_relationship()) self.assertEqual(stringid.lower(), fs.to_stringid().lower()) - -"""" -Provides a gateway to test sqlalchemy queries, while -using django models. The idea is to use django.db's connection.cursor() -to execute the query, and make a literal query for it. This is done because django's unit test -will run inside a nested transaction, and any other transaction will never see the changes -so SqlAlchemy queries will not see the changes. - -Multiple-session context safe. So, the following usage is valid - -with session.context() as session: - query_1 = session.query(...) - query_2 = session.query(...) - -""" - -def setup_sqlalchemy(url: str): - engine = sqlalchemy.create_engine(url, pool_recycle=settings.SA_POOL_RECYCLE, - connect_args={'cursorclass': SSCursor}) - - # BUG: Raise 0-row exception somewhere here. - @event.listens_for(engine, 'before_cursor_execute', retval=True) - # Listen to low-level cursor execution events. Just before query is executed by SQLAlchemy, run it instead - # by Django, and then return a wrapped sql statement which will return the same result set. - def run_django_query(conn, cursor, statement, parameters, context, executemany): - django_cursor = connection.cursor() - # Get MySQL Compatible compiled query. - # print('##################################################################') - # print(statement % parameters) - # print('##################################################################') - django_cursor.execute(statement, parameters) - result_set = django_cursor.fetchall() - columns = django_cursor.description - # SqlAlchemy needs to find columns back in the rows, hence adding label to columns - selects = [sqlalchemy.select([(sqlalchemy.null() if column is None else sqlalchemy.sql.expression.literal(column) if isinstance(column, str) else column) for idx, column in enumerate(row)]) for row - in result_set] - # union all instead of union because rows can be duplicated in the original query, - # but still need to preserve the duplication - unioned = sqlalchemy.union_all(*selects) - # Tests will fail when migrated to different background. TODO: Auto-detect dialects - final_query = str(unioned.compile(compile_kwargs={"literal_binds": True, }, dialect=mysql.dialect())) - - return final_query, () - - Session = orm.sessionmaker(bind=engine) - return engine, models.make_session_context(Session) - -class SQLAlchemySetup(ApiTests): - - test_sa_url = None - engine = None - test_session_context = None - - @classmethod - def setUpClass(cls): - # Django creates a new database for testing. SQLAlchemy needs to connect to the test database - super().setUpClass() - - engine, session_context = setup_sqlalchemy(settings.SA_TEST_DB_URL) - cls.engine = engine - cls.test_session_context = session_context - -class SQLAlchemySetupTest(SQLAlchemySetup): - - def test_collection_object_count(self): - - with SQLAlchemySetupTest.test_session_context() as session: - - co_aliased = orm.aliased(models.CollectionObject) - sa_collection_objects = list(session.query(co_aliased._id).filter(co_aliased.collectionMemberId == self.collection.id)) - sa_ids = [_id for (_id, ) in sa_collection_objects] - ids = [co.id for co in self.collectionobjects] - - self.assertEqual(sa_ids, ids) - min_co_id, = session.query(sqlalchemy.sql.func.min(co_aliased.collectionObjectId)).filter(co_aliased.collectionMemberId == self.collection.id).first() - - self.assertEqual(min_co_id, min(ids)) - - max_co_id, = session.query(sqlalchemy.sql.func.max(co_aliased.collectionObjectId)).filter(co_aliased.collectionMemberId == self.collection.id).first() - - self.assertEqual(max_co_id, max(ids)) - - -class FormatterAggregatorTests(SQLAlchemySetup): - - def setUp(self): - super().setUp() - object_formatter = ObjectFormatter(self.collection, self.specifyuser, False) - def _get_formatter(formatter_def): - object_formatter.formattersDom = ElementTree.fromstring(formatter_def) - return object_formatter - self.get_formatter = _get_formatter - - def test_basic_formatters(self): - - formatter_def = """ - - - - - accessionNumber - - - - - - - agent - role - - - - - - - lastName - - - lastName - firstName - middleInitial - - - lastName - - - lastName - - - - - - - - """ - - object_formatter = self.get_formatter(formatter_def) - - accession_1 = spmodels.Accession.objects.create( - accessionnumber='1', - division=self.division - ) - - agent_2 = spmodels.Agent.objects.create( - agenttype=1, - firstname="Test", - lastname="User", - middleinitial="MiddleInitial", - division=self.division, - specifyuser=self.specifyuser - ) - - accession_agent_1 = spmodels.Accessionagent.objects.create( - agent=self.agent, - role='role1', - accession=accession_1 - ) - accession_agent_2 = spmodels.Accessionagent.objects.create( - agent=agent_2, - role='role2', - accession=accession_1 - ) - - with FormatterAggregatorTests.test_session_context() as session: - query = QueryConstruct( - collection=self.collection, - objectformatter=object_formatter, - query=session.query() - ) - _, accession_expr = object_formatter.objformat(query, models.Accession, None) - self.assertEqual(str(accession_expr), 'IFNULL(accession."AccessionNumber", \'\')') - _, agent_expr = object_formatter.objformat(query, models.Agent, None) - self.assertEqual(str(agent_expr), - 'IFNULL(CASE IFNULL(agent."AgentType", \'\') ' - 'WHEN :param_1 THEN IFNULL(agent."LastName", \'\') ' - 'WHEN :param_2 THEN concat(IFNULL(agent."LastName", \'\'), IFNULL(concat(:concat_1, agent."FirstName"), \'\'), IFNULL(concat(:concat_2, agent."MiddleInitial"), \'\')) ' - 'WHEN :param_3 THEN IFNULL(agent."LastName", \'\') ' - 'WHEN :param_4 THEN IFNULL(agent."LastName", \'\') END, \'\')') - orm_field = object_formatter.aggregate(query, spmodels.datamodel.get_table('Accession').get_relationship('accessionagents'), models.Accession, None, []) - self.assertEqual(sqlparse.format(str(orm_field), reindent=True), - '\n ' - '(SELECT IFNULL(GROUP_CONCAT(IFNULL(concat(IFNULL(CASE IFNULL(agent_1."AgentType", \'\')' - '\n WHEN :param_1 THEN IFNULL(agent_1."LastName", \'\')' - '\n WHEN :param_2 THEN concat(IFNULL(agent_1."LastName", \'\'), IFNULL(concat(:concat_1, agent_1."FirstName"), \'\'), IFNULL(concat(:concat_2, agent_1."MiddleInitial"), \'\'))' - '\n WHEN :param_3 THEN IFNULL(agent_1."LastName", \'\')' - '\n WHEN :param_4 THEN IFNULL(agent_1."LastName", \'\')' - '\n END, \'\'), IFNULL(concat(:concat_3, accessionagent."Role"), \'\')), \'\') SEPARATOR :sep), \'\') AS blank_nulls_1' - '\n FROM accession,' - '\n accessionagent' - '\n LEFT OUTER JOIN agent AS agent_1 ON agent_1."AgentID" = accessionagent."AgentID"' - '\n WHERE accessionagent."AccessionID" = accession."AccessionID"' - '\n LIMIT :param_5)') - query, expr = object_formatter.objformat(query, models.AccessionAgent, None) - query = query.query.add_column(expr) - self.assertCountEqual(list(query), [('User - role1',), ('User, Test MiddleInitial - role2',)]) - - - def test_aggregation_in_formatters(self): - formatter_def = """ - - - - - accessionAgents - - - - - - - role - - - - - - - - """ - object_formatter = self.get_formatter(formatter_def) - accession_1 = spmodels.Accession.objects.create( - accessionnumber='a', - division=self.division) - accession_2 = spmodels.Accession.objects.create( - accessionnumber='b', - division=self.division) - accession_agent_1 = spmodels.Accessionagent.objects.create( - agent=self.agent, - role='role2', - accession=accession_1, - ) - accession_agent_2 = spmodels.Accessionagent.objects.create( - agent=self.agent, - role='role1', - accession=accession_1, - ) - accession_agent_3 = spmodels.Accessionagent.objects.create( - agent=self.agent, - role='role3', - accession=accession_2, - - ) - accession_agent_4 = spmodels.Accessionagent.objects.create( - agent=self.agent, - role='role4', - accession=accession_2, - ) - with FormatterAggregatorTests.test_session_context() as session: - query = QueryConstruct( - collection=self.collection, - objectformatter=object_formatter, - query=session.query() - ) - query, expr = object_formatter.objformat(query, models.Accession, None) - self.assertEqual(sqlparse.format(str(expr), reindent=True), - 'IFNULL(' - '\n (SELECT IFNULL(GROUP_CONCAT(IFNULL(accessionagent."Role", \'\')' - '\n ORDER BY accessionagent."TimestampCreated" SEPARATOR :sep), \'\') AS blank_nulls_1' - '\n FROM accessionagent, accession' - '\n WHERE accessionagent."AccessionID" = accession."AccessionID"), \'\')' - ) - query = query.query.add_columns(models.Accession.accessionNumber, expr) - self.assertCountEqual(list(query), [('a', 'role2; role1'), ('b', 'role3; role4')]) - - def test_detect_cycles(self): - formatter_def = """ - - - - - accessionAgents - - - - - - - role - accession - accession.accessionnumber - - - - - - - - """ - object_formatter = self.get_formatter(formatter_def) - accession_1 = spmodels.Accession.objects.create( - accessionnumber='Some_number', - division=self.division) - accession_agent_1 = spmodels.Accessionagent.objects.create( - agent=self.agent, - role='roleA', - accession=accession_1, - ) - with FormatterAggregatorTests.test_session_context() as session: - query = QueryConstruct( - collection=self.collection, - objectformatter=object_formatter, - query=session.query() - ) - query, expr = object_formatter.objformat(query, models.Accession, None) - query = query.query.add_column(expr) - self.assertCountEqual(list(query), - [( - "roleASome_number",)] - ) - - def test_relationships_in_switch_fields(self): - formatter_def = """ - - - - - text1 - - - - - - - accession - - - accession.text2 - role - - - - - """ - object_formatter = self.get_formatter(formatter_def) - accession_1 = spmodels.Accession.objects.create( - accessionnumber='1', - division=self.division, - text1='text 1 value for this accession', - text2='this should never be seen' - ) - accession_2 = spmodels.Accession.objects.create( - accessionnumber='2', - division=self.division, - text1='this should never be seen', - text2='text 2 value for this accession' - ) - - accession_agent_1 = spmodels.Accessionagent.objects.create( - agent=self.agent, - role='role', - accession=accession_1, - - ) - accession_agent_2 = spmodels.Accessionagent.objects.create( - agent=self.agent, - role='role2', - accession=accession_2, - ) - with FormatterAggregatorTests.test_session_context() as session: - query = QueryConstruct( - collection=self.collection, - objectformatter=object_formatter, - query=session.query() - ) - query, expr = object_formatter.objformat(query, models.AccessionAgent, None) - query = query.query.add_columns(expr) - self.assertCountEqual(list(query), [('text 1 value for this accession',), (' text 2 value for this accession role2',)]) - @skip("These tests are out of date.") class StoredQueriesTests(ApiTests): # def setUp(self): @@ -746,65 +288,6 @@ def test_date_part_filter_combined(self): # self.assertEqual(params, (7, 1, 2, 8, 1, 2)) -def test_sqlalchemy_model(datamodel_table): - table_errors = { - 'not_found': [], # Fields / Relationships not found - 'incorrect_direction': {}, # Relationship direct not correct - 'incorrect_columns': {}, # Relationship columns not correct - 'incorrect_table': {} # Relationship related model not correct - } - orm_table = orm.aliased(getattr(models, datamodel_table.name)) - known_fields = datamodel_table.all_fields - - for field in known_fields: - - in_sql = getattr(orm_table, field.name, None) or getattr(orm_table, field.name.lower(), None) - - if in_sql is None: - table_errors['not_found'].append(field.name) - continue - - if not field.is_relationship: - continue - - sa_relationship = inspect(in_sql).property - - sa_direction = sa_relationship.direction.name.lower() - datamodel_direction = field.type.replace('-', '').lower() - - if sa_direction != datamodel_direction: - table_errors['incorrect_direction'][field.name] = [sa_direction, datamodel_direction] - print(f"Incorrect direction: {field.name} {sa_direction} {datamodel_direction}") - - remote_sql_table = sa_relationship.target.name.lower() - remote_datamodel_table = field.relatedModelName.lower() - - if remote_sql_table.lower() != remote_datamodel_table: - # Check case where the relation model's name is different from the DB table name - remote_sql_table = sa_relationship.mapper._log_desc.split('(')[1].split('|')[0].lower() - if remote_sql_table.lower() != remote_datamodel_table: - table_errors['incorrect_table'][field.name] = [remote_sql_table, remote_datamodel_table] - print(f"Incorrect table: {field.name} {remote_sql_table} {remote_datamodel_table}") - - sa_column = list(sa_relationship.local_columns)[0].name - if sa_column.lower() != ( - datamodel_table.idColumn.lower() if not getattr(field, 'column', None) else field.column.lower()): - table_errors['incorrect_columns'][field.name] = [sa_column, datamodel_table.idColumn.lower(), - getattr(field, 'column', None)] - print(f"Incorrect columns: {field.name} {sa_column} {datamodel_table.idColumn.lower()} {getattr(field, 'column', None)}") - - return {key: value for key, value in table_errors.items() if len(value) > 0} - -class SQLAlchemyModelTest(TestCase): - def test_sqlalchemy_model_errors(self): - for table in spmodels.datamodel.tables: - table_errors = test_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: - table_errors['not_found'] = sorted(table_errors['not_found']) - if table_errors: - self.assertDictEqual(table_errors, expected_errors[table.name]) - STRINGID_LIST = [ # (stringid, isrelfld) ("1,10,110-collectingEventAttachments,41.attachment.attachment", 1), @@ -1228,130 +711,4 @@ def test_sqlalchemy_model_errors(self): ("52.loan.yesNo1", 0), ("69.referencework.text2", 0), ("69.referencework.title", 0), -] - -expected_errors = { - "Attachment": { - "incorrect_table": { - "dnaSequencingRunAttachments": [ - "dnasequencerunattachment", - "dnasequencingrunattachment" - ] - } - }, - "AutoNumberingScheme": { - "not_found": [ - "collections", - "disciplines", - "divisions" - ] - }, - "Collection": { - "not_found": [ - "numberingSchemes", - "userGroups" - ] - }, - "CollectionObject": { - "not_found": [ - "projects" - ] - }, - "DNASequencingRun": { - "incorrect_table": { - "attachments": [ - "dnasequencerunattachment", - "dnasequencingrunattachment" - ] - } - }, - "Discipline": { - "not_found": [ - "numberingSchemes", - "userGroups" - ], - "incorrect_direction": { - "taxonTreeDef": [ - "manytoone", - "onetoone" - ] - } - }, - "Division": { - "not_found": [ - "numberingSchemes", - "userGroups" - ] - }, - "Institution": { - "not_found": [ - "userGroups" - ] - }, - "InstitutionNetwork": { - "not_found": [ - "collections", - "contacts" - ] - }, - "Locality": { - "incorrect_direction": { - "geoCoordDetails": [ - "onetomany", - "zerotoone" - ], - "localityDetails": [ - "onetomany", - "zerotoone" - ] - } - }, - "Project": { - "not_found": [ - "collectionObjects" - ] - }, - "SpExportSchema": { - "not_found": [ - "spExportSchemaMappings" - ] - }, - "SpExportSchemaMapping": { - "not_found": [ - "spExportSchemas" - ] - }, - "SpPermission": { - "not_found": [ - "principals" - ] - }, - "SpPrincipal": { - "not_found": [ - "permissions", - "scope", - "specifyUsers" - ] - }, - "SpReport": { - "incorrect_direction": { - "workbenchTemplate": [ - "manytoone", - "onetoone" - ] - } - }, - "SpecifyUser": { - "not_found": [ - "spPrincipals" - ] - }, - "TaxonTreeDef": { - "incorrect_direction": { - "discipline": [ - "onetomany", - "onetoone" - ] - } - } -} \ No newline at end of file +] \ No newline at end of file diff --git a/specifyweb/workbench/models.py b/specifyweb/workbench/models.py index 8e4f945c7ab..0367384ddd9 100644 --- a/specifyweb/workbench/models.py +++ b/specifyweb/workbench/models.py @@ -6,6 +6,7 @@ from django.http import Http404 from django.utils import timezone +from specifyweb.specify.func import Func from specifyweb.specify.models import Collection, Specifyuser, Agent, datamodel, custom_save from specifyweb.specify.api import uri_for_model @@ -75,7 +76,7 @@ def get_dataset_as_dict(self): ds_dict = {key: getattr(self, key) for key in self.object_response_fields} ds_dict.update({ "rows": self.data, - "uploadplan": json.loads(self.uploadplan) if self.uploadplan else None, + "uploadplan": Func.maybe(self.uploadplan, json.loads), "createdbyagent": uri_for_model('agent', self.createdbyagent_id) if self.createdbyagent_id is not None else None, "modifiedbyagent": uri_for_model('agent', self.modifiedbyagent_id) if self.modifiedbyagent_id is not None else None }) diff --git a/specifyweb/workbench/tests.py b/specifyweb/workbench/tests.py index 01019706640..daf89cc851e 100644 --- a/specifyweb/workbench/tests.py +++ b/specifyweb/workbench/tests.py @@ -6,41 +6,55 @@ from specifyweb.workbench.models import Spdataset from specifyweb.specify.tests.test_api import ApiTests from .upload import upload as uploader -from django.conf import settings + + class DataSetTests(ApiTests): def test_reset_uploadplan_to_null(self) -> None: c = Client() c.force_login(self.specifyuser) response = c.post( - '/api/workbench/dataset/', + "/api/workbench/dataset/", data={ - 'name': "Test data set", - 'columns': [], - 'rows': [], - 'importedfilename': "foobar", + "name": "Test data set", + "columns": [], + "rows": [], + "importedfilename": "foobar", }, - content_type='application/json', + content_type="application/json", ) self.assertEqual(response.status_code, 201) data = json.loads(response.content) - datasetid = data['id'] + datasetid = data["id"] response = c.put( - f'/api/workbench/dataset/{datasetid}/', + f"/api/workbench/dataset/{datasetid}/", data={ - 'name': "Test data set modified", - 'uploadplan': {"baseTableName": "preptype", "uploadable": {"uploadTable": {"wbcols": {"name": "Preparation Type", "isloanable": "Is Loanable"}, "static": {}, "toOne": {}, "toMany": {}}}}, + "name": "Test data set modified", + "uploadplan": { + "baseTableName": "preptype", + "uploadable": { + "uploadTable": { + "wbcols": { + "name": "Preparation Type", + "isloanable": "Is Loanable", + }, + "static": {}, + "toOne": {}, + "toMany": {}, + } + }, + }, }, - content_type='application/json', + content_type="application/json", ) self.assertEqual(response.status_code, 204) response = c.put( - f'/api/workbench/dataset/{datasetid}/', + f"/api/workbench/dataset/{datasetid}/", data={ - 'name': "Test data set modified modified", - 'uploadplan': None, + "name": "Test data set modified modified", + "uploadplan": None, }, - content_type='application/json', + content_type="application/json", ) self.assertEqual(response.status_code, 204) dataset = Spdataset.objects.get(id=datasetid) @@ -50,36 +64,54 @@ def test_create_record_set(self) -> None: c = Client() c.force_login(self.specifyuser) response = c.post( - '/api/workbench/dataset/', + "/api/workbench/dataset/", data={ - 'name': "Test data set", - 'columns': ["catno"], - 'rows': [["1"], ["2"], ["3"]], - 'importedfilename': "foobar", + "name": "Test data set", + "columns": ["catno"], + "rows": [["1"], ["2"], ["3"]], + "importedfilename": "foobar", }, - content_type='application/json', + content_type="application/json", ) self.assertEqual(response.status_code, 201) data = json.loads(response.content) - datasetid = data['id'] + datasetid = data["id"] response = c.put( - f'/api/workbench/dataset/{datasetid}/', + f"/api/workbench/dataset/{datasetid}/", data={ - 'name': "Test data set modified", - 'uploadplan': {"baseTableName": "collectionobject", "uploadable": {"uploadTable": {"wbcols": {"catalognumber": "catno",}, "static": {}, "toOne": {}, "toMany": {}}}}, + "name": "Test data set modified", + "uploadplan": { + "baseTableName": "collectionobject", + "uploadable": { + "uploadTable": { + "wbcols": { + "catalognumber": "catno", + }, + "static": {}, + "toOne": {}, + "toMany": {}, + } + }, + }, }, - content_type='application/json', + content_type="application/json", ) self.assertEqual(response.status_code, 204) dataset = Spdataset.objects.get(id=datasetid) - results = uploader.do_upload_dataset(self.collection, self.agent.id, dataset, no_commit=False, allow_partial=False) + results = uploader.do_upload_dataset( + self.collection, + self.agent.id, + dataset, + no_commit=False, + allow_partial=False, + ) - self.assertTrue(dataset.uploadresult['success']) + self.assertTrue(dataset.uploadresult["success"]) response = c.post( - f'/api/workbench/create_recordset/{datasetid}/', - data={'name': 'Foobar upload'}, + f"/api/workbench/create_recordset/{datasetid}/", + data={"name": "Foobar upload"}, ) self.assertEqual(response.status_code, 201) recordset_id = json.loads(response.content) diff --git a/specifyweb/workbench/upload/clone.py b/specifyweb/workbench/upload/clone.py index 3a56c51133b..c03fd63a0b3 100644 --- a/specifyweb/workbench/upload/clone.py +++ b/specifyweb/workbench/upload/clone.py @@ -22,7 +22,8 @@ 'version', 'id', 'createdbyagent_id', - 'modifiedbyagent_id' + 'modifiedbyagent_id', + 'guid' ] @transaction.atomic() @@ -43,8 +44,7 @@ def clone_record(reference_record, inserter: Callable[[Model, Dict[str, Any]], M def _cloned(value, field, is_dependent): if not is_dependent: return value - # Don't fetch the actual object till the very end, and when we _need_ to clone (as an optimization) - return clone_record(getattr(reference_record, field.name), inserter, one_to_ones) + return clone_record(getattr(reference_record, field.name), inserter, one_to_ones).pk attrs = { field.attname: Func.maybe(getattr(reference_record, field.attname), lambda obj: _cloned(obj, field, is_dependent)) # type: ignore diff --git a/specifyweb/workbench/upload/preferences.py b/specifyweb/workbench/upload/preferences.py index 2cc5e4d52e2..6bfed78e1d1 100644 --- a/specifyweb/workbench/upload/preferences.py +++ b/specifyweb/workbench/upload/preferences.py @@ -14,6 +14,7 @@ class PrefItem(TypedDict): 'null_check': PrefItem(path=r'sp7\.batchEdit.deferForNullCheck=(.+)', default=False) } +# During testing, this is mocked, so we don't have touch app resource data. During real deal, it'd touch the db. def should_defer_fields(for_type: DEFER_KEYS) -> bool: pref_item = DeferFieldPrefs[for_type] match = re.search(pref_item['path'], get_remote_prefs()) diff --git a/specifyweb/workbench/upload/scoping.py b/specifyweb/workbench/upload/scoping.py index 33081c57f9f..eb1d3628bc0 100644 --- a/specifyweb/workbench/upload/scoping.py +++ b/specifyweb/workbench/upload/scoping.py @@ -159,22 +159,28 @@ def apply_scoping_to_uploadtable(ut: UploadTable, collection, generator: ScopeGe for (key, value) in ut.toOne.items() } + model = getattr(models, table.django_name) + + def _backref(key): + return model._meta.get_field(key).remote_field.attname + to_many = { - key: [set_order_number(i, apply_scoping(key, record)) for i, record in enumerate(records)] + key: [set_order_number(i, apply_scoping(key, record))._replace(strong_ignore=[_backref(key)]) for i, record in enumerate(records)] for (key, records) in ut.toMany.items() } scoped_table = ScopedUploadTable( name=ut.name, wbcols={f: extend_columnoptions(colopts, collection, table.name, f) for f, colopts in ut.wbcols.items()}, - static=static_adjustments(table, ut.wbcols, ut.static), + static=ut.static, toOne=to_ones, toMany=to_many, #type: ignore scopingAttrs=scoping_relationships(collection, table), disambiguation=None, # Often, we'll need to recur down to clone (nested one-to-ones). Having this entire is handy in such a case to_one_fields = to_one_fields, - match_payload=None + match_payload=None, + strong_ignore=[] ) return scoped_table @@ -189,16 +195,6 @@ def get_to_one_fields(collection) -> Dict[str, List['str']]: **({collection.discipline.paleocontextchildtable.lower(): ['paleocontext']} if collection.discipline.ispaleocontextembedded else {}) } -def static_adjustments(table: Table, wbcols: Dict[str, ColumnOptions], static: Dict[str, Any]) -> Dict[str, Any]: - # not sure if this is the right place for this, but it will work for now. - if table.name == 'Agent' and 'agenttype' not in wbcols and 'agenttype' not in static: - static = {'agenttype': 1, **static} - elif table.name == 'Determination' and 'iscurrent' not in wbcols and 'iscurrent' not in static: - static = {'iscurrent': True, **static} - else: - static = static - return static - def set_order_number(i: int, tmr: ScopedUploadTable) -> ScopedUploadTable: table = datamodel.get_table_strict(tmr.name) if table.get_field('ordernumber'): diff --git a/specifyweb/workbench/upload/tests/base.py b/specifyweb/workbench/upload/tests/base.py index 83f5e090583..276583e0f8f 100644 --- a/specifyweb/workbench/upload/tests/base.py +++ b/specifyweb/workbench/upload/tests/base.py @@ -9,7 +9,8 @@ def setUp(self) -> None: spard = get_table('Spappresourcedir').objects.create(usertype='Prefs') spar = get_table('Spappresource').objects.create(name='preferences', spappresourcedir=spard, level=3, specifyuser=self.specifyuser) - get_table('Spappresourcedata').objects.create(data='ui.formatting.scrdateformat=dd/MM/yyyy\n', spappresource=spar) + + self.app_resource_data = get_table('Spappresourcedata').objects.create(data='ui.formatting.scrdateformat=dd/MM/yyyy\n', spappresource=spar) self.collection.catalognumformatname = "CatalogNumberNumeric" self.collection.save() diff --git a/specifyweb/workbench/upload/tests/test_batch_edit_table.py b/specifyweb/workbench/upload/tests/test_batch_edit_table.py new file mode 100644 index 00000000000..4cdc007967a --- /dev/null +++ b/specifyweb/workbench/upload/tests/test_batch_edit_table.py @@ -0,0 +1,640 @@ +from unittest.mock import patch +from specifyweb.context.remote_prefs import get_remote_prefs +from specifyweb.specify.func import Func +from specifyweb.specify.tests.test_api import get_table +from specifyweb.stored_queries.batch_edit import BatchEditPack, run_batch_edit_query +from specifyweb.stored_queries.queryfieldspec import QueryFieldSpec +from specifyweb.stored_queries.tests.test_batch_edit import QueryConstructionTests +from specifyweb.stored_queries.tests.tests import SQLAlchemySetup +from specifyweb.workbench.upload.preferences import DEFER_KEYS +from specifyweb.workbench.upload.tests.base import UploadTestsBase +from specifyweb.workbench.upload.upload import do_upload +from specifyweb.specify import auditcodes +from specifyweb.workbench.upload.upload_result import ( + Deleted, + MatchedAndChanged, + MatchedMultiple, + NoChange, + NullRecord, + PropagatedFailure, + ReportInfo, + Uploaded, + Updated, + Matched, + UploadResult +) +from specifyweb.workbench.upload.upload_table import UploadTable +from specifyweb.workbench.views import regularize_rows +from ..upload_plan_schema import parse_column_options, parse_plan, schema + +from jsonschema import validate # type: ignore + +from specifyweb.specify.models import Spauditlogfield, Collectionobject, Agent, Determination, Preparation, Collectingeventattribute, Collectingevent, Address, Agentspecialty + +lookup_in_auditlog = lambda model, _id: get_table("Spauditlog").objects.filter( + recordid=_id, tablenum=get_table(model).specify_model.tableId +) + +def make_defer(match, null, force: DEFER_KEYS=None): + def _defer(key: DEFER_KEYS): + if force and key == DEFER_KEYS: + raise Exception(f"Did not epect {key}") + if key == 'match': + return match + elif key == 'null_check': + return null + return _defer + +class UpdateTests(UploadTestsBase): + + def test_basic_save(self): + plan_json = { + "baseTableName": "collectionobject", + "uploadable": { + "uploadTable": { + "wbcols": { + "catalognumber": "Catno", + "integer1": "Reference Number", + "remarks": "Remarks field", + }, + "static": {}, + "toOne": {}, + "toMany": {}, + } + }, + } + validate(plan_json, schema) + plan = parse_plan(plan_json) + data = [ + {"Catno": "1", "Reference Number": "10", "Remarks field": "Foo"}, + {"Catno": "2", "Reference Number": "8982", "Remarks field": "Bar"}, + { + "Catno": "1029", + "Remarks field": "FizzBuzz", + "Reference Number": "", + }, + { + "Catno": "9024", + "Remarks field": "Should be created", + "Reference Number": "", + }, + {"Catno": "89282", "Remarks field": "Reference", "Reference Number": "292"}, + ] + co_0 = get_table("collectionobject").objects.create( + catalognumber="1".rjust(9, "0"), + integer1=10, # This would be no change + remarks="OriginalRemarks", + collection=self.collection, + ) + co_1 = get_table("collectionobject").objects.create( + catalognumber="2".rjust(9, "0"), + integer1=92, + remarks="Foo", + collection=self.collection, + ) + co_2 = get_table("collectionobject").objects.create( + catalognumber="1029".rjust(9, "0"), + integer1=92, + remarks="PaleoObject", + collection=self.collection, + ) + co_3 = get_table("collectionobject").objects.create( + catalognumber="89282".rjust(9, "0"), + integer1=292, + remarks="Reference", + collection=self.collection, + ) + batch_edit_packs = [ + {"self": {"id": co_0.id, "version": 0, "ordernumber": None}}, + {"self": {"id": co_1.id, "version": 0, "ordernumber": None}}, + {"self": {"id": co_2.id, "version": 0, "ordernumber": None}}, + None, + {"self": {"id": co_3.id, "version": 0, "ordernumber": None}}, + ] + results = do_upload( + self.collection, + data, + plan, + self.agent.id, + batch_edit_packs=batch_edit_packs, + ) + for r in results[:3]: + self.assertIsInstance(r.record_result, Updated) + + self.assertIsInstance(results[3].record_result, Uploaded) + self.assertIsInstance(results[4].record_result, NoChange) + + co_0.refresh_from_db() + co_1.refresh_from_db() + co_2.refresh_from_db() + co_3.refresh_from_db() + + self.assertEqual(co_0.integer1, 10) + self.assertEqual(co_0.remarks, "Foo") + + self.assertEqual(co_1.integer1, 8982) + self.assertEqual(co_1.remarks, "Bar") + + self.assertIsNone(co_2.integer1) + self.assertEqual(co_2.remarks, "FizzBuzz") + + changed_columns = [ + ["Remarks field"], + ["Reference Number", "Remarks field"], + ["Reference Number", "Remarks field"], + ] + + static_mapping = plan_json["uploadable"]["uploadTable"]["wbcols"] + + for _changed_columns, result in zip(changed_columns, results[:3]): + self.assertCountEqual(result.record_result.info.columns, _changed_columns) + co_entries = lookup_in_auditlog( + "Collectionobject", result.record_result.get_id() + ) + self.assertEqual(1, co_entries.count()) + entry = co_entries.first() + self.assertEqual(auditcodes.UPDATE, entry.action) + self.assertEqual(self.agent.id, entry.createdbyagent_id) + log_fields = Spauditlogfield.objects.filter(spauditlog=entry) + self.assertEqual(log_fields.count(), len(_changed_columns)) + for log_field in log_fields: + self.assertTrue(static_mapping[log_field.fieldname] in _changed_columns) + self.assertEqual(log_field.createdbyagent_id, self.agent.id) + + # The the non-modified record shouldn't be in the audit log + self.assertEqual( + 0, + lookup_in_auditlog( + "Collectionobject", results[4].record_result.get_id() + ).count(), + ) + +class OneToOneUpdateTests(UploadTestsBase): + def setUp(self): + super().setUp() + self.plan = UploadTable( + name="Collectionobject", + wbcols={ + "catalognumber": parse_column_options("catno"), + }, + overrideScope=None, + static={}, + toMany={}, + toOne={ + "collectionobjectattribute": UploadTable( + name="collectionobjectattribute", + wbcols={"number1": parse_column_options("number")}, + static={}, + toOne={}, + toMany={}, + ) + }, + ) + + def inserted_to_pack(self, inserted): + return [ + { + "self": {"id": co.id}, + "to_one": { + "collectionobjectattribute": { + "self": { + "id": coa.id + } + } + } + } + for (co, coa) in inserted + ] + + def make_co_coa_pair(self, data): + inserted = [] + for record in data: + coa = get_table("Collectionobjectattribute").objects.create( + collectionmemberid=self.collection.id + ) + co = get_table("Collectionobject").objects.create( + collection=self.collection, + catalognumber=record['catno'].zfill(9), + collectionobjectattribute=coa + ) + inserted.append((co, coa)) + return inserted + + def test_one_to_one_updates(self): + plan = self.plan + data = [ + {"catno": "9090", "number": "762"}, + {"catno": "9022", "number": "212"}, + {"catno": "1221", "number": "121"}, + ] + + inserted = self.make_co_coa_pair(data) + batch_edit_pack = self.inserted_to_pack(inserted) + + self._update(inserted[0][1], {"number1": 102}) + self._update(inserted[1][1], {"number1": 212}) + self._update(inserted[2][1], {"number1": 874}) + + co_to_create = get_table("Collectionobject").objects.create( + collection=self.collection, + catalognumber="88".zfill(9), + ) + + data = [*data, {"catno": "88", "number": "902"}] + batch_edit_pack = [*batch_edit_pack, {"self": {"id": co_to_create.id}}] + results = do_upload( + self.collection, data, plan, self.agent.id, batch_edit_packs=batch_edit_pack + ) + + correct = [(NoChange, coa_result) for coa_result in [Updated, NoChange, Updated, Uploaded]] + + for _id, result in enumerate(zip(results, correct)): + top, (co_result, coa_result) = result + msg = f"failed at {_id}" + self.assertIsInstance(top.record_result, co_result, msg) + self.assertIsInstance(top.toOne['collectionobjectattribute'].record_result, coa_result, msg) + coa_id = top.toOne['collectionobjectattribute'].record_result.get_id() + # Do a fresh sync and assert that the relationship was truly established + self.assertEqual(get_table("Collectionobject").objects.get(id=top.record_result.get_id()).collectionobjectattribute_id, coa_id) + + def test_one_to_one_deleting_no_hidden(self): + + # We don't epect matching to happen. Also, match is a lower "priority". + # The code is smart enough to be as strict as possible when there is ambiguity. This tests that. + for defer in [make_defer(match=True, null=False, force='match'), make_defer(match=True, null=True, force='match')]: + + data = [dict(catno="9090", number=''), dict(catno="22222", number=''), dict(catno="122", number='')] + inserted = self.make_co_coa_pair(data) + batch_edit_pack = self.inserted_to_pack(inserted) + + self._update(inserted[0][1], {"number1": 102}) + self._update(inserted[1][1], {"number1": 212}) + + with patch('specifyweb.workbench.upload.preferences.should_defer_fields', new=defer): + results = do_upload( + self.collection, data, self.plan, self.agent.id, batch_edit_packs=batch_edit_pack + ) + for result in results: + self.assertIsInstance(result.record_result, NoChange) + self.assertIsInstance(result.toOne['collectionobjectattribute'].record_result, Deleted) + + self.assertFalse(get_table("Collectionobjectattribute").objects.filter(id__in=[coa.id for coa in Func.second(inserted)]).exists()) + + get_table('Collectionobject').objects.all().delete() + get_table("Collectionobjectattribute").objects.all().delete() + + def test_one_to_one_deleting_hidden(self): + + def _make_data(): + data = [dict(catno="9090", number=''), dict(catno="22222", number=''), dict(catno="122", number='')] + inserted = self.make_co_coa_pair(data) + batch_edit_pack = self.inserted_to_pack(inserted) + + self._update(inserted[0][1], {"number1": 102, "number2": 212, "text22": "hidden value"}) + self._update(inserted[1][1], {"number1": 212, "number2": 764, "text22": "hidden value for coa"}) + self._update(inserted[2][1], {"number1": 874, "number6": 822, "text22": "hidden value for another coa"}) + + return data, inserted, batch_edit_pack + + data, inserted, batch_edit_pack = _make_data() + + defer = make_defer(match=True, null=False, force='match') + + with patch('specifyweb.workbench.upload.preferences.should_defer_fields', new=defer): + results = do_upload( + self.collection, data, self.plan, self.agent.id, batch_edit_packs=batch_edit_pack + ) + for result in results: + self.assertIsInstance(result.record_result, NoChange) + # Records cannot be deleted now + self.assertIsInstance(result.toOne['collectionobjectattribute'].record_result, Updated) + + get_table('Collectionobject').objects.all().delete() + get_table("Collectionobjectattribute").objects.all().delete() + + data, _, batch_edit_pack = _make_data() + + defer = make_defer(match=True, null=True, force='match') + + with patch('specifyweb.workbench.upload.preferences.should_defer_fields', new=defer): + results = do_upload( + self.collection, data, self.plan, self.agent.id, batch_edit_packs=batch_edit_pack + ) + for result in results: + self.assertIsInstance(result.record_result, NoChange) + self.assertIsInstance(result.toOne['collectionobjectattribute'].record_result, Deleted) + + self.assertFalse(get_table("Collectionobjectattribute").objects.filter(id__in=[coa.id for coa in Func.second(inserted)]).exists()) + + +# I can see why this might be a bad idea, but want to playaround with making unittests completely end-to-end at least for some type +# So we start from query and end with batch-edit results as the core focus of all these tests. +# This also allows for more complicated tests, with less manual work + self checking. +class SQLUploadTests(QueryConstructionTests, UploadTestsBase): + def setUp(self): + super().setUp() + + get_table('Collectionobject').objects.all().delete() + self.test_agent_1 = Agent.objects.create(firstname='John', lastname="Doe", division=self.division, agenttype=0) + self.test_agent_2 = Agent.objects.create(firstname="Jame", division=self.division, agenttype=1) + self.test_agent_3 = Agent.objects.create(firstname="Jame", lastname="Blo", division=self.division, agenttype=1) + self.test_agent_4 = Agent.objects.create(firstname="John", lastname="Doe", division=self.division, agenttype=1) + + self.cea_1 = Collectingeventattribute.objects.create(integer1=78, discipline=self.discipline) + self.ce_1 = Collectingevent.objects.create(stationfieldnumber="test_sfn_1", collectingeventattribute=self.cea_1, discipline=self.discipline, remarks="hidden value") + + self.cea_2 = Collectingeventattribute.objects.create(integer1=22, discipline=self.discipline) + self.ce_2 = Collectingevent.objects.create(stationfieldnumber="test_sfn_2", collectingeventattribute=self.cea_2, discipline=self.discipline, remarks="hidden value2") + + self.co_1 = Collectionobject.objects.create(catalognumber="7924".zfill(9), cataloger=self.test_agent_1, remarks="test_field", collectingevent=self.ce_1, collection=self.collection) + self.co_2 = Collectionobject.objects.create(catalognumber="0102".zfill(9), cataloger=self.test_agent_1, remarks="some remarks field", collectingevent=self.ce_1, collection=self.collection) + self.co_3 = Collectionobject.objects.create(catalognumber="1122".zfill(9), cataloger=self.test_agent_2, remarks="remarks for collection", collectingevent=self.ce_2, collection=self.collection) + + self.co_1_prep_1 = Preparation.objects.create(collectionobject=self.co_1, text1="Value for preparation", countamt=20, preptype=self.preptype) + self.co_1_prep_2 = Preparation.objects.create(collectionobject=self.co_1, text1="Second value for preparation", countamt=5, preptype=self.preptype) + self.co_1_prep_3 = Preparation.objects.create(collectionobject=self.co_1, text1="Third value for preparation", countamt=88, preptype=self.preptype) + + self.co_2_prep_1 = Preparation.objects.create(collectionobject=self.co_2, text1="Value for preparation for second CO", countamt=89, preptype=self.preptype) + self.co_2_prep_2 = Preparation.objects.create(collectionobject=self.co_2, countamt=27, preptype=self.preptype) + + self.co_3_prep_1 = Preparation.objects.create(collectionobject=self.co_3, text1="Needs to be deleted", preptype=self.preptype) + + def _build_props(self, query_fields, base_table): + raw = self.build_props(query_fields, base_table) + raw['session_maker'] = SQLUploadTests.test_session_context + return raw + + def enforcer(self, result: UploadResult, valid_results=[NoChange, NullRecord, Matched]): + self.assertTrue(any(isinstance(result.record_result, valid) for valid in valid_results), f"Failed for {result.record_result}") + to_one = list([self.enforcer(result) for result in result.toOne.values()]) + to_many = list([self.enforcer(result) for result in _results] for _results in result.toMany.values()) + + def test_no_op(self): + query_paths = [ + ['catalognumber'], + ['integer1'], + ['cataloger', 'firstname'], + ['cataloger', 'lastname'], + ['preparations', 'countamt'], + ['preparations', 'text1'], + ['collectingevent', 'stationfieldnumber'], + ['collectingevent', 'collectingeventattribute', 'integer1'] + ] + added = [('Collectionobject', *path) for path in query_paths] + + query_fields = [ + BatchEditPack._query_field(QueryFieldSpec.from_path(path), 0) + for path in added + ] + + props = self._build_props(query_fields, "Collectionobject") + + (headers, rows, packs, plan_json, _) = run_batch_edit_query(props) + + regularized_rows = regularize_rows(len(headers), rows) + + dicted = [dict(zip(headers, row)) for row in regularized_rows] + + validate(plan_json, schema) + plan = parse_plan(plan_json) + + results = do_upload( + self.collection, dicted, plan, self.agent.id, batch_edit_packs=packs + ) + + # We didn't change anything, nothing should change. verify just that + list([self.enforcer(result) for result in results]) + + def enforce_created_in_log(self, record_id, table): + entries = lookup_in_auditlog(table, record_id) + self.assertEqual(1, entries.count()) + entry = entries.first() + self.assertEqual(entry.action, auditcodes.INSERT) + + def query_to_results(self, base_table, query_paths): + added = [(base_table, *path) for path in query_paths] + + query_fields = [ + BatchEditPack._query_field(QueryFieldSpec.from_path(path), 0) + for path in added + ] + + props = self._build_props(query_fields, base_table) + + (headers, rows, packs, plan_json, _) = run_batch_edit_query(props) + + validate(plan_json, schema) + plan = parse_plan(plan_json) + + regularized_rows = regularize_rows(len(headers), rows) + + + return (headers, regularized_rows, packs, plan) + + + def test_to_one_cloned(self): + query_paths = [ + ['catalognumber'], + ['integer1'], + ['collectingevent', 'stationfieldnumber'], + ] + added = [('Collectionobject', *path) for path in query_paths] + + query_fields = [ + BatchEditPack._query_field(QueryFieldSpec.from_path(path), 0) + for path in added + ] + + props = self._build_props(query_fields, "Collectionobject") + + (headers, rows, pack, plan_json, _) = run_batch_edit_query(props) + + validate(plan_json, schema) + plan = parse_plan(plan_json) + + regularized_rows = regularize_rows(len(headers), rows) + + dicted = [dict(zip(headers, row)) for row in regularized_rows] + + data = [ + {'CollectionObject catalogNumber': '7924'.zfill(9), 'CollectionObject integer1': '', 'CollectingEvent stationFieldNumber': 'test_sfn_4'}, + {'CollectionObject catalogNumber': '102'.zfill(9), 'CollectionObject integer1': '', 'CollectingEvent stationFieldNumber': 'test_sfn_1'}, + {'CollectionObject catalogNumber': '1122'.zfill(9), 'CollectionObject integer1': '', 'CollectingEvent stationFieldNumber': 'test_sfn_2'} + ] + + results = do_upload( + self.collection, data, plan, self.agent.id, batch_edit_packs=pack + ) + + list([self.enforcer(result) for result in results[1:]]) + + self.assertIsInstance(results[0].record_result, NoChange) + self.assertIsInstance(results[0].toOne['collectingevent'].record_result, Uploaded) + + ce_created_id = results[0].toOne['collectingevent'].record_result.get_id() + ce_created = Collectingevent.objects.get(id=ce_created_id) + + self.assertEqual(ce_created.remarks, self.ce_1.remarks) + self.assertNotEqual(ce_created.collectingeventattribute_id, self.ce_1.collectingeventattribute_id) + + self.assertEqual(ce_created.collectingeventattribute.integer1, self.cea_1.integer1) + + self.enforce_created_in_log(ce_created_id, "collectingevent") + self.enforce_created_in_log(ce_created.collectingeventattribute.id, "collectingeventattribute") + + def _run_matching_test(self): + co_4 = Collectionobject.objects.create(catalognumber="1000".zfill(9), collection=self.collection) + co_5 = Collectionobject.objects.create(catalognumber="1024".zfill(9), collection=self.collection) + + query_paths = [ + ['catalognumber'], + ['cataloger', 'firstname'], + ['cataloger', 'lastname'] + ] + + (headers, rows, pack, plan) = self.query_to_results('collectionobject', query_paths) + + dicted = [dict(zip(headers, row)) for row in rows] + + data = [ + {'CollectionObject catalogNumber': '7924'.zfill(9), 'Agent firstName': 'John', 'Agent lastName': 'Doe'}, + {'CollectionObject catalogNumber': '102'.zfill(9), 'Agent firstName': 'John', 'Agent lastName': 'Doe'}, + {'CollectionObject catalogNumber': '1122'.zfill(9), 'Agent firstName': 'John', 'Agent lastName': 'Doe'}, # This won't be matched in-case of non-defer to the first agent, because of differing agent types + {'CollectionObject catalogNumber': '1000'.zfill(9), 'Agent firstName': 'NewAgent', 'Agent lastName': ''}, + {'CollectionObject catalogNumber': '1024'.zfill(9), 'Agent firstName': 'NewAgent', 'Agent lastName': ''} + ] + + results = do_upload(self.collection, data, plan, self.agent.id, batch_edit_packs=pack) + return results + + + def test_matching_without_defer(self): + + defer = make_defer(match=False, null=False) + + with patch('specifyweb.workbench.upload.preferences.should_defer_fields', new=defer): + results = self._run_matching_test() + + list([self.enforcer(result) for result in results[:2]]) + + self.assertIsInstance(results[2].record_result, NoChange) + cataloger_0 = results[2].toOne['cataloger'].record_result + self.assertIsInstance(cataloger_0, MatchedAndChanged) + + self.assertIsInstance(results[-2].record_result, NoChange) + cataloger_1 = results[-2].toOne['cataloger'].record_result + self.assertIsInstance(cataloger_1, Uploaded) + + self.assertIsInstance(results[-1].record_result, NoChange) + cataloger_2 = results[-1].toOne['cataloger'].record_result + self.assertIsInstance(cataloger_2, MatchedAndChanged) + + self.assertEqual(cataloger_0.get_id(), self.test_agent_4.id) + self.assertEqual(cataloger_2.get_id(), cataloger_1.get_id()) + + def test_matching_with_defer(self): + defer = make_defer(match=True, null=True) # null doesn't matter, can be true or false + + with patch('specifyweb.workbench.upload.preferences.should_defer_fields', new=defer): + results = self._run_matching_test() + + list([self.enforcer(result) for result in results[:2]]) + + self.assertIsInstance(results[2].record_result, PropagatedFailure) + cataloger_0 = results[2].toOne['cataloger'].record_result + self.assertIsInstance(cataloger_0, MatchedMultiple) + + self.assertIsInstance(results[-2].record_result, NoChange) + cataloger_1 = results[-2].toOne['cataloger'].record_result + self.assertIsInstance(cataloger_1, Uploaded) + + self.assertIsInstance(results[-1].record_result, NoChange) + cataloger_2 = results[-1].toOne['cataloger'].record_result + self.assertIsInstance(cataloger_2, MatchedAndChanged) + + self.assertTrue(self.test_agent_1.id in cataloger_0.ids and self.test_agent_4.id in cataloger_0.ids) + self.assertEqual(cataloger_2.get_id(), cataloger_1.get_id()) + + def test_bidirectional_to_many(self): + agt_1_add_1 = Address.objects.create(address="testaddress1", agent=self.test_agent_1) + agt_1_add_2 = Address.objects.create(address="testaddress2", agent=self.test_agent_1) + + agt_1_spec_1 = Agentspecialty.objects.create(specialtyname="specialty1", agent=self.test_agent_1) + agt_1_spec_2 = Agentspecialty.objects.create(specialtyname="specialty2", agent=self.test_agent_1) + + query_paths = [ + ['integer1'], + ['cataloger', 'firstname'], + ['cataloger', 'lastname'], + ['cataloger', 'addresses', 'address'], + ['preparations', 'countamt'], + ['preparations', 'text1'] + ] + + (headers, rows, pack, plan) = self.query_to_results('collectionobject', query_paths) + + dicted = [dict(zip(headers, row)) for row in rows] + + # original_data = [ + # {'CollectionObject integer1': '', 'Agent firstName': 'John', 'Agent lastName': 'Doe', 'Address address': 'testaddress1', 'Address address #2': 'testaddress2', 'Preparation countAmt': '20', 'Preparation text1': 'Value for preparation', 'Preparation countAmt #2': '5', 'Preparation text1 #2': 'Second value for preparation', 'Preparation countAmt #3': '88', 'Preparation text1 #3': 'Third value for preparation'}, + # {'CollectionObject integer1': '', 'Agent firstName': 'John', 'Agent lastName': 'Doe', 'Address address': 'testaddress1', 'Address address #2': 'testaddress2', 'Preparation countAmt': '89', 'Preparation text1': 'Value for preparation for second CO', 'Preparation countAmt #2': '27', 'Preparation text1 #2': '', 'Preparation countAmt #3': '', 'Preparation text1 #3': ''}, + # {'CollectionObject integer1': '', 'Agent firstName': 'Jame', 'Agent lastName': '', 'Address address': '', 'Address address #2': '', 'Preparation countAmt': '', 'Preparation text1': 'Needs to be deleted', 'Preparation countAmt #2': '', 'Preparation text1 #2': '', 'Preparation countAmt #3': '', 'Preparation text1 #3': ''} + # ] + + data = [ + {'CollectionObject integer1': '', 'Agent firstName': 'John', 'Agent lastName': 'Doe', 'Address address': 'testaddress1 changed', 'Address address #2': 'testaddress2', 'Preparation countAmt': '20', 'Preparation text1': 'Value for prep changed', 'Preparation countAmt #2': '5', 'Preparation text1 #2': 'Second value for preparation', 'Preparation countAmt #3': '88', 'Preparation text1 #3': 'Third value for preparation'}, + {'CollectionObject integer1': '', 'Agent firstName': 'John', 'Agent lastName': 'Dave', 'Address address': 'testaddress1', 'Address address #2': 'testaddress2', 'Preparation countAmt': '89', 'Preparation text1': 'Value for preparation for second CO', 'Preparation countAmt #2': '27', 'Preparation text1 #2': '', 'Preparation countAmt #3': '9999', 'Preparation text1 #3': 'Value here was modified'}, + {'CollectionObject integer1': '', 'Agent firstName': 'Jame', 'Agent lastName': '', 'Address address': '', 'Address address #2': '', 'Preparation countAmt': '', 'Preparation text1': '', 'Preparation countAmt #2': '', 'Preparation text1 #2': '', 'Preparation countAmt #3': '', 'Preparation text1 #3': ''} + ] + + results = do_upload(self.collection, data, plan, self.agent.id, batch_edit_packs=pack) + + print(results) + + def test_to_many_match_is_possible(self): + + defer = make_defer(match=False, null=True) + + agt_1_add_1 = Address.objects.create(address="testaddress1", agent=self.test_agent_1) + agt_1_add_2 = Address.objects.create(address="testaddress2", agent=self.test_agent_1) + + agt_2_add_1 = Address.objects.create(address="testaddress4", agent=self.test_agent_2) + agt_2_add_2 = Address.objects.create(address="testaddress5", agent=self.test_agent_2) + + query_paths = [ + ['integer1'], + ['cataloger', 'firstname'], + ['cataloger', 'lastname'], + ['cataloger', 'addresses', 'address'], + ['cataloger', 'agenttype'] + ] + + + (headers, rows, pack, plan) = self.query_to_results('collectionobject', query_paths) + + dicted = [dict(zip(headers, row)) for row in rows] + + # original_data = [ + # {'CollectionObject integer1': '', 'Agent firstName': 'John', 'Agent lastName': 'Doe', 'Agent agentType': 'Organization', 'Address address': 'testaddress1', 'Address address #2': 'testaddress2'}, + # {'CollectionObject integer1': '', 'Agent firstName': 'John', 'Agent lastName': 'Doe', 'Agent agentType': 'Organization', 'Address address': 'testaddress1', 'Address address #2': 'testaddress2'}, + # {'CollectionObject integer1': '', 'Agent firstName': 'Jame', 'Agent lastName': '', 'Agent agentType': 'Person', 'Address address': 'testaddress4', 'Address address #2': 'testaddress5'} + # ] + + # Here is a (now resolved) bug below. We need to remove the reverse relationship in predicates for this to match, no way around that. + # Otherwise, it'd be impossible to match third agent to first (agent on row1 and row2 are same), in deferForMatch=False + + data = [ + {'CollectionObject integer1': '', 'Agent firstName': 'John', 'Agent lastName': 'Doe', 'Agent agentType': 'Organization', 'Address address': 'testaddress1', 'Address address #2': 'testaddress2'}, + {'CollectionObject integer1': '', 'Agent firstName': 'John', 'Agent lastName': 'Doe', 'Agent agentType': 'Organization', 'Address address': 'testaddress1', 'Address address #2': 'testaddress2'}, + {'CollectionObject integer1': '', 'Agent firstName': 'John', 'Agent lastName': 'Doe', 'Agent agentType': 'Organization', 'Address address': 'testaddress1', 'Address address #2': 'testaddress2'} + ] + + with patch('specifyweb.workbench.upload.preferences.should_defer_fields', new=defer): + results = do_upload( + self.collection, data, plan, self.agent.id, batch_edit_packs=pack + ) + + list([self.enforcer(record) for record in results[:2]]) + + self.assertIsInstance(results[2].toOne['cataloger'].record_result, MatchedAndChanged) + self.assertEqual(results[2].toOne['cataloger'].record_result.get_id(), self.test_agent_1.id) diff --git a/specifyweb/workbench/upload/tests/test_bugs.py b/specifyweb/workbench/upload/tests/test_bugs.py index bae3344f247..892c6d01ba0 100644 --- a/specifyweb/workbench/upload/tests/test_bugs.py +++ b/specifyweb/workbench/upload/tests/test_bugs.py @@ -3,9 +3,7 @@ import json import csv -from unittest import expectedFailure - -from ..upload_result import Uploaded, Matched, NullRecord +from ..upload_result import Uploaded, Matched from ..upload import do_upload_csv, validate_row from ..upload_plan_schema import parse_plan @@ -46,7 +44,7 @@ def test_bogus_null_record(self) -> None: up = parse_plan(plan).apply_scoping(self.collection) result = validate_row(self.collection, up, self.agent.id, dict(zip(cols, row)), None) - self.assertNotIsInstance(result.record_result, NullRecord, "The CO should be created b/c it has determinations.") + self.assertNotIsInstance(result.record_result, Uploaded, "The CO should be created b/c it has determinations.") def test_duplicate_refworks(self) -> None: """ Andy found that duplicate reference works were being created from data similar to the following. """ diff --git a/specifyweb/workbench/upload/tests/testuploading.py b/specifyweb/workbench/upload/tests/testuploading.py index 901f99b665a..42b7bd53fb0 100644 --- a/specifyweb/workbench/upload/tests/testuploading.py +++ b/specifyweb/workbench/upload/tests/testuploading.py @@ -22,7 +22,6 @@ from ..upload_table import UploadTable from ..uploadable import Auditor -from django.conf import settings class UploadTreeSetup(TestTree, UploadTestsBase): pass class TreeMatchingTests(UploadTreeSetup): diff --git a/specifyweb/workbench/upload/treerecord.py b/specifyweb/workbench/upload/treerecord.py index b762c12eb21..bdd0400f674 100644 --- a/specifyweb/workbench/upload/treerecord.py +++ b/specifyweb/workbench/upload/treerecord.py @@ -12,7 +12,7 @@ from specifyweb.specify import models from specifyweb.workbench.upload.clone import clone_record from specifyweb.workbench.upload.predicates import ContetRef, DjangoPredicates, SkippablePredicate, ToRemove, resolve_reference_attributes, safe_fetch -from specifyweb.workbench.upload.preferences import should_defer_fields +import specifyweb.workbench.upload.preferences as defer_preference from .column_options import ColumnOptions, ExtendedColumnOptions from .parsing import ParseResult, WorkBenchParseFailure, parse_many, filter_and_upload, Filter @@ -63,7 +63,7 @@ def apply_batch_edit_pack(self, batch_edit_pack: Optional[Dict[str, Any]]) -> "S if batch_edit_pack is None: return self # batch-edit considers ranks as self-relationships, and are trivially stored in to-one - rank_from_pack = batch_edit_pack['to_one'] + rank_from_pack = batch_edit_pack.get('to_one', {}) return self._replace(batch_edit_pack={rank: pack['self'] for (rank, pack) in rank_from_pack.items()}) def get_treedefs(self) -> Set: @@ -186,12 +186,13 @@ def _handle_row(self, must_match: bool) -> UploadResult: return UploadResult(match_result, {}, {}) def _to_match(self, references=None) -> List[TreeDefItemWithParseResults]: + print(references) return [ TreeDefItemWithParseResults(tdi, self.parsedFields[tdi.name]) for tdi in self.treedefitems if tdi.name in self.parsedFields and (any(v is not None for r in self.parsedFields[tdi.name] for v in r.filter_on.values()) - and ((references is None) or any(v is not None for v in references[tdi.name]['attrs']))) + and ((references is None) or (tdi.name not in references) or (references[tdi.name] is None) or (any(v is not None for v in references[tdi.name]['attrs'])))) ] def _match(self, tdiwprs: List[TreeDefItemWithParseResults], references=None) -> Tuple[List[TreeDefItemWithParseResults], MatchResult]: @@ -423,6 +424,9 @@ def force_upload_row(self) -> UploadResult: raise NotImplementedError() def _get_reference(self) -> Optional[Dict[str, Any]]: + + FIELDS_TO_SKIP = ['nodenumber', 'highestchildnodenumber', 'parent_id'] + # Much simpler than uploadTable. Just fetch all rank's references. Since we also require name to be not null, # the "deferForNull" is redundant. We, do, however need to look at deferForMatch, and we are done. @@ -431,7 +435,7 @@ def _get_reference(self) -> Optional[Dict[str, Any]]: model = getattr(models, self.name) - should_defer = should_defer_fields('match') + should_defer = defer_preference.should_defer_fields('match') references = {} @@ -450,7 +454,7 @@ def _get_reference(self) -> Optional[Dict[str, Any]]: raise BusinessRuleException(str(e), {}, info) previous_parent_id = reference.parent_id - references[tdi.name] = None if should_defer else {'ref': reference, 'attrs': resolve_reference_attributes([], model, reference)} + references[tdi.name] = None if should_defer else {'ref': reference, 'attrs': resolve_reference_attributes(FIELDS_TO_SKIP, model, reference)} return references diff --git a/specifyweb/workbench/upload/upload.py b/specifyweb/workbench/upload/upload.py index 2922326e594..3b8c429b1b0 100644 --- a/specifyweb/workbench/upload/upload.py +++ b/specifyweb/workbench/upload/upload.py @@ -219,7 +219,7 @@ def do_upload( # I'd make this a generator (so "global" variable is internal, rather than a rogue callback setting a global variable) gen = Func.make_generator() - + with savepoint("main upload"): tic = time.perf_counter() results: List[UploadResult] = [] @@ -382,7 +382,7 @@ def _commit_uploader(result): # Don't use parent's plan... base_table, upload_plan = get_raw_ds_upload_plan(backer) - results = do_upload(collection, rows_to_backup, upload_plan, agent, None, False, False, progress, packs) + results = do_upload(collection, rows_to_backup, upload_plan, agent.id, None, False, False, progress, packs) success = not any(r.contains_failure() for r in results) diff --git a/specifyweb/workbench/upload/upload_table.py b/specifyweb/workbench/upload/upload_table.py index cb9a92c4b49..55219585d27 100644 --- a/specifyweb/workbench/upload/upload_table.py +++ b/specifyweb/workbench/upload/upload_table.py @@ -10,16 +10,45 @@ from specifyweb.specify.func import Func from specifyweb.specify.field_change_info import FieldChangeInfo from specifyweb.workbench.upload.clone import clone_record -from specifyweb.workbench.upload.predicates import ContetRef, DjangoPredicates, SkippablePredicate, ToRemove, resolve_reference_attributes, safe_fetch -from specifyweb.workbench.upload.preferences import should_defer_fields +from specifyweb.workbench.upload.predicates import ( + ContetRef, + DjangoPredicates, + SkippablePredicate, + ToRemove, + resolve_reference_attributes, + safe_fetch, +) +import specifyweb.workbench.upload.preferences as defer_preference from .column_options import ColumnOptions, ExtendedColumnOptions from .parsing import parse_many, ParseResult, WorkBenchParseFailure -from .upload_result import Deleted, MatchedAndChanged, NoChange, Updated, UploadResult, Uploaded, NoMatch, Matched, \ - MatchedMultiple, NullRecord, FailedBusinessRule, ReportInfo, \ - PicklistAddition, ParseFailures, PropagatedFailure -from .uploadable import NULL_RECORD, Row, ScopeGenerator, Uploadable, ScopedUploadable, \ - BoundUploadable, Disambiguation, Auditor +from .upload_result import ( + Deleted, + MatchedAndChanged, + NoChange, + Updated, + UploadResult, + Uploaded, + NoMatch, + Matched, + MatchedMultiple, + NullRecord, + FailedBusinessRule, + ReportInfo, + PicklistAddition, + ParseFailures, + PropagatedFailure, +) +from .uploadable import ( + NULL_RECORD, + Row, + ScopeGenerator, + Uploadable, + ScopedUploadable, + BoundUploadable, + Disambiguation, + Auditor, +) logger = logging.getLogger(__name__) @@ -35,96 +64,147 @@ class UploadTable(NamedTuple): toOne: Dict[str, Uploadable] toMany: Dict[str, List[Uploadable]] - overrideScope: Optional[Dict[Literal['collection'], Optional[int]]] = None + overrideScope: Optional[Dict[Literal["collection"], Optional[int]]] = None - def apply_scoping(self, collection, generator: ScopeGenerator = None, row=None) -> "ScopedUploadTable": + def apply_scoping( + self, collection, generator: ScopeGenerator = None, row=None + ) -> "ScopedUploadTable": from .scoping import apply_scoping_to_uploadtable + return apply_scoping_to_uploadtable(self, collection, generator, row) def get_cols(self) -> Set[str]: - return set(cd.column for cd in self.wbcols.values()) \ - | set(col for u in self.toOne.values() for col in u.get_cols()) \ - | set(col for rs in self.toMany.values() for r in rs for col in r.get_cols()) + return ( + set(cd.column for cd in self.wbcols.values()) + | set(col for u in self.toOne.values() for col in u.get_cols()) + | set( + col for rs in self.toMany.values() for r in rs for col in r.get_cols() + ) + ) def _to_json(self) -> Dict: result = dict( - wbcols={k: v.to_json() for k,v in self.wbcols.items()}, - static=self.static + wbcols={k: v.to_json() for k, v in self.wbcols.items()}, static=self.static ) - result['toOne'] = { - key: uploadable.to_json() - for key, uploadable in self.toOne.items() + result["toOne"] = { + key: uploadable.to_json() for key, uploadable in self.toOne.items() } - result['toMany'] = { + result["toMany"] = { # legacy behaviour, don't know a better way without migrations - key: [to_many.to_json()['uploadTable'] for to_many in to_manys] + key: [to_many.to_json()["uploadTable"] for to_many in to_manys] for key, to_manys in self.toMany.items() } return result def to_json(self) -> Dict: - return { 'uploadTable': self._to_json() } + return {"uploadTable": self._to_json()} def unparse(self) -> Dict: - return { 'baseTableName': self.name, 'uploadable': self.to_json() } + return {"baseTableName": self.name, "uploadable": self.to_json()} + +def static_adjustments(table: str, wbcols: Dict[str, ColumnOptions], static: Dict[str, Any]) -> Dict[str, Any]: + # not sure if this is the right place for this, but it will work for now. + if table.lower() == 'agent' and 'agenttype' not in wbcols and 'agenttype' not in static: + static = {'agenttype': 1, **static} + elif table.lower() == 'Determination' and 'iscurrent' not in wbcols and 'iscurrent' not in static: + static = {'iscurrent': True, **static} + else: + static = static + return static class ScopedUploadTable(NamedTuple): name: str wbcols: Dict[str, ExtendedColumnOptions] static: Dict[str, Any] toOne: Dict[str, ScopedUploadable] - toMany: Dict[str, List['ScopedUploadable']] # type: ignore + toMany: Dict[str, List["ScopedUploadable"]] # type: ignore scopingAttrs: Dict[str, int] disambiguation: Optional[int] - to_one_fields: Dict[str, List[str]] # TODO: Consider making this a payload.. + to_one_fields: Dict[str, List[str]] # TODO: Consider making this a payload.. match_payload: Optional[Dict[str, Any]] - + strong_ignore: List[str] + def disambiguate(self, disambiguation: Disambiguation) -> "ScopedUploadable": if disambiguation is None: return self return self._replace( - disambiguation = disambiguation.disambiguate(), + disambiguation=disambiguation.disambiguate(), toOne={ - fieldname: uploadable.disambiguate(disambiguation.disambiguate_to_one(fieldname)) + fieldname: uploadable.disambiguate( + disambiguation.disambiguate_to_one(fieldname) + ) for fieldname, uploadable in self.toOne.items() }, toMany={ fieldname: [ - record.disambiguate(disambiguation.disambiguate_to_many(fieldname, i)) + record.disambiguate( + disambiguation.disambiguate_to_many(fieldname, i) + ) for i, record in enumerate(records) ] for fieldname, records in self.toMany.items() - } + }, ) - - def apply_batch_edit_pack(self, batch_edit_pack: Optional[Dict[str, Any]]) -> "ScopedUploadable": + + def apply_batch_edit_pack( + self, batch_edit_pack: Optional[Dict[str, Any]] + ) -> "ScopedUploadable": + # Static adjustments cannot happen before this without handling around a dirty prop for it. + # Plus, adding static adjustments here make things MUCH simpler when we realize "oh shoot, we need to create a record" bc it was null initially. if batch_edit_pack is None: - return self + return self._replace(static=static_adjustments(self.name, self.wbcols, self.static)) + return self._replace( - match_payload=batch_edit_pack['self'], + match_payload=batch_edit_pack["self"], toOne={ - fieldname: uploadable.apply_batch_edit_pack(batch_edit_pack['to_one'].get(fieldname)) + fieldname: uploadable.apply_batch_edit_pack( + # The batch-edit pack is very compressed, and contains only necessary data. + # It may not have to-one. It may not have the necessary field too if it is redundant + Func.maybe( + batch_edit_pack.get("to_one", None), + lambda pack: pack.get(fieldname, None), + ) + ) for fieldname, uploadable in self.toOne.items() }, toMany={ fieldname: [ - record.apply_batch_edit_pack(batch_edit_pack['to_many'].get(fieldname)[_id]) + record.apply_batch_edit_pack( + Func.maybe( + batch_edit_pack.get("to_many", None), + lambda pack: ( + pack[fieldname][_id] if fieldname in pack else None + ), + ) + ) for (_id, record) in enumerate(records) ] for fieldname, records in self.toMany.items() - } + }, ) def get_treedefs(self) -> Set: - return ( - set(td for toOne in self.toOne.values() for td in toOne.get_treedefs()) | - set(td for toMany in self.toMany.values() for tmr in toMany for td in tmr.get_treedefs()) + return set( + td for toOne in self.toOne.values() for td in toOne.get_treedefs() + ) | set( + td + for toMany in self.toMany.values() + for tmr in toMany + for td in tmr.get_treedefs() ) - def bind(self, row: Row, uploadingAgentId: int, auditor: Auditor, cache: Optional[Dict]=None) -> Union["BoundUploadTable", ParseFailures]: + def bind( + self, + row: Row, + uploadingAgentId: int, + auditor: Auditor, + cache: Optional[Dict] = None, + ) -> Union["BoundUploadTable", ParseFailures]: - current_id = None if self.match_payload is None else self.match_payload.get('id') + current_id = ( + None if self.match_payload is None else self.match_payload.get("id") + ) if current_id == NULL_RECORD: parsedFields: List[ParseResult] = [] @@ -154,7 +234,7 @@ def bind(self, row: Row, uploadingAgentId: int, auditor: Auditor, cache: Optiona if parseFails: return ParseFailures(parseFails) - + return BoundUploadTable( name=self.name, static=self.static, @@ -167,29 +247,37 @@ def bind(self, row: Row, uploadingAgentId: int, auditor: Auditor, cache: Optiona auditor=auditor, cache=cache, to_one_fields=self.to_one_fields, - match_payload=self.match_payload + match_payload=self.match_payload, + strong_ignore=self.strong_ignore ) + class OneToOneTable(UploadTable): - def apply_scoping(self, collection, generator: ScopeGenerator = None, row=None) -> "ScopedOneToOneTable": + def apply_scoping( + self, collection, generator: ScopeGenerator = None, row=None + ) -> "ScopedOneToOneTable": s = super().apply_scoping(collection, generator, row) return ScopedOneToOneTable(*s) def to_json(self) -> Dict: - return { 'oneToOneTable': self._to_json() } + return {"oneToOneTable": self._to_json()} + class ScopedOneToOneTable(ScopedUploadTable): def bind(self, row: Row, uploadingAgentId: int, auditor: Auditor, cache: Optional[Dict]=None) -> Union["BoundOneToOneTable", ParseFailures]: b = super().bind(row, uploadingAgentId, auditor, cache) return BoundOneToOneTable(*b) if isinstance(b, BoundUploadTable) else b + class MustMatchTable(UploadTable): - def apply_scoping(self, collection, generator: ScopeGenerator = None, row=None) -> "ScopedMustMatchTable": + def apply_scoping( + self, collection, generator: ScopeGenerator = None, row=None + ) -> "ScopedMustMatchTable": s = super().apply_scoping(collection, generator, row) return ScopedMustMatchTable(*s) def to_json(self) -> Dict: - return { 'mustMatchTable': self._to_json() } + return {"mustMatchTable": self._to_json()} class ScopedMustMatchTable(ScopedUploadTable): def bind(self,row: Row, uploadingAgentId: int, auditor: Auditor, cache: Optional[Dict]=None @@ -197,6 +285,7 @@ def bind(self,row: Row, uploadingAgentId: int, auditor: Auditor, cache: Optional b = super().bind(row, uploadingAgentId, auditor, cache) return BoundMustMatchTable(*b) if isinstance(b, BoundUploadTable) else b + class BoundUploadTable(NamedTuple): name: str static: Dict[str, Any] @@ -210,55 +299,66 @@ class BoundUploadTable(NamedTuple): cache: Optional[Dict] to_one_fields: Dict[str, List[str]] match_payload: Optional[Dict[str, Any]] + strong_ignore: List[str] # fields to stricly ignore for anything. unfortunately, depends needs parent-backref. See comment in "test_batch_edit_table.py/test_to_many_match_is_possible" @property def current_id(self): - return None if self.match_payload is None else self.match_payload.get('id') - + return None if self.match_payload is None else self.match_payload.get("id") + @property def current_version(self): - return None if self.match_payload is None else self.match_payload.get('version', None) - + return ( + None + if self.match_payload is None + else self.match_payload.get("version", None) + ) + def is_one_to_one(self) -> bool: return False def must_match(self) -> bool: return False - + def can_save(self) -> bool: return isinstance(self.current_id, int) - + @property def django_model(self) -> Model: return getattr(models, self.name.capitalize()) - + @property def _reference_cache_key(self): # Caching NEVER changes the logic of the uploads. Only makes things faster. current_id = self.current_id - assert isinstance(current_id, int), "Attempting to lookup a null record in cache!" + assert isinstance( + current_id, int + ), "Attempting to lookup a null record in cache!" return (REFERENCE_KEY, self.name, current_id) - + @property def _should_defer_match(self): - return should_defer_fields('match') - - def get_django_predicates(self, should_defer_match: bool, to_one_override: Dict[str, UploadResult] = {}) -> DjangoPredicates: + return defer_preference.should_defer_fields("match") + + def get_django_predicates( + self, should_defer_match: bool, to_one_override: Dict[str, UploadResult] = {} + ) -> DjangoPredicates: model = self.django_model if self.disambiguation is not None: if model.objects.filter(id=self.disambiguation).exists(): - return DjangoPredicates(filters={ - 'id': self.disambiguation - }) + return DjangoPredicates(filters={"id": self.disambiguation}) if self.current_id == NULL_RECORD: return SkippablePredicate() - + # This is always the first hit, for both the updates/deletes and uploads. record_ref = self._get_reference() - attrs = {} if (record_ref is None or should_defer_match) else self._resolve_reference_attributes(model, record_ref) + attrs = ( + {} + if (record_ref is None or should_defer_match) + else self._resolve_reference_attributes(model, record_ref) + ) direct_filters = { fieldname: value @@ -267,30 +367,45 @@ def get_django_predicates(self, should_defer_match: bool, to_one_override: Dict[ } to_ones = { - key: to_one_override[key].get_id() - if key in to_one_override - # For simplicity in typing, to-ones are also considered as a list - else value.get_django_predicates(should_defer_match=should_defer_match).reduce_for_to_one() + key: ( + to_one_override[key].get_id() + if key in to_one_override + # For simplicity in typing, to-ones are also considered as a list + else value.get_django_predicates( + should_defer_match=should_defer_match + ).reduce_for_to_one() + ) for key, value in self.toOne.items() } to_many = { - key: [value.get_django_predicates(should_defer_match=should_defer_match).reduce_for_to_many(value) for value in values] + key: [ + value.get_django_predicates( + should_defer_match=should_defer_match + ).reduce_for_to_many(value) + for value in values + ] for key, values in self.toMany.items() } - combined_filters = DjangoPredicates(filters={**attrs, **direct_filters, **to_ones, **to_many}) + combined_filters = DjangoPredicates( + filters={**attrs, **direct_filters, **to_ones, **to_many} + ) if combined_filters.is_reducible(): return DjangoPredicates() - - combined_filters = combined_filters._replace(filters={**combined_filters.filters, **self.scopingAttrs, **self.static}) + + combined_filters = combined_filters._replace( + filters={**combined_filters.filters, **self.scopingAttrs, **self.static} + ) return combined_filters def get_to_remove(self) -> ToRemove: - return ToRemove(model_name=self.name, filter_on={**self.scopingAttrs, **self.static}) - + return ToRemove( + model_name=self.name, filter_on={**self.scopingAttrs, **self.static} + ) + def process_row(self) -> UploadResult: return self._handle_row(skip_match=False, allow_null=True) @@ -299,21 +414,25 @@ def force_upload_row(self) -> UploadResult: def match_row(self) -> UploadResult: return BoundMustMatchTable(*self).process_row() - + def save_row(self, force=False) -> UploadResult: current_id = self.current_id if current_id is None: return self.force_upload_row() update_table = BoundUpdateTable(*self) - return update_table.process_row() if force else update_table.process_row_with_null() - + return ( + update_table.process_row() + if force + else update_table.process_row_with_null() + ) + def _get_reference(self, should_cache=True) -> Optional[Model]: model: Model = self.django_model current_id = self.current_id - + if current_id is None: return None - + cache_key = self._reference_cache_key cache_hit = None if self.cache is None else self.cache.get(cache_key, None) @@ -321,32 +440,44 @@ def _get_reference(self, should_cache=True) -> Optional[Model]: if not should_cache: # As an optimization, for the first update, return the cached one, but immediately evict it. # Currently, it is not possible for more than 1 successive write-intent access to _get_reference so this is very good for it. - # If somewhere, somehow, we do have more than that, this algorithm still works, since the read/write table evicts it. + # If somewhere, somehow, we do have more than that, this algorithm still works, since the read/write table evicts it. # Eample: If we do have more than 1, the first one will evict it, and then the second one will refetch it (won't get a cache hit) -- cache coherency not broken # Using pop as a _different_ memory optimization. assert self.cache is not None self.cache.pop(cache_key) return cache_hit - reference_record = safe_fetch(model, {'id':current_id}, self.current_version) - + reference_record = safe_fetch(model, {"id": current_id}, self.current_version) + if should_cache and self.cache is not None: self.cache[cache_key] = reference_record return reference_record - + def _resolve_reference_attributes(self, model, reference_record) -> Dict[str, Any]: - return resolve_reference_attributes(self.scopingAttrs.keys(), model, reference_record) + return resolve_reference_attributes( + [*self.scopingAttrs.keys(), *self.strong_ignore], model, reference_record + ) def _handle_row(self, skip_match: bool, allow_null: bool) -> UploadResult: model = self.django_model if self.disambiguation is not None: if model.objects.filter(id=self.disambiguation).exists(): - return UploadResult(Matched(id=self.disambiguation, info=ReportInfo(self.name, [], None)), {}, {}) - - info = ReportInfo(tableName=self.name, columns=[pr.column for pr in self.parsedFields], treeInfo=None) + return UploadResult( + Matched( + id=self.disambiguation, info=ReportInfo(self.name, [], None) + ), + {}, + {}, + ) + + info = ReportInfo( + tableName=self.name, + columns=[pr.column for pr in self.parsedFields], + treeInfo=None, + ) current_id = self.current_id @@ -356,7 +487,7 @@ def _handle_row(self, skip_match: bool, allow_null: bool) -> UploadResult: if any(result.get_id() == "Failure" for result in to_one_results.values()): return UploadResult(PropagatedFailure(), to_one_results, {}) - + attrs = { fieldname_: value for parsedField in self.parsedFields @@ -366,17 +497,28 @@ def _handle_row(self, skip_match: bool, allow_null: bool) -> UploadResult: # This is very handy to check for whether the entire record needs to be skipped or not. # This also returns predicates for to-many, we if this is empty, we really are a null record try: - filter_predicate = self.get_django_predicates(should_defer_match=self._should_defer_match, to_one_override=to_one_results) + filter_predicate = self.get_django_predicates( + should_defer_match=self._should_defer_match, + to_one_override=to_one_results, + ) except ContetRef as e: # Not sure if there is a better way for this. Consider moving this to binding. - return UploadResult(FailedBusinessRule(str(e), {}, info), to_one_results, {}) - + return UploadResult( + FailedBusinessRule(str(e), {}, info), to_one_results, {} + ) + attrs = { - **({} if should_defer_fields('null_check') else self._resolve_reference_attributes(model, self._get_reference())), - **attrs + **( + {} + if defer_preference.should_defer_fields("null_check") + else self._resolve_reference_attributes(model, self._get_reference()) + ), + **attrs, } - if (all(v is None for v in attrs.values()) and not filter_predicate.filters) and allow_null: + if ( + all(v is None for v in attrs.values()) and not filter_predicate.filters + ) and allow_null: # nothing to upload return UploadResult(NullRecord(info), to_one_results, {}) @@ -390,28 +532,33 @@ def _handle_row(self, skip_match: bool, allow_null: bool) -> UploadResult: def _process_to_ones(self) -> Dict[str, UploadResult]: return { fieldname: to_one_def.process_row() - for fieldname, to_one_def in - Func.sort_by_key(self.toOne) # make the upload order deterministic + for fieldname, to_one_def in Func.sort_by_key( + self.toOne + ) # make the upload order deterministic # we don't care about being able to process one-to-one. Instead, we include them in the matching predicates. # this allows handing "MatchedMultiple" case of one-to-ones more gracefully, while allowing us to include them - # in the matching. See "test_ambiguous_one_to_one_match" in testuploading.py. + # in the matching. See "test_ambiguous_one_to_one_match" in testuploading.py. # BUT, we need to still perform a save incase we are updating. if not to_one_def.is_one_to_one() } - def _match(self, predicates: DjangoPredicates, info: ReportInfo) -> Union[Matched, MatchedMultiple, None]: + def _match( + self, predicates: DjangoPredicates, info: ReportInfo + ) -> Union[Matched, MatchedMultiple, None]: cache_key = predicates.get_cache_key(self.name) - cache_hit: Optional[List[int]] = self.cache.get(cache_key, None) if self.cache is not None else None + cache_hit: Optional[List[int]] = ( + self.cache.get(cache_key, None) if self.cache is not None else None + ) if cache_hit is not None: ids = cache_hit else: - query = predicates.apply_to_query(self.name).values_list('id', flat=True) + query = predicates.apply_to_query(self.name).values_list("id", flat=True) current_id = self.current_id ids = [] if current_id is not None: - # Consider user added a column in query which is not unique. We'll always get more than one match in that case. That is, very likely, not the intended + # Consider user added a column in query which is not unique. We'll always get more than one match in that case. That is, very likely, not the intended # behaviour is. To handle that case, run the query twice. First, using the id we have, then without it, if we don't find a match. # I don't want to cache this, since we got lucky. we can't naively compare the attributes though, we'll incorrectly ignore # filters on to-many in that case. can't get more than one match though. I guess we could cache this if we add id to predicates... @@ -419,7 +566,7 @@ def _match(self, predicates: DjangoPredicates, info: ReportInfo) -> Union[Matche ids = list(query_with_self) if not ids: query = query[:10] - ids = list(query.values_list('id', flat=True)) + ids = list(query.values_list("id", flat=True)) if self.cache is not None and ids: self.cache[cache_key] = ids @@ -429,6 +576,7 @@ def _match(self, predicates: DjangoPredicates, info: ReportInfo) -> Union[Matche elif n_matched == 1: return Matched(id=ids[0], info=info) else: + print('did not find for', predicates) return None def _check_missing_required(self) -> Optional[ParseFailures]: @@ -442,16 +590,18 @@ def _check_missing_required(self) -> Optional[ParseFailures]: if missing_requireds: return ParseFailures(missing_requireds) - + return None - - def _do_upload(self, model, to_one_results: Dict[str, UploadResult], info: ReportInfo) -> UploadResult: + + def _do_upload( + self, model, to_one_results: Dict[str, UploadResult], info: ReportInfo + ) -> UploadResult: missing_required = self._check_missing_required() if missing_required is not None: return UploadResult(missing_required, to_one_results, {}) - + attrs = { fieldname_: value for parsedField in self.parsedFields @@ -459,14 +609,17 @@ def _do_upload(self, model, to_one_results: Dict[str, UploadResult], info: Repor } # by the time we get here, we know we need to so something. - to_one_results = {**to_one_results, **{ - fieldname: to_one_def.force_upload_row() - for fieldname, to_one_def in - # Make the upload order deterministic (maybe? depends on if it matched I guess) - # But because the records can't be shared, the unupload order shouldn't matter anyways... - Func.sort_by_key(self.toOne) - if to_one_def.is_one_to_one() - }} + to_one_results = { + **to_one_results, + **{ + fieldname: to_one_def.force_upload_row() + for fieldname, to_one_def in + # Make the upload order deterministic (maybe? depends on if it matched I guess) + # But because the records can't be shared, the unupload order shouldn't matter anyways... + Func.sort_by_key(self.toOne) + if to_one_def.is_one_to_one() + }, + } to_one_ids: Dict[str, Optional[int]] = {} for field, result in to_one_results.items(): @@ -476,13 +629,20 @@ def _do_upload(self, model, to_one_results: Dict[str, UploadResult], info: Repor to_one_ids[field] = id new_attrs = { - **attrs, - **self.scopingAttrs, - **self.static, - **{ model._meta.get_field(fieldname).attname: id for fieldname, id in to_one_ids.items() }, - **({'createdbyagent_id': self.uploadingAgentId} if model.specify_model.get_field('createdbyagent') else {}) - } - + **attrs, + **self.scopingAttrs, + **self.static, + **{ + model._meta.get_field(fieldname).attname: id + for fieldname, id in to_one_ids.items() + }, + **( + {"createdbyagent_id": self.uploadingAgentId} + if model.specify_model.get_field("createdbyagent") + else {} + ), + } + with transaction.atomic(): try: if self.current_id is None: @@ -491,7 +651,9 @@ def _do_upload(self, model, to_one_results: Dict[str, UploadResult], info: Repor uploaded = self._do_clone(new_attrs) picklist_additions = self._do_picklist_additions() except (BusinessRuleException, IntegrityError) as e: - return UploadResult(FailedBusinessRule(str(e), {}, info), to_one_results, {}) + return UploadResult( + FailedBusinessRule(str(e), {}, info), to_one_results, {} + ) record = Uploaded(uploaded.id, info, picklist_additions) @@ -501,76 +663,99 @@ def _do_upload(self, model, to_one_results: Dict[str, UploadResult], info: Repor def _handle_to_many(self, update: bool, parent_id: int, model: Model): return { - fieldname: _upload_to_manys(model, parent_id, fieldname, update, records, self._relationship_is_dependent(fieldname)) - for fieldname, records in - Func.sort_by_key(self.toMany) + fieldname: _upload_to_manys( + model, + parent_id, + fieldname, + update, + records, + self._relationship_is_dependent(fieldname), + ) + for fieldname, records in Func.sort_by_key(self.toMany) } def _do_insert(self, model, attrs) -> Any: inserter = self._get_inserter() return inserter(model, attrs) - + def _do_clone(self, attrs) -> Any: inserter = self._get_inserter() to_ignore = [ - *self.toOne.keys(), # Don't touch mapped to-ones - *self.toMany.keys(), # Don't touch mapped to-manys - ] - return clone_record(self._get_reference(), inserter, self.to_one_fields, to_ignore, attrs) + *self.toOne.keys(), # Don't touch mapped to-ones + *self.toMany.keys(), # Don't touch mapped to-manys + ] + return clone_record( + self._get_reference(), inserter, self.to_one_fields, to_ignore, attrs + ) def _get_inserter(self): def _inserter(model, attrs): uploaded = model.objects.create(**attrs) - self.auditor.insert(uploaded, None) + self.auditor.insert(uploaded, None) return uploaded + return _inserter - + def _do_picklist_additions(self) -> List[PicklistAddition]: added_picklist_items = [] for parsedField in self.parsedFields: if parsedField.add_to_picklist is not None: a = parsedField.add_to_picklist - pli = a.picklist.picklistitems.create(value=a.value, title=a.value, createdbyagent_id=self.uploadingAgentId) + pli = a.picklist.picklistitems.create( + value=a.value, + title=a.value, + createdbyagent_id=self.uploadingAgentId, + ) self.auditor.insert(pli, None) - added_picklist_items.append(PicklistAddition(name=a.picklist.name, caption=a.column, value=a.value, id=pli.id)) + added_picklist_items.append( + PicklistAddition( + name=a.picklist.name, caption=a.column, value=a.value, id=pli.id + ) + ) return added_picklist_items - + def delete_row(self, info, parent_obj=None) -> UploadResult: if self.current_id is None: return UploadResult(NullRecord(info), {}, {}) - # By the time we are here, we know if we can't have a not null to-one or to-many mapping. - # So, we can just go ahead and follow the general delete protocol. Don't need version-control here. + # By the time we are here, we know if we can't have a not null to-one or to-many mapping. + # So, we can just go ahead and follow the general delete protocol. Don't need version-control here. # Also caching still works (we'll, always, get a hit) because updates and deletes are independent (update wouldn't have been called). reference_record = self._get_reference( - should_cache=False # Need to evict the last copy, in case someone tries accessing it, we'll then get a stale record + should_cache=False # Need to evict the last copy, in case someone tries accessing it, we'll then get a stale record ) result: Optional[Union[Deleted, FailedBusinessRule]] = None with transaction.atomic(): try: - delete_obj(reference_record, parent_obj=parent_obj, deleter=self.auditor.delete) + delete_obj( + reference_record, parent_obj=parent_obj, deleter=self.auditor.delete + ) result = Deleted(self.current_id, info) except (BusinessRuleException, IntegrityError) as e: result = FailedBusinessRule(str(e), {}, info) assert result is not None - return UploadResult(result, {}, {}) + return UploadResult(result, {}, {}) def _relationship_is_dependent(self, field_name) -> bool: django_model = self.django_model # We could check to_one_fields, but we are not going to, because that is just redundant with is_one_to_one. if field_name in self.toOne: return self.toOne[field_name].is_one_to_one() - return django_model.specify_model.get_relationship(field_name).dependent # type: ignore - + return django_model.specify_model.get_relationship( + field_name + ).dependent # type: ignore + + class BoundOneToOneTable(BoundUploadTable): def is_one_to_one(self) -> bool: return True + class BoundMustMatchTable(BoundUploadTable): def must_match(self) -> bool: return True def force_upload_row(self) -> UploadResult: - raise Exception('trying to force upload of must-match table') + raise Exception("trying to force upload of must-match table") def _process_to_ones(self) -> Dict[str, UploadResult]: return { @@ -579,58 +764,90 @@ def _process_to_ones(self) -> Dict[str, UploadResult]: if not to_one_def.is_one_to_one() } - def _do_upload(self, model, toOneResults: Dict[str, UploadResult], info: ReportInfo) -> UploadResult: + def _do_upload( + self, model, toOneResults: Dict[str, UploadResult], info: ReportInfo + ) -> UploadResult: return UploadResult(NoMatch(info), toOneResults, {}) -def _upload_to_manys(parent_model, parent_id, parent_field, is_update, records, is_dependent) -> List[UploadResult]: + +def _upload_to_manys( + parent_model, parent_id, parent_field, is_update, records, is_dependent +) -> List[UploadResult]: fk_field = parent_model._meta.get_field(parent_field).remote_field.attname - bound_tables = [record._replace(disambiguation=None, static={**record.static, fk_field: parent_id}) for record in records] - return [(record.force_upload_row() if not is_update else record.save_row(force=(not is_dependent))) for record in bound_tables] + bound_tables = [ + record._replace( + disambiguation=None, static={**record.static, fk_field: parent_id} + ) + for record in records + ] + return [ + ( + record.force_upload_row() + if not is_update + else record.save_row(force=(not is_dependent)) + ) + for record in bound_tables + ] class BoundUpdateTable(BoundUploadTable): - + def process_row(self): return self._handle_row(skip_match=True, allow_null=False) - + def process_row_with_null(self): return self._handle_row(skip_match=True, allow_null=True) - + @property def _should_defer_match(self): # Complicated. consider the case where deferForMatch is true. In that case, we can't always just defer fields, # because during updates, we'd wrongly skip to-manys -- and possibly delete them -- if they contain field values (not visible) BUT get skipped due to above. # So, we handle it by always going by null_check ONLY IF we know we are doing an update, which we know at this point. - return should_defer_fields('null_check') - + return defer_preference.should_defer_fields("null_check") + def _handle_row(self, skip_match: bool, allow_null: bool): - assert self.disambiguation is None, "Did not epect disambigution for update tables!" - assert self.match_payload is not None, "Trying to perform a save on unhandled type of payload!" - assert self.current_id is not None, "Did not find any identifier to go by. You likely meant to upload instead of save" + assert ( + self.disambiguation is None + ), "Did not epect disambigution for update tables!" + assert ( + self.match_payload is not None + ), "Trying to perform a save on unhandled type of payload!" + assert ( + self.current_id is not None + ), "Did not find any identifier to go by. You likely meant to upload instead of save" current_id = self.current_id - info = ReportInfo(tableName=self.name, columns=[pr.column for pr in self.parsedFields], treeInfo=None) + info = ReportInfo( + tableName=self.name, + columns=[pr.column for pr in self.parsedFields], + treeInfo=None, + ) if current_id == NULL_RECORD: return UploadResult(NoChange(current_id, info), {}, {}) return super()._handle_row(skip_match=True, allow_null=allow_null) - + def _process_to_ones(self) -> Dict[str, UploadResult]: return { - field_name: (to_one_def.save_row() if to_one_def.is_one_to_one() else to_one_def.process_row()) - for field_name, to_one_def in - Func.sort_by_key(self.toOne) + field_name: ( + to_one_def.save_row() + if to_one_def.is_one_to_one() + else to_one_def.process_row() + ) + for field_name, to_one_def in Func.sort_by_key(self.toOne) } - def _do_upload(self, model, to_one_results: Dict[str, UploadResult], info: ReportInfo) -> UploadResult: + def _do_upload( + self, model, to_one_results: Dict[str, UploadResult], info: ReportInfo + ) -> UploadResult: missing_required = self._check_missing_required() if missing_required is not None: return UploadResult(missing_required, to_one_results, {}) - + attrs = { **{ fieldname_: value @@ -638,11 +855,12 @@ def _do_upload(self, model, to_one_results: Dict[str, UploadResult], info: Repor for fieldname_, value in parsedField.upload.items() }, **self.scopingAttrs, - **self.static + **self.static, } to_one_ids = { - model._meta.get_field(fieldname).attname: result.get_id() for fieldname, result in to_one_results.items() + model._meta.get_field(fieldname).attname: result.get_id() + for fieldname, result in to_one_results.items() } # Should also always get a cache hit at this point, evict the hit. @@ -650,102 +868,136 @@ def _do_upload(self, model, to_one_results: Dict[str, UploadResult], info: Repor assert reference_record is not None - concrete_field_changes = BoundUpdateTable._field_changed(reference_record, attrs) - - if any(scoping_attr in concrete_field_changes for scoping_attr in self.scopingAttrs.keys()): + concrete_field_changes = BoundUpdateTable._field_changed( + reference_record, attrs + ) + if any( + scoping_attr in concrete_field_changes + for scoping_attr in self.scopingAttrs.keys() + ): # I don't know what else to do. I don't think this will ever get raised. I don't know what I'll need to debug this, so showing everything. - raise Exception(f"Attempting to change the scope of the record: {reference_record} at {self}") - + raise Exception( + f"Attempting to change the scope of the record: {reference_record} at {self}" + ) + to_one_changes = BoundUpdateTable._field_changed(reference_record, to_one_ids) to_one_matched_and_changed = { - related: result._replace(record_result=MatchedAndChanged(*result.record_result)) + related: result._replace( + record_result=MatchedAndChanged(*result.record_result) + ) for related, result in to_one_results.items() if isinstance(result.record_result, Matched) and model._meta.get_field(related).attname in to_one_changes - } - - to_one_results = { - **to_one_results, - **to_one_matched_and_changed } - changed = len(concrete_field_changes) != 0 - - record: Optional[Union[NoChange, Updated]] = None - if not changed: - # We aren't done here. That is, we can't just return from here. This is because we'll need to still look at - # to-manys. There can be changes there that we'd need to catch. Yuk. - record = NoChange(reference_record.pk, info) + to_one_results = {**to_one_results, **to_one_matched_and_changed} - else: + changed = len(concrete_field_changes) != 0 - modified_columns = [parsed.column for parsed in self.parsedFields if (any(fieldname in concrete_field_changes for fieldname in parsed.upload.keys()))] - # Only report modified columns + if changed: + modified_columns = [ + parsed.column + for parsed in self.parsedFields + if ( + any( + fieldname in concrete_field_changes + for fieldname in parsed.upload.keys() + ) + ) + ] info = info._replace(columns=modified_columns) - attrs = { - **attrs, - **to_one_ids, - **({'modifiedbyagent_id': self.uploadingAgentId} if hasattr(reference_record, 'modifiedbyagent_id') else {}) - } - - with transaction.atomic(): - try: - updated = self._do_update(reference_record, [*to_one_changes.values(), *concrete_field_changes.values()], **attrs) - picklist_additions = self._do_picklist_additions() - except (BusinessRuleException, IntegrityError) as e: - return UploadResult(FailedBusinessRule(str(e), {}, info), to_one_results, {}) - - record = record or Updated(updated.pk, info, picklist_additions) + # Changed is just concrete field changes. We might have changed a to-one too. + # This is done like this to avoid an unecessary save when we know there is no + if changed or to_one_changes: + attrs = { + **attrs, + **to_one_ids, + **( + {"modifiedbyagent_id": self.uploadingAgentId} + if hasattr(reference_record, "modifiedbyagent_id") + else {} + ), + } + with transaction.atomic(): + try: + updated = self._do_update( + reference_record, + [*to_one_changes.values(), *concrete_field_changes.values()], + **attrs, + ) + picklist_additions = self._do_picklist_additions() + except (BusinessRuleException, IntegrityError) as e: + return UploadResult( + FailedBusinessRule(str(e), {}, info), to_one_results, {} + ) + + record = Updated(updated.pk, info, picklist_additions) if changed else NoChange(reference_record.pk, info) to_many_results = self._handle_to_many(True, record.get_id(), model) - to_one_adjusted, to_many_adjusted = self._clean_up_fks(to_one_results, to_many_results) + to_one_adjusted, to_many_adjusted = self._clean_up_fks( + to_one_results, to_many_results + ) return UploadResult(record, to_one_adjusted, to_many_adjusted) - + def _do_update(self, reference_obj, dirty_fields, **attrs): # TODO: Try handling parent_obj. Quite complicated and ugly. self.auditor.update(reference_obj, None, dirty_fields) - for (key, value) in attrs.items(): + for key, value in attrs.items(): setattr(reference_obj, key, value) - if hasattr(reference_obj, 'version'): - # Consider using bump_version here. + if hasattr(reference_obj, "version"): + # Consider using bump_version here. # I'm not doing it for performance reasons -- we already checked our version at this point, and have a lock, so can just increment the version. - setattr(reference_obj, 'version', getattr(reference_obj, 'version') + 1) + setattr(reference_obj, "version", getattr(reference_obj, "version") + 1) reference_obj.save() return reference_obj - + def _do_insert(self): raise Exception("Attempting to insert into a save table directly!") - + def force_upload_row(self) -> UploadResult: - raise Exception("Attempting to force upload! Can't force upload to a save table") - - def _clean_up_fks(self, to_one_results: Dict[str, UploadResult], to_many_results: Dict[str, List[UploadResult]]) -> Tuple[Dict[str, UploadResult], Dict[str, List[UploadResult]]]: + raise Exception( + "Attempting to force upload! Can't force upload to a save table" + ) + + def _clean_up_fks( + self, + to_one_results: Dict[str, UploadResult], + to_many_results: Dict[str, List[UploadResult]], + ) -> Tuple[Dict[str, UploadResult], Dict[str, List[UploadResult]]]: to_one_deleted = { - key: uploadable.delete_row(to_one_results[key].record_result.info) # type: ignore + key: uploadable.delete_row(to_one_results[key].record_result.info) # type: ignore for (key, uploadable) in self.toOne.items() - if self._relationship_is_dependent(key) and isinstance(to_one_results[key].record_result, NullRecord) + if self._relationship_is_dependent(key) + and isinstance(to_one_results[key].record_result, NullRecord) } to_many_deleted = { key: [ - (uploadable.delete_row(result.record_result.info) if isinstance(result.record_result, NullRecord) else result) - for (result, uploadable) in zip(to_many_results[key], uploadables)] + ( + uploadable.delete_row(result.record_result.info) + if isinstance(result.record_result, NullRecord) + else result + ) + for (result, uploadable) in zip(to_many_results[key], uploadables) + ] for (key, uploadables) in self.toMany.items() if self._relationship_is_dependent(key) } - return {**to_one_results, **to_one_deleted}, {**to_many_results, **to_many_deleted} + return {**to_one_results, **to_one_deleted}, { + **to_many_results, + **to_many_deleted, + } @staticmethod def _field_changed(reference_record, attrs: Dict[str, Any]): return { - key: FieldChangeInfo(field_name=key, old_value=getattr(reference_record, key), new_value=new_value) # type: ignore + key: FieldChangeInfo(field_name=key, old_value=getattr(reference_record, key), new_value=new_value) # type: ignore for (key, new_value) in attrs.items() if getattr(reference_record, key) != new_value } - \ No newline at end of file diff --git a/specifyweb/workbench/views.py b/specifyweb/workbench/views.py index cc517062ce5..30a6335d90f 100644 --- a/specifyweb/workbench/views.py +++ b/specifyweb/workbench/views.py @@ -12,8 +12,7 @@ from jsonschema.exceptions import ValidationError # type: ignore from specifyweb.middleware.general import require_GET, require_http_methods -from specifyweb.specify.api import create_obj, get_object_or_404, obj_to_data, \ - toJson, uri_for_model +from specifyweb.specify.api import get_object_or_404 from specifyweb.specify.views import login_maybe_required, openapi from specifyweb.specify.models import Recordset, Specifyuser from specifyweb.notifications.models import Message @@ -21,7 +20,6 @@ check_permission_targets, check_table_permissions from . import models, tasks from .upload import upload as uploader, upload_plan_schema -from .upload.upload import do_upload_dataset, rollback_batch_edit logger = logging.getLogger(__name__) @@ -683,7 +681,7 @@ def unupload(request, ds) -> http.HttpResponse: return http.HttpResponse('dataset has not been uploaded.', status=400) taskid = str(uuid4()) - async_result = tasks.unupload.apply_async([ds.id, request.specify_collection.id, request.specify_user_agent.id], task_id=taskid) + async_result = tasks.unupload.apply_async([request.specify_collection.id, ds.id, request.specify_user_agent.id], task_id=taskid) ds.uploaderstatus = { 'operation': "unuploading", 'taskid': taskid From 9f16726c3ab2ba54c897df2248ca2af3458df7f7 Mon Sep 17 00:00:00 2001 From: realVinayak Date: Tue, 20 Aug 2024 08:28:02 -0500 Subject: [PATCH 30/63] (tests): Backend unit tests --- specifyweb/specify/load_datamodel.py | 600 ++++---- specifyweb/specify/tests/test_trees.py | 586 +++++--- specifyweb/specify/tree_views.py | 2 +- specifyweb/stored_queries/batch_edit.py | 303 ++-- specifyweb/stored_queries/queryfieldspec.py | 195 ++- specifyweb/stored_queries/tests/__init__.py | 0 .../stored_queries/tests/static/co_query.json | 420 ++++++ .../tests/static/co_query_row_plan.py | 268 ++++ .../stored_queries/tests/test_batch_edit.py | 173 +-- specifyweb/stored_queries/tests/tests.py | 247 ++-- specifyweb/workbench/upload/predicates.py | 2 +- .../upload/tests/test_batch_edit_table.py | 900 +++++++++--- .../workbench/upload/tests/test_bugs.py | 142 +- .../upload/tests/test_upload_results_json.py | 1 - specifyweb/workbench/upload/treerecord.py | 5 +- specifyweb/workbench/upload/upload.py | 14 +- specifyweb/workbench/upload/upload_table.py | 109 +- specifyweb/workbench/upload/uploadable.py | 35 +- specifyweb/workbench/views.py | 1264 +++++++++-------- 19 files changed, 3505 insertions(+), 1761 deletions(-) create mode 100644 specifyweb/stored_queries/tests/__init__.py create mode 100644 specifyweb/stored_queries/tests/static/co_query.json create mode 100644 specifyweb/stored_queries/tests/static/co_query_row_plan.py diff --git a/specifyweb/specify/load_datamodel.py b/specifyweb/specify/load_datamodel.py index 73754789258..b6b50ec3e2e 100644 --- a/specifyweb/specify/load_datamodel.py +++ b/specifyweb/specify/load_datamodel.py @@ -3,23 +3,28 @@ import os import warnings import logging + logger = logging.getLogger(__name__) -from django.conf import settings # type: ignore +from django.conf import settings # type: ignore from django.utils.translation import gettext as _ + class DoesNotExistError(Exception): pass + class TableDoesNotExistError(DoesNotExistError): pass + class FieldDoesNotExistError(DoesNotExistError): pass -T = TypeVar('T') -U = TypeVar('U') +T = TypeVar("T") +U = TypeVar("U") + def strict_to_optional(f: Callable[[U], T], lookup: U, strict: bool) -> Optional[T]: try: @@ -30,37 +35,47 @@ def strict_to_optional(f: Callable[[U], T], lookup: U, strict: bool) -> Optional return None raise + class Datamodel(object): - tables: List['Table'] + tables: List["Table"] - def __init__(self, tables: List['Table'] = []): + def __init__(self, tables: List["Table"] = []): self.tables = tables - def get_table(self, tablename: str, strict: bool=False) -> Optional['Table']: + def get_table(self, tablename: str, strict: bool = False) -> Optional["Table"]: return strict_to_optional(self.get_table_strict, tablename, strict) - def get_table_strict(self, tablename: str) -> 'Table': + def get_table_strict(self, tablename: str) -> "Table": tablename = tablename.lower() for table in self.tables: if table.name.lower() == tablename: return table - raise TableDoesNotExistError(_("No table with name: %(table_name)r") % {'table_name':tablename}) + raise TableDoesNotExistError( + _("No table with name: %(table_name)r") % {"table_name": tablename} + ) - def get_table_by_id(self, table_id: int, strict: bool=False) -> Optional['Table']: + def get_table_by_id(self, table_id: int, strict: bool = False) -> Optional["Table"]: return strict_to_optional(self.get_table_by_id_strict, table_id, strict) - def get_table_by_id_strict(self, table_id: int, strict: bool=False) -> 'Table': + def get_table_by_id_strict(self, table_id: int, strict: bool = False) -> "Table": for table in self.tables: if table.tableId == table_id: return table - raise TableDoesNotExistError(_("No table with id: %(table_id)d") % {'table_id':table_id}) - - def reverse_relationship(self, relationship: 'Relationship') -> Optional['Relationship']: - if hasattr(relationship, 'otherSideName'): - return self.get_table_strict(relationship.relatedModelName).get_relationship(relationship.otherSideName) + raise TableDoesNotExistError( + _("No table with id: %(table_id)d") % {"table_id": table_id} + ) + + def reverse_relationship( + self, relationship: "Relationship" + ) -> Optional["Relationship"]: + if hasattr(relationship, "otherSideName"): + return self.get_table_strict( + relationship.relatedModelName + ).get_relationship(relationship.otherSideName) else: return None + class Table(object): system: bool = False classname: str @@ -68,22 +83,34 @@ class Table(object): tableId: int idColumn: str idFieldName: str - idField: 'Field' + idField: "Field" view: Optional[str] searchDialog: Optional[str] - fields: List['Field'] - indexes: List['Index'] - relationships: List['Relationship'] + fields: List["Field"] + indexes: List["Index"] + relationships: List["Relationship"] fieldAliases: List[Dict[str, str]] sp7_only: bool = False - django_app: str = 'specify' - - def __init__(self, classname: str = None, table: str = None, tableId: int = None, - idColumn: str = None, idFieldName: str = None, idField: 'Field' = None, - view: Optional[str] = None, searchDialog: Optional[str] = None, fields: List['Field'] = None, - indexes: List['Index'] = None, relationships: List['Relationship'] = None, - fieldAliases: List[Dict[str, str]] = None, system: bool = False, - sp7_only: bool = False, django_app: str = 'specify'): + django_app: str = "specify" + + def __init__( + self, + classname: str = None, + table: str = None, + tableId: int = None, + idColumn: str = None, + idFieldName: str = None, + idField: "Field" = None, + view: Optional[str] = None, + searchDialog: Optional[str] = None, + fields: List["Field"] = None, + indexes: List["Index"] = None, + relationships: List["Relationship"] = None, + fieldAliases: List[Dict[str, str]] = None, + system: bool = False, + sp7_only: bool = False, + django_app: str = "specify", + ): if not classname: raise ValueError("classname is required") if not table: @@ -114,15 +141,15 @@ def __init__(self, classname: str = None, table: str = None, tableId: int = None @property def name(self) -> str: - return self.classname.split('.')[-1] + return self.classname.split(".")[-1] @property def django_name(self) -> str: return self.name.capitalize() @property - def all_fields(self) -> List[Union['Field', 'Relationship']]: - def af() -> Iterable[Union['Field', 'Relationship']]: + def all_fields(self) -> List[Union["Field", "Relationship"]]: + def af() -> Iterable[Union["Field", "Relationship"]]: for f in self.fields: yield f for r in self.relationships: @@ -131,46 +158,56 @@ def af() -> Iterable[Union['Field', 'Relationship']]: return list(af()) - - def get_field(self, fieldname: str, strict: bool=False) -> Union['Field', 'Relationship', None]: + def get_field( + self, fieldname: str, strict: bool = False + ) -> Union["Field", "Relationship", None]: return strict_to_optional(self.get_field_strict, fieldname, strict) - def get_field_strict(self, fieldname: str) -> Union['Field', 'Relationship']: + def get_field_strict(self, fieldname: str) -> Union["Field", "Relationship"]: fieldname = fieldname.lower() for field in self.all_fields: if field.name.lower() == fieldname: return field - raise FieldDoesNotExistError(_("Field %(field_name)s not in table %(table_name)s. ") % {'field_name':fieldname, 'table_name':self.name} + - _("Fields: %(fields)s") % {'fields':[f.name for f in self.all_fields]}) + raise FieldDoesNotExistError( + _("Field %(field_name)s not in table %(table_name)s. ") + % {"field_name": fieldname, "table_name": self.name} + + _("Fields: %(fields)s") % {"fields": [f.name for f in self.all_fields]} + ) - def get_relationship(self, name: str) -> 'Relationship': + def get_relationship(self, name: str) -> "Relationship": field = self.get_field_strict(name) if not isinstance(field, Relationship): - raise FieldDoesNotExistError(f"Field {name} in table {self.name} is not a relationship.") + raise FieldDoesNotExistError( + f"Field {name} in table {self.name} is not a relationship." + ) return field - - def get_index(self, indexname: str, strict: bool=False) -> Optional['Index']: + + def get_index(self, indexname: str, strict: bool = False) -> Optional["Index"]: for index in self.indexes: if indexname in index.name: return index if strict: - raise FieldDoesNotExistError(_("Index %(index_name)s not in table %(table_name)s. ") % {'index_name':indexname, 'table_name':self.name} + - _("Indexes: %(indexes)s") % {'indexes':[i.name for i in self.indexes]}) + raise FieldDoesNotExistError( + _("Index %(index_name)s not in table %(table_name)s. ") + % {"index_name": indexname, "table_name": self.name} + + _("Indexes: %(indexes)s") + % {"indexes": [i.name for i in self.indexes]} + ) return None @property - def attachments_field(self) -> Optional['Relationship']: + def attachments_field(self) -> Optional["Relationship"]: try: - return self.get_relationship('attachments') + return self.get_relationship("attachments") except FieldDoesNotExistError: try: - return self.get_relationship(self.name + 'attachments') + return self.get_relationship(self.name + "attachments") except FieldDoesNotExistError: return None @property def is_attachment_jointable(self) -> bool: - return self.name.endswith('Attachment') and self.name != 'Attachment' + return self.name.endswith("Attachment") and self.name != "Attachment" def __repr__(self) -> str: return "" % self.name @@ -186,27 +223,40 @@ class Field(object): type: str length: Optional[int] - def __init__(self, name: str = None, column: str = None, indexed: bool = None, - unique: bool = None, required: bool = None, type: str = None, - length: int = None, is_relationship: bool = False): + def __init__( + self, + name: str = None, + column: str = None, + indexed: bool = None, + unique: bool = None, + required: bool = None, + type: str = None, + length: int = None, + is_relationship: bool = False, + ): if not name: raise ValueError("name is required") - if not column and type == 'many-to-one': + if not column and type == "many-to-one": raise ValueError("column is required") self.is_relationship = is_relationship - self.name = name or '' - self.column = column or '' + self.name = name or "" + self.column = column or "" self.indexed = indexed if indexed is not None else False self.unique = unique if unique is not None else False self.required = required if required is not None else False - self.type = type if type is not None else '' + self.type = type if type is not None else "" self.length = length if length is not None else None def __repr__(self) -> str: return "" % self.name def is_temporal(self) -> bool: - return self.type in ('java.util.Date', 'java.util.Calendar', 'java.sql.Timestamp') + return self.type in ( + "java.util.Date", + "java.util.Calendar", + "java.sql.Timestamp", + ) + class Index(object): name: str @@ -215,25 +265,40 @@ class Index(object): def __init__(self, name: str = None, column_names: List[str] = None): if not name: raise ValueError("name is required") - self.name = name or '' + self.name = name or "" self.column_names = column_names if column_names is not None else [] def __repr__(self) -> str: return "" % self.name + class IdField(Field): name: str column: str type: str required: bool = True - def __init__(self, name: str = None, column: str = None, - type: str = None, required: bool = True): - super().__init__(name, column, indexed=False, unique=False, required=required, type=type, length=0) + def __init__( + self, + name: str = None, + column: str = None, + type: str = None, + required: bool = True, + ): + super().__init__( + name, + column, + indexed=False, + unique=False, + required=required, + type=type, + length=0, + ) def __repr__(self) -> str: return "" % self.name + class Relationship(Field): is_relationship: bool = True dependent: bool = False @@ -243,91 +308,132 @@ class Relationship(Field): relatedModelName: str column: str otherSideName: str - + @property def is_to_many(self) -> bool: - return 'to_many' in self.type - - def __init__(self, name: str = None, type: str = None, required: bool = None, - relatedModelName: str = None, column: str = None, - otherSideName: str = None, dependent: bool = False, is_relationship: bool = True, - is_to_many: bool = None): - super().__init__(name, column, indexed=False, unique=False, required=required, - type=type, length=0, is_relationship=is_relationship) + return "to_many" in self.type + + def __init__( + self, + name: str = None, + type: str = None, + required: bool = None, + relatedModelName: str = None, + column: str = None, + otherSideName: str = None, + dependent: bool = False, + is_relationship: bool = True, + is_to_many: bool = None, + ): + super().__init__( + name, + column, + indexed=False, + unique=False, + required=required, + type=type, + length=0, + is_relationship=is_relationship, + ) self.dependent = dependent if dependent is not None else False - self.relatedModelName = relatedModelName or '' - self.otherSideName = otherSideName or '' + self.relatedModelName = relatedModelName or "" + self.otherSideName = otherSideName or "" # self.is_to_many = is_to_many if is_to_many is not None else 'to_many' in self.type + def make_table(tabledef: ElementTree.Element) -> Table: - iddef = tabledef.find('id') + iddef = tabledef.find("id") assert iddef is not None - display = tabledef.find('display') + display = tabledef.find("display") table = Table( - classname=tabledef.attrib['classname'], - table=tabledef.attrib['table'], - tableId=int(tabledef.attrib['tableid']), - idColumn=iddef.attrib['column'], - idFieldName=iddef.attrib['name'], + classname=tabledef.attrib["classname"], + table=tabledef.attrib["table"], + tableId=int(tabledef.attrib["tableid"]), + idColumn=iddef.attrib["column"], + idFieldName=iddef.attrib["name"], idField=make_id_field(iddef), - view=display.attrib.get('view', None) if display is not None else None, - searchDialog=display.attrib.get('searchdlg', None) if display is not None else None, - fields=[make_field(fielddef) for fielddef in tabledef.findall('field')], - indexes=[make_index(indexdef) for indexdef in tabledef.findall('tableindex')], - relationships=[make_relationship(reldef) for reldef in tabledef.findall('relationship')], - fieldAliases=[make_field_alias(aliasdef) for aliasdef in tabledef.findall('fieldalias')] + view=display.attrib.get("view", None) if display is not None else None, + searchDialog=( + display.attrib.get("searchdlg", None) if display is not None else None + ), + fields=[make_field(fielddef) for fielddef in tabledef.findall("field")], + indexes=[make_index(indexdef) for indexdef in tabledef.findall("tableindex")], + relationships=[ + make_relationship(reldef) for reldef in tabledef.findall("relationship") + ], + fieldAliases=[ + make_field_alias(aliasdef) for aliasdef in tabledef.findall("fieldalias") + ], ) return table + def make_id_field(fielddef: ElementTree.Element) -> IdField: return IdField( - name=fielddef.attrib['name'], - column=fielddef.attrib['column'], - type=fielddef.attrib['type'], - required=True + name=fielddef.attrib["name"], + column=fielddef.attrib["column"], + type=fielddef.attrib["type"], + required=True, ) + def make_field(fielddef: ElementTree.Element) -> Field: field = Field( - name=fielddef.attrib['name'], - column=fielddef.attrib['column'], - indexed=(fielddef.attrib['indexed'] == "true"), - unique=(fielddef.attrib['unique'] == "true"), - required=(fielddef.attrib['required'] == "true"), - type=fielddef.attrib['type'], - length=int(fielddef.attrib['length']) if 'length' in fielddef.attrib else None + name=fielddef.attrib["name"], + column=fielddef.attrib["column"], + indexed=(fielddef.attrib["indexed"] == "true"), + unique=(fielddef.attrib["unique"] == "true"), + required=(fielddef.attrib["required"] == "true"), + type=fielddef.attrib["type"], + length=int(fielddef.attrib["length"]) if "length" in fielddef.attrib else None, ) return field + def make_index(indexdef: ElementTree.Element) -> Index: index = Index( - name=indexdef.attrib['indexName'], - column_names=indexdef.attrib['columnNames'].split(',') + name=indexdef.attrib["indexName"], + column_names=indexdef.attrib["columnNames"].split(","), ) return index + def make_relationship(reldef: ElementTree.Element) -> Relationship: rel = Relationship( - name=reldef.attrib['relationshipname'], - type=reldef.attrib['type'], - required=(reldef.attrib['required'] == "true"), - relatedModelName=reldef.attrib['classname'].split('.')[-1], - column=reldef.attrib.get('columnname', None) if 'columnname' in reldef.attrib else None, - otherSideName=reldef.attrib.get('othersidename', None) if 'othersidename' in reldef.attrib else None + name=reldef.attrib["relationshipname"], + type=reldef.attrib["type"], + required=(reldef.attrib["required"] == "true"), + relatedModelName=reldef.attrib["classname"].split(".")[-1], + column=( + reldef.attrib.get("columnname", None) + if "columnname" in reldef.attrib + else None + ), + otherSideName=( + reldef.attrib.get("othersidename", None) + if "othersidename" in reldef.attrib + else None + ), ) return rel + def make_field_alias(aliasdef: ElementTree.Element) -> Dict[str, str]: alias = dict(aliasdef.attrib) return alias + def load_datamodel() -> Optional[Datamodel]: try: - datamodeldef = ElementTree.parse(os.path.join(settings.SPECIFY_CONFIG_DIR, 'specify_datamodel.xml')) + datamodeldef = ElementTree.parse( + os.path.join(settings.SPECIFY_CONFIG_DIR, "specify_datamodel.xml") + ) except FileNotFoundError: return None datamodel = Datamodel() - datamodel.tables = [make_table(tabledef) for tabledef in datamodeldef.findall('table')] + datamodel.tables = [ + make_table(tabledef) for tabledef in datamodeldef.findall("table") + ] add_collectingevents_to_locality(datamodel) flag_dependent_fields(datamodel) @@ -335,34 +441,41 @@ def load_datamodel() -> Optional[Datamodel]: return datamodel + def add_collectingevents_to_locality(datamodel: Datamodel) -> None: rel = Relationship( - name='collectingEvents', - type='one-to-many', + name="collectingEvents", + type="one-to-many", required=False, - relatedModelName='collectingEvent', - otherSideName='locality' + relatedModelName="collectingEvent", + otherSideName="locality", ) - datamodel.get_table_strict('collectingevent').get_relationship('locality').otherSideName = 'collectingEvents' - datamodel.get_table_strict('locality').relationships.append(rel) + datamodel.get_table_strict("collectingevent").get_relationship( + "locality" + ).otherSideName = "collectingEvents" + datamodel.get_table_strict("locality").relationships.append(rel) + def flag_dependent_fields(datamodel: Datamodel) -> None: for name in dependent_fields: - tablename, fieldname = name.split('.') + tablename, fieldname = name.split(".") try: field = datamodel.get_table_strict(tablename).get_relationship(fieldname) except DoesNotExistError as e: - logger.warn("missing table or relationship setting dependent field: %s", name) + logger.warn( + "missing table or relationship setting dependent field: %s", name + ) continue field.dependent = True for table in datamodel.tables: if table.is_attachment_jointable: - table.get_relationship('attachment').dependent = True + table.get_relationship("attachment").dependent = True if table.attachments_field: table.attachments_field.dependent = True + def flag_system_tables(datamodel: Datamodel) -> None: for name in system_tables: datamodel.get_table_strict(name).system = True @@ -370,142 +483,143 @@ def flag_system_tables(datamodel: Datamodel) -> None: for table in datamodel.tables: if table.is_attachment_jointable: table.system = True - if table.name.endswith('treedef') or table.name.endswith('treedefitem'): + if table.name.endswith("treedef") or table.name.endswith("treedefitem"): table.system = True + dependent_fields = { - 'Accession.accessionagents', - 'Accession.accessionauthorizations', - 'Accession.addressofrecord', - 'Agent.addresses', - 'Agent.agentgeographies', - 'Agent.agentspecialties', - 'Agent.groups', - 'Agent.identifiers', - 'Agent.variants', - 'Borrow.addressofrecord', - 'Borrow.borrowagents', - 'Borrow.borrowmaterials', - 'Borrow.shipments', - 'Borrowmaterial.borrowreturnmaterials', - 'Collectingevent.collectingeventattribute', - 'Collectingevent.collectingeventattrs', - 'Collectingevent.collectingeventauthorizations', - 'Collectingevent.collectors', - 'Collectingtrip.collectingtripattribute', - 'Collectingtrip.collectingtripauthorizations', - 'Collectingtrip.fundingagents', - 'Collectionobject.collectionobjectattribute', - 'Collectionobject.collectionobjectattrs', - 'Collectionobject.collectionobjectcitations', - 'Collectionobject.conservdescriptions', - 'Collectionobject.determinations', - 'Collectionobject.dnasequences', - 'Collectionobject.exsiccataitems', - 'CollectionObject.leftsiderels', - 'Collectionobject.otheridentifiers', - 'Collectionobject.preparations', - 'Collectionobject.collectionobjectproperties', - 'CollectionObject.rightsiderels', - 'Collectionobject.treatmentevents', - 'Collectionobject.voucherrelationships', - 'Commonnametx.citations', - 'Conservdescription.events', - 'Deaccession.deaccessionagents', - 'Determination.determinationcitations', - 'Determination.determiners', - 'Disposal.disposalagents', - 'Disposal.disposalpreparations', - 'Dnasequence.dnasequencingruns', - 'Dnasequencingrun.citations', - 'Exchangein.exchangeinpreps', - 'Exchangein.addressofrecord', - 'Exchangeout.exchangeoutpreps', - 'Exchangeout.addressofrecord', - 'Exsiccata.exsiccataitems', - 'Fieldnotebook.pagesets', - 'Fieldnotebookpageset.pages', - 'Geographytreedef.treedefitems', - 'Geologictimeperiodtreedef.treedefitems', - 'Gift.addressofrecord', - 'Gift.giftagents', - 'Gift.giftpreparations', - 'Gift.shipments', - 'Latlonpolygon.points', - 'lithostrattreedef.treedefitems', - 'Loan.addressofrecord', - 'Loan.loanagents', - 'Loan.loanpreparations', - 'Loan.shipments', - 'Loanpreparation.loanreturnpreparations', - 'Locality.geocoorddetails', - 'Locality.latlonpolygons', - 'Locality.localitycitations', - 'Locality.localitydetails', - 'Locality.localitynamealiass', - 'Materialsample.dnasequences', - 'Picklist.picklistitems', - 'Preparation.materialsamples', - 'Preparation.preparationattribute', - 'Preparation.preparationattrs', - 'Preparation.preparationproperties', - 'Preptype.attributedefs', - 'Referencework.authors', - 'Repositoryagreement.addressofrecord', - 'Repositoryagreement.repositoryagreementagents', - 'Repositoryagreement.repositoryagreementauthorizations', - 'Spquery.fields', - 'Storagetreedef.treedefitems', - 'Taxon.commonnames', - 'Taxon.taxoncitations', - 'Taxon.taxonattribute', - 'Taxontreedef.treedefitems', - 'Workbench.workbenchtemplate', - 'Workbenchtemplate.workbenchtemplatemappingitems', + "Accession.accessionagents", + "Accession.accessionauthorizations", + "Accession.addressofrecord", + "Agent.addresses", + "Agent.agentgeographies", + "Agent.agentspecialties", + "Agent.groups", + "Agent.identifiers", + "Agent.variants", + "Borrow.addressofrecord", + "Borrow.borrowagents", + "Borrow.borrowmaterials", + "Borrow.shipments", + "Borrowmaterial.borrowreturnmaterials", + "Collectingevent.collectingeventattribute", + "Collectingevent.collectingeventattrs", + "Collectingevent.collectingeventauthorizations", + "Collectingevent.collectors", + "Collectingtrip.collectingtripattribute", + "Collectingtrip.collectingtripauthorizations", + "Collectingtrip.fundingagents", + "Collectionobject.collectionobjectattribute", + "Collectionobject.collectionobjectattrs", + "Collectionobject.collectionobjectcitations", + "Collectionobject.conservdescriptions", + "Collectionobject.determinations", + "Collectionobject.dnasequences", + "Collectionobject.exsiccataitems", + "CollectionObject.leftsiderels", + "Collectionobject.otheridentifiers", + "Collectionobject.preparations", + "Collectionobject.collectionobjectproperties", + "CollectionObject.rightsiderels", + "Collectionobject.treatmentevents", + "Collectionobject.voucherrelationships", + "Commonnametx.citations", + "Conservdescription.events", + "Deaccession.deaccessionagents", + "Determination.determinationcitations", + "Determination.determiners", + "Disposal.disposalagents", + "Disposal.disposalpreparations", + "Dnasequence.dnasequencingruns", + "Dnasequencingrun.citations", + "Exchangein.exchangeinpreps", + "Exchangein.addressofrecord", + "Exchangeout.exchangeoutpreps", + "Exchangeout.addressofrecord", + "Exsiccata.exsiccataitems", + "Fieldnotebook.pagesets", + "Fieldnotebookpageset.pages", + "Geographytreedef.treedefitems", + "Geologictimeperiodtreedef.treedefitems", + "Gift.addressofrecord", + "Gift.giftagents", + "Gift.giftpreparations", + "Gift.shipments", + "Latlonpolygon.points", + "lithostrattreedef.treedefitems", + "Loan.addressofrecord", + "Loan.loanagents", + "Loan.loanpreparations", + "Loan.shipments", + "Loanpreparation.loanreturnpreparations", + "Locality.geocoorddetails", + "Locality.latlonpolygons", + "Locality.localitycitations", + "Locality.localitydetails", + "Locality.localitynamealiass", + "Materialsample.dnasequences", + "Picklist.picklistitems", + "Preparation.materialsamples", + "Preparation.preparationattribute", + "Preparation.preparationattrs", + "Preparation.preparationproperties", + "Preptype.attributedefs", + "Referencework.authors", + "Repositoryagreement.addressofrecord", + "Repositoryagreement.repositoryagreementagents", + "Repositoryagreement.repositoryagreementauthorizations", + "Spquery.fields", + "Storagetreedef.treedefitems", + "Taxon.commonnames", + "Taxon.taxoncitations", + "Taxon.taxonattribute", + "Taxontreedef.treedefitems", + "Workbench.workbenchtemplate", + "Workbenchtemplate.workbenchtemplatemappingitems", } system_tables = { - 'Attachment', - 'Attachmentimageattribute', - 'Attachmentmetadata', - 'Attachmenttag', - 'Attributedef', - 'Autonumberingscheme', - 'Datatype', - 'Morphbankview', - 'Picklist', - 'Picklistitem', - 'Recordset', - 'Recordsetitem', - 'Spappresource', - 'Spappresourcedata', - 'Spappresourcedir', - 'Spauditlog', - 'Spauditlogfield', - 'Spexportschema', - 'Spexportschemaitem', - 'Spexportschemaitemmapping', - 'Spexportschemamapping', - 'Spfieldvaluedefault', - 'Splocalecontainer', - 'Splocalecontaineritem', - 'Splocaleitemstr', - 'Sppermission', - 'Spprincipal', - 'Spquery', - 'Spqueryfield', - 'Spreport', - 'Sptasksemaphore', - 'Spversion', - 'Spviewsetobj', - 'Spvisualquery', - 'Specifyuser', - 'Workbench', - 'Workbenchdataitem', - 'Workbenchrow', - 'Workbenchrowexportedrelationship', - 'Workbenchrowimage', - 'Workbenchtemplate', - 'Workbenchtemplatemappingitem', -} \ No newline at end of file + "Attachment", + "Attachmentimageattribute", + "Attachmentmetadata", + "Attachmenttag", + "Attributedef", + "Autonumberingscheme", + "Datatype", + "Morphbankview", + "Picklist", + "Picklistitem", + "Recordset", + "Recordsetitem", + "Spappresource", + "Spappresourcedata", + "Spappresourcedir", + "Spauditlog", + "Spauditlogfield", + "Spexportschema", + "Spexportschemaitem", + "Spexportschemaitemmapping", + "Spexportschemamapping", + "Spfieldvaluedefault", + "Splocalecontainer", + "Splocalecontaineritem", + "Splocaleitemstr", + "Sppermission", + "Spprincipal", + "Spquery", + "Spqueryfield", + "Spreport", + "Sptasksemaphore", + "Spversion", + "Spviewsetobj", + "Spvisualquery", + "Specifyuser", + "Workbench", + "Workbenchdataitem", + "Workbenchrow", + "Workbenchrowexportedrelationship", + "Workbenchrowimage", + "Workbenchtemplate", + "Workbenchtemplatemappingitem", +} diff --git a/specifyweb/specify/tests/test_trees.py b/specifyweb/specify/tests/test_trees.py index aa880c2fe6b..402f30d46d9 100644 --- a/specifyweb/specify/tests/test_trees.py +++ b/specifyweb/specify/tests/test_trees.py @@ -10,34 +10,35 @@ from contextlib import contextmanager from django.db import connection + class TestTreeSetup(ApiTests): def setUp(self) -> None: super().setUp() - self.geographytreedef.treedefitems.create(name='Continent', rankid=100) - self.geographytreedef.treedefitems.create(name='Country', rankid=200) - self.geographytreedef.treedefitems.create(name='State', rankid=300) - self.geographytreedef.treedefitems.create(name='County', rankid=400) - self.geographytreedef.treedefitems.create(name='City', rankid=500) - + self.geographytreedef.treedefitems.create(name="Continent", rankid=100) + self.geographytreedef.treedefitems.create(name="Country", rankid=200) + self.geographytreedef.treedefitems.create(name="State", rankid=300) + self.geographytreedef.treedefitems.create(name="County", rankid=400) + self.geographytreedef.treedefitems.create(name="City", rankid=500) self.taxontreedef = models.Taxontreedef.objects.create(name="Test Taxonomy") - self.taxontreedef.treedefitems.create(name='Taxonomy Root', rankid=0) - self.taxontreedef.treedefitems.create(name='Kingdom', rankid=10) - self.taxontreedef.treedefitems.create(name='Phylum', rankid=30) - self.taxontreedef.treedefitems.create(name='Class', rankid=60) - self.taxontreedef.treedefitems.create(name='Order', rankid=100) - self.taxontreedef.treedefitems.create(name='Superfamily', rankid=130) - self.taxontreedef.treedefitems.create(name='Family', rankid=140) - self.taxontreedef.treedefitems.create(name='Genus', rankid=180) - self.taxontreedef.treedefitems.create(name='Subgenus', rankid=190) - self.taxontreedef.treedefitems.create(name='Species', rankid=220) - self.taxontreedef.treedefitems.create(name='Subspecies', rankid=230) + self.taxontreedef.treedefitems.create(name="Taxonomy Root", rankid=0) + self.taxontreedef.treedefitems.create(name="Kingdom", rankid=10) + self.taxontreedef.treedefitems.create(name="Phylum", rankid=30) + self.taxontreedef.treedefitems.create(name="Class", rankid=60) + self.taxontreedef.treedefitems.create(name="Order", rankid=100) + self.taxontreedef.treedefitems.create(name="Superfamily", rankid=130) + self.taxontreedef.treedefitems.create(name="Family", rankid=140) + self.taxontreedef.treedefitems.create(name="Genus", rankid=180) + self.taxontreedef.treedefitems.create(name="Subgenus", rankid=190) + self.taxontreedef.treedefitems.create(name="Species", rankid=220) + self.taxontreedef.treedefitems.create(name="Subspecies", rankid=230) + class TestTree: - def setUp(self)->None: + def setUp(self) -> None: super().setUp() - + self.earth = self.make_geotree("Earth", "Planet") self.na = self.make_geotree("North America", "Continent", parent=self.earth) @@ -60,14 +61,21 @@ def setUp(self)->None: def make_geotree(self, name, rank_name, **extra_kwargs): return get_table("Geography").objects.create( name=name, - definitionitem=get_table('Geographytreedefitem').objects.get(name=rank_name), + definitionitem=get_table("Geographytreedefitem").objects.get( + name=rank_name + ), definition=self.geographytreedef, **extra_kwargs ) - -class GeographyTree(TestTree, TestTreeSetup): pass -class SqlTreeSetup(SQLAlchemySetup, GeographyTree): pass + +class GeographyTree(TestTree, TestTreeSetup): + pass + + +class SqlTreeSetup(SQLAlchemySetup, GeographyTree): + pass + class TreeViewsTest(SqlTreeSetup): @@ -77,31 +85,28 @@ def setUp(self): localityname="somewhere1", srclatlongunit=0, discipline=self.discipline, - geography=self.usa + geography=self.usa, ) locality_2 = models.Locality.objects.create( localityname="somewhere2", srclatlongunit=0, discipline=self.discipline, - geography=self.springill + geography=self.springill, ) locality_3 = models.Locality.objects.create( localityname="somewhere3", srclatlongunit=0, discipline=self.discipline, - geography=self.ill + geography=self.ill, ) collecting_event_1 = models.Collectingevent.objects.create( - discipline=self.discipline, - locality=locality_1 + discipline=self.discipline, locality=locality_1 ) collecting_event_2 = models.Collectingevent.objects.create( - discipline=self.discipline, - locality=locality_2 + discipline=self.discipline, locality=locality_2 ) collecting_event_3 = models.Collectingevent.objects.create( - discipline=self.discipline, - locality=locality_3 + discipline=self.discipline, locality=locality_3 ) self.collectionobjects[0].collectingevent = collecting_event_1 self.collectionobjects[1].collectingevent = collecting_event_2 @@ -112,25 +117,37 @@ def setUp(self): _saved = [co.save() for co in self.collectionobjects] second_collection = models.Collection.objects.create( - catalognumformatname='test', - collectionname='TestCollection2', + catalognumformatname="test", + collectionname="TestCollection2", isembeddedcollectingevent=False, - discipline=self.discipline) + discipline=self.discipline, + ) collection_object_another_collection = models.Collectionobject.objects.create( - collection=second_collection, - catalognumber="num-5" + collection=second_collection, catalognumber="num-5" ) def _run_nn_and_cte(*args, **kwargs): - cte_results = get_tree_stats(*args, **kwargs, session_context=TreeViewsTest.test_session_context, using_cte=True) - node_number_results = get_tree_stats(*args, **kwargs, session_context=TreeViewsTest.test_session_context, using_cte=False) + cte_results = get_tree_stats( + *args, + **kwargs, + session_context=TreeViewsTest.test_session_context, + using_cte=True + ) + node_number_results = get_tree_stats( + *args, + **kwargs, + session_context=TreeViewsTest.test_session_context, + using_cte=False + ) self.assertCountEqual(cte_results, node_number_results) return cte_results self.validate_tree_stats = lambda *args, **kwargs: ( - lambda true_results: self.assertCountEqual(_run_nn_and_cte(*args, **kwargs), true_results)) - + lambda true_results: self.assertCountEqual( + _run_nn_and_cte(*args, **kwargs), true_results + ) + ) def test_counts_correctness(self): correct_results = { @@ -145,29 +162,55 @@ def test_counts_correctness(self): self.ill.id: [ (self.sangomon.id, 0, 1), ], - self.sangomon.id: [ - (self.springill.id, 1, 1) - ] - } + self.sangomon.id: [(self.springill.id, 1, 1)], + } _results = [ - self.validate_tree_stats(self.geographytreedef.id, 'geography', parent_id, self.collection)(correct) + self.validate_tree_stats( + self.geographytreedef.id, "geography", parent_id, self.collection + )(correct) for parent_id, correct in correct_results.items() ] - - def test_test_synonyms_concat(self): + + def test_synonyms_concat(self): self.maxDiff = None - na_syn_0 = self.make_geotree("NA Syn 0", "Continent", - acceptedgeography=self.na, - # fullname is not set by default for not-accepted - fullname="NA Syn 0", - parent=self.earth - ) - na_syn_1 = self.make_geotree("NA Syn 1", "Continent", acceptedgeography=self.na, fullname="NA Syn 1", parent=self.earth) - - usa_syn_0 = self.make_geotree("USA Syn 0", "Country", acceptedgeography=self.usa, parent=self.na, fullname="USA Syn 0") - usa_syn_1 = self.make_geotree("USA Syn 1", "Country", acceptedgeography=self.usa, parent=self.na, fullname="USA Syn 1") - usa_syn_2 = self.make_geotree("USA Syn 2", "Country", acceptedgeography=self.usa, parent=self.na, fullname="USA Syn 2") + na_syn_0 = self.make_geotree( + "NA Syn 0", + "Continent", + acceptedgeography=self.na, + # fullname is not set by default for not-accepted + fullname="NA Syn 0", + parent=self.earth, + ) + na_syn_1 = self.make_geotree( + "NA Syn 1", + "Continent", + acceptedgeography=self.na, + fullname="NA Syn 1", + parent=self.earth, + ) + + usa_syn_0 = self.make_geotree( + "USA Syn 0", + "Country", + acceptedgeography=self.usa, + parent=self.na, + fullname="USA Syn 0", + ) + usa_syn_1 = self.make_geotree( + "USA Syn 1", + "Country", + acceptedgeography=self.usa, + parent=self.na, + fullname="USA Syn 1", + ) + usa_syn_2 = self.make_geotree( + "USA Syn 2", + "Country", + acceptedgeography=self.usa, + parent=self.na, + fullname="USA Syn 2", + ) # need to refresh _some_ nodes (but not all) # just the immediate parents and siblings inserted before us @@ -191,113 +234,236 @@ def _run_for_row(): with _run_for_row() as session: results = get_tree_rows( - self.geographytreedef.id, "Geography", self.earth.id, "geographyid", False, session + self.geographytreedef.id, + "Geography", + self.earth.id, + "geographyid", + False, + session, ) expected = [ - (self.na.id, self.na.name, self.na.fullname, self.na.nodenumber, self.na.highestchildnodenumber, self.na.rankid, None, None, 'NULL', self.na.children.count(), 'NA Syn 0, NA Syn 1'), - (na_syn_0.id, na_syn_0.name, na_syn_0.fullname, na_syn_0.nodenumber, na_syn_0.highestchildnodenumber, na_syn_0.rankid, self.na.id, self.na.fullname, 'NULL', na_syn_0.children.count(), None), - (na_syn_1.id, na_syn_1.name, na_syn_1.fullname, na_syn_1.nodenumber, na_syn_1.highestchildnodenumber, na_syn_1.rankid, self.na.id, self.na.fullname, 'NULL', na_syn_1.children.count(), None), - ] - - self.assertCountEqual( - results, - expected - ) - + ( + self.na.id, + self.na.name, + self.na.fullname, + self.na.nodenumber, + self.na.highestchildnodenumber, + self.na.rankid, + None, + None, + "NULL", + self.na.children.count(), + "NA Syn 0, NA Syn 1", + ), + ( + na_syn_0.id, + na_syn_0.name, + na_syn_0.fullname, + na_syn_0.nodenumber, + na_syn_0.highestchildnodenumber, + na_syn_0.rankid, + self.na.id, + self.na.fullname, + "NULL", + na_syn_0.children.count(), + None, + ), + ( + na_syn_1.id, + na_syn_1.name, + na_syn_1.fullname, + na_syn_1.nodenumber, + na_syn_1.highestchildnodenumber, + na_syn_1.rankid, + self.na.id, + self.na.fullname, + "NULL", + na_syn_1.children.count(), + None, + ), + ] + + self.assertCountEqual(results, expected) + with _run_for_row() as session: results = get_tree_rows( - self.geographytreedef.id, "Geography", self.na.id, "name", False, session + self.geographytreedef.id, + "Geography", + self.na.id, + "name", + False, + session, ) expected = [ - (self.usa.id, self.usa.name, self.usa.fullname, self.usa.nodenumber, self.usa.highestchildnodenumber, self.usa.rankid, None, None, 'NULL', self.usa.children.count(), 'USA Syn 0, USA Syn 1, USA Syn 2'), - (usa_syn_0.id, usa_syn_0.name, usa_syn_0.fullname, usa_syn_0.nodenumber, usa_syn_0.highestchildnodenumber, usa_syn_0.rankid, self.usa.id, self.usa.fullname, 'NULL', 0, None), - (usa_syn_1.id, usa_syn_1.name, usa_syn_1.fullname, usa_syn_1.nodenumber, usa_syn_1.highestchildnodenumber, usa_syn_1.rankid, self.usa.id, self.usa.fullname, 'NULL', 0, None), - (usa_syn_2.id, usa_syn_2. name, usa_syn_2.fullname, usa_syn_2.nodenumber, usa_syn_2.highestchildnodenumber, usa_syn_2.rankid, self.usa.id, self.usa.fullname, 'NULL', 0, None) - - ] - self.assertCountEqual( - results, - expected - ) + ( + self.usa.id, + self.usa.name, + self.usa.fullname, + self.usa.nodenumber, + self.usa.highestchildnodenumber, + self.usa.rankid, + None, + None, + "NULL", + self.usa.children.count(), + "USA Syn 0, USA Syn 1, USA Syn 2", + ), + ( + usa_syn_0.id, + usa_syn_0.name, + usa_syn_0.fullname, + usa_syn_0.nodenumber, + usa_syn_0.highestchildnodenumber, + usa_syn_0.rankid, + self.usa.id, + self.usa.fullname, + "NULL", + 0, + None, + ), + ( + usa_syn_1.id, + usa_syn_1.name, + usa_syn_1.fullname, + usa_syn_1.nodenumber, + usa_syn_1.highestchildnodenumber, + usa_syn_1.rankid, + self.usa.id, + self.usa.fullname, + "NULL", + 0, + None, + ), + ( + usa_syn_2.id, + usa_syn_2.name, + usa_syn_2.fullname, + usa_syn_2.nodenumber, + usa_syn_2.highestchildnodenumber, + usa_syn_2.rankid, + self.usa.id, + self.usa.fullname, + "NULL", + 0, + None, + ), + ] + self.assertCountEqual(results, expected) + class AddDeleteRankResourcesTest(ApiTests): def test_add_ranks_without_defaults(self): c = Client() c.force_login(self.specifyuser) - treedef_geo = models.Geographytreedef.objects.create(name='GeographyTest') + treedef_geo = models.Geographytreedef.objects.create(name="GeographyTest") # Test adding non-default rank on empty heirarchy data = { - 'name': 'Universe', - 'parent': None, - 'treedef': treedef_geo, - 'rankid': 100 + "name": "Universe", + "parent": None, + "treedef": treedef_geo, + "rankid": 100, } - universe_rank = api.create_obj(self.collection, self.agent, 'geographytreedefitem', data) - self.assertEqual(100, models.Geographytreedefitem.objects.get(name='Universe').rankid) + universe_rank = api.create_obj( + self.collection, self.agent, "geographytreedefitem", data + ) + self.assertEqual( + 100, models.Geographytreedefitem.objects.get(name="Universe").rankid + ) # Test adding non-default rank to the end of the heirarchy data = { - 'name': 'Galaxy', - 'parent': api.uri_for_model(models.Geographytreedefitem, universe_rank.id), - 'treedef': treedef_geo, - 'rankid': 200 + "name": "Galaxy", + "parent": api.uri_for_model(models.Geographytreedefitem, universe_rank.id), + "treedef": treedef_geo, + "rankid": 200, } - galaxy_rank = api.create_obj(self.collection, self.agent, 'geographytreedefitem', data) - self.assertEqual(200, models.Geographytreedefitem.objects.get(name='Galaxy').rankid) + galaxy_rank = api.create_obj( + self.collection, self.agent, "geographytreedefitem", data + ) + self.assertEqual( + 200, models.Geographytreedefitem.objects.get(name="Galaxy").rankid + ) # Test adding non-default rank to the front of the heirarchy data = { - 'name': 'Multiverse', - 'parent': None, - 'treedef': treedef_geo, - 'rankid': 50 + "name": "Multiverse", + "parent": None, + "treedef": treedef_geo, + "rankid": 50, } - multiverse_rank = api.create_obj(self.collection, self.agent, 'geographytreedefitem', data) - self.assertEqual(50, models.Geographytreedefitem.objects.get(name='Multiverse').rankid) + multiverse_rank = api.create_obj( + self.collection, self.agent, "geographytreedefitem", data + ) + self.assertEqual( + 50, models.Geographytreedefitem.objects.get(name="Multiverse").rankid + ) # Test adding non-default rank in the middle of the heirarchy data = { - 'name': 'Dimension', - 'parent': api.uri_for_model(models.Geographytreedefitem, universe_rank.id), - 'treedef': treedef_geo, - 'rankid': 150 + "name": "Dimension", + "parent": api.uri_for_model(models.Geographytreedefitem, universe_rank.id), + "treedef": treedef_geo, + "rankid": 150, } - dimersion_rank = api.create_obj(self.collection, self.agent, 'geographytreedefitem', data) - self.assertEqual(150, models.Geographytreedefitem.objects.get(name='Dimension').rankid) + dimersion_rank = api.create_obj( + self.collection, self.agent, "geographytreedefitem", data + ) + self.assertEqual( + 150, models.Geographytreedefitem.objects.get(name="Dimension").rankid + ) # Test foreign keys - self.assertEqual(4, models.Geographytreedefitem.objects.filter(treedef=treedef_geo).count()) + self.assertEqual( + 4, models.Geographytreedefitem.objects.filter(treedef=treedef_geo).count() + ) # Create test nodes - cfc = models.Geography.objects.create(name='Central Finite Curve', rankid=50, definition=treedef_geo, - definitionitem=models.Geographytreedefitem.objects.get(name='Multiverse')) - c137 = models.Geography.objects.create(name='C137', rankid=100, parent=cfc, definition=treedef_geo, - definitionitem=models.Geographytreedefitem.objects.get(name='Universe')) - d3 = models.Geography.objects.create(name='3D', rankid=150, parent=c137, definition=treedef_geo, - definitionitem=models.Geographytreedefitem.objects.get(name='Dimension')) - milky_way = models.Geography.objects.create(name='Milky Way', parent=d3, rankid=200, definition=treedef_geo, - definitionitem=models.Geographytreedefitem.objects.get( - name='Galaxy')) - + cfc = models.Geography.objects.create( + name="Central Finite Curve", + rankid=50, + definition=treedef_geo, + definitionitem=models.Geographytreedefitem.objects.get(name="Multiverse"), + ) + c137 = models.Geography.objects.create( + name="C137", + rankid=100, + parent=cfc, + definition=treedef_geo, + definitionitem=models.Geographytreedefitem.objects.get(name="Universe"), + ) + d3 = models.Geography.objects.create( + name="3D", + rankid=150, + parent=c137, + definition=treedef_geo, + definitionitem=models.Geographytreedefitem.objects.get(name="Dimension"), + ) + milky_way = models.Geography.objects.create( + name="Milky Way", + parent=d3, + rankid=200, + definition=treedef_geo, + definitionitem=models.Geographytreedefitem.objects.get(name="Galaxy"), + ) + # Test full name reconstruction set_fullnames(treedef_geo, null_only=False, node_number_range=None) if cfc.fullname is not None: - self.assertEqual('Central Finite Curve', cfc.fullname) + self.assertEqual("Central Finite Curve", cfc.fullname) if c137.fullname is not None: - self.assertEqual('C137', c137.fullname) + self.assertEqual("C137", c137.fullname) if d3.fullname is not None: - self.assertEqual('3D', d3.fullname) + self.assertEqual("3D", d3.fullname) if milky_way.fullname is not None: - self.assertEqual('Milky Way', milky_way.fullname) - + self.assertEqual("Milky Way", milky_way.fullname) + # Test parents of child nodes self.assertEqual(cfc.id, c137.parent.id) self.assertEqual(c137.id, d3.parent.id) self.assertEqual(d3.id, milky_way.parent.id) - def test_add_ranks_with_defaults(self): c = Client() c.force_login(self.specifyuser) @@ -305,113 +471,157 @@ def test_add_ranks_with_defaults(self): for obj in models.Taxontreedefitem.objects.all(): obj.delete() - treedef_taxon = models.Taxontreedef.objects.create(name='TaxonTest') + treedef_taxon = models.Taxontreedef.objects.create(name="TaxonTest") # Test adding default rank on empty heirarchy - data = { - 'name': 'Taxonomy Root', - 'parent': None, - 'treedef': treedef_taxon - } - taxon_root_rank = api.create_obj(self.collection, self.agent, 'taxontreedefitem', data) - self.assertEqual(0, models.Taxontreedefitem.objects.get(name='Taxonomy Root').rankid) + data = {"name": "Taxonomy Root", "parent": None, "treedef": treedef_taxon} + taxon_root_rank = api.create_obj( + self.collection, self.agent, "taxontreedefitem", data + ) + self.assertEqual( + 0, models.Taxontreedefitem.objects.get(name="Taxonomy Root").rankid + ) # Test adding non-default rank in front of rank 0 - data = { - 'name': 'Invalid', - 'parent': None, - 'treedef': treedef_taxon - } + data = {"name": "Invalid", "parent": None, "treedef": treedef_taxon} with self.assertRaises(TreeBusinessRuleException): - api.create_obj(self.collection, self.agent, 'taxontreedefitem', data) - self.assertEqual(0, models.Taxontreedefitem.objects.filter(name='Invalid').count()) + api.create_obj(self.collection, self.agent, "taxontreedefitem", data) + self.assertEqual( + 0, models.Taxontreedefitem.objects.filter(name="Invalid").count() + ) # Test adding default rank to the end of the heirarchy data = { - 'name': 'Division', - 'parent': api.uri_for_model(models.Taxontreedefitem, taxon_root_rank.id), - 'treedef': treedef_taxon + "name": "Division", + "parent": api.uri_for_model(models.Taxontreedefitem, taxon_root_rank.id), + "treedef": treedef_taxon, } - division_rank = api.create_obj(self.collection, self.agent, 'taxontreedefitem', data) - self.assertEqual(30, models.Taxontreedefitem.objects.get(name='Division').rankid) + division_rank = api.create_obj( + self.collection, self.agent, "taxontreedefitem", data + ) + self.assertEqual( + 30, models.Taxontreedefitem.objects.get(name="Division").rankid + ) # Test adding default rank to the middle of the heirarchy data = { - 'name': 'Kingdom', - 'parent': api.uri_for_model(models.Taxontreedefitem, taxon_root_rank.id), - 'treedef': treedef_taxon + "name": "Kingdom", + "parent": api.uri_for_model(models.Taxontreedefitem, taxon_root_rank.id), + "treedef": treedef_taxon, } - kingdom_rank = api.create_obj(self.collection, self.agent, 'taxontreedefitem', data) - self.assertEqual(10, models.Taxontreedefitem.objects.get(name='Kingdom').rankid) - self.assertEqual(models.Taxontreedefitem.objects.get(name='Division').parent.id, - models.Taxontreedefitem.objects.get(name='Kingdom').id) - self.assertEqual(models.Taxontreedefitem.objects.get(name='Kingdom').parent.id, - models.Taxontreedefitem.objects.get(name='Taxonomy Root').id) + kingdom_rank = api.create_obj( + self.collection, self.agent, "taxontreedefitem", data + ) + self.assertEqual(10, models.Taxontreedefitem.objects.get(name="Kingdom").rankid) + self.assertEqual( + models.Taxontreedefitem.objects.get(name="Division").parent.id, + models.Taxontreedefitem.objects.get(name="Kingdom").id, + ) + self.assertEqual( + models.Taxontreedefitem.objects.get(name="Kingdom").parent.id, + models.Taxontreedefitem.objects.get(name="Taxonomy Root").id, + ) # Test foreign keys for rank in models.Taxontreedefitem.objects.all(): self.assertEqual(treedef_taxon.id, rank.treedef.id) # Create test nodes - pokemon = models.Taxon.objects.create(name='Pokemon', rankid=50, definition=treedef_taxon, - definitionitem=models.Taxontreedefitem.objects.get(name='Taxonomy Root')) - water = models.Taxon.objects.create(name='Water', rankid=100, parent=pokemon, definition=treedef_taxon, - definitionitem=models.Taxontreedefitem.objects.get(name='Kingdom')) - squirtle = models.Taxon.objects.create(name='Squirtle', rankid=150, parent=water, definition=treedef_taxon, - definitionitem=models.Taxontreedefitem.objects.get(name='Division')) - blastoise = models.Taxon.objects.create(name='Blastoise', parent=water, rankid=200, definition=treedef_taxon, - definitionitem=models.Taxontreedefitem.objects.get(name='Division')) - + pokemon = models.Taxon.objects.create( + name="Pokemon", + rankid=50, + definition=treedef_taxon, + definitionitem=models.Taxontreedefitem.objects.get(name="Taxonomy Root"), + ) + water = models.Taxon.objects.create( + name="Water", + rankid=100, + parent=pokemon, + definition=treedef_taxon, + definitionitem=models.Taxontreedefitem.objects.get(name="Kingdom"), + ) + squirtle = models.Taxon.objects.create( + name="Squirtle", + rankid=150, + parent=water, + definition=treedef_taxon, + definitionitem=models.Taxontreedefitem.objects.get(name="Division"), + ) + blastoise = models.Taxon.objects.create( + name="Blastoise", + parent=water, + rankid=200, + definition=treedef_taxon, + definitionitem=models.Taxontreedefitem.objects.get(name="Division"), + ) + # Test full name reconstruction set_fullnames(treedef_taxon, null_only=False, node_number_range=None) if pokemon.fullname is not None: - self.assertEqual('Pokemon', pokemon.fullname) + self.assertEqual("Pokemon", pokemon.fullname) if water.fullname is not None: - self.assertEqual('Water', water.fullname) + self.assertEqual("Water", water.fullname) if squirtle.fullname is not None: - self.assertEqual('Squirtle', squirtle.fullname) + self.assertEqual("Squirtle", squirtle.fullname) if blastoise.fullname is not None: - self.assertEqual('Blastoise', blastoise.fullname) + self.assertEqual("Blastoise", blastoise.fullname) def test_delete_ranks(self): c = Client() c.force_login(self.specifyuser) - treedef_geotimeperiod = models.Geologictimeperiodtreedef.objects.create(name='GeographyTimePeriodTest') + treedef_geotimeperiod = models.Geologictimeperiodtreedef.objects.create( + name="GeographyTimePeriodTest" + ) era_rank = models.Geologictimeperiodtreedefitem.objects.create( - name='Era', - rankid=100, - treedef=treedef_geotimeperiod + name="Era", rankid=100, treedef=treedef_geotimeperiod ) period_rank = models.Geologictimeperiodtreedefitem.objects.create( - name='Period', - rankid=200, - treedef=treedef_geotimeperiod, - parent=era_rank + name="Period", rankid=200, treedef=treedef_geotimeperiod, parent=era_rank ) epoch_rank = models.Geologictimeperiodtreedefitem.objects.create( - name='Epoch', - rankid=300, - treedef=treedef_geotimeperiod, - parent=period_rank + name="Epoch", rankid=300, treedef=treedef_geotimeperiod, parent=period_rank ) age_rank = models.Geologictimeperiodtreedefitem.objects.create( - name='Age', - rankid=400, - treedef=treedef_geotimeperiod, - parent=epoch_rank + name="Age", rankid=400, treedef=treedef_geotimeperiod, parent=epoch_rank ) # Test deleting a rank in the middle of the heirarchy - api.delete_resource(self.collection, self.agent, 'Geologictimeperiodtreedefitem', epoch_rank.id, epoch_rank.version) - self.assertEqual(None, models.Geologictimeperiodtreedefitem.objects.filter(name='Epoch').first()) - self.assertEqual(period_rank.id, models.Geologictimeperiodtreedefitem.objects.get(name='Age').parent.id) + api.delete_resource( + self.collection, + self.agent, + "Geologictimeperiodtreedefitem", + epoch_rank.id, + epoch_rank.version, + ) + self.assertEqual( + None, + models.Geologictimeperiodtreedefitem.objects.filter(name="Epoch").first(), + ) + self.assertEqual( + period_rank.id, + models.Geologictimeperiodtreedefitem.objects.get(name="Age").parent.id, + ) # Test deleting a rank at the end of the heirarchy - api.delete_resource(self.collection, self.agent, 'Geologictimeperiodtreedefitem', age_rank.id, age_rank.version) - self.assertEqual(None, models.Geologictimeperiodtreedefitem.objects.filter(name='Age').first()) + api.delete_resource( + self.collection, + self.agent, + "Geologictimeperiodtreedefitem", + age_rank.id, + age_rank.version, + ) + self.assertEqual( + None, + models.Geologictimeperiodtreedefitem.objects.filter(name="Age").first(), + ) # Test deleting a rank at the head of the heirarchy with self.assertRaises(TreeBusinessRuleException): - api.delete_resource(self.collection, self.agent, 'Geologictimeperiodtreedefitem', era_rank.id, era_rank.version) - \ No newline at end of file + api.delete_resource( + self.collection, + self.agent, + "Geologictimeperiodtreedefitem", + era_rank.id, + era_rank.version, + ) diff --git a/specifyweb/specify/tree_views.py b/specifyweb/specify/tree_views.py index 13d47f59609..c9749129c0f 100644 --- a/specifyweb/specify/tree_views.py +++ b/specifyweb/specify/tree_views.py @@ -163,7 +163,7 @@ def get_tree_rows(treedef, tree, parentid, sortfield, include_author, session): id_col = getattr(node, node._id) child_id = getattr(child, node._id) treedef_col = getattr(node, tree_table.name + "TreeDefID") - orderby = tree_table.name.lower() + "." + sortfield + orderby = getattr(node, tree_table.get_field_strict(sortfield).name) col_args = [ node.name, diff --git a/specifyweb/stored_queries/batch_edit.py b/specifyweb/stored_queries/batch_edit.py index d28c417311a..27dab116201 100644 --- a/specifyweb/stored_queries/batch_edit.py +++ b/specifyweb/stored_queries/batch_edit.py @@ -1,12 +1,18 @@ +# type: ignore + +# ^^ The above is because we etensively use recursive typedefs of named tuple in this file not supported on our MyPy 0.97 version. +# When typechecked in MyPy 1.11 (supports recursive typedefs), there is no type issue in the file. +# However, using 1.11 makes things slower in other files. + from functools import reduce from typing import Any, Callable, Dict, List, NamedTuple, Optional, Tuple, TypedDict from specifyweb.specify.models import datamodel -from specifyweb.specify.load_datamodel import Field, Table +from specifyweb.specify.load_datamodel import Field, Relationship, Table from specifyweb.stored_queries.execution import execute from specifyweb.stored_queries.queryfield import QueryField, fields_from_json from specifyweb.stored_queries.queryfieldspec import ( - FieldSpecJoinPath, QueryFieldSpec, + QueryNode, TreeRankQuery, ) from specifyweb.workbench.models import Spdataset @@ -53,14 +59,14 @@ def _get_nested_order(field_spec: QueryFieldSpec): class BatchEditFieldPack(NamedTuple): field: Optional[QueryField] = None - idx: Optional[int] = None + idx: Optional[int] = None # default value not there, for type safety value: Any = None # stricten this? class BatchEditPack(NamedTuple): id: BatchEditFieldPack - order: Optional[BatchEditFieldPack] = None - version: Optional[BatchEditFieldPack] = None + order: BatchEditFieldPack + version: BatchEditFieldPack # extends a path to contain the last field + for a defined fields @staticmethod @@ -70,17 +76,22 @@ def from_field_spec(field_spec: QueryFieldSpec) -> "BatchEditPack": if batch_edit_fields["id"][1] == 0 or batch_edit_fields["order"][1] == 0: raise Exception("the ID field should always be sorted!") - def extend_callback(field): - return field_spec._replace( - join_path=(*field_spec.join_path, field), date_part=None - ) + def extend_callback(sort_type): + def _callback(field): + return BatchEditPack._query_field( + field_spec._replace( + join_path=(*field_spec.join_path, field), date_part=None + ), + sort_type, + ) + + return _callback new_field_specs = { - key: Func.maybe( - Func.maybe(callback(field_spec), extend_callback), - lambda field_spec: BatchEditFieldPack( - field=BatchEditPack._query_field(field_spec, sort_type) - ), + key: BatchEditFieldPack( + idx=None, + field=Func.maybe(callback(field_spec), extend_callback(sort_type)), + value=None, ) for key, (callback, sort_type) in batch_edit_fields.items() } @@ -102,48 +113,53 @@ def _query_field(field_spec: QueryFieldSpec, sort_type: int): def _index( self, start_idx: int, - current: Tuple[Dict[str, Optional[BatchEditFieldPack]], List[QueryField]], + current: Tuple[Dict[str, BatchEditFieldPack], List[QueryField]], next: Tuple[int, Tuple[str, Tuple[MaybeField, int]]], ): current_dict, fields = current field_idx, (field_name, _) = next - value: Optional[BatchEditFieldPack] = getattr(self, field_name) + value: BatchEditFieldPack = getattr(self, field_name) new_dict = { **current_dict, - field_name: ( - None - if value is None - else value._replace(idx=(field_idx + start_idx), field=None) + field_name: value._replace( + field=None, idx=((field_idx + start_idx) if value.field else None) ), } - new_fields = fields if value is None else [*fields, value.field] + new_fields = fields if value.field is None else [*fields, value.field] return new_dict, new_fields def index_plan(self, start_index=0) -> Tuple["BatchEditPack", List[QueryField]]: + init: Tuple[Dict[str, BatchEditFieldPack], List[QueryField]] = ( + {}, + [], + ) _dict, fields = reduce( lambda accum, next: self._index( start_idx=start_index, current=accum, next=next ), enumerate(batch_edit_fields.items()), - ({}, []), + init, ) return BatchEditPack(**_dict), fields def bind(self, row: Tuple[Any]): return BatchEditPack( - **{ - key: Func.maybe( - getattr(self, key), lambda pack: pack._replace(value=row[pack.idx]) - ) - for key in batch_edit_fields.keys() - } + id=BatchEditFieldPack( + value=row[self.id.idx] if self.id.idx is not None else None + ), + order=BatchEditFieldPack( + value=row[self.order.idx] if self.order.idx is not None else None + ), + version=BatchEditFieldPack( + value=row[self.version.idx] if self.version.idx is not None else None + ), ) def to_json(self) -> Dict[str, Any]: return { "id": self.id.value, - "ordernumber": self.order.value if self.order is not None else None, - "version": self.version.value if self.version is not None else None, + "ordernumber": self.order.value, + "version": self.version.value, } # we not only care that it is part of tree, but also care that there is rank to tree @@ -157,16 +173,19 @@ def is_part_of_tree(self, query_fields: List[QueryField]) -> bool: return False return isinstance(join_path[-2], TreeRankQuery) +# These constants are purely for memory optimization, no code depends and/or cares if this is constant. +EMPTY_FIELD = BatchEditFieldPack() +EMPTY_PACK = BatchEditPack(id=EMPTY_FIELD, order=EMPTY_FIELD, version=EMPTY_FIELD) # FUTURE: this already supports nested-to-many for most part # wb plan, but contains query fields along with indexes to look-up in a result row. # TODO: see if it can be moved + combined with front-end logic. I kept all parsing on backend, but there might be possible beneft in doing this # on the frontend (it already has code from mapping path -> upload plan) class RowPlanMap(NamedTuple): + batch_edit_pack: BatchEditPack columns: List[BatchEditFieldPack] = [] to_one: Dict[str, "RowPlanMap"] = {} to_many: Dict[str, "RowPlanMap"] = {} - batch_edit_pack: Optional[BatchEditPack] = None has_filters: bool = False @staticmethod @@ -194,16 +213,17 @@ def merge( self: "RowPlanMap", other: "RowPlanMap", has_filter_on_parent=False ) -> "RowPlanMap": new_columns = [*self.columns, *other.columns] - batch_edit_pack = self.batch_edit_pack or other.batch_edit_pack + batch_edit_pack = other.batch_edit_pack or self.batch_edit_pack has_self_filters = has_filter_on_parent or self.has_filters or other.has_filters to_one = reduce( RowPlanMap._merge(has_self_filters), other.to_one.items(), self.to_one ) to_many = reduce(RowPlanMap._merge(False), other.to_many.items(), self.to_many) return RowPlanMap( - new_columns, to_one, to_many, batch_edit_pack, has_filters=has_self_filters + batch_edit_pack, new_columns, to_one, to_many, has_filters=has_self_filters ) + @staticmethod def _index( current: Tuple[int, Dict[str, "RowPlanMap"], List[QueryField]], other: Tuple[str, "RowPlanMap"], @@ -231,16 +251,19 @@ def index_plan(self, start_index=1) -> Tuple["RowPlanMap", List[QueryField]]: else (None, []) ) next_index += len(_batch_fields) + init: Callable[[int], Tuple[int, Dict[str, RowPlanMap], List[QueryField]]] = ( + lambda _start: (_start, {}, []) + ) next_index, _to_one, to_one_fields = reduce( RowPlanMap._index, # makes the order deterministic, would be funny otherwise Func.sort_by_key(self.to_one), - (next_index, {}, []), + init(next_index), ) next_index, _to_many, to_many_fields = reduce( - RowPlanMap._index, Func.sort_by_key(self.to_many), (next_index, {}, []) + RowPlanMap._index, Func.sort_by_key(self.to_many), (init(next_index)) ) - column_fields = [column.field for column in self.columns] + column_fields = [column.field for column in self.columns if column.field] return ( RowPlanMap( columns=_columns, @@ -259,8 +282,8 @@ def index_plan(self, start_index=1) -> Tuple["RowPlanMap", List[QueryField]]: # on the colletors table (as a column). Instead, we put it as a column in collectingevent. This has no visual difference (it is unmapped) anyways. @staticmethod def _recur_row_plan( - running_path: FieldSpecJoinPath, - next_path: FieldSpecJoinPath, + running_path: List[QueryNode], # using tuple causes typing issue + next_path: List[QueryNode], next_table: Table, # bc queryfieldspecs will be terminated early on original_field: QueryField, ) -> "RowPlanMap": @@ -269,15 +292,22 @@ def _recur_row_plan( # contains partial path partial_field_spec = original_field_spec._replace( - join_path=running_path, table=next_table + join_path=tuple(running_path), table=next_table ) # to handle CO->(formatted), that's it. this function will never be called with empty path other than top-level formatted/aggregated - node, *rest = (None,) if not next_path else next_path + rest: List[QueryNode] = [] + + if len(next_path) == 0: + node = None + rest = [] + else: + node = next_path[0] + rest = next_path[1:] # we can't edit relationships's formatted/aggregated anyways. batch_edit_pack = ( - None + EMPTY_PACK if original_field_spec.needs_formatted() else BatchEditPack.from_field_spec(partial_field_spec) ) @@ -290,7 +320,9 @@ def _recur_row_plan( has_filters=(original_field.op_num != 8), ) - assert node.is_relationship, "using a non-relationship as a pass through!" + assert isinstance(node, TreeRankQuery) or isinstance( + node, Relationship + ), "using a non-relationship as a pass through!" rel_type = ( "to_many" @@ -301,18 +333,21 @@ def _recur_row_plan( rel_name = ( node.name.lower() if not isinstance(node, TreeRankQuery) else node.name ) - return RowPlanMap( - **{ - rel_type: { - rel_name: RowPlanMap._recur_row_plan( - (*running_path, node), - rest, - datamodel.get_table(node.relatedModelName), - original_field, - ) - }, - "batch_edit_pack": batch_edit_pack, - } + + rest_plan = { + rel_name: RowPlanMap._recur_row_plan( + [*running_path, node], + rest, + datamodel.get_table_strict(node.relatedModelName), + original_field, + ) + } + + boiler = RowPlanMap(columns=[], batch_edit_pack=batch_edit_pack) + return ( + boiler._replace(to_one=rest_plan) + if rel_type == "to_one" + else boiler._replace(to_many=rest_plan) ) # generates multiple row plan maps, and merges them into one @@ -320,16 +355,20 @@ def _recur_row_plan( # instead, see usage of index_plan() which indexes the plan in one go. @staticmethod def get_row_plan(fields: List[QueryField]) -> "RowPlanMap": + start: List[QueryNode] = [] iter = [ RowPlanMap._recur_row_plan( - (), field.fieldspec.join_path, field.fieldspec.root_table, field + start, + list(field.fieldspec.join_path), + field.fieldspec.root_table, + field, ) for field in fields ] return reduce( lambda current, other: current.merge(other, has_filter_on_parent=False), iter, - RowPlanMap(), + RowPlanMap(batch_edit_pack=EMPTY_PACK), ) @staticmethod @@ -342,14 +381,16 @@ def bind(self, row: Tuple[Any]) -> "RowPlanCanonical": columns = [ column._replace(value=row[column.idx], field=None) for column in self.columns + # Careful: this can be 0, so not doing "if not column.idx" + if column.idx is not None ] to_ones = {key: value.bind(row) for (key, value) in self.to_one.items()} to_many = { key: RowPlanMap._bind_null(value.bind(row)) for (key, value) in self.to_many.items() } - pack = self.batch_edit_pack.bind(row) if self.batch_edit_pack else None - return RowPlanCanonical(columns, to_ones, to_many, pack) + pack = self.batch_edit_pack.bind(row) + return RowPlanCanonical(pack, columns, to_ones, to_many) # gets a null record to fill-out empty space # doesn't support nested-to-many's yet - complicated @@ -361,12 +402,12 @@ def nullify(self) -> "RowPlanCanonical": for pack in self.columns ] to_ones = {key: value.nullify() for (key, value) in self.to_one.items()} - batch_edit_pack = ( - BatchEditPack(id=BatchEditFieldPack(value=NULL_RECORD)) - if self.has_filters - else None + batch_edit_pack = BatchEditPack( + id=BatchEditFieldPack(value=(NULL_RECORD if self.has_filters else None)), + order=EMPTY_FIELD, + version=EMPTY_FIELD, ) - return RowPlanCanonical(columns, to_ones, batch_edit_pack=batch_edit_pack) + return RowPlanCanonical(batch_edit_pack, columns, to_ones) # a fake upload plan that keeps track of the maximum ids / order numbrs seen in to-manys def to_many_planner(self) -> "RowPlanMap": @@ -375,16 +416,27 @@ def to_many_planner(self) -> "RowPlanMap": key: RowPlanMap( batch_edit_pack=( BatchEditPack( - order=BatchEditFieldPack(value=0), id=BatchEditFieldPack() + order=BatchEditFieldPack(value=0), + id=EMPTY_FIELD, + version=EMPTY_FIELD, ) - if value.batch_edit_pack.order + if value.batch_edit_pack.order.idx is not None # only use id if order field is not present - else BatchEditPack(id=BatchEditFieldPack(value=0)) + else BatchEditPack( + id=BatchEditFieldPack(value=0), + order=EMPTY_FIELD, + version=EMPTY_FIELD, + ) ) ) for (key, value) in self.to_many.items() } - return RowPlanMap(to_one=to_one, to_many=to_many) + return RowPlanMap( + batch_edit_pack=EMPTY_PACK, + columns=[], + to_one=to_one, + to_many=to_many, + ) # the main data-structure which stores the data @@ -393,14 +445,14 @@ def to_many_planner(self) -> "RowPlanMap": class RowPlanCanonical(NamedTuple): + batch_edit_pack: BatchEditPack columns: List[BatchEditFieldPack] = [] to_one: Dict[str, "RowPlanCanonical"] = {} - to_many: Dict[str, List[Optional["RowPlanCanonical"]]] = {} - batch_edit_pack: Optional[BatchEditPack] = None + to_many: Dict[str, List["RowPlanCanonical"]] = {} @staticmethod def _maybe_extend( - values: List[Optional["RowPlanCanonical"]], + values: List["RowPlanCanonical"], result: Tuple[bool, "RowPlanCanonical"], ): is_new = result[0] @@ -412,7 +464,7 @@ def merge( self, row: Tuple[Any], indexed_plan: RowPlanMap ) -> Tuple[bool, "RowPlanCanonical"]: # nothing to compare against. useful for recursion + handing default null as default value for reduce - if self.batch_edit_pack is None: + if self.batch_edit_pack.id.value is None: return False, indexed_plan.bind(row) # trying to defer actual bind to later @@ -432,12 +484,13 @@ def _reduce_to_one( new_stalled, result = ( (True, value) if is_stalled - else value.merge(row, indexed_plan.to_one.get(key)) + else value.merge(row, indexed_plan.to_one[key]) ) return (is_stalled or new_stalled, {**previous_chain, key: result}) + init: Tuple[bool, Dict[str, RowPlanCanonical]] = (False, {}) to_one_stalled, to_one = reduce( - _reduce_to_one, Func.sort_by_key(self.to_one), (False, {}) + _reduce_to_one, Func.sort_by_key(self.to_one), init ) # the most tricky lines in this file @@ -456,7 +509,7 @@ def _reduce_to_many( (True, values) if is_stalled else RowPlanCanonical._maybe_extend( - values, values[-1].merge(row, indexed_plan.to_many.get(key)) + values, values[-1].merge(row, indexed_plan.to_many[key]) ) ) return ( @@ -469,8 +522,11 @@ def _reduce_to_many( to_many_stalled = True else: # We got stalled early on. + init_to_many: Tuple[ + int, List[Tuple[str, bool, List["RowPlanCanonical"]]] + ] = (0, []) most_length, to_many_result = reduce( - _reduce_to_many, Func.sort_by_key(self.to_many), (0, []) + _reduce_to_many, Func.sort_by_key(self.to_many), init_to_many ) to_many_stalled = ( @@ -481,12 +537,15 @@ def _reduce_to_many( # TODO: explain why those arguments stalled = to_one_stalled or to_many_stalled return stalled, RowPlanCanonical( - self.columns, to_one, to_many, self.batch_edit_pack + self.batch_edit_pack, + self.columns, + to_one, + to_many, ) @staticmethod def _update_id_order(values: List["RowPlanCanonical"], plan: RowPlanMap): - is_id = plan.batch_edit_pack.order is None + is_id = plan.batch_edit_pack.order.value is None new_value = ( len(values) if is_id @@ -501,13 +560,12 @@ def _update_id_order(values: List["RowPlanCanonical"], plan: RowPlanMap): if not is_id else plan.batch_edit_pack.id.value ) + new_pack = BatchEditFieldPack(field=None, value=max(new_value, current_value)) return RowPlanMap( - batch_edit_pack=plan.batch_edit_pack._replace( - **{ - ("id" if is_id else "order"): BatchEditFieldPack( - value=max(new_value, current_value) - ) - } + batch_edit_pack=( + plan.batch_edit_pack._replace(id=new_pack) + if is_id + else plan.batch_edit_pack._replace(order=new_pack) ) ) @@ -515,16 +573,14 @@ def _update_id_order(values: List["RowPlanCanonical"], plan: RowPlanMap): # this is done to expand the rows at the end def update_to_manys(self, to_many_planner: RowPlanMap) -> RowPlanMap: to_one = { - key: value.update_to_manys(to_many_planner.to_one.get(key)) + key: value.update_to_manys(to_many_planner.to_one[key]) for (key, value) in self.to_one.items() } to_many = { - key: RowPlanCanonical._update_id_order( - values, to_many_planner.to_many.get(key) - ) + key: RowPlanCanonical._update_id_order(values, to_many_planner.to_many[key]) for key, values in self.to_many.items() } - return RowPlanMap(to_one=to_one, to_many=to_many) + return RowPlanMap(batch_edit_pack=EMPTY_PACK, to_one=to_one, to_many=to_many) @staticmethod def _extend_id_order( @@ -532,7 +588,7 @@ def _extend_id_order( to_many_planner: RowPlanMap, indexed_plan: RowPlanMap, ) -> List["RowPlanCanonical"]: - is_id = to_many_planner.batch_edit_pack.order is None + is_id = to_many_planner.batch_edit_pack.order.value is None fill_out = None # minor memoization, hehe null_record = indexed_plan.nullify() @@ -573,9 +629,7 @@ def _extend_id_order( _ids = [ value.batch_edit_pack.id.value for value in values - if value - and value.batch_edit_pack - and value.batch_edit_pack.id.value != NULL_RECORD + if isinstance(value.batch_edit_pack.id.value, int) ] if len(_ids) != len(set(_ids)): raise Exception("Inserted duplicate ids") @@ -585,19 +639,21 @@ def extend( self, to_many_planner: RowPlanMap, plan: RowPlanMap ) -> "RowPlanCanonical": to_ones = { - key: value.extend(to_many_planner.to_one.get(key), plan.to_one.get(key)) + key: value.extend(to_many_planner.to_one[key], plan.to_one[key]) for (key, value) in self.to_one.items() } to_many = { key: RowPlanCanonical._extend_id_order( - values, to_many_planner.to_many.get(key), plan.to_many.get(key) + values, to_many_planner.to_many[key], plan.to_many[key] ) for (key, values) in self.to_many.items() } return self._replace(to_one=to_ones, to_many=to_many) @staticmethod - def _make_to_one_flat(callback: Callable[[str, Func.I], Func.O]): + def _make_to_one_flat( + callback: Callable[[str, Func.I], Tuple[List[Any], Dict[str, Func.O]]] + ): def _flat( accum: Tuple[List[Any], Dict[str, Func.O]], current: Tuple[str, Func.I] ): @@ -607,10 +663,12 @@ def _flat( return _flat @staticmethod - def _make_to_many_flat(callback: Callable[[str, Func.I], Func.O]): + def _make_to_many_flat( + callback: Callable[[str, Func.I], Tuple[List[Any], Dict[str, Func.O]]] + ): def _flat( - accum: Tuple[List[Any], Dict[str, Any]], - current: Tuple[str, List["RowPlanCanonical"]], + accum: Tuple[List[Any], Dict[str, Func.O]], + current: Tuple[str, List[Func.I]], ): rel_name, to_many = current to_many_flattened = [callback(rel_name, canonical) for canonical in to_many] @@ -620,12 +678,11 @@ def _flat( return _flat - def flatten(self) -> Tuple[List[Any], Dict[str, Any]]: + def flatten(self) -> Tuple[List[Any], Optional[Dict[str, Any]]]: cols = [col.value for col in self.columns] base_pack = ( self.batch_edit_pack.to_json() - if self.batch_edit_pack is not None - and self.batch_edit_pack.id.value is not None + if self.batch_edit_pack.id.value is not None else None ) @@ -634,8 +691,12 @@ def _flatten(_: str, _self: "RowPlanCanonical"): _to_one_reducer = RowPlanCanonical._make_to_one_flat(_flatten) _to_many_reducer = RowPlanCanonical._make_to_many_flat(_flatten) - to_ones = reduce(_to_one_reducer, Func.sort_by_key(self.to_one), ([], {})) - to_many = reduce(_to_many_reducer, Func.sort_by_key(self.to_many), ([], {})) + + to_one_init: Tuple[List[Any], Dict[str, Any]] = ([], {}) + to_many_init: Tuple[List[Any], Dict[str, List[Any]]] = ([], {}) + + to_ones = reduce(_to_one_reducer, Func.sort_by_key(self.to_one), to_one_init) + to_many = reduce(_to_many_reducer, Func.sort_by_key(self.to_many), to_many_init) all_data = [*cols, *to_ones[0], *to_many[0]] # Removing all the unnecceary keys to save up on the size of the dataset @@ -734,11 +795,21 @@ def _to_upload_plan(rel_name: str, _self: "RowPlanCanonical"): _to_one_reducer = RowPlanCanonical._make_to_one_flat(_to_upload_plan) _to_many_reducer = RowPlanCanonical._make_to_many_flat(_to_upload_plan) + # will don't modify the list directly, so we can use it for both to-one and to-many + headers_init: List[Tuple[Tuple[int, int], str]] = [] + _to_one_table: Dict[str, Uploadable] = {} + to_one_headers, to_one_upload_tables = reduce( - _to_one_reducer, Func.sort_by_key(self.to_one), ([], {}) + _to_one_reducer, + Func.sort_by_key(self.to_one), + (headers_init, _to_one_table), ) + + _to_many_table: Dict[str, List[Uploadable]] = {} to_many_headers, to_many_upload_tables = reduce( - _to_many_reducer, Func.sort_by_key(self.to_many), ([], {}) + _to_many_reducer, + Func.sort_by_key(self.to_many), + (headers_init, _to_many_table), ) raw_headers = [ @@ -748,10 +819,10 @@ def _to_upload_plan(rel_name: str, _self: "RowPlanCanonical"): if intermediary_to_tree: assert len(to_many_upload_tables) == 0, "Found to-many for tree!" - upload_plan = TreeRecord( + upload_plan: Uploadable = TreeRecord( name=base_table.django_name, ranks={ - key: upload_table.wbcols + key: upload_table.wbcols # type: ignore for (key, upload_table) in to_one_upload_tables.items() }, ) @@ -794,7 +865,7 @@ def run_batch_edit(collection, user, spquery, agent): mapped_raws = [ [*row, json.dumps({"batch_edit": pack})] for (row, pack) in zip(rows, packs) ] - regularized_rows = regularize_rows(len(headers), mapped_raws) + regularized_rows = regularize_rows(len(headers), mapped_raws, skip_empty=False) return make_dataset( user=user, collection=collection, @@ -811,11 +882,11 @@ def run_batch_edit(collection, user, spquery, agent): class BatchEditProps(TypedDict): collection: Any user: Any - contexttableid: int = None - captions: Any = None - limit: Optional[int] = 0 - recordsetid: Optional[int] = None - session_maker: Any = models.session_context + contexttableid: int + captions: Any + limit: Optional[int] + recordsetid: Optional[int] + session_maker: Any fields: List[QueryField] @@ -875,7 +946,7 @@ def run_batch_edit_query(props: BatchEditProps): visited_rows: List[RowPlanCanonical] = [] previous_id = None - previous_row = RowPlanCanonical() + previous_row = RowPlanCanonical(EMPTY_PACK) for row in rows["results"]: _, new_row = previous_row.merge(row, indexed) to_many_planner = new_row.update_to_manys(to_many_planner) @@ -891,7 +962,7 @@ def run_batch_edit_query(props: BatchEditProps): visited_rows = visited_rows[1:] assert len(visited_rows) > 0, "nothing to return!" - raw_rows: List[Tuple[List[Any], Dict[str, Any]]] = [] + raw_rows: List[Tuple[List[Any], Optional[Dict[str, Any]]]] = [] for visited_row in visited_rows: extend_row = visited_row.extend(to_many_planner, indexed) row_data, row_batch_edit_pack = extend_row.flatten() @@ -911,7 +982,7 @@ def _get_orig_column(string_id: str): # The keys are lookups into original query field (not modified by us). Used to get ids in the original one. key_and_headers, upload_plan = extend_row.to_upload_plan( - datamodel.get_table_by_id(tableid), + datamodel.get_table_by_id_strict(tableid, strict=True), localization_dump, query_fields, {}, diff --git a/specifyweb/stored_queries/queryfieldspec.py b/specifyweb/stored_queries/queryfieldspec.py index c984ca51eec..6c6872940a8 100644 --- a/specifyweb/stored_queries/queryfieldspec.py +++ b/specifyweb/stored_queries/queryfieldspec.py @@ -17,23 +17,27 @@ # (1) the join path to the specify field. # (2) the name of the table containing the field. # (3) name of the specify field. -STRINGID_RE = re.compile(r'^([^\.]*)\.([^\.]*)\.(.*)$') +STRINGID_RE = re.compile(r"^([^\.]*)\.([^\.]*)\.(.*)$") # A date field name can be suffixed with 'numericday', 'numericmonth' or 'numericyear' # to request a filter on that subportion of the date. -DATE_PART_RE = re.compile(r'(.*)((NumericDay)|(NumericMonth)|(NumericYear))$') +DATE_PART_RE = re.compile(r"(.*)((NumericDay)|(NumericMonth)|(NumericYear))$") + def extract_date_part(fieldname): match = DATE_PART_RE.match(fieldname) if match: fieldname, date_part = match.groups()[:2] - date_part = date_part.replace('Numeric', '') + date_part = date_part.replace("Numeric", "") else: date_part = None return fieldname, date_part + def make_table_list(fs): - path = fs.join_path if not fs.join_path or fs.is_relationship() else fs.join_path[:-1] + path = ( + fs.join_path if not fs.join_path or fs.is_relationship() else fs.join_path[:-1] + ) first = [str(fs.root_table.tableId)] def field_to_elem(field): @@ -43,52 +47,59 @@ def field_to_elem(field): else: return "%d-%s" % (related_model.tableId, field.name.lower()) - rest = [field_to_elem(f) for f in path if not isinstance(f, TreeRankQuery)] - return ','.join(first + rest) + return ",".join(first + rest) + def make_tree_fieldnames(table: Table, reverse=False): - mapping = { - 'ID': table.idFieldName.lower(), - '': 'name' - } + mapping = {"ID": table.idFieldName.lower(), "": "name"} if reverse: return {value: key for (key, value) in mapping.items()} return mapping + def find_tree_and_field(table, fieldname: str): fieldname = fieldname.strip() - if fieldname == '': + if fieldname == "": return None, None - tree_rank_and_field = fieldname.split(' ') + tree_rank_and_field = fieldname.split(" ") mapping = make_tree_fieldnames(table) if len(tree_rank_and_field) == 1: return tree_rank_and_field[0], mapping[""] tree_rank, tree_field = tree_rank_and_field return tree_rank, mapping.get(tree_field, tree_field) + def make_stringid(fs, table_list): tree_ranks = [f.name for f in fs.join_path if isinstance(f, TreeRankQuery)] if tree_ranks: field_name = tree_ranks reverse = make_tree_fieldnames(fs.table, reverse=True) last_field_name = fs.join_path[-1].name - field_name = " ".join([*field_name, reverse.get(last_field_name.lower(), last_field_name)]) + field_name = " ".join( + [*field_name, reverse.get(last_field_name.lower(), last_field_name)] + ) else: # BUG: Malformed previous stringids are rejected. they desrve it. - field_name = (fs.join_path[-1].name if fs.join_path else '') + field_name = fs.join_path[-1].name if fs.join_path else "" if fs.date_part is not None and fs.date_part != "Full Date": - field_name += 'Numeric' + fs.date_part + field_name += "Numeric" + fs.date_part return table_list, fs.table.name.lower(), field_name.strip() + class TreeRankQuery(Relationship): # FUTURE: used to remember what the previous value was. Useless after 6 retires original_field: str pass -FieldSpecJoinPath = Tuple[Union[Field, Relationship, TreeRankQuery]] -class QueryFieldSpec(namedtuple("QueryFieldSpec", "root_table root_sql_table join_path table date_part")): +QueryNode = Union[Field, Relationship, TreeRankQuery] +FieldSpecJoinPath = Tuple[QueryNode] + + +class QueryFieldSpec( + namedtuple("QueryFieldSpec", "root_table root_sql_table join_path table date_part") +): root_table: Table root_sql_table: SQLTable join_path: FieldSpecJoinPath @@ -115,17 +126,20 @@ def from_path(cls, path_in, add_id=False): if add_id: join_path.append(node.idField) - return cls(root_table=root_table, - root_sql_table=getattr(models, root_table.name), - join_path=tuple(join_path), - table=node, - date_part='Full Date' if (join_path and join_path[-1].is_temporal()) else None) - + return cls( + root_table=root_table, + root_sql_table=getattr(models, root_table.name), + join_path=tuple(join_path), + table=node, + date_part=( + "Full Date" if (join_path and join_path[-1].is_temporal()) else None + ), + ) @classmethod def from_stringid(cls, stringid, is_relation): path_str, table_name, field_name = STRINGID_RE.match(stringid).groups() - path = deque(path_str.split(',')) + path = deque(path_str.split(",")) root_table = datamodel.get_table_by_id(int(path.popleft())) if is_relation: @@ -135,65 +149,78 @@ def from_stringid(cls, stringid, is_relation): node = root_table for elem in path: try: - tableid, fieldname = elem.split('-') + tableid, fieldname = elem.split("-") except ValueError: tableid, fieldname = elem, None table = datamodel.get_table_by_id(int(tableid)) - field = node.get_field(fieldname) if fieldname else node.get_field(table.name) + field = ( + node.get_field(fieldname) if fieldname else node.get_field(table.name) + ) join_path.append(field) node = table extracted_fieldname, date_part = extract_date_part(field_name) field = node.get_field(extracted_fieldname, strict=False) - if field is None: # try finding tree + if field is None: # try finding tree tree_rank_name, field = find_tree_and_field(node, extracted_fieldname) if tree_rank_name: tree_rank = TreeRankQuery(name=tree_rank_name) # doesn't make sense to query across ranks of trees. no, it doesn't block a theoretical query like family -> continent tree_rank.relatedModelName = node.name - tree_rank.type = 'many-to-one' + tree_rank.type = "many-to-one" join_path.append(tree_rank) - field = node.get_field(field or 'name') # to replicate 6 for now. + field = node.get_field(field or "name") # to replicate 6 for now. if field is not None: join_path.append(field) if field.is_temporal() and date_part is None: date_part = "Full Date" - result = cls(root_table=root_table, - root_sql_table=getattr(models, root_table.name), - join_path=tuple(join_path), - table=node, - date_part=date_part) - - logger.debug('parsed %s (is_relation %s) to %s. extracted_fieldname = %s', - stringid, is_relation, result, extracted_fieldname) + result = cls( + root_table=root_table, + root_sql_table=getattr(models, root_table.name), + join_path=tuple(join_path), + table=node, + date_part=date_part, + ) + + logger.debug( + "parsed %s (is_relation %s) to %s. extracted_fieldname = %s", + stringid, + is_relation, + result, + extracted_fieldname, + ) return result def __init__(self, *args, **kwargs): - valid_date_parts = ('Full Date', 'Day', 'Month', 'Year', None) + valid_date_parts = ("Full Date", "Day", "Month", "Year", None) assert self.is_temporal() or self.date_part is None - if self.date_part not in valid_date_parts: raise AssertionError( - f"Invalid date part '{self.date_part}'. Expected one of {valid_date_parts}", - {"datePart" : self.date_part, - "validDateParts" : str(valid_date_parts), - "localizationKey" : "invalidDatePart"}) + if self.date_part not in valid_date_parts: + raise AssertionError( + f"Invalid date part '{self.date_part}'. Expected one of {valid_date_parts}", + { + "datePart": self.date_part, + "validDateParts": str(valid_date_parts), + "localizationKey": "invalidDatePart", + }, + ) def to_spquery_attrs(self): table_list = make_table_list(self) stringid = make_stringid(self, table_list) return { - 'tablelist': table_list, - 'stringid': '.'.join(stringid), - 'fieldname': stringid[-1], - 'isrelfld': self.is_relationship() + "tablelist": table_list, + "stringid": ".".join(stringid), + "fieldname": stringid[-1], + "isrelfld": self.is_relationship(), } def to_stringid(self): table_list = make_table_list(self) - return '.'.join(make_stringid(self, table_list)) + return ".".join(make_stringid(self, table_list)) def get_field(self): try: @@ -210,22 +237,33 @@ def is_temporal(self): def is_json(self): field = self.get_field() - return field is not None and field.type == 'json' + return field is not None and field.type == "json" def build_join(self, query, join_path): return query.build_join(self.root_table, self.root_sql_table, join_path) def is_auditlog_obj_format_field(self, formatauditobjs): - return formatauditobjs and self.join_path and self.table.name.lower() == 'spauditlog' and self.get_field().name.lower() in ['oldvalue','newvalue'] + return ( + formatauditobjs + and self.join_path + and self.table.name.lower() == "spauditlog" + and self.get_field().name.lower() in ["oldvalue", "newvalue"] + ) def is_specify_username_end(self): # TODO: Add unit tests. - return self.join_path and self.table.name.lower() == 'specifyuser' and self.join_path[-1].name == 'name' + return ( + self.join_path + and self.table.name.lower() == "specifyuser" + and self.join_path[-1].name == "name" + ) def needs_formatted(self): return len(self.join_path) == 0 or self.is_relationship() - - def apply_filter(self, query, orm_field, field, table, value=None, op_num=None, negate=False): + + def apply_filter( + self, query, orm_field, field, table, value=None, op_num=None, negate=False + ): no_filter = op_num is None or (self.get_field() is None) if not no_filter: if isinstance(value, QueryFieldSpec): @@ -233,7 +271,9 @@ def apply_filter(self, query, orm_field, field, table, value=None, op_num=None, uiformatter = None value = other_field else: - uiformatter = field and get_uiformatter(query.collection, table.name, field.name) + uiformatter = field and get_uiformatter( + query.collection, table.name, field.name + ) value = value op = QueryOps(uiformatter).by_op_num(op_num) @@ -246,7 +286,15 @@ def apply_filter(self, query, orm_field, field, table, value=None, op_num=None, query = query.reset_joinpoint() return query, orm_field, predicate - def add_to_query(self, query, value=None, op_num=None, negate=False, formatter=None, formatauditobjs=False): + def add_to_query( + self, + query, + value=None, + op_num=None, + negate=False, + formatter=None, + formatauditobjs=False, + ): # print "############################################################################" # print "formatauditobjs " + str(formatauditobjs) # if self.get_field() is not None: @@ -256,25 +304,46 @@ def add_to_query(self, query, value=None, op_num=None, negate=False, formatter=N query, orm_field, field, table = self.add_spec_to_query(query, formatter) return self.apply_filter(query, orm_field, field, table, value, op_num, negate) - def add_spec_to_query(self, query, formatter=None, aggregator=None, cycle_detector=[]): + def add_spec_to_query( + self, query, formatter=None, aggregator=None, cycle_detector=[] + ): if self.get_field() is None: - return (*query.objectformatter.objformat( - query, self.root_sql_table, formatter), None, self.root_table) + return ( + *query.objectformatter.objformat(query, self.root_sql_table, formatter), + None, + self.root_table, + ) if self.is_relationship(): # will be formatting or aggregating related objects - if self.get_field().type == 'many-to-one': + if self.get_field().type == "many-to-one": query, orm_model, table, field = self.build_join(query, self.join_path) - query, orm_field = query.objectformatter.objformat(query, orm_model, formatter, cycle_detector) + query, orm_field = query.objectformatter.objformat( + query, orm_model, formatter, cycle_detector + ) else: - query, orm_model, table, field = self.build_join(query, self.join_path[:-1]) - orm_field = query.objectformatter.aggregate(query, self.get_field(), orm_model, aggregator or formatter, cycle_detector) + query, orm_model, table, field = self.build_join( + query, self.join_path[:-1] + ) + orm_field = query.objectformatter.aggregate( + query, + self.get_field(), + orm_model, + aggregator or formatter, + cycle_detector, + ) else: 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, field.name, self.join_path[tree_rank_idx+1:], self) + query, orm_field, field, table = query.handle_tree_field( + orm_model, + table, + field.name, + self.join_path[tree_rank_idx + 1 :], + self, + ) else: orm_field = getattr(orm_model, self.get_field().name) if field.type == "java.sql.Timestamp": diff --git a/specifyweb/stored_queries/tests/__init__.py b/specifyweb/stored_queries/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/specifyweb/stored_queries/tests/static/co_query.json b/specifyweb/stored_queries/tests/static/co_query.json new file mode 100644 index 00000000000..0d53f22fb0f --- /dev/null +++ b/specifyweb/stored_queries/tests/static/co_query.json @@ -0,0 +1,420 @@ +{ + "name": "New Query", + "contextname": "CollectionObject", + "contexttableid": 1, + "selectdistinct": false, + "countonly": true, + "formatauditrecids": false, + "specifyuser": "/api/specify/specifyuser/1/", + "isfavorite": true, + "ordinal": 32767, + "fields": [ + { + "tablelist": "1", + "stringid": "1.collectionobject.", + "fieldname": "", + "isrelfld": false, + "sorttype": 0, + "position": 0, + "isdisplay": true, + "operstart": 8, + "startvalue": "", + "isnot": false + }, + { + "tablelist": "1", + "stringid": "1.collectionobject.catalogedDate", + "fieldname": "catalogedDate", + "isrelfld": false, + "sorttype": 0, + "position": 1, + "isdisplay": true, + "operstart": 8, + "startvalue": "", + "isnot": false + }, + { + "tablelist": "1", + "stringid": "1.collectionobject.catalogedDateNumericDay", + "fieldname": "catalogedDateNumericDay", + "isrelfld": false, + "sorttype": 0, + "position": 2, + "isdisplay": true, + "operstart": 8, + "startvalue": "", + "isnot": false + }, + { + "tablelist": "1", + "stringid": "1.collectionobject.catalogedDateNumericMonth", + "fieldname": "catalogedDateNumericMonth", + "isrelfld": false, + "sorttype": 0, + "position": 3, + "isdisplay": true, + "operstart": 8, + "startvalue": "", + "isnot": false + }, + { + "tablelist": "1", + "stringid": "1.collectionobject.catalogNumber", + "fieldname": "catalogNumber", + "isrelfld": false, + "sorttype": 0, + "position": 4, + "isdisplay": true, + "operstart": 8, + "startvalue": "", + "isnot": false + }, + { + "tablelist": "1", + "stringid": "1.collectionobject.guid", + "fieldname": "guid", + "isrelfld": false, + "sorttype": 0, + "position": 5, + "isdisplay": true, + "operstart": 8, + "startvalue": "", + "isnot": false + }, + { + "tablelist": "1,5-cataloger", + "stringid": "1,5-cataloger.agent.cataloger", + "fieldname": "cataloger", + "isrelfld": true, + "sorttype": 0, + "position": 6, + "isdisplay": true, + "operstart": 8, + "startvalue": "", + "isnot": false + }, + { + "tablelist": "1,5-cataloger", + "stringid": "1,5-cataloger.agent.abbreviation", + "fieldname": "abbreviation", + "isrelfld": false, + "sorttype": 0, + "position": 7, + "isdisplay": true, + "operstart": 8, + "startvalue": "", + "isnot": false + }, + { + "tablelist": "1,5-cataloger", + "stringid": "1,5-cataloger.agent.agentType", + "fieldname": "agentType", + "isrelfld": false, + "sorttype": 0, + "position": 8, + "isdisplay": true, + "operstart": 8, + "startvalue": "", + "isnot": false + }, + { + "tablelist": "1,5-cataloger", + "stringid": "1,5-cataloger.agent.firstName", + "fieldname": "firstName", + "isrelfld": false, + "sorttype": 0, + "position": 9, + "isdisplay": true, + "operstart": 8, + "startvalue": "", + "isnot": false + }, + { + "tablelist": "1,9-determinations", + "stringid": "1,9-determinations.determination.isCurrent", + "fieldname": "isCurrent", + "isrelfld": false, + "sorttype": 0, + "position": 10, + "isdisplay": true, + "operstart": 8, + "startvalue": "", + "isnot": false + }, + { + "tablelist": "1,9-determinations", + "stringid": "1,9-determinations.determination.determinations", + "fieldname": "determinations", + "isrelfld": true, + "sorttype": 0, + "position": 11, + "isdisplay": true, + "operstart": 8, + "startvalue": "", + "isnot": false + }, + { + "tablelist": "1,63-preparations", + "stringid": "1,63-preparations.preparation.preparations", + "fieldname": "preparations", + "isrelfld": true, + "sorttype": 0, + "position": 12, + "isdisplay": true, + "operstart": 8, + "startvalue": "", + "isnot": false + }, + { + "tablelist": "1,63-preparations", + "stringid": "1,63-preparations.preparation.text5", + "fieldname": "text5", + "isrelfld": false, + "sorttype": 0, + "position": 13, + "isdisplay": true, + "operstart": 8, + "startvalue": "", + "isnot": false + }, + { + "tablelist": "1,10", + "stringid": "1,10.collectingevent.collectingEvent", + "fieldname": "collectingEvent", + "isrelfld": true, + "sorttype": 0, + "position": 14, + "isdisplay": true, + "operstart": 8, + "startvalue": "", + "isnot": false + }, + { + "tablelist": "1,10,2", + "stringid": "1,10,2.locality.locality", + "fieldname": "locality", + "isrelfld": true, + "sorttype": 0, + "position": 15, + "isdisplay": true, + "operstart": 8, + "startvalue": "", + "isnot": false + }, + { + "tablelist": "1,10,2", + "stringid": "1,10,2.locality.text2", + "fieldname": "text2", + "isrelfld": false, + "sorttype": 0, + "position": 16, + "isdisplay": true, + "operstart": 8, + "startvalue": "", + "isnot": false + }, + { + "tablelist": "1,10,2", + "stringid": "1,10,2.locality.remarks", + "fieldname": "remarks", + "isrelfld": false, + "sorttype": 0, + "position": 17, + "isdisplay": true, + "operstart": 8, + "startvalue": "", + "isnot": false + }, + { + "tablelist": "1,10,2,3", + "stringid": "1,10,2,3.geography.Continent", + "fieldname": "Continent", + "isrelfld": false, + "sorttype": 0, + "position": 18, + "isdisplay": true, + "operstart": 8, + "startvalue": "", + "isnot": false + }, + { + "tablelist": "1,10,2,3", + "stringid": "1,10,2,3.geography.geographyCode", + "fieldname": "geographyCode", + "isrelfld": false, + "sorttype": 0, + "position": 19, + "isdisplay": true, + "operstart": 8, + "startvalue": "", + "isnot": false + }, + { + "tablelist": "1,10,2,3", + "stringid": "1,10,2,3.geography.Country", + "fieldname": "Country", + "isrelfld": false, + "sorttype": 0, + "position": 20, + "isdisplay": true, + "operstart": 8, + "startvalue": "", + "isnot": false + }, + { + "tablelist": "1,10,2,3", + "stringid": "1,10,2,3.geography.Province", + "fieldname": "Province", + "isrelfld": false, + "sorttype": 0, + "position": 21, + "isdisplay": true, + "operstart": 8, + "startvalue": "", + "isnot": false + }, + { + "tablelist": "1,10,2,3", + "stringid": "1,10,2,3.geography.County", + "fieldname": "County", + "isrelfld": false, + "sorttype": 0, + "position": 22, + "isdisplay": true, + "operstart": 8, + "startvalue": "", + "isnot": false + }, + { + "tablelist": "1,9-determinations,4", + "stringid": "1,9-determinations,4.taxon.Subspecies", + "fieldname": "Subspecies", + "isrelfld": false, + "sorttype": 0, + "position": 23, + "isdisplay": true, + "operstart": 8, + "startvalue": "", + "isnot": false + }, + { + "tablelist": "1,9-determinations,4", + "stringid": "1,9-determinations,4.taxon.Species", + "fieldname": "Species", + "isrelfld": false, + "sorttype": 0, + "position": 24, + "isdisplay": true, + "operstart": 8, + "startvalue": "", + "isnot": false + }, + { + "tablelist": "1,9-determinations,4", + "stringid": "1,9-determinations,4.taxon.Genus", + "fieldname": "Genus", + "isrelfld": false, + "sorttype": 0, + "position": 25, + "isdisplay": true, + "operstart": 8, + "startvalue": "", + "isnot": false + }, + { + "tablelist": "1,10,2", + "stringid": "1,10,2.locality.latitude1", + "fieldname": "latitude1", + "isrelfld": false, + "sorttype": 0, + "position": 26, + "isdisplay": true, + "operstart": 8, + "startvalue": "", + "isnot": false + }, + { + "tablelist": "1,10,2", + "stringid": "1,10,2.locality.longitude1", + "fieldname": "longitude1", + "isrelfld": false, + "sorttype": 0, + "position": 27, + "isdisplay": true, + "operstart": 8, + "startvalue": "", + "isnot": false + }, + { + "tablelist": "1,10,2", + "stringid": "1,10,2.locality.latitude2", + "fieldname": "latitude2", + "isrelfld": false, + "sorttype": 0, + "position": 28, + "isdisplay": true, + "operstart": 8, + "startvalue": "", + "isnot": false + }, + { + "tablelist": "1,10,2", + "stringid": "1,10,2.locality.longitude2", + "fieldname": "longitude2", + "isrelfld": false, + "sorttype": 0, + "position": 29, + "isdisplay": true, + "operstart": 8, + "startvalue": "", + "isnot": false + }, + { + "tablelist": "1,10,2", + "stringid": "1,10,2.locality.latLongType", + "fieldname": "latLongType", + "isrelfld": false, + "sorttype": 0, + "position": 30, + "isdisplay": true, + "operstart": 8, + "startvalue": "", + "isnot": false + }, + { + "tablelist": "1,10,2", + "stringid": "1,10,2.locality.latLongAccuracy", + "fieldname": "latLongAccuracy", + "isrelfld": false, + "sorttype": 0, + "position": 31, + "isdisplay": true, + "operstart": 8, + "startvalue": "", + "isnot": false + }, + { + "tablelist": "1,10,2", + "stringid": "1,10,2.locality.localityId", + "fieldname": "localityId", + "isrelfld": false, + "sorttype": 0, + "position": 32, + "isdisplay": true, + "operstart": 8, + "startvalue": "", + "isnot": false + } + ], + "_tablename": "SpQuery", + "remarks": null, + "searchsynonymy": null, + "smushed": null, + "sqlstr": null, + "timestampcreated": "2024-08-20", + "timestampmodified": null, + "version": 1, + "createdbyagent": null, + "modifiedbyagent": null, + "limit": 40 +} \ No newline at end of file diff --git a/specifyweb/stored_queries/tests/static/co_query_row_plan.py b/specifyweb/stored_queries/tests/static/co_query_row_plan.py new file mode 100644 index 00000000000..e128737659f --- /dev/null +++ b/specifyweb/stored_queries/tests/static/co_query_row_plan.py @@ -0,0 +1,268 @@ +from specifyweb.stored_queries.batch_edit import ( + BatchEditFieldPack, + BatchEditPack, + RowPlanMap, +) + + +row_plan_map = RowPlanMap( + batch_edit_pack=BatchEditPack( + id=BatchEditFieldPack(field=None, idx=11, value=None), + order=BatchEditFieldPack(field=None, idx=None, value=None), + version=BatchEditFieldPack(field=None, idx=12, value=None), + ), + columns=[ + BatchEditFieldPack(field=None, idx=1, value=None), + BatchEditFieldPack(field=None, idx=2, value=None), + BatchEditFieldPack(field=None, idx=3, value=None), + BatchEditFieldPack(field=None, idx=4, value=None), + BatchEditFieldPack(field=None, idx=5, value=None), + BatchEditFieldPack(field=None, idx=6, value=None), + BatchEditFieldPack(field=None, idx=7, value=None), + BatchEditFieldPack(field=None, idx=8, value=None), + BatchEditFieldPack(field=None, idx=9, value=None), + BatchEditFieldPack(field=None, idx=10, value=None), + ], + to_one={ + "cataloger": RowPlanMap( + batch_edit_pack=BatchEditPack( + id=BatchEditFieldPack(field=None, idx=16, value=None), + order=BatchEditFieldPack(field=None, idx=None, value=None), + version=BatchEditFieldPack(field=None, idx=17, value=None), + ), + columns=[ + BatchEditFieldPack(field=None, idx=13, value=None), + BatchEditFieldPack(field=None, idx=14, value=None), + BatchEditFieldPack(field=None, idx=15, value=None), + ], + to_one={}, + to_many={}, + has_filters=False, + ), + "collectingevent": RowPlanMap( + batch_edit_pack=BatchEditPack( + id=BatchEditFieldPack(field=None, idx=19, value=None), + order=BatchEditFieldPack(field=None, idx=None, value=None), + version=BatchEditFieldPack(field=None, idx=20, value=None), + ), + columns=[BatchEditFieldPack(field=None, idx=18, value=None)], + to_one={ + "locality": RowPlanMap( + batch_edit_pack=BatchEditPack( + id=BatchEditFieldPack(field=None, idx=30, value=None), + order=BatchEditFieldPack(field=None, idx=None, value=None), + version=BatchEditFieldPack(field=None, idx=31, value=None), + ), + columns=[ + BatchEditFieldPack(field=None, idx=21, value=None), + BatchEditFieldPack(field=None, idx=22, value=None), + BatchEditFieldPack(field=None, idx=23, value=None), + BatchEditFieldPack(field=None, idx=24, value=None), + BatchEditFieldPack(field=None, idx=25, value=None), + BatchEditFieldPack(field=None, idx=26, value=None), + BatchEditFieldPack(field=None, idx=27, value=None), + BatchEditFieldPack(field=None, idx=28, value=None), + BatchEditFieldPack(field=None, idx=29, value=None), + ], + to_one={ + "geography": RowPlanMap( + batch_edit_pack=BatchEditPack( + id=BatchEditFieldPack(field=None, idx=33, value=None), + order=BatchEditFieldPack( + field=None, idx=None, value=None + ), + version=BatchEditFieldPack( + field=None, idx=34, value=None + ), + ), + columns=[ + BatchEditFieldPack(field=None, idx=32, value=None) + ], + to_one={ + "Continent": RowPlanMap( + batch_edit_pack=BatchEditPack( + id=BatchEditFieldPack( + field=None, idx=36, value=None + ), + order=BatchEditFieldPack( + field=None, idx=None, value=None + ), + version=BatchEditFieldPack( + field=None, idx=37, value=None + ), + ), + columns=[ + BatchEditFieldPack( + field=None, idx=35, value=None + ) + ], + to_one={}, + to_many={}, + has_filters=False, + ), + "Country": RowPlanMap( + batch_edit_pack=BatchEditPack( + id=BatchEditFieldPack( + field=None, idx=39, value=None + ), + order=BatchEditFieldPack( + field=None, idx=None, value=None + ), + version=BatchEditFieldPack( + field=None, idx=40, value=None + ), + ), + columns=[ + BatchEditFieldPack( + field=None, idx=38, value=None + ) + ], + to_one={}, + to_many={}, + has_filters=False, + ), + "County": RowPlanMap( + batch_edit_pack=BatchEditPack( + id=BatchEditFieldPack( + field=None, idx=42, value=None + ), + order=BatchEditFieldPack( + field=None, idx=None, value=None + ), + version=BatchEditFieldPack( + field=None, idx=43, value=None + ), + ), + columns=[ + BatchEditFieldPack( + field=None, idx=41, value=None + ) + ], + to_one={}, + to_many={}, + has_filters=False, + ), + "Province": RowPlanMap( + batch_edit_pack=BatchEditPack( + id=BatchEditFieldPack( + field=None, idx=45, value=None + ), + order=BatchEditFieldPack( + field=None, idx=None, value=None + ), + version=BatchEditFieldPack( + field=None, idx=46, value=None + ), + ), + columns=[ + BatchEditFieldPack( + field=None, idx=44, value=None + ) + ], + to_one={}, + to_many={}, + has_filters=False, + ), + }, + to_many={}, + has_filters=False, + ) + }, + to_many={}, + has_filters=False, + ) + }, + to_many={}, + has_filters=False, + ), + }, + to_many={ + "determinations": RowPlanMap( + batch_edit_pack=BatchEditPack( + id=BatchEditFieldPack(field=None, idx=48, value=None), + order=BatchEditFieldPack(field=None, idx=None, value=None), + version=BatchEditFieldPack(field=None, idx=49, value=None), + ), + columns=[BatchEditFieldPack(field=None, idx=47, value=None)], + to_one={ + "taxon": RowPlanMap( + batch_edit_pack=BatchEditPack( + id=BatchEditFieldPack(field=None, idx=50, value=None), + order=BatchEditFieldPack(field=None, idx=None, value=None), + version=BatchEditFieldPack(field=None, idx=51, value=None), + ), + columns=[], + to_one={ + "Genus": RowPlanMap( + batch_edit_pack=BatchEditPack( + id=BatchEditFieldPack(field=None, idx=53, value=None), + order=BatchEditFieldPack( + field=None, idx=None, value=None + ), + version=BatchEditFieldPack( + field=None, idx=54, value=None + ), + ), + columns=[ + BatchEditFieldPack(field=None, idx=52, value=None) + ], + to_one={}, + to_many={}, + has_filters=False, + ), + "Species": RowPlanMap( + batch_edit_pack=BatchEditPack( + id=BatchEditFieldPack(field=None, idx=56, value=None), + order=BatchEditFieldPack( + field=None, idx=None, value=None + ), + version=BatchEditFieldPack( + field=None, idx=57, value=None + ), + ), + columns=[ + BatchEditFieldPack(field=None, idx=55, value=None) + ], + to_one={}, + to_many={}, + has_filters=False, + ), + "Subspecies": RowPlanMap( + batch_edit_pack=BatchEditPack( + id=BatchEditFieldPack(field=None, idx=59, value=None), + order=BatchEditFieldPack( + field=None, idx=None, value=None + ), + version=BatchEditFieldPack( + field=None, idx=60, value=None + ), + ), + columns=[ + BatchEditFieldPack(field=None, idx=58, value=None) + ], + to_one={}, + to_many={}, + has_filters=False, + ), + }, + to_many={}, + has_filters=False, + ) + }, + to_many={}, + has_filters=False, + ), + "preparations": RowPlanMap( + batch_edit_pack=BatchEditPack( + id=BatchEditFieldPack(field=None, idx=62, value=None), + order=BatchEditFieldPack(field=None, idx=None, value=None), + version=BatchEditFieldPack(field=None, idx=63, value=None), + ), + columns=[BatchEditFieldPack(field=None, idx=61, value=None)], + to_one={}, + to_many={}, + has_filters=False, + ), + }, + has_filters=False, +) diff --git a/specifyweb/stored_queries/tests/test_batch_edit.py b/specifyweb/stored_queries/tests/test_batch_edit.py index 1dd33d6ee0e..6f87734bab8 100644 --- a/specifyweb/stored_queries/tests/test_batch_edit.py +++ b/specifyweb/stored_queries/tests/test_batch_edit.py @@ -19,11 +19,29 @@ from specifyweb.workbench.upload.upload_plan_schema import schema from jsonschema import validate +from specifyweb.stored_queries.tests.static.co_query_row_plan import row_plan_map + def apply_visual_order(headers, order): return [headers[col] for col in order] +def props_builder(self, session_maker): + def _builder(query_fields, base_table): + return BatchEditProps( + collection=self.collection, + user=self.specifyuser, + contexttableid=datamodel.get_table_strict(base_table).tableId, + fields=query_fields, + session_maker=session_maker, + captions=None, + limit=None, + recordsetid=None, + ) + + return _builder + + # NOTES: Yes, it is more convenient to hard code ids (instead of defining variables.). # But, using variables can make bugs apparent # what if a object doesn't appear in the resulsts? Using the variables will trigger IDEs unused variable warning, making things more safer @@ -52,165 +70,18 @@ def _create(model, kwargs): collection=self.collection, ) - self.build_props = lambda query_fields, base_table: BatchEditProps( - collection=self.collection, - user=self.specifyuser, - contexttableid=datamodel.get_table_strict(base_table).tableId, - fields=query_fields, - session_maker=QueryConstructionTests.test_session_context, - captions=None, - limit=None, - recordsetid=None, + self.build_props = props_builder( + self, QueryConstructionTests.test_session_context ) def test_query_construction(self): - query = json.load( - open("specifyweb/stored_queries/tests/static/test_co_query.json") - ) + query = json.load(open("specifyweb/stored_queries/tests/static/co_query.json")) query_fields = fields_from_json(query["fields"]) visible_fields = [field for field in query_fields if field.display] row_plan = RowPlanMap.get_row_plan(visible_fields) plan, fields = row_plan.index_plan() - prev_plan = RowPlanMap( - columns=[ - BatchEditFieldPack(field=None, idx=1, value=None), - BatchEditFieldPack(field=None, idx=2, value=None), - BatchEditFieldPack(field=None, idx=3, value=None), - BatchEditFieldPack(field=None, idx=4, value=None), - BatchEditFieldPack(field=None, idx=5, value=None), - BatchEditFieldPack(field=None, idx=6, value=None), - ], - to_one={ - "cataloger": RowPlanMap( - columns=[ - BatchEditFieldPack(field=None, idx=9, value=None), - BatchEditFieldPack(field=None, idx=10, value=None), - BatchEditFieldPack(field=None, idx=11, value=None), - ], - to_one={}, - to_many={ - "collectors": RowPlanMap( - columns=[ - BatchEditFieldPack(field=None, idx=14, value=None), - BatchEditFieldPack(field=None, idx=15, value=None), - ], - to_one={ - "collectingevent": RowPlanMap( - columns=[ - BatchEditFieldPack( - field=None, idx=19, value=None - ) - ], - to_one={}, - to_many={}, - batch_edit_pack=BatchEditPack( - id=BatchEditFieldPack( - field=None, idx=20, value=None - ), - order=None, - version=BatchEditFieldPack( - field=None, idx=21, value=None - ), - ), - has_filters=False, - ) - }, - to_many={}, - batch_edit_pack=BatchEditPack( - id=BatchEditFieldPack(field=None, idx=16, value=None), - order=BatchEditFieldPack( - field=None, idx=18, value=None - ), - version=BatchEditFieldPack( - field=None, idx=17, value=None - ), - ), - has_filters=False, - ) - }, - batch_edit_pack=BatchEditPack( - id=BatchEditFieldPack(field=None, idx=12, value=None), - order=None, - version=BatchEditFieldPack(field=None, idx=13, value=None), - ), - has_filters=False, - ), - "collectingevent": RowPlanMap( - columns=[], - to_one={ - "locality": RowPlanMap( - columns=[ - BatchEditFieldPack(field=None, idx=24, value=None), - BatchEditFieldPack(field=None, idx=25, value=None), - BatchEditFieldPack(field=None, idx=26, value=None), - BatchEditFieldPack(field=None, idx=27, value=None), - BatchEditFieldPack(field=None, idx=28, value=None), - BatchEditFieldPack(field=None, idx=29, value=None), - BatchEditFieldPack(field=None, idx=30, value=None), - BatchEditFieldPack(field=None, idx=31, value=None), - BatchEditFieldPack(field=None, idx=32, value=None), - ], - to_one={}, - to_many={}, - batch_edit_pack=BatchEditPack( - id=BatchEditFieldPack(field=None, idx=33, value=None), - order=None, - version=BatchEditFieldPack( - field=None, idx=34, value=None - ), - ), - has_filters=False, - ) - }, - to_many={}, - batch_edit_pack=BatchEditPack( - id=BatchEditFieldPack(field=None, idx=22, value=None), - order=None, - version=BatchEditFieldPack(field=None, idx=23, value=None), - ), - has_filters=False, - ), - }, - to_many={ - "determinations": RowPlanMap( - columns=[ - BatchEditFieldPack(field=None, idx=35, value=None), - BatchEditFieldPack(field=None, idx=36, value=None), - ], - to_one={}, - to_many={}, - batch_edit_pack=BatchEditPack( - id=BatchEditFieldPack(field=None, idx=37, value=None), - order=None, - version=BatchEditFieldPack(field=None, idx=38, value=None), - ), - has_filters=False, - ), - "preparations": RowPlanMap( - columns=[ - BatchEditFieldPack(field=None, idx=39, value=None), - BatchEditFieldPack(field=None, idx=40, value=None), - ], - to_one={}, - to_many={}, - batch_edit_pack=BatchEditPack( - id=BatchEditFieldPack(field=None, idx=41, value=None), - order=None, - version=BatchEditFieldPack(field=None, idx=42, value=None), - ), - has_filters=False, - ), - }, - batch_edit_pack=BatchEditPack( - id=BatchEditFieldPack(field=None, idx=7, value=None), - order=None, - version=BatchEditFieldPack(field=None, idx=8, value=None), - ), - has_filters=False, - ) - - self.assertEqual(plan, prev_plan) + self.assertEqual(plan, row_plan_map) def test_basic_run(self): base_table = "collectionobject" diff --git a/specifyweb/stored_queries/tests/tests.py b/specifyweb/stored_queries/tests/tests.py index 68ed4496699..eee42b28fbd 100644 --- a/specifyweb/stored_queries/tests/tests.py +++ b/specifyweb/stored_queries/tests/tests.py @@ -29,11 +29,14 @@ def setup_sqlalchemy(url: str): - engine = sqlalchemy.create_engine(url, pool_recycle=settings.SA_POOL_RECYCLE, - connect_args={'cursorclass': SSCursor}) + engine = sqlalchemy.create_engine( + url, + pool_recycle=settings.SA_POOL_RECYCLE, + connect_args={"cursorclass": SSCursor}, + ) # BUG: Raise 0-row exception somewhere here. - @event.listens_for(engine, 'before_cursor_execute', retval=True) + @event.listens_for(engine, "before_cursor_execute", retval=True) # Listen to low-level cursor execution events. Just before query is executed by SQLAlchemy, run it instead # by Django, and then return a wrapped sql statement which will return the same result set. def run_django_query(conn, cursor, statement, parameters, context, executemany): @@ -48,13 +51,29 @@ def run_django_query(conn, cursor, statement, parameters, context, executemany): # SqlAlchemy needs to find columns back in the rows, hence adding label to columns selects = [ sqlalchemy.select( - [(sqlalchemy.sql.null() if column is None else sqlalchemy.literal(column)).label(columns[idx][0]) - for idx, column in enumerate(row)]) for row in result_set] + [ + ( + sqlalchemy.sql.null() + if column is None + else sqlalchemy.literal(column) + ).label(columns[idx][0]) + for idx, column in enumerate(row) + ] + ) + for row in result_set + ] # union all instead of union because rows can be duplicated in the original query, # but still need to preserve the duplication unioned = sqlalchemy.union_all(*selects) # Tests will fail when migrated to different background. TODO: Auto-detect dialects - final_query = str(unioned.compile(compile_kwargs={"literal_binds": True, }, dialect=mysql.dialect())) + final_query = str( + unioned.compile( + compile_kwargs={ + "literal_binds": True, + }, + dialect=mysql.dialect(), + ) + ) return final_query, () @@ -85,21 +104,28 @@ def test_collection_object_count(self): with SQLAlchemySetupTest.test_session_context() as session: co_aliased = orm.aliased(models.CollectionObject) - sa_collection_objects = list(session.query(co_aliased._id).filter( - co_aliased.collectionMemberId == self.collection.id)) - sa_ids = [_id for (_id, ) in sa_collection_objects] + sa_collection_objects = list( + session.query(co_aliased._id).filter( + co_aliased.collectionMemberId == self.collection.id + ) + ) + sa_ids = [_id for (_id,) in sa_collection_objects] ids = [co.id for co in self.collectionobjects] self.assertEqual(sa_ids, ids) - min_co_id, = session.query( - sqlalchemy.sql.func.min(co_aliased.collectionObjectId)).filter( - co_aliased.collectionMemberId == self.collection.id).first() + (min_co_id,) = ( + session.query(sqlalchemy.sql.func.min(co_aliased.collectionObjectId)) + .filter(co_aliased.collectionMemberId == self.collection.id) + .first() + ) self.assertEqual(min_co_id, min(ids)) - max_co_id, = session.query( - sqlalchemy.sql.func.max(co_aliased.collectionObjectId)).filter( - co_aliased.collectionMemberId == self.collection.id).first() + (max_co_id,) = ( + session.query(sqlalchemy.sql.func.max(co_aliased.collectionObjectId)) + .filter(co_aliased.collectionMemberId == self.collection.id) + .first() + ) self.assertEqual(max_co_id, max(ids)) @@ -107,22 +133,24 @@ def test_collection_object_count(self): class SQLAlchemyModelTest(TestCase): @staticmethod - def test_sqlalchemy_model(datamodel_table): + def validate_sqlalchemy_model(datamodel_table): table_errors = { - 'not_found': [], # Fields / Relationships not found - 'incorrect_direction': {}, # Relationship direct not correct - 'incorrect_columns': {}, # Relationship columns not correct - 'incorrect_table': {} # Relationship related model not correct + "not_found": [], # Fields / Relationships not found + "incorrect_direction": {}, # Relationship direct not correct + "incorrect_columns": {}, # Relationship columns not correct + "incorrect_table": {}, # Relationship related model not correct } orm_table = orm.aliased(getattr(models, datamodel_table.name)) known_fields = datamodel_table.all_fields for field in known_fields: - in_sql = getattr(orm_table, field.name, None) or getattr(orm_table, field.name.lower(), None) + in_sql = getattr(orm_table, field.name, None) or getattr( + orm_table, field.name.lower(), None + ) if in_sql is None: - table_errors['not_found'].append(field.name) + table_errors["not_found"].append(field.name) continue if not field.is_relationship: @@ -131,39 +159,60 @@ def test_sqlalchemy_model(datamodel_table): sa_relationship = inspect(in_sql).property sa_direction = sa_relationship.direction.name.lower() - datamodel_direction = field.type.replace('-', '').lower() + datamodel_direction = field.type.replace("-", "").lower() if sa_direction != datamodel_direction: - table_errors['incorrect_direction'][field.name] = [sa_direction, datamodel_direction] - print(f"Incorrect direction: {field.name} {sa_direction} {datamodel_direction}") + table_errors["incorrect_direction"][field.name] = [ + sa_direction, + datamodel_direction, + ] + print( + f"Incorrect direction: {field.name} {sa_direction} {datamodel_direction}" + ) remote_sql_table = sa_relationship.target.name.lower() remote_datamodel_table = field.relatedModelName.lower() if remote_sql_table.lower() != remote_datamodel_table: # Check case where the relation model's name is different from the DB table name - remote_sql_table = sa_relationship.mapper._log_desc.split('(')[1].split('|')[0].lower() + remote_sql_table = ( + sa_relationship.mapper._log_desc.split("(")[1].split("|")[0].lower() + ) if remote_sql_table.lower() != remote_datamodel_table: - table_errors['incorrect_table'][field.name] = [remote_sql_table, remote_datamodel_table] - print(f"Incorrect table: {field.name} {remote_sql_table} {remote_datamodel_table}") + table_errors["incorrect_table"][field.name] = [ + remote_sql_table, + remote_datamodel_table, + ] + print( + f"Incorrect table: {field.name} {remote_sql_table} {remote_datamodel_table}" + ) sa_column = list(sa_relationship.local_columns)[0].name if sa_column.lower() != ( - datamodel_table.idColumn.lower() if not getattr(field, 'column', None) else field.column.lower()): - table_errors['incorrect_columns'][field.name] = [sa_column, datamodel_table.idColumn.lower(), - getattr(field, 'column', None)] + datamodel_table.idColumn.lower() + if not getattr(field, "column", None) + else field.column.lower() + ): + table_errors["incorrect_columns"][field.name] = [ + sa_column, + datamodel_table.idColumn.lower(), + getattr(field, "column", None), + ] print( - f"Incorrect columns: {field.name} {sa_column} {datamodel_table.idColumn.lower()} {getattr(field, 'column', None)}") + f"Incorrect columns: {field.name} {sa_column} {datamodel_table.idColumn.lower()} {getattr(field, 'column', None)}" + ) 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: - table_errors = SQLAlchemyModelTest.test_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: - table_errors['not_found'] = sorted(table_errors['not_found']) + table_errors = SQLAlchemyModelTest.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: + table_errors["not_found"] = sorted(table_errors["not_found"]) if table_errors: self.assertDictEqual(table_errors, expected_errors[table.name]) @@ -173,123 +222,39 @@ def test_sqlalchemy_model_errors(self): "incorrect_table": { "dnaSequencingRunAttachments": [ "dnasequencerunattachment", - "dnasequencingrunattachment" + "dnasequencingrunattachment", ] } }, - "AutoNumberingScheme": { - "not_found": [ - "collections", - "disciplines", - "divisions" - ] - }, - "Collection": { - "not_found": [ - "numberingSchemes", - "userGroups" - ] - }, - "CollectionObject": { - "not_found": [ - "projects" - ] - }, + "AutoNumberingScheme": {"not_found": ["collections", "disciplines", "divisions"]}, + "Collection": {"not_found": ["numberingSchemes", "userGroups"]}, + "CollectionObject": {"not_found": ["projects"]}, "DNASequencingRun": { "incorrect_table": { - "attachments": [ - "dnasequencerunattachment", - "dnasequencingrunattachment" - ] + "attachments": ["dnasequencerunattachment", "dnasequencingrunattachment"] } }, "Discipline": { - "not_found": [ - "numberingSchemes", - "userGroups" - ], - "incorrect_direction": { - "taxonTreeDef": [ - "manytoone", - "onetoone" - ] - } - }, - "Division": { - "not_found": [ - "numberingSchemes", - "userGroups" - ] - }, - "Institution": { - "not_found": [ - "userGroups" - ] - }, - "InstitutionNetwork": { - "not_found": [ - "collections", - "contacts" - ] + "not_found": ["numberingSchemes", "userGroups"], + "incorrect_direction": {"taxonTreeDef": ["manytoone", "onetoone"]}, }, + "Division": {"not_found": ["numberingSchemes", "userGroups"]}, + "Institution": {"not_found": ["userGroups"]}, + "InstitutionNetwork": {"not_found": ["collections", "contacts"]}, "Locality": { "incorrect_direction": { - "geoCoordDetails": [ - "onetomany", - "zerotoone" - ], - "localityDetails": [ - "onetomany", - "zerotoone" - ] + "geoCoordDetails": ["onetomany", "zerotoone"], + "localityDetails": ["onetomany", "zerotoone"], } }, - "Project": { - "not_found": [ - "collectionObjects" - ] - }, - "SpExportSchema": { - "not_found": [ - "spExportSchemaMappings" - ] - }, - "SpExportSchemaMapping": { - "not_found": [ - "spExportSchemas" - ] - }, - "SpPermission": { - "not_found": [ - "principals" - ] - }, - "SpPrincipal": { - "not_found": [ - "permissions", - "scope", - "specifyUsers" - ] - }, + "Project": {"not_found": ["collectionObjects"]}, + "SpExportSchema": {"not_found": ["spExportSchemaMappings"]}, + "SpExportSchemaMapping": {"not_found": ["spExportSchemas"]}, + "SpPermission": {"not_found": ["principals"]}, + "SpPrincipal": {"not_found": ["permissions", "scope", "specifyUsers"]}, "SpReport": { - "incorrect_direction": { - "workbenchTemplate": [ - "manytoone", - "onetoone" - ] - } + "incorrect_direction": {"workbenchTemplate": ["manytoone", "onetoone"]} }, - "SpecifyUser": { - "not_found": [ - "spPrincipals" - ] - }, - "TaxonTreeDef": { - "incorrect_direction": { - "discipline": [ - "onetomany", - "onetoone" - ] - } - } + "SpecifyUser": {"not_found": ["spPrincipals"]}, + "TaxonTreeDef": {"incorrect_direction": {"discipline": ["onetomany", "onetoone"]}}, } diff --git a/specifyweb/workbench/upload/predicates.py b/specifyweb/workbench/upload/predicates.py index 0e2c0504e82..a71ac256888 100644 --- a/specifyweb/workbench/upload/predicates.py +++ b/specifyweb/workbench/upload/predicates.py @@ -246,7 +246,7 @@ def resolve_reference_attributes(fields_to_skip, model, reference_record) -> Dic all_fields = [ field.attname for field in model._meta.get_fields(include_hidden=True) - if field.concrete and (field.attname not in fields_to_skip) + if field.concrete and (field.attname not in fields_to_skip and field.name not in fields_to_skip) ] clone_attrs = { diff --git a/specifyweb/workbench/upload/tests/test_batch_edit_table.py b/specifyweb/workbench/upload/tests/test_batch_edit_table.py index 4cdc007967a..ed998ae13cd 100644 --- a/specifyweb/workbench/upload/tests/test_batch_edit_table.py +++ b/specifyweb/workbench/upload/tests/test_batch_edit_table.py @@ -1,10 +1,11 @@ +from typing import Literal, Union from unittest.mock import patch -from specifyweb.context.remote_prefs import get_remote_prefs from specifyweb.specify.func import Func from specifyweb.specify.tests.test_api import get_table -from specifyweb.stored_queries.batch_edit import BatchEditPack, run_batch_edit_query +from specifyweb.stored_queries.batch_edit import run_batch_edit_query # type: ignore +from specifyweb.stored_queries.queryfield import QueryField from specifyweb.stored_queries.queryfieldspec import QueryFieldSpec -from specifyweb.stored_queries.tests.test_batch_edit import QueryConstructionTests +from specifyweb.stored_queries.tests.test_batch_edit import props_builder from specifyweb.stored_queries.tests.tests import SQLAlchemySetup from specifyweb.workbench.upload.preferences import DEFER_KEYS from specifyweb.workbench.upload.tests.base import UploadTestsBase @@ -20,8 +21,8 @@ ReportInfo, Uploaded, Updated, - Matched, - UploadResult + Matched, + UploadResult, ) from specifyweb.workbench.upload.upload_table import UploadTable from specifyweb.workbench.views import regularize_rows @@ -29,22 +30,36 @@ from jsonschema import validate # type: ignore -from specifyweb.specify.models import Spauditlogfield, Collectionobject, Agent, Determination, Preparation, Collectingeventattribute, Collectingevent, Address, Agentspecialty +from specifyweb.specify.models import ( + Spauditlogfield, + Collectionobject, + Agent, + Determination, + Preptype, + Preparation, + Collectingeventattribute, + Collectingevent, + Address, + Agentspecialty, +) lookup_in_auditlog = lambda model, _id: get_table("Spauditlog").objects.filter( recordid=_id, tablenum=get_table(model).specify_model.tableId ) -def make_defer(match, null, force: DEFER_KEYS=None): + +def make_defer(match, null, force: DEFER_KEYS = None): def _defer(key: DEFER_KEYS): if force and key == DEFER_KEYS: raise Exception(f"Did not epect {key}") - if key == 'match': + if key == "match": return match - elif key == 'null_check': + elif key == "null_check": return null + return _defer + class UpdateTests(UploadTestsBase): def test_basic_save(self): @@ -169,6 +184,7 @@ def test_basic_save(self): ).count(), ) + class OneToOneUpdateTests(UploadTestsBase): def setUp(self): super().setUp() @@ -190,19 +206,13 @@ def setUp(self): ) }, ) - + def inserted_to_pack(self, inserted): return [ { "self": {"id": co.id}, - "to_one": { - "collectionobjectattribute": { - "self": { - "id": coa.id - } - } - } - } + "to_one": {"collectionobjectattribute": {"self": {"id": coa.id}}}, + } for (co, coa) in inserted ] @@ -214,8 +224,8 @@ def make_co_coa_pair(self, data): ) co = get_table("Collectionobject").objects.create( collection=self.collection, - catalognumber=record['catno'].zfill(9), - collectionobjectattribute=coa + catalognumber=record["catno"].zfill(9), + collectionobjectattribute=coa, ) inserted.append((co, coa)) return inserted @@ -246,145 +256,300 @@ def test_one_to_one_updates(self): self.collection, data, plan, self.agent.id, batch_edit_packs=batch_edit_pack ) - correct = [(NoChange, coa_result) for coa_result in [Updated, NoChange, Updated, Uploaded]] + correct = [ + (NoChange, coa_result) + for coa_result in [Updated, NoChange, Updated, Uploaded] + ] for _id, result in enumerate(zip(results, correct)): top, (co_result, coa_result) = result msg = f"failed at {_id}" self.assertIsInstance(top.record_result, co_result, msg) - self.assertIsInstance(top.toOne['collectionobjectattribute'].record_result, coa_result, msg) - coa_id = top.toOne['collectionobjectattribute'].record_result.get_id() + self.assertIsInstance( + top.toOne["collectionobjectattribute"].record_result, coa_result, msg + ) + coa_id = top.toOne["collectionobjectattribute"].record_result.get_id() # Do a fresh sync and assert that the relationship was truly established - self.assertEqual(get_table("Collectionobject").objects.get(id=top.record_result.get_id()).collectionobjectattribute_id, coa_id) + self.assertEqual( + get_table("Collectionobject") + .objects.get(id=top.record_result.get_id()) + .collectionobjectattribute_id, + coa_id, + ) def test_one_to_one_deleting_no_hidden(self): - + # We don't epect matching to happen. Also, match is a lower "priority". # The code is smart enough to be as strict as possible when there is ambiguity. This tests that. - for defer in [make_defer(match=True, null=False, force='match'), make_defer(match=True, null=True, force='match')]: - - data = [dict(catno="9090", number=''), dict(catno="22222", number=''), dict(catno="122", number='')] + for defer in [ + make_defer(match=True, null=False, force="match"), + make_defer(match=True, null=True, force="match"), + ]: + + data = [ + dict(catno="9090", number=""), + dict(catno="22222", number=""), + dict(catno="122", number=""), + ] inserted = self.make_co_coa_pair(data) batch_edit_pack = self.inserted_to_pack(inserted) - + self._update(inserted[0][1], {"number1": 102}) self._update(inserted[1][1], {"number1": 212}) - with patch('specifyweb.workbench.upload.preferences.should_defer_fields', new=defer): + with patch( + "specifyweb.workbench.upload.preferences.should_defer_fields", new=defer + ): results = do_upload( - self.collection, data, self.plan, self.agent.id, batch_edit_packs=batch_edit_pack - ) + self.collection, + data, + self.plan, + self.agent.id, + batch_edit_packs=batch_edit_pack, + ) for result in results: self.assertIsInstance(result.record_result, NoChange) - self.assertIsInstance(result.toOne['collectionobjectattribute'].record_result, Deleted) + self.assertIsInstance( + result.toOne["collectionobjectattribute"].record_result, Deleted + ) + + self.assertFalse( + get_table("Collectionobjectattribute") + .objects.filter(id__in=[coa.id for coa in Func.second(inserted)]) + .exists() + ) - self.assertFalse(get_table("Collectionobjectattribute").objects.filter(id__in=[coa.id for coa in Func.second(inserted)]).exists()) - - get_table('Collectionobject').objects.all().delete() + get_table("Collectionobject").objects.all().delete() get_table("Collectionobjectattribute").objects.all().delete() def test_one_to_one_deleting_hidden(self): def _make_data(): - data = [dict(catno="9090", number=''), dict(catno="22222", number=''), dict(catno="122", number='')] + data = [ + dict(catno="9090", number=""), + dict(catno="22222", number=""), + dict(catno="122", number=""), + ] inserted = self.make_co_coa_pair(data) batch_edit_pack = self.inserted_to_pack(inserted) - self._update(inserted[0][1], {"number1": 102, "number2": 212, "text22": "hidden value"}) - self._update(inserted[1][1], {"number1": 212, "number2": 764, "text22": "hidden value for coa"}) - self._update(inserted[2][1], {"number1": 874, "number6": 822, "text22": "hidden value for another coa"}) + self._update( + inserted[0][1], + {"number1": 102, "number2": 212, "text22": "hidden value"}, + ) + self._update( + inserted[1][1], + {"number1": 212, "number2": 764, "text22": "hidden value for coa"}, + ) + self._update( + inserted[2][1], + { + "number1": 874, + "number6": 822, + "text22": "hidden value for another coa", + }, + ) return data, inserted, batch_edit_pack data, inserted, batch_edit_pack = _make_data() - defer = make_defer(match=True, null=False, force='match') + defer = make_defer(match=True, null=False, force="match") - with patch('specifyweb.workbench.upload.preferences.should_defer_fields', new=defer): + with patch( + "specifyweb.workbench.upload.preferences.should_defer_fields", new=defer + ): results = do_upload( - self.collection, data, self.plan, self.agent.id, batch_edit_packs=batch_edit_pack - ) + self.collection, + data, + self.plan, + self.agent.id, + batch_edit_packs=batch_edit_pack, + ) for result in results: self.assertIsInstance(result.record_result, NoChange) # Records cannot be deleted now - self.assertIsInstance(result.toOne['collectionobjectattribute'].record_result, Updated) - - get_table('Collectionobject').objects.all().delete() + self.assertIsInstance( + result.toOne["collectionobjectattribute"].record_result, Updated + ) + + get_table("Collectionobject").objects.all().delete() get_table("Collectionobjectattribute").objects.all().delete() data, _, batch_edit_pack = _make_data() - defer = make_defer(match=True, null=True, force='match') + defer = make_defer(match=True, null=True, force="match") - with patch('specifyweb.workbench.upload.preferences.should_defer_fields', new=defer): + with patch( + "specifyweb.workbench.upload.preferences.should_defer_fields", new=defer + ): results = do_upload( - self.collection, data, self.plan, self.agent.id, batch_edit_packs=batch_edit_pack - ) + self.collection, + data, + self.plan, + self.agent.id, + batch_edit_packs=batch_edit_pack, + ) for result in results: self.assertIsInstance(result.record_result, NoChange) - self.assertIsInstance(result.toOne['collectionobjectattribute'].record_result, Deleted) + self.assertIsInstance( + result.toOne["collectionobjectattribute"].record_result, Deleted + ) - self.assertFalse(get_table("Collectionobjectattribute").objects.filter(id__in=[coa.id for coa in Func.second(inserted)]).exists()) + self.assertFalse( + get_table("Collectionobjectattribute") + .objects.filter(id__in=[coa.id for coa in Func.second(inserted)]) + .exists() + ) # I can see why this might be a bad idea, but want to playaround with making unittests completely end-to-end at least for some type # So we start from query and end with batch-edit results as the core focus of all these tests. -# This also allows for more complicated tests, with less manual work + self checking. -class SQLUploadTests(QueryConstructionTests, UploadTestsBase): +# This also allows for more complicated tests, with less manual work + self checking. +class SQLUploadTests(SQLAlchemySetup, UploadTestsBase): def setUp(self): super().setUp() + self.build_props = props_builder(self, SQLUploadTests.test_session_context) + get_table("Collectionobject").objects.all().delete() + self.test_agent_1 = Agent.objects.create( + firstname="John", lastname="Doe", division=self.division, agenttype=0 + ) + self.test_agent_2 = Agent.objects.create( + firstname="Jame", division=self.division, agenttype=1 + ) + self.test_agent_3 = Agent.objects.create( + firstname="Jame", lastname="Blo", division=self.division, agenttype=1 + ) + self.test_agent_4 = Agent.objects.create( + firstname="John", lastname="Doe", division=self.division, agenttype=1 + ) - get_table('Collectionobject').objects.all().delete() - self.test_agent_1 = Agent.objects.create(firstname='John', lastname="Doe", division=self.division, agenttype=0) - self.test_agent_2 = Agent.objects.create(firstname="Jame", division=self.division, agenttype=1) - self.test_agent_3 = Agent.objects.create(firstname="Jame", lastname="Blo", division=self.division, agenttype=1) - self.test_agent_4 = Agent.objects.create(firstname="John", lastname="Doe", division=self.division, agenttype=1) + self.cea_1 = Collectingeventattribute.objects.create( + integer1=78, discipline=self.discipline + ) + self.ce_1 = Collectingevent.objects.create( + stationfieldnumber="test_sfn_1", + collectingeventattribute=self.cea_1, + discipline=self.discipline, + remarks="hidden value", + ) - self.cea_1 = Collectingeventattribute.objects.create(integer1=78, discipline=self.discipline) - self.ce_1 = Collectingevent.objects.create(stationfieldnumber="test_sfn_1", collectingeventattribute=self.cea_1, discipline=self.discipline, remarks="hidden value") + self.cea_2 = Collectingeventattribute.objects.create( + integer1=22, discipline=self.discipline + ) + self.ce_2 = Collectingevent.objects.create( + stationfieldnumber="test_sfn_2", + collectingeventattribute=self.cea_2, + discipline=self.discipline, + remarks="hidden value2", + ) - self.cea_2 = Collectingeventattribute.objects.create(integer1=22, discipline=self.discipline) - self.ce_2 = Collectingevent.objects.create(stationfieldnumber="test_sfn_2", collectingeventattribute=self.cea_2, discipline=self.discipline, remarks="hidden value2") + self.co_1 = Collectionobject.objects.create( + catalognumber="7924".zfill(9), + cataloger=self.test_agent_1, + remarks="test_field", + collectingevent=self.ce_1, + collection=self.collection, + ) + self.co_2 = Collectionobject.objects.create( + catalognumber="0102".zfill(9), + cataloger=self.test_agent_1, + remarks="some remarks field", + collectingevent=self.ce_1, + collection=self.collection, + ) + self.co_3 = Collectionobject.objects.create( + catalognumber="1122".zfill(9), + cataloger=self.test_agent_2, + remarks="remarks for collection", + collectingevent=self.ce_2, + collection=self.collection, + ) - self.co_1 = Collectionobject.objects.create(catalognumber="7924".zfill(9), cataloger=self.test_agent_1, remarks="test_field", collectingevent=self.ce_1, collection=self.collection) - self.co_2 = Collectionobject.objects.create(catalognumber="0102".zfill(9), cataloger=self.test_agent_1, remarks="some remarks field", collectingevent=self.ce_1, collection=self.collection) - self.co_3 = Collectionobject.objects.create(catalognumber="1122".zfill(9), cataloger=self.test_agent_2, remarks="remarks for collection", collectingevent=self.ce_2, collection=self.collection) + self.preptype = Preptype.objects.create( + name="testPrepType", + isloanable=False, + collection=self.collection, + ) + self.co_1_prep_1 = Preparation.objects.create( + collectionobject=self.co_1, + text1="Value for preparation", + countamt=20, + preptype=self.preptype, + ) + self.co_1_prep_2 = Preparation.objects.create( + collectionobject=self.co_1, + text1="Second value for preparation", + countamt=5, + preptype=self.preptype, + ) + self.co_1_prep_3 = Preparation.objects.create( + collectionobject=self.co_1, + text1="Third value for preparation", + countamt=88, + preptype=self.preptype, + ) - self.co_1_prep_1 = Preparation.objects.create(collectionobject=self.co_1, text1="Value for preparation", countamt=20, preptype=self.preptype) - self.co_1_prep_2 = Preparation.objects.create(collectionobject=self.co_1, text1="Second value for preparation", countamt=5, preptype=self.preptype) - self.co_1_prep_3 = Preparation.objects.create(collectionobject=self.co_1, text1="Third value for preparation", countamt=88, preptype=self.preptype) - - self.co_2_prep_1 = Preparation.objects.create(collectionobject=self.co_2, text1="Value for preparation for second CO", countamt=89, preptype=self.preptype) - self.co_2_prep_2 = Preparation.objects.create(collectionobject=self.co_2, countamt=27, preptype=self.preptype) - - self.co_3_prep_1 = Preparation.objects.create(collectionobject=self.co_3, text1="Needs to be deleted", preptype=self.preptype) + self.co_2_prep_1 = Preparation.objects.create( + collectionobject=self.co_2, + text1="Value for preparation for second CO", + countamt=89, + preptype=self.preptype, + ) + self.co_2_prep_2 = Preparation.objects.create( + collectionobject=self.co_2, countamt=27, preptype=self.preptype + ) + + self.co_3_prep_1 = Preparation.objects.create( + collectionobject=self.co_3, + text1="Needs to be deleted", + preptype=self.preptype, + ) def _build_props(self, query_fields, base_table): raw = self.build_props(query_fields, base_table) - raw['session_maker'] = SQLUploadTests.test_session_context + raw["session_maker"] = SQLUploadTests.test_session_context return raw - def enforcer(self, result: UploadResult, valid_results=[NoChange, NullRecord, Matched]): - self.assertTrue(any(isinstance(result.record_result, valid) for valid in valid_results), f"Failed for {result.record_result}") + def make_query(self, field_spec, sort_type): + return QueryField( + fieldspec=field_spec, + op_num=8, + value=None, + negate=False, + display=True, + format_name=None, + sort_type=sort_type, + ) + + def enforcer( + self, result: UploadResult, valid_results=[NoChange, NullRecord, Matched] + ): + self.assertTrue( + any(isinstance(result.record_result, valid) for valid in valid_results), + f"Failed for {result.record_result}", + ) to_one = list([self.enforcer(result) for result in result.toOne.values()]) - to_many = list([self.enforcer(result) for result in _results] for _results in result.toMany.values()) - + to_many = list( + [self.enforcer(result) for result in _results] + for _results in result.toMany.values() + ) + def test_no_op(self): query_paths = [ - ['catalognumber'], - ['integer1'], - ['cataloger', 'firstname'], - ['cataloger', 'lastname'], - ['preparations', 'countamt'], - ['preparations', 'text1'], - ['collectingevent', 'stationfieldnumber'], - ['collectingevent', 'collectingeventattribute', 'integer1'] + ["catalognumber"], + ["integer1"], + ["cataloger", "firstname"], + ["cataloger", "lastname"], + ["preparations", "countamt"], + ["preparations", "text1"], + ["collectingevent", "stationfieldnumber"], + ["collectingevent", "collectingeventattribute", "integer1"], ] - added = [('Collectionobject', *path) for path in query_paths] + added = [("Collectionobject", *path) for path in query_paths] query_fields = [ - BatchEditPack._query_field(QueryFieldSpec.from_path(path), 0) - for path in added + self.make_query(QueryFieldSpec.from_path(path), 0) for path in added ] props = self._build_props(query_fields, "Collectionobject") @@ -405,18 +570,17 @@ def test_no_op(self): # We didn't change anything, nothing should change. verify just that list([self.enforcer(result) for result in results]) - def enforce_created_in_log(self, record_id, table): + def enforce_in_log(self, record_id, table, audit_code: Union[Literal['INSERT'], Literal['UPDATE'], Literal['REMOVE']]): entries = lookup_in_auditlog(table, record_id) self.assertEqual(1, entries.count()) entry = entries.first() - self.assertEqual(entry.action, auditcodes.INSERT) + self.assertEqual(entry.action, getattr(auditcodes, audit_code)) def query_to_results(self, base_table, query_paths): added = [(base_table, *path) for path in query_paths] query_fields = [ - BatchEditPack._query_field(QueryFieldSpec.from_path(path), 0) - for path in added + self.make_query(QueryFieldSpec.from_path(path), 0) for path in added ] props = self._build_props(query_fields, base_table) @@ -428,21 +592,18 @@ def query_to_results(self, base_table, query_paths): regularized_rows = regularize_rows(len(headers), rows) - return (headers, regularized_rows, packs, plan) - def test_to_one_cloned(self): query_paths = [ - ['catalognumber'], - ['integer1'], - ['collectingevent', 'stationfieldnumber'], + ["catalognumber"], + ["integer1"], + ["collectingevent", "stationfieldnumber"], ] - added = [('Collectionobject', *path) for path in query_paths] + added = [("Collectionobject", *path) for path in query_paths] query_fields = [ - BatchEditPack._query_field(QueryFieldSpec.from_path(path), 0) - for path in added + self.make_query(QueryFieldSpec.from_path(path), 0) for path in added ] props = self._build_props(query_fields, "Collectionobject") @@ -457,11 +618,23 @@ def test_to_one_cloned(self): dicted = [dict(zip(headers, row)) for row in regularized_rows] data = [ - {'CollectionObject catalogNumber': '7924'.zfill(9), 'CollectionObject integer1': '', 'CollectingEvent stationFieldNumber': 'test_sfn_4'}, - {'CollectionObject catalogNumber': '102'.zfill(9), 'CollectionObject integer1': '', 'CollectingEvent stationFieldNumber': 'test_sfn_1'}, - {'CollectionObject catalogNumber': '1122'.zfill(9), 'CollectionObject integer1': '', 'CollectingEvent stationFieldNumber': 'test_sfn_2'} - ] - + { + "CollectionObject catalogNumber": "7924".zfill(9), + "CollectionObject integer1": "", + "CollectingEvent stationFieldNumber": "test_sfn_4", + }, + { + "CollectionObject catalogNumber": "102".zfill(9), + "CollectionObject integer1": "", + "CollectingEvent stationFieldNumber": "test_sfn_1", + }, + { + "CollectionObject catalogNumber": "1122".zfill(9), + "CollectionObject integer1": "", + "CollectingEvent stationFieldNumber": "test_sfn_2", + }, + ] + results = do_upload( self.collection, data, plan, self.agent.id, batch_edit_packs=pack ) @@ -469,172 +642,535 @@ def test_to_one_cloned(self): list([self.enforcer(result) for result in results[1:]]) self.assertIsInstance(results[0].record_result, NoChange) - self.assertIsInstance(results[0].toOne['collectingevent'].record_result, Uploaded) + self.assertIsInstance( + results[0].toOne["collectingevent"].record_result, Uploaded + ) - ce_created_id = results[0].toOne['collectingevent'].record_result.get_id() + ce_created_id = results[0].toOne["collectingevent"].record_result.get_id() ce_created = Collectingevent.objects.get(id=ce_created_id) self.assertEqual(ce_created.remarks, self.ce_1.remarks) - self.assertNotEqual(ce_created.collectingeventattribute_id, self.ce_1.collectingeventattribute_id) + self.assertNotEqual( + ce_created.collectingeventattribute_id, + self.ce_1.collectingeventattribute_id, + ) - self.assertEqual(ce_created.collectingeventattribute.integer1, self.cea_1.integer1) + self.assertEqual( + ce_created.collectingeventattribute.integer1, self.cea_1.integer1 + ) + + self.enforce_in_log(ce_created_id, "collectingevent", 'INSERT') + self.enforce_in_log( + ce_created.collectingeventattribute.id, "collectingeventattribute", 'INSERT' + ) - self.enforce_created_in_log(ce_created_id, "collectingevent") - self.enforce_created_in_log(ce_created.collectingeventattribute.id, "collectingeventattribute") - def _run_matching_test(self): - co_4 = Collectionobject.objects.create(catalognumber="1000".zfill(9), collection=self.collection) - co_5 = Collectionobject.objects.create(catalognumber="1024".zfill(9), collection=self.collection) + co_4 = Collectionobject.objects.create( + catalognumber="1000".zfill(9), collection=self.collection + ) + co_5 = Collectionobject.objects.create( + catalognumber="1024".zfill(9), collection=self.collection + ) query_paths = [ - ['catalognumber'], - ['cataloger', 'firstname'], - ['cataloger', 'lastname'] + ["catalognumber"], + ["cataloger", "firstname"], + ["cataloger", "lastname"], ] - (headers, rows, pack, plan) = self.query_to_results('collectionobject', query_paths) + (headers, rows, pack, plan) = self.query_to_results( + "collectionobject", query_paths + ) dicted = [dict(zip(headers, row)) for row in rows] data = [ - {'CollectionObject catalogNumber': '7924'.zfill(9), 'Agent firstName': 'John', 'Agent lastName': 'Doe'}, - {'CollectionObject catalogNumber': '102'.zfill(9), 'Agent firstName': 'John', 'Agent lastName': 'Doe'}, - {'CollectionObject catalogNumber': '1122'.zfill(9), 'Agent firstName': 'John', 'Agent lastName': 'Doe'}, # This won't be matched in-case of non-defer to the first agent, because of differing agent types - {'CollectionObject catalogNumber': '1000'.zfill(9), 'Agent firstName': 'NewAgent', 'Agent lastName': ''}, - {'CollectionObject catalogNumber': '1024'.zfill(9), 'Agent firstName': 'NewAgent', 'Agent lastName': ''} - ] - - results = do_upload(self.collection, data, plan, self.agent.id, batch_edit_packs=pack) - return results + { + "CollectionObject catalogNumber": "7924".zfill(9), + "Agent firstName": "John", + "Agent lastName": "Doe", + }, + { + "CollectionObject catalogNumber": "102".zfill(9), + "Agent firstName": "John", + "Agent lastName": "Doe", + }, + { + "CollectionObject catalogNumber": "1122".zfill(9), + "Agent firstName": "John", + "Agent lastName": "Doe", + }, # This won't be matched in-case of non-defer to the first agent, because of differing agent types + { + "CollectionObject catalogNumber": "1000".zfill(9), + "Agent firstName": "NewAgent", + "Agent lastName": "", + }, + { + "CollectionObject catalogNumber": "1024".zfill(9), + "Agent firstName": "NewAgent", + "Agent lastName": "", + }, + ] + results = do_upload( + self.collection, data, plan, self.agent.id, batch_edit_packs=pack + ) + return results def test_matching_without_defer(self): defer = make_defer(match=False, null=False) - with patch('specifyweb.workbench.upload.preferences.should_defer_fields', new=defer): + with patch( + "specifyweb.workbench.upload.preferences.should_defer_fields", new=defer + ): results = self._run_matching_test() list([self.enforcer(result) for result in results[:2]]) self.assertIsInstance(results[2].record_result, NoChange) - cataloger_0 = results[2].toOne['cataloger'].record_result + cataloger_0 = results[2].toOne["cataloger"].record_result self.assertIsInstance(cataloger_0, MatchedAndChanged) self.assertIsInstance(results[-2].record_result, NoChange) - cataloger_1 = results[-2].toOne['cataloger'].record_result + cataloger_1 = results[-2].toOne["cataloger"].record_result self.assertIsInstance(cataloger_1, Uploaded) self.assertIsInstance(results[-1].record_result, NoChange) - cataloger_2 = results[-1].toOne['cataloger'].record_result + cataloger_2 = results[-1].toOne["cataloger"].record_result self.assertIsInstance(cataloger_2, MatchedAndChanged) self.assertEqual(cataloger_0.get_id(), self.test_agent_4.id) self.assertEqual(cataloger_2.get_id(), cataloger_1.get_id()) def test_matching_with_defer(self): - defer = make_defer(match=True, null=True) # null doesn't matter, can be true or false - with patch('specifyweb.workbench.upload.preferences.should_defer_fields', new=defer): + defer = make_defer( + match=True, null=True + ) # null doesn't matter, can be true or false + + with patch( + "specifyweb.workbench.upload.preferences.should_defer_fields", new=defer + ): results = self._run_matching_test() list([self.enforcer(result) for result in results[:2]]) self.assertIsInstance(results[2].record_result, PropagatedFailure) - cataloger_0 = results[2].toOne['cataloger'].record_result + cataloger_0 = results[2].toOne["cataloger"].record_result self.assertIsInstance(cataloger_0, MatchedMultiple) self.assertIsInstance(results[-2].record_result, NoChange) - cataloger_1 = results[-2].toOne['cataloger'].record_result + cataloger_1 = results[-2].toOne["cataloger"].record_result self.assertIsInstance(cataloger_1, Uploaded) self.assertIsInstance(results[-1].record_result, NoChange) - cataloger_2 = results[-1].toOne['cataloger'].record_result + cataloger_2 = results[-1].toOne["cataloger"].record_result self.assertIsInstance(cataloger_2, MatchedAndChanged) - self.assertTrue(self.test_agent_1.id in cataloger_0.ids and self.test_agent_4.id in cataloger_0.ids) + self.assertTrue( + self.test_agent_1.id in cataloger_0.ids + and self.test_agent_4.id in cataloger_0.ids + ) self.assertEqual(cataloger_2.get_id(), cataloger_1.get_id()) - - def test_bidirectional_to_many(self): - agt_1_add_1 = Address.objects.create(address="testaddress1", agent=self.test_agent_1) - agt_1_add_2 = Address.objects.create(address="testaddress2", agent=self.test_agent_1) - agt_1_spec_1 = Agentspecialty.objects.create(specialtyname="specialty1", agent=self.test_agent_1) - agt_1_spec_2 = Agentspecialty.objects.create(specialtyname="specialty2", agent=self.test_agent_1) + def test_update_to_many_without_defer(self): + + defer = make_defer( + # These reulsts below should how true for all these cases + match=False, null=False + ) + + query_paths = [ + ["integer1"], + ["cataloger", "firstname"], + ["cataloger", "lastname"], + ["preparations", "countamt"], + ["preparations", "text1"], + ["preparations", "preptype", "name"], + ["preparations", "preptype", "isloanable"], + ] + + (headers, rows, pack, plan) = self.query_to_results( + "collectionobject", query_paths + ) + + dicted = [dict(zip(headers, row)) for row in rows] + + # original_data = [ + # {'CollectionObject integer1': '', 'Agent firstName': 'John', 'Agent lastName': 'Doe', 'Preparation countAmt': '20', 'Preparation text1': 'Value for preparation', 'PrepType name': 'testPrepType', 'PrepType isLoanable': 'False', 'Preparation countAmt #2': '5', 'Preparation text1 #2': 'Second value for preparation', 'PrepType name #2': 'testPrepType', 'PrepType isLoanable #2': 'False', 'Preparation countAmt #3': '88', 'Preparation text1 #3': 'Third value for preparation', 'PrepType name #3': 'testPrepType', 'PrepType isLoanable #3': 'False'}, + # {'CollectionObject integer1': '', 'Agent firstName': 'John', 'Agent lastName': 'Doe', 'Preparation countAmt': '89', 'Preparation text1': 'Value for preparation for second CO', 'PrepType name': 'testPrepType', 'PrepType isLoanable': 'False', 'Preparation countAmt #2': '27', 'Preparation text1 #2': '', 'PrepType name #2': 'testPrepType', 'PrepType isLoanable #2': 'False', 'Preparation countAmt #3': '', 'Preparation text1 #3': '', 'PrepType name #3': '', 'PrepType isLoanable #3': ''}, + # {'CollectionObject integer1': '', 'Agent firstName': 'Jame', 'Agent lastName': '', 'Preparation countAmt': '', 'Preparation text1': 'Needs to be deleted', 'PrepType name': 'testPrepType', 'PrepType isLoanable': 'False', 'Preparation countAmt #2': '', 'Preparation text1 #2': '', 'PrepType name #2': '', 'PrepType isLoanable #2': '', 'Preparation countAmt #3': '', 'Preparation text1 #3': '', 'PrepType name #3': '', 'PrepType isLoanable #3': ''} + # ] + + data = [ + { + "CollectionObject integer1": "8982", + "Agent firstName": "John", + "Agent lastName": "Doe", + "Preparation countAmt": "20", + "Preparation text1": "Changed Value for preparation", + "PrepType name": "AnotherPrepType", + "PrepType isLoanable": "False", + "Preparation countAmt #2": "2", + "Preparation text1 #2": "", + "PrepType name #2": "testPrepType", + "PrepType isLoanable #2": "False", + "Preparation countAmt #3": "1001", + "Preparation text1 #3": "Changed Value", + "PrepType name #3": "testPrepType", + "PrepType isLoanable #3": "False", + }, + { + "CollectionObject integer1": "", + "Agent firstName": "John", + "Agent lastName": "Doe", + "Preparation countAmt": "", + "Preparation text1": "", + "PrepType name": "testPrepType", + "PrepType isLoanable": "False", + "Preparation countAmt #2": "27", + "Preparation text1 #2": "", + "PrepType name #2": "testPrepType", + "PrepType isLoanable #2": "False", + "Preparation countAmt #3": "", + "Preparation text1 #3": "", + "PrepType name #3": "", + "PrepType isLoanable #3": "", + }, + { + "CollectionObject integer1": "", + "Agent firstName": "Jame", + "Agent lastName": "", + "Preparation countAmt": "", + "Preparation text1": "", + "PrepType name": "", + "PrepType isLoanable": "", + "Preparation countAmt #2": "788", + "Preparation text1 #2": "Created using batch-edit", + "PrepType name #2": "AnotherPrepType", + "PrepType isLoanable #2": "False", + "Preparation countAmt #3": "80112", + "Preparation text1 #3": "Another prep created via batch-edit", + "PrepType name #3": "testPrepType", + "PrepType isLoanable #3": "False", + }, + ] + + with patch( + "specifyweb.workbench.upload.preferences.should_defer_fields", new=defer + ): + results = do_upload( + self.collection, data, plan, self.agent.id, batch_edit_packs=pack + ) + + result_map = {} + + def wrap_is_instance(result, instance): + self.assertIsInstance(result, instance) + is_success = isinstance(result.get_id(), int) + if is_success: + result_map[instance] = [*result_map.get(instance, []), (result.info.tableName, result.get_id())] + + wrap_is_instance(results[0].record_result, Updated) + self.assertEqual(['CollectionObject integer1'], results[0].record_result.info.columns) + + self.assertIsInstance(results[0].toOne['cataloger'].record_result, Matched) + + co_1_prep_1 = results[0].toMany['preparations'][0] + + wrap_is_instance(co_1_prep_1.record_result, Updated) + self.assertEqual(["Preparation text1"], co_1_prep_1.record_result.info.columns) + + self.assertIsInstance(co_1_prep_1.toOne['preptype'].record_result, Uploaded) + + co_1_prep_2 = results[0].toMany['preparations'][1] + wrap_is_instance(co_1_prep_2.record_result, Updated) + self.assertCountEqual(co_1_prep_2.record_result.info.columns, ["Preparation text1 #2", "Preparation countAmt #2"]) + self.assertIsInstance(co_1_prep_2.toOne['preptype'].record_result, Matched) + + co_1_prep_3 = results[0].toMany['preparations'][2] + wrap_is_instance(co_1_prep_3.record_result, Updated) + self.assertCountEqual(co_1_prep_3.record_result.info.columns, ["Preparation text1 #3", "Preparation countAmt #3"]) + self.assertIsInstance(co_1_prep_3.toOne['preptype'].record_result, Matched) + + self.assertEqual(co_1_prep_3.toOne['preptype'].record_result.get_id(), self.preptype.id) + self.assertEqual(co_1_prep_2.toOne['preptype'].record_result.get_id(), self.preptype.id) + + self.assertIsInstance(results[1].record_result, NoChange) + self.assertIsInstance(results[1].toOne['cataloger'].record_result, Matched) + + wrap_is_instance(results[1].toMany['preparations'][0].record_result, Updated) + self.assertCountEqual(results[1].toMany['preparations'][0].record_result.info.columns, ["Preparation countAmt", "Preparation text1"]) + + self.assertIsInstance(results[1].toMany['preparations'][1].record_result, NoChange) + + self.assertIsInstance(results[1].toMany['preparations'][2].record_result, NullRecord) + + self.assertIsInstance(results[2].record_result, NoChange) + + co_3_preps = results[2].toMany['preparations'] + + wrap_is_instance(co_3_preps[0].record_result, Deleted) + + wrap_is_instance(co_3_preps[1].record_result, Uploaded) + wrap_is_instance(co_3_preps[2].record_result, Uploaded) + + self.assertIsInstance(co_3_preps[1].toOne['preptype'].record_result, Matched) + self.assertEqual(co_3_preps[1].toOne['preptype'].record_result.get_id(), co_1_prep_1.toOne['preptype'].record_result.get_id()) + + self.assertEqual(co_3_preps[2].toOne['preptype'].record_result.get_id(), self.preptype.id) + self.assertFalse(Preparation.objects.filter(id=results[2].toMany['preparations'][0].record_result.get_id()).exists()) + + [self.enforce_in_log(result_id, table, ('INSERT' if _type == Uploaded else ('REMOVE' if _type == Deleted else 'UPDATE'))) + for (_type, results) in result_map.items() + for (table, result_id) in results] + def _run_with_defer(self): query_paths = [ ['integer1'], ['cataloger', 'firstname'], - ['cataloger', 'lastname'], - ['cataloger', 'addresses', 'address'], - ['preparations', 'countamt'], - ['preparations', 'text1'] + ['preparations', 'countAmt'] ] - (headers, rows, pack, plan) = self.query_to_results('collectionobject', query_paths) + data = [ + {'CollectionObject integer1': '', 'Agent firstName': 'John', 'Preparation countAmt': '', 'Preparation countAmt #2': '', 'Preparation countAmt #3': ''}, + {'CollectionObject integer1': '', 'Agent firstName': 'John', 'Preparation countAmt': '', 'Preparation countAmt #2': '', 'Preparation countAmt #3': ''}, + {'CollectionObject integer1': '', 'Agent firstName': 'Jame', 'Preparation countAmt': '', 'Preparation countAmt #2': '', 'Preparation countAmt #3': ''} + ] + return (headers, data, pack, plan) + + def test_update_to_many_with_defer(self): + + with patch( + "specifyweb.workbench.upload.preferences.should_defer_fields", new=make_defer(match=True, null=False) + ): + (headers, data, pack, plan) = self._run_with_defer() + + results = do_upload(self.collection, data, plan, self.agent.id, batch_edit_packs=pack) + + self.assertIsInstance(results[0].toMany['preparations'][0].record_result, Updated) + self.assertIsInstance(results[0].toMany['preparations'][1].record_result, Updated) + self.assertIsInstance(results[0].toMany['preparations'][2].record_result, Updated) + + self.assertIsInstance(results[1].toMany['preparations'][0].record_result, Updated) + self.assertIsInstance(results[1].toMany['preparations'][1].record_result, Updated) + self.assertIsInstance(results[1].toMany['preparations'][2].record_result, NullRecord) + + self.assertIsInstance(results[2].toMany['preparations'][0].record_result, NoChange) + self.assertIsInstance(results[2].toMany['preparations'][1].record_result, NullRecord) + self.assertIsInstance(results[2].toMany['preparations'][2].record_result, NullRecord) + + with patch("specifyweb.workbench.upload.preferences.should_defer_fields", new=make_defer + (match=False, + null=True, + force='match' + )): + + (headers, data, pack, plan) = self._run_with_defer() + + results = do_upload(self.collection, data, plan, self.agent.id, batch_edit_packs=pack) + + self.assertIsInstance(results[0].toMany['preparations'][0].record_result, Deleted) + self.assertIsInstance(results[0].toMany['preparations'][1].record_result, Deleted) + self.assertIsInstance(results[0].toMany['preparations'][2].record_result, Deleted) + + self.assertIsInstance(results[1].toMany['preparations'][0].record_result, Deleted) + self.assertIsInstance(results[1].toMany['preparations'][1].record_result, Deleted) + self.assertIsInstance(results[1].toMany['preparations'][2].record_result, NullRecord) + + self.assertIsInstance(results[2].toMany['preparations'][0].record_result, Deleted) + self.assertIsInstance(results[2].toMany['preparations'][1].record_result, NullRecord) + self.assertIsInstance(results[2].toMany['preparations'][2].record_result, NullRecord) + + # original_data = [ + # {'CollectionObject integer1': '', 'Agent firstName': 'John', 'Preparation countAmt': '20', 'Preparation countAmt #2': '5', 'Preparation countAmt #3': '88'}, + # {'CollectionObject integer1': '', 'Agent firstName': 'John', 'Preparation countAmt': '89', 'Preparation countAmt #2': '27', 'Preparation countAmt #3': ''}, + # {'CollectionObject integer1': '', 'Agent firstName': 'Jame', 'Preparation countAmt': '', 'Preparation countAmt #2': '', 'Preparation countAmt #3': ''} + # ] + + + def test_bidirectional_to_many(self): + self._update(self.test_agent_1, {'remarks': 'changed for dup testing'}) + self.co_4 = Collectionobject.objects.create( + catalognumber="84".zfill(9), + cataloger=self.test_agent_2, + remarks="test_field", + collectingevent=self.ce_2, + collection=self.collection, + ) + agt_1_add_1 = Address.objects.create( + address="testaddress1", agent=self.test_agent_1 + ) + agt_1_add_2 = Address.objects.create( + address="testaddress2", agent=self.test_agent_1 + ) + + agt_1_spec_1 = Agentspecialty.objects.create( + specialtyname="specialty1", agent=self.test_agent_1 + ) + agt_1_spec_2 = Agentspecialty.objects.create( + specialtyname="specialty2", agent=self.test_agent_1 + ) + + query_paths = [ + ["integer1"], + ["cataloger", "firstname"], + ["cataloger", "lastname"], + ["cataloger", "addresses", "address"], + ['cataloger', 'agenttype'], + ["preparations", "countamt"], + ["preparations", "text1"], + ["preparations", "preptype", "name"], + ] + + (headers, rows, pack, plan) = self.query_to_results( + "collectionobject", query_paths + ) dicted = [dict(zip(headers, row)) for row in rows] # original_data = [ - # {'CollectionObject integer1': '', 'Agent firstName': 'John', 'Agent lastName': 'Doe', 'Address address': 'testaddress1', 'Address address #2': 'testaddress2', 'Preparation countAmt': '20', 'Preparation text1': 'Value for preparation', 'Preparation countAmt #2': '5', 'Preparation text1 #2': 'Second value for preparation', 'Preparation countAmt #3': '88', 'Preparation text1 #3': 'Third value for preparation'}, - # {'CollectionObject integer1': '', 'Agent firstName': 'John', 'Agent lastName': 'Doe', 'Address address': 'testaddress1', 'Address address #2': 'testaddress2', 'Preparation countAmt': '89', 'Preparation text1': 'Value for preparation for second CO', 'Preparation countAmt #2': '27', 'Preparation text1 #2': '', 'Preparation countAmt #3': '', 'Preparation text1 #3': ''}, - # {'CollectionObject integer1': '', 'Agent firstName': 'Jame', 'Agent lastName': '', 'Address address': '', 'Address address #2': '', 'Preparation countAmt': '', 'Preparation text1': 'Needs to be deleted', 'Preparation countAmt #2': '', 'Preparation text1 #2': '', 'Preparation countAmt #3': '', 'Preparation text1 #3': ''} + # {'CollectionObject integer1': '', 'Agent firstName': 'John', 'Agent lastName': 'Doe', 'Agent agentType': 'Organization', 'Address address': 'testaddress1', 'Address address #2': 'testaddress2', 'Preparation countAmt': '20', 'Preparation text1': 'Value for preparation', 'PrepType name': 'testPrepType', 'Preparation countAmt #2': '5', 'Preparation text1 #2': 'Second value for preparation', 'PrepType name #2': 'testPrepType', 'Preparation countAmt #3': '88', 'Preparation text1 #3': 'Third value for preparation', 'PrepType name #3': 'testPrepType'}, + # {'CollectionObject integer1': '', 'Agent firstName': 'John', 'Agent lastName': 'Doe', 'Agent agentType': 'Organization', 'Address address': 'testaddress1', 'Address address #2': 'testaddress2', 'Preparation countAmt': '89', 'Preparation text1': 'Value for preparation for second CO', 'PrepType name': 'testPrepType', 'Preparation countAmt #2': '27', 'Preparation text1 #2': '', 'PrepType name #2': 'testPrepType', 'Preparation countAmt #3': '', 'Preparation text1 #3': '', 'PrepType name #3': ''}, + # {'CollectionObject integer1': '', 'Agent firstName': 'Jame', 'Agent lastName': '', 'Agent agentType': 'Person', 'Address address': '', 'Address address #2': '', 'Preparation countAmt': '', 'Preparation text1': 'Needs to be deleted', 'PrepType name': 'testPrepType', 'Preparation countAmt #2': '', 'Preparation text1 #2': '', 'PrepType name #2': '', 'Preparation countAmt #3': '', 'Preparation text1 #3': '', 'PrepType name #3': ''}, + # {'CollectionObject integer1': '', 'Agent firstName': 'Jame', 'Agent lastName': '', 'Agent agentType': 'Person', 'Address address': '', 'Address address #2': '', 'Preparation countAmt': '', 'Preparation text1': '', 'PrepType name': '', 'Preparation countAmt #2': '', 'Preparation text1 #2': '', 'PrepType name #2': '', 'Preparation countAmt #3': '', 'Preparation text1 #3': '', 'PrepType name #3': ''} # ] data = [ - {'CollectionObject integer1': '', 'Agent firstName': 'John', 'Agent lastName': 'Doe', 'Address address': 'testaddress1 changed', 'Address address #2': 'testaddress2', 'Preparation countAmt': '20', 'Preparation text1': 'Value for prep changed', 'Preparation countAmt #2': '5', 'Preparation text1 #2': 'Second value for preparation', 'Preparation countAmt #3': '88', 'Preparation text1 #3': 'Third value for preparation'}, - {'CollectionObject integer1': '', 'Agent firstName': 'John', 'Agent lastName': 'Dave', 'Address address': 'testaddress1', 'Address address #2': 'testaddress2', 'Preparation countAmt': '89', 'Preparation text1': 'Value for preparation for second CO', 'Preparation countAmt #2': '27', 'Preparation text1 #2': '', 'Preparation countAmt #3': '9999', 'Preparation text1 #3': 'Value here was modified'}, - {'CollectionObject integer1': '', 'Agent firstName': 'Jame', 'Agent lastName': '', 'Address address': '', 'Address address #2': '', 'Preparation countAmt': '', 'Preparation text1': '', 'Preparation countAmt #2': '', 'Preparation text1 #2': '', 'Preparation countAmt #3': '', 'Preparation text1 #3': ''} + {'CollectionObject integer1': '', 'Agent firstName': 'John', 'Agent lastName': 'Dew', 'Agent agentType': 'Organization', 'Address address': 'testaddress1 changed', 'Address address #2': 'testaddress2', 'Preparation countAmt': '288', 'Preparation text1': 'Value for preparation', 'PrepType name': 'testPrepType', 'Preparation countAmt #2': '5', 'Preparation text1 #2': 'Second value for preparation', 'PrepType name #2': 'testPrepType', 'Preparation countAmt #3': '88', 'Preparation text1 #3': 'Third value for preparation', 'PrepType name #3': 'testPrepType'}, + {'CollectionObject integer1': '', 'Agent firstName': 'John', 'Agent lastName': 'Dew', 'Agent agentType': 'Organization', 'Address address': 'testaddress1 changed', 'Address address #2': 'testaddress2', 'Preparation countAmt': '892', 'Preparation text1': 'Value for preparation for second CO', 'PrepType name': 'testPrepType', 'Preparation countAmt #2': '27', 'Preparation text1 #2': '', 'PrepType name #2': 'testPrepType', 'Preparation countAmt #3': '', 'Preparation text1 #3': '', 'PrepType name #3': ''}, + {'CollectionObject integer1': '', 'Agent firstName': '', 'Agent lastName': '', 'Agent agentType': '', 'Address address': '', 'Address address #2': '', 'Preparation countAmt': '', 'Preparation text1': 'Needs to be deleted', 'PrepType name': 'testPrepType', 'Preparation countAmt #2': '', 'Preparation text1 #2': '', 'PrepType name #2': '', 'Preparation countAmt #3': '', 'Preparation text1 #3': '', 'PrepType name #3': ''}, ] - results = do_upload(self.collection, data, plan, self.agent.id, batch_edit_packs=pack) + results = do_upload( + self.collection, data, plan, self.agent.id, batch_edit_packs=pack + ) + self.assertIsInstance(results[0].record_result, NoChange) + self.assertIsInstance(results[0].toOne['cataloger'].record_result, Uploaded) + [self.enforcer(record_result, [Uploaded]) for record_result in results[0].toOne['cataloger'].toMany['addresses']] + self.assertIsInstance(results[0].toMany['preparations'][0].record_result, Updated) + self.assertCountEqual(results[0].toMany['preparations'][0].record_result.info.columns, ['Preparation countAmt']) + [self.enforcer(record_result, [NoChange]) for record_result in results[0].toMany['preparations'][1:]] + + self.co_1.refresh_from_db() + self.assertEqual(self.co_1.cataloger_id, results[0].toOne['cataloger'].record_result.get_id()) + cataloger_created = self.co_1.cataloger + + # All assertions below check if clone was correct + + self.assertEqual(cataloger_created.addresses.all().count(), self.test_agent_1.addresses.all().count()) + self.assertEqual(cataloger_created.agentspecialties.all().count(), self.test_agent_1.agentspecialties.all().count()) + self.assertEqual(cataloger_created.addresses.all().filter(address='testaddress1 changed').count(), 1) + self.assertEqual(cataloger_created.addresses.all().filter(address='testaddress2').count(), 1) + self.assertEqual(cataloger_created.agentspecialties.all().filter(specialtyname='specialty1').count(), 1) + self.assertEqual(cataloger_created.agentspecialties.all().filter(specialtyname='specialty2').count(), 1) + self.assertEqual(cataloger_created.remarks, self.test_agent_1.remarks) + + self.assertIsInstance(results[1].record_result, NoChange) + self.assertIsInstance(results[1].toOne['cataloger'].record_result, MatchedAndChanged) + self.assertEqual(results[1].toOne['cataloger'].record_result.get_id(), cataloger_created.id) + self.assertIsInstance(results[1].toMany['preparations'][0].record_result, Updated) + self.assertCountEqual(results[1].toMany['preparations'][0].record_result.info.columns, ['Preparation countAmt']) + self.assertIsInstance(results[1].toMany['preparations'][1].record_result, NoChange) + self.assertIsInstance(results[1].toMany['preparations'][2].record_result, NullRecord) + + + self.assertIsInstance(results[2].record_result, NoChange) + self.assertIsInstance(results[2].toOne['cataloger'].record_result, NullRecord) + + self.assertIsInstance(results[2].toMany['preparations'][0].record_result, NoChange) + self.assertIsInstance(results[2].toMany['preparations'][1].record_result, NullRecord) + self.assertIsInstance(results[2].toMany['preparations'][2].record_result, NullRecord) + + # Make sure stuff was audited (creating clone is audited) + [self.enforce_in_log(record.pk, 'address', 'INSERT') for record in cataloger_created.addresses.all()] + [self.enforce_in_log(record.pk, 'agentspecialty', 'INSERT') for record in cataloger_created.agentspecialties.all()] + self.enforce_in_log(cataloger_created.pk, 'agent', 'INSERT') + + self.enforce_in_log(results[1].toMany['preparations'][0].record_result.get_id(), 'preparation', 'UPDATE') + self.enforce_in_log(results[0].toMany['preparations'][0].record_result.get_id(), 'preparation', 'UPDATE') - print(results) - def test_to_many_match_is_possible(self): defer = make_defer(match=False, null=True) - agt_1_add_1 = Address.objects.create(address="testaddress1", agent=self.test_agent_1) - agt_1_add_2 = Address.objects.create(address="testaddress2", agent=self.test_agent_1) + agt_1_add_1 = Address.objects.create( + address="testaddress1", agent=self.test_agent_1 + ) + agt_1_add_2 = Address.objects.create( + address="testaddress2", agent=self.test_agent_1 + ) - agt_2_add_1 = Address.objects.create(address="testaddress4", agent=self.test_agent_2) - agt_2_add_2 = Address.objects.create(address="testaddress5", agent=self.test_agent_2) + agt_2_add_1 = Address.objects.create( + address="testaddress4", agent=self.test_agent_2 + ) + agt_2_add_2 = Address.objects.create( + address="testaddress5", agent=self.test_agent_2 + ) query_paths = [ - ['integer1'], - ['cataloger', 'firstname'], - ['cataloger', 'lastname'], - ['cataloger', 'addresses', 'address'], - ['cataloger', 'agenttype'] + ["integer1"], + ["cataloger", "firstname"], + ["cataloger", "lastname"], + ["cataloger", "addresses", "address"], + ["cataloger", "agenttype"], ] - - (headers, rows, pack, plan) = self.query_to_results('collectionobject', query_paths) + (headers, rows, pack, plan) = self.query_to_results( + "collectionobject", query_paths + ) dicted = [dict(zip(headers, row)) for row in rows] - + # original_data = [ # {'CollectionObject integer1': '', 'Agent firstName': 'John', 'Agent lastName': 'Doe', 'Agent agentType': 'Organization', 'Address address': 'testaddress1', 'Address address #2': 'testaddress2'}, # {'CollectionObject integer1': '', 'Agent firstName': 'John', 'Agent lastName': 'Doe', 'Agent agentType': 'Organization', 'Address address': 'testaddress1', 'Address address #2': 'testaddress2'}, # {'CollectionObject integer1': '', 'Agent firstName': 'Jame', 'Agent lastName': '', 'Agent agentType': 'Person', 'Address address': 'testaddress4', 'Address address #2': 'testaddress5'} # ] - + # Here is a (now resolved) bug below. We need to remove the reverse relationship in predicates for this to match, no way around that. # Otherwise, it'd be impossible to match third agent to first (agent on row1 and row2 are same), in deferForMatch=False data = [ - {'CollectionObject integer1': '', 'Agent firstName': 'John', 'Agent lastName': 'Doe', 'Agent agentType': 'Organization', 'Address address': 'testaddress1', 'Address address #2': 'testaddress2'}, - {'CollectionObject integer1': '', 'Agent firstName': 'John', 'Agent lastName': 'Doe', 'Agent agentType': 'Organization', 'Address address': 'testaddress1', 'Address address #2': 'testaddress2'}, - {'CollectionObject integer1': '', 'Agent firstName': 'John', 'Agent lastName': 'Doe', 'Agent agentType': 'Organization', 'Address address': 'testaddress1', 'Address address #2': 'testaddress2'} - ] - - with patch('specifyweb.workbench.upload.preferences.should_defer_fields', new=defer): + { + "CollectionObject integer1": "", + "Agent firstName": "John", + "Agent lastName": "Doe", + "Agent agentType": "Organization", + "Address address": "testaddress1", + "Address address #2": "testaddress2", + }, + { + "CollectionObject integer1": "", + "Agent firstName": "John", + "Agent lastName": "Doe", + "Agent agentType": "Organization", + "Address address": "testaddress1", + "Address address #2": "testaddress2", + }, + { + "CollectionObject integer1": "", + "Agent firstName": "John", + "Agent lastName": "Doe", + "Agent agentType": "Organization", + "Address address": "testaddress1", + "Address address #2": "testaddress2", + }, + ] + + with patch( + "specifyweb.workbench.upload.preferences.should_defer_fields", new=defer + ): results = do_upload( self.collection, data, plan, self.agent.id, batch_edit_packs=pack ) - + list([self.enforcer(record) for record in results[:2]]) - self.assertIsInstance(results[2].toOne['cataloger'].record_result, MatchedAndChanged) - self.assertEqual(results[2].toOne['cataloger'].record_result.get_id(), self.test_agent_1.id) + self.assertIsInstance( + results[2].toOne["cataloger"].record_result, MatchedAndChanged + ) + self.assertEqual( + results[2].toOne["cataloger"].record_result.get_id(), self.test_agent_1.id + ) diff --git a/specifyweb/workbench/upload/tests/test_bugs.py b/specifyweb/workbench/upload/tests/test_bugs.py index 892c6d01ba0..51f3bb8b5b6 100644 --- a/specifyweb/workbench/upload/tests/test_bugs.py +++ b/specifyweb/workbench/upload/tests/test_bugs.py @@ -1,4 +1,3 @@ - import io import json import csv @@ -11,31 +10,69 @@ from .base import UploadTestsBase from specifyweb.specify.tests.test_api import get_table + class BugTests(UploadTestsBase): def test_bogus_null_record(self) -> None: - Taxon = get_table('Taxon') - life = Taxon.objects.create(name='Life', definitionitem=self.taxontreedef.treedefitems.get(name='Taxonomy Root')) - funduloidea = Taxon.objects.create(name='Funduloidea', definitionitem=self.taxontreedef.treedefitems.get(name='Family'), parent=life) - profunduloidea = Taxon.objects.create(name='Profunduloidea', definitionitem=self.taxontreedef.treedefitems.get(name='Family'), parent=life) - fundulus1 = Taxon.objects.create(name='Fundulus', definitionitem=self.taxontreedef.treedefitems.get(name='Genus'), parent=funduloidea) - fundulus2 = Taxon.objects.create(name='Fundulus', definitionitem=self.taxontreedef.treedefitems.get(name='Genus'), parent=profunduloidea) + Taxon = get_table("Taxon") + life = Taxon.objects.create( + name="Life", + definitionitem=self.taxontreedef.treedefitems.get(name="Taxonomy Root"), + ) + funduloidea = Taxon.objects.create( + name="Funduloidea", + definitionitem=self.taxontreedef.treedefitems.get(name="Family"), + parent=life, + ) + profunduloidea = Taxon.objects.create( + name="Profunduloidea", + definitionitem=self.taxontreedef.treedefitems.get(name="Family"), + parent=life, + ) + fundulus1 = Taxon.objects.create( + name="Fundulus", + definitionitem=self.taxontreedef.treedefitems.get(name="Genus"), + parent=funduloidea, + ) + fundulus2 = Taxon.objects.create( + name="Fundulus", + definitionitem=self.taxontreedef.treedefitems.get(name="Genus"), + parent=profunduloidea, + ) plan = { "baseTableName": "collectionobject", "uploadable": { "uploadTable": { - "wbcols": {"catalognumber": "Cat #",}, + "wbcols": { + "catalognumber": "Cat #", + }, "static": {}, "toOne": {}, "toMany": { - "determinations": [{ - "wbcols": {}, - "static": {}, - "toOne": { - "taxon": {"treeRecord": { - "ranks": { - "Genus": {"treeNodeCols": {"name": "Genus"}}, - "Species": {"treeNodeCols": {"name": "Species"}}}}},}}],}}}} + "determinations": [ + { + "wbcols": {}, + "static": {}, + "toOne": { + "taxon": { + "treeRecord": { + "ranks": { + "Genus": { + "treeNodeCols": {"name": "Genus"} + }, + "Species": { + "treeNodeCols": {"name": "Species"} + }, + } + } + }, + }, + } + ], + }, + } + }, + } cols = ["Cat #", "Genus", "Species"] @@ -43,14 +80,21 @@ def test_bogus_null_record(self) -> None: up = parse_plan(plan).apply_scoping(self.collection) - result = validate_row(self.collection, up, self.agent.id, dict(zip(cols, row)), None) - self.assertNotIsInstance(result.record_result, Uploaded, "The CO should be created b/c it has determinations.") + result = validate_row( + self.collection, up, self.agent.id, dict(zip(cols, row)), None + ) + self.assertIsInstance( + result.record_result, + Uploaded, + "The CO should be created b/c it has determinations.", + ) def test_duplicate_refworks(self) -> None: - """ Andy found that duplicate reference works were being created from data similar to the following. """ + """Andy found that duplicate reference works were being created from data similar to the following.""" - reader = csv.DictReader(io.StringIO( -'''Catalog number,Type,Title,Volume,Pages,Date,DOI,URL,Author last name 1,Author first name 1,Author MI 1,Author last name 2,Author first name 2,Author MI 2,Author last name 3,Author first name 3,Author MI 3 + reader = csv.DictReader( + io.StringIO( + """Catalog number,Type,Title,Volume,Pages,Date,DOI,URL,Author last name 1,Author first name 1,Author MI 1,Author last name 2,Author first name 2,Author MI 2,Author last name 3,Author first name 3,Author MI 3 10026,1,catfish,282,315,1969,10.5479/si.03629236.282.1,https://doi.org/10.5479/si.03629236.282.1,Taylor,William,R,,,,,, 10168,1,catfish,282,315,1969,10.5479/si.03629236.282.1,https://doi.org/10.5479/si.03629236.282.1,Taylor,William,R,,,,,, 10194,1,catfish,282,315,1969,10.5479/si.03629236.282.1,https://doi.org/10.5479/si.03629236.282.1,Taylor,William,R,,,,,, @@ -71,35 +115,35 @@ def test_duplicate_refworks(self) -> None: 7542,1,The Clupeocephala,45,635-657,2010,10.4067/S0718-19572010000400009,https://doi.org/10.4067/S0718-19572010000400009,Arratia,Gloria,,,,,,, 7588,1,The Clupeocephala,45,635-657,2010,10.4067/S0718-19572010000400009,https://doi.org/10.4067/S0718-19572010000400009,Arratia,Gloria,,,,,,, 7602,1,The Clupeocephala,45,635-657,2010,10.4067/S0718-19572010000400009,https://doi.org/10.4067/S0718-19572010000400009,Arratia,Gloria,,,,,,, -''')) +""" + ) + ) expected = [ - Uploaded, # 10026,1,catfish,282,315,1969,10.5479/si.03629236.282.1,https://doi.org/10.5479/si.03629236.282.1,Taylor,William,R,,,,,, - Matched, # 10168,1,catfish,282,315,1969,10.5479/si.03629236.282.1,https://doi.org/10.5479/si.03629236.282.1,Taylor,William,R,,,,,, - Matched, # 10194,1,catfish,282,315,1969,10.5479/si.03629236.282.1,https://doi.org/10.5479/si.03629236.282.1,Taylor,William,R,,,,,, - Matched, # 10199,1,catfish,282,315,1969,10.5479/si.03629236.282.1,https://doi.org/10.5479/si.03629236.282.1,Taylor,William,R,,,,,, - Matched, # 10206,1,catfish,282,315,1969,10.5479/si.03629236.282.1,https://doi.org/10.5479/si.03629236.282.1,Taylor,William,R,,,,,, - - Uploaded, # 1861,1,pearl,1686,1-28,2008,10.11646/zootaxa.1686.1.1,https://doi.org/10.11646/zootaxa.1686.1.1,Conway,Kevin,W,Chen,,Wei-Jen,Mayden,Richard,L - Matched, # 5311,1,pearl,1686,1-28,2008,10.11646/zootaxa.1686.1.1,https://doi.org/10.11646/zootaxa.1686.1.1,Conway,Kevin,W,Chen,,Wei-Jen,Mayden,Richard,L - Matched, # 5325,1,pearl,1686,1-28,2008,10.11646/zootaxa.1686.1.1,https://doi.org/10.11646/zootaxa.1686.1.1,Conway,Kevin,W,Chen,,Wei-Jen,Mayden,Richard,L - - Uploaded, # 5340,1,nepal,1047,1-19,2005,10.11646/zootaxa.1047.1.1,https://doi.org/10.11646/zootaxa.1047.1.1,Ng,Heok,H,Edds,David,R,,, - Matched, # 5362,1,nepal,1047,1-19,2005,10.11646/zootaxa.1047.1.1,https://doi.org/10.11646/zootaxa.1047.1.1,Ng,Heok,H,Edds,David,R,,, - Matched, # 5282,1,nepal,1047,1-19,2005,10.11646/zootaxa.1047.1.1,https://doi.org/10.11646/zootaxa.1047.1.1,Ng,Heok,H,Edds,David,R,,, - Matched, # 5900,1,nepal,1047,1-19,2005,10.11646/zootaxa.1047.1.1,https://doi.org/10.11646/zootaxa.1047.1.1,Ng,Heok,H,Edds,David,R,,, - - Uploaded, # 6527,1,Centrum,44,721-732,2007,10.1139/e06-137,https://doi.org/10.1139/e06-137,Newbrey,Michael,G,Wilson,Mark,VH,Ashworth,Allan,C - Matched, # 7350,1,Centrum,44,721-732,2007,10.1139/e06-137,https://doi.org/10.1139/e06-137,Newbrey,Michael,G,Wilson,Mark,VH,Ashworth,Allan,C - Matched, # 7357,1,Centrum,44,721-732,2007,10.1139/e06-137,https://doi.org/10.1139/e06-137,Newbrey,Michael,G,Wilson,Mark,VH,Ashworth,Allan,C - - Uploaded, # 7442,1,The Clupeocephala,45,635-657,2010,10.4067/S0718-19572010000400009,https://doi.org/10.4067/S0718-19572010000400009,Arratia,Gloria,,,,,,, - Matched, # 7486,1,The Clupeocephala,45,635-657,2010,10.4067/S0718-19572010000400009,https://doi.org/10.4067/S0718-19572010000400009,Arratia,Gloria,,,,,,, - Matched, # 7542,1,The Clupeocephala,45,635-657,2010,10.4067/S0718-19572010000400009,https://doi.org/10.4067/S0718-19572010000400009,Arratia,Gloria,,,,,,, - Matched, # 7588,1,The Clupeocephala,45,635-657,2010,10.4067/S0718-19572010000400009,https://doi.org/10.4067/S0718-19572010000400009,Arratia,Gloria,,,,,,, - Matched, # 7602,1,The Clupeocephala,45,635-657,2010,10.4067/S0718-19572010000400009,https://doi.org/10.4067/S0718-19572010000400009,Arratia,Gloria,,,,,,, + Uploaded, # 10026,1,catfish,282,315,1969,10.5479/si.03629236.282.1,https://doi.org/10.5479/si.03629236.282.1,Taylor,William,R,,,,,, + Matched, # 10168,1,catfish,282,315,1969,10.5479/si.03629236.282.1,https://doi.org/10.5479/si.03629236.282.1,Taylor,William,R,,,,,, + Matched, # 10194,1,catfish,282,315,1969,10.5479/si.03629236.282.1,https://doi.org/10.5479/si.03629236.282.1,Taylor,William,R,,,,,, + Matched, # 10199,1,catfish,282,315,1969,10.5479/si.03629236.282.1,https://doi.org/10.5479/si.03629236.282.1,Taylor,William,R,,,,,, + Matched, # 10206,1,catfish,282,315,1969,10.5479/si.03629236.282.1,https://doi.org/10.5479/si.03629236.282.1,Taylor,William,R,,,,,, + Uploaded, # 1861,1,pearl,1686,1-28,2008,10.11646/zootaxa.1686.1.1,https://doi.org/10.11646/zootaxa.1686.1.1,Conway,Kevin,W,Chen,,Wei-Jen,Mayden,Richard,L + Matched, # 5311,1,pearl,1686,1-28,2008,10.11646/zootaxa.1686.1.1,https://doi.org/10.11646/zootaxa.1686.1.1,Conway,Kevin,W,Chen,,Wei-Jen,Mayden,Richard,L + Matched, # 5325,1,pearl,1686,1-28,2008,10.11646/zootaxa.1686.1.1,https://doi.org/10.11646/zootaxa.1686.1.1,Conway,Kevin,W,Chen,,Wei-Jen,Mayden,Richard,L + Uploaded, # 5340,1,nepal,1047,1-19,2005,10.11646/zootaxa.1047.1.1,https://doi.org/10.11646/zootaxa.1047.1.1,Ng,Heok,H,Edds,David,R,,, + Matched, # 5362,1,nepal,1047,1-19,2005,10.11646/zootaxa.1047.1.1,https://doi.org/10.11646/zootaxa.1047.1.1,Ng,Heok,H,Edds,David,R,,, + Matched, # 5282,1,nepal,1047,1-19,2005,10.11646/zootaxa.1047.1.1,https://doi.org/10.11646/zootaxa.1047.1.1,Ng,Heok,H,Edds,David,R,,, + Matched, # 5900,1,nepal,1047,1-19,2005,10.11646/zootaxa.1047.1.1,https://doi.org/10.11646/zootaxa.1047.1.1,Ng,Heok,H,Edds,David,R,,, + Uploaded, # 6527,1,Centrum,44,721-732,2007,10.1139/e06-137,https://doi.org/10.1139/e06-137,Newbrey,Michael,G,Wilson,Mark,VH,Ashworth,Allan,C + Matched, # 7350,1,Centrum,44,721-732,2007,10.1139/e06-137,https://doi.org/10.1139/e06-137,Newbrey,Michael,G,Wilson,Mark,VH,Ashworth,Allan,C + Matched, # 7357,1,Centrum,44,721-732,2007,10.1139/e06-137,https://doi.org/10.1139/e06-137,Newbrey,Michael,G,Wilson,Mark,VH,Ashworth,Allan,C + Uploaded, # 7442,1,The Clupeocephala,45,635-657,2010,10.4067/S0718-19572010000400009,https://doi.org/10.4067/S0718-19572010000400009,Arratia,Gloria,,,,,,, + Matched, # 7486,1,The Clupeocephala,45,635-657,2010,10.4067/S0718-19572010000400009,https://doi.org/10.4067/S0718-19572010000400009,Arratia,Gloria,,,,,,, + Matched, # 7542,1,The Clupeocephala,45,635-657,2010,10.4067/S0718-19572010000400009,https://doi.org/10.4067/S0718-19572010000400009,Arratia,Gloria,,,,,,, + Matched, # 7588,1,The Clupeocephala,45,635-657,2010,10.4067/S0718-19572010000400009,https://doi.org/10.4067/S0718-19572010000400009,Arratia,Gloria,,,,,,, + Matched, # 7602,1,The Clupeocephala,45,635-657,2010,10.4067/S0718-19572010000400009,https://doi.org/10.4067/S0718-19572010000400009,Arratia,Gloria,,,,,,, ] - plan = parse_plan(json.loads(''' + plan = parse_plan( + json.loads( + """ { "baseTableName": "referencework", "uploadable": { @@ -165,7 +209,9 @@ def test_duplicate_refworks(self) -> None: } } } -''')) +""" + ) + ) upload_results = do_upload_csv(self.collection, reader, plan, self.agent.id) rr = [r.record_result.__class__ for r in upload_results] self.assertEqual(expected, rr) diff --git a/specifyweb/workbench/upload/tests/test_upload_results_json.py b/specifyweb/workbench/upload/tests/test_upload_results_json.py index 2004cf1bafd..fb43346b002 100644 --- a/specifyweb/workbench/upload/tests/test_upload_results_json.py +++ b/specifyweb/workbench/upload/tests/test_upload_results_json.py @@ -104,7 +104,6 @@ def testUploadResultExplicit(self): toMany={k: [UploadResult(v, {}, {}) for v in vs] for k, vs in toMany.items()} ) d = uploadResult.to_json() - print(d) j = json.dumps(d) e = json.loads(j) validate([e], schema) diff --git a/specifyweb/workbench/upload/treerecord.py b/specifyweb/workbench/upload/treerecord.py index bdd0400f674..5c54ace9785 100644 --- a/specifyweb/workbench/upload/treerecord.py +++ b/specifyweb/workbench/upload/treerecord.py @@ -19,7 +19,7 @@ from .upload_result import UploadResult, NullRecord, NoMatch, Matched, \ MatchedMultiple, Uploaded, ParseFailures, FailedBusinessRule, ReportInfo, \ TreeInfo -from .uploadable import Row, Disambiguation as DA, Auditor, ScopeGenerator +from .uploadable import Row, Disambiguation as DA, Auditor, ScopeGenerator, BatchEditJson logger = logging.getLogger(__name__) @@ -59,7 +59,7 @@ class ScopedTreeRecord(NamedTuple): def disambiguate(self, disambiguation: DA) -> "ScopedTreeRecord": return self._replace(disambiguation=disambiguation.disambiguate_tree()) if disambiguation is not None else self - def apply_batch_edit_pack(self, batch_edit_pack: Optional[Dict[str, Any]]) -> "ScopedTreeRecord": + def apply_batch_edit_pack(self, batch_edit_pack: Optional[BatchEditJson]) -> "ScopedTreeRecord": if batch_edit_pack is None: return self # batch-edit considers ranks as self-relationships, and are trivially stored in to-one @@ -186,7 +186,6 @@ def _handle_row(self, must_match: bool) -> UploadResult: return UploadResult(match_result, {}, {}) def _to_match(self, references=None) -> List[TreeDefItemWithParseResults]: - print(references) return [ TreeDefItemWithParseResults(tdi, self.parsedFields[tdi.name]) for tdi in self.treedefitems diff --git a/specifyweb/workbench/upload/upload.py b/specifyweb/workbench/upload/upload.py index 3b8c429b1b0..4890f119da9 100644 --- a/specifyweb/workbench/upload/upload.py +++ b/specifyweb/workbench/upload/upload.py @@ -4,7 +4,7 @@ import time from contextlib import contextmanager from datetime import datetime, timezone -from typing import List, Dict, Union, Callable, Optional, Sized, Tuple, Any, cast +from typing import List, Dict, Literal, Union, Callable, Optional, Sized, Tuple, Any, TypedDict from django.db import transaction from django.db.utils import OperationalError, IntegrityError @@ -18,7 +18,7 @@ from . import disambiguation from .upload_plan_schema import schema, parse_plan_with_basetable from .upload_result import Deleted, RecordResult, Updated, Uploaded, UploadResult, ParseFailures -from .uploadable import ScopedUploadable, Row, Disambiguation, Auditor, Uploadable +from .uploadable import Extra, ScopedUploadable, Row, Disambiguation, Auditor, Uploadable, BatchEditJson from ..models import Spdataset Rows = Union[List[Row], csv.DictReader] @@ -172,12 +172,12 @@ def create_recordset(ds: Spdataset, name: str): return rs def get_disambiguation_from_row(ncols: int, row: List) -> Disambiguation: - extra = json.loads(row[ncols]) if row[ncols] else None + extra: Optional[Extra] = json.loads(row[ncols]) if row[ncols] else None return disambiguation.from_json(extra['disambiguation']) if extra and 'disambiguation' in extra else None -def get_batch_edit_pack_from_row(ncols: int, row: List) -> Optional[Dict[str, Any]]: - extra: Optional[Dict[str, Any]] = json.loads(row[ncols]) if row[ncols] else None - return extra.get('batch_edit', None) if extra else None +def get_batch_edit_pack_from_row(ncols: int, row: List) -> Optional[BatchEditJson]: + extra: Optional[Extra] = json.loads(row[ncols]) if row[ncols] else None + return extra.get('batch_edit') if extra is not None else None def get_raw_ds_upload_plan(ds: Spdataset) -> Tuple[Table, Uploadable]: if ds.uploadplan is None: @@ -205,7 +205,7 @@ def do_upload( no_commit: bool=False, allow_partial: bool=True, progress: Optional[Progress]=None, - batch_edit_packs: Optional[List[Optional[Dict[str, Any]]]] = None + batch_edit_packs: Optional[List[Optional[BatchEditJson]]] = None ) -> List[UploadResult]: cache: Dict = {} _auditor = Auditor(collection=collection, audit_log=None if no_commit else auditlog, diff --git a/specifyweb/workbench/upload/upload_table.py b/specifyweb/workbench/upload/upload_table.py index 55219585d27..4a0fd1a9a5b 100644 --- a/specifyweb/workbench/upload/upload_table.py +++ b/specifyweb/workbench/upload/upload_table.py @@ -2,7 +2,6 @@ from typing import List, Dict, Any, NamedTuple, Union, Optional, Set, Literal, Tuple from django.db import transaction, IntegrityError -from django.db.models import Model from specifyweb.businessrules.exceptions import BusinessRuleException from specifyweb.specify import models @@ -41,6 +40,7 @@ ) from .uploadable import ( NULL_RECORD, + ModelWithTable, Row, ScopeGenerator, Uploadable, @@ -48,6 +48,8 @@ BoundUploadable, Disambiguation, Auditor, + BatchEditJson, + BatchEditSelf, ) @@ -57,6 +59,7 @@ # Even if you've another validation on the same thread, this won't cause an issue REFERENCE_KEY = object() + class UploadTable(NamedTuple): name: str wbcols: Dict[str, ColumnOptions] @@ -102,16 +105,27 @@ def to_json(self) -> Dict: def unparse(self) -> Dict: return {"baseTableName": self.name, "uploadable": self.to_json()} -def static_adjustments(table: str, wbcols: Dict[str, ColumnOptions], static: Dict[str, Any]) -> Dict[str, Any]: - # not sure if this is the right place for this, but it will work for now. - if table.lower() == 'agent' and 'agenttype' not in wbcols and 'agenttype' not in static: - static = {'agenttype': 1, **static} - elif table.lower() == 'Determination' and 'iscurrent' not in wbcols and 'iscurrent' not in static: - static = {'iscurrent': True, **static} + +def static_adjustments( + table: str, wbcols: Dict[str, ExtendedColumnOptions], static: Dict[str, Any] +) -> Dict[str, Any]: + if ( + table.lower() == "agent" + and "agenttype" not in wbcols + and "agenttype" not in static + ): + static = {"agenttype": 1, **static} + elif ( + table.lower() == "determination" + and "iscurrent" not in wbcols + and "iscurrent" not in static + ): + static = {"iscurrent": True, **static} else: static = static return static + class ScopedUploadTable(NamedTuple): name: str wbcols: Dict[str, ExtendedColumnOptions] @@ -121,7 +135,7 @@ class ScopedUploadTable(NamedTuple): scopingAttrs: Dict[str, int] disambiguation: Optional[int] to_one_fields: Dict[str, List[str]] # TODO: Consider making this a payload.. - match_payload: Optional[Dict[str, Any]] + match_payload: Optional[BatchEditSelf] strong_ignore: List[str] def disambiguate(self, disambiguation: Disambiguation) -> "ScopedUploadable": @@ -148,13 +162,11 @@ def disambiguate(self, disambiguation: Disambiguation) -> "ScopedUploadable": ) def apply_batch_edit_pack( - self, batch_edit_pack: Optional[Dict[str, Any]] + self, batch_edit_pack: Optional[BatchEditJson] ) -> "ScopedUploadable": - # Static adjustments cannot happen before this without handling around a dirty prop for it. - # Plus, adding static adjustments here make things MUCH simpler when we realize "oh shoot, we need to create a record" bc it was null initially. if batch_edit_pack is None: - return self._replace(static=static_adjustments(self.name, self.wbcols, self.static)) - + return self + return self._replace( match_payload=batch_edit_pack["self"], toOne={ @@ -237,7 +249,13 @@ def bind( return BoundUploadTable( name=self.name, - static=self.static, + # Static adjustments should not happen for records selected for batch-edit. Handling it here makes things simple: it'll be even added for records + # that we may potentially create. + static=( + static_adjustments(self.name, self.wbcols, self.static) + if self.match_payload is None + else self.static + ), scopingAttrs=self.scopingAttrs, disambiguation=self.disambiguation, parsedFields=parsedFields, @@ -248,7 +266,7 @@ def bind( cache=cache, to_one_fields=self.to_one_fields, match_payload=self.match_payload, - strong_ignore=self.strong_ignore + strong_ignore=self.strong_ignore, ) @@ -264,7 +282,13 @@ def to_json(self) -> Dict: class ScopedOneToOneTable(ScopedUploadTable): - def bind(self, row: Row, uploadingAgentId: int, auditor: Auditor, cache: Optional[Dict]=None) -> Union["BoundOneToOneTable", ParseFailures]: + def bind( + self, + row: Row, + uploadingAgentId: int, + auditor: Auditor, + cache: Optional[Dict] = None, + ) -> Union["BoundOneToOneTable", ParseFailures]: b = super().bind(row, uploadingAgentId, auditor, cache) return BoundOneToOneTable(*b) if isinstance(b, BoundUploadTable) else b @@ -279,9 +303,15 @@ def apply_scoping( def to_json(self) -> Dict: return {"mustMatchTable": self._to_json()} + class ScopedMustMatchTable(ScopedUploadTable): - def bind(self,row: Row, uploadingAgentId: int, auditor: Auditor, cache: Optional[Dict]=None - ) -> Union["BoundMustMatchTable", ParseFailures]: + def bind( + self, + row: Row, + uploadingAgentId: int, + auditor: Auditor, + cache: Optional[Dict] = None, + ) -> Union["BoundMustMatchTable", ParseFailures]: b = super().bind(row, uploadingAgentId, auditor, cache) return BoundMustMatchTable(*b) if isinstance(b, BoundUploadTable) else b @@ -298,8 +328,10 @@ class BoundUploadTable(NamedTuple): auditor: Auditor cache: Optional[Dict] to_one_fields: Dict[str, List[str]] - match_payload: Optional[Dict[str, Any]] - strong_ignore: List[str] # fields to stricly ignore for anything. unfortunately, depends needs parent-backref. See comment in "test_batch_edit_table.py/test_to_many_match_is_possible" + match_payload: Optional[BatchEditSelf] + strong_ignore: List[ + str + ] # fields to stricly ignore for anything. unfortunately, depends needs parent-backref. See ctest_to_many_match_is_possibleomment in "test_batch_edit_table.py/test_to_many_match_is_possible" @property def current_id(self): @@ -323,7 +355,7 @@ def can_save(self) -> bool: return isinstance(self.current_id, int) @property - def django_model(self) -> Model: + def django_model(self) -> ModelWithTable: return getattr(models, self.name.capitalize()) @property @@ -426,8 +458,8 @@ def save_row(self, force=False) -> UploadResult: else update_table.process_row_with_null() ) - def _get_reference(self, should_cache=True) -> Optional[Model]: - model: Model = self.django_model + def _get_reference(self, should_cache=True) -> Optional[ModelWithTable]: + model: ModelWithTable = self.django_model current_id = self.current_id if current_id is None: @@ -454,11 +486,17 @@ def _get_reference(self, should_cache=True) -> Optional[Model]: return reference_record - def _resolve_reference_attributes(self, model, reference_record) -> Dict[str, Any]: return resolve_reference_attributes( - [*self.scopingAttrs.keys(), *self.strong_ignore], model, reference_record + [ + *self.scopingAttrs.keys(), + *self.strong_ignore, + *self.toOne.keys(), + *self.toMany.keys(), + ], + model, + reference_record, ) def _handle_row(self, skip_match: bool, allow_null: bool) -> UploadResult: @@ -521,7 +559,6 @@ def _handle_row(self, skip_match: bool, allow_null: bool) -> UploadResult: ) and allow_null: # nothing to upload return UploadResult(NullRecord(info), to_one_results, {}) - if not skip_match: match = self._match(filter_predicate, info) if match: @@ -576,7 +613,6 @@ def _match( elif n_matched == 1: return Matched(id=ids[0], info=info) else: - print('did not find for', predicates) return None def _check_missing_required(self) -> Optional[ParseFailures]: @@ -594,7 +630,10 @@ def _check_missing_required(self) -> Optional[ParseFailures]: return None def _do_upload( - self, model, to_one_results: Dict[str, UploadResult], info: ReportInfo + self, + model: ModelWithTable, + to_one_results: Dict[str, UploadResult], + info: ReportInfo, ) -> UploadResult: missing_required = self._check_missing_required() @@ -633,7 +672,7 @@ def _do_upload( **self.scopingAttrs, **self.static, **{ - model._meta.get_field(fieldname).attname: id + model._meta.get_field(fieldname).attname: id # type: ignore for fieldname, id in to_one_ids.items() }, **( @@ -661,7 +700,7 @@ def _do_upload( return UploadResult(record, to_one_results, to_many_results) - def _handle_to_many(self, update: bool, parent_id: int, model: Model): + def _handle_to_many(self, update: bool, parent_id: int, model: ModelWithTable): return { fieldname: _upload_to_manys( model, @@ -740,9 +779,7 @@ def _relationship_is_dependent(self, field_name) -> bool: # We could check to_one_fields, but we are not going to, because that is just redundant with is_one_to_one. if field_name in self.toOne: return self.toOne[field_name].is_one_to_one() - return django_model.specify_model.get_relationship( - field_name - ).dependent # type: ignore + return django_model.specify_model.get_relationship(field_name).dependent class BoundOneToOneTable(BoundUploadTable): @@ -934,7 +971,11 @@ def _do_upload( FailedBusinessRule(str(e), {}, info), to_one_results, {} ) - record = Updated(updated.pk, info, picklist_additions) if changed else NoChange(reference_record.pk, info) + record: Union[Updated, NoChange] = ( + Updated(updated.pk, info, picklist_additions) + if changed + else NoChange(reference_record.pk, info) + ) to_many_results = self._handle_to_many(True, record.get_id(), model) to_one_adjusted, to_many_adjusted = self._clean_up_fks( diff --git a/specifyweb/workbench/upload/uploadable.py b/specifyweb/workbench/upload/uploadable.py index 2705a85f191..fca822e9314 100644 --- a/specifyweb/workbench/upload/uploadable.py +++ b/specifyweb/workbench/upload/uploadable.py @@ -1,15 +1,30 @@ -from contextlib import contextmanager -import re -from typing import Dict, Generator, Callable, Literal, NamedTuple, Tuple, Any, Optional, TypedDict, Union, Set +from typing import Dict, Generator, Callable, Any, List, Optional, TypedDict, Union, Set from typing_extensions import Protocol - -from specifyweb.context.remote_prefs import get_remote_prefs +from specifyweb.specify.load_datamodel import Table from specifyweb.workbench.upload.predicates import DjangoPredicates, ToRemove +from django.db.models import Model + from .upload_result import UploadResult, ParseFailures from .auditor import Auditor +class BatchEditSelf(TypedDict): + id: int + ordernumber: Optional[int] + version: Optional[int] + +class BatchEditJson(TypedDict): + self: BatchEditSelf + to_one: Dict[str, Any] + to_many: Dict[str, List[Any]] + +class Extra(TypedDict): + batch_edit: Optional[BatchEditJson] + disambiguation: Dict[str, int] + +Disambiguation = Optional["DisambiguationInfo"] + NULL_RECORD = 'null_record' ScopeGenerator = Optional[Generator[int, None, None]] @@ -20,6 +35,11 @@ Filter = Dict[str, Any] +# TODO: Use this everywhere +class ModelWithTable(Model): + specify_model: Table + class Meta: + abstract = True class Uploadable(Protocol): # also returns if the scoped table returned can be cached or not. # depends on whether scope depends on other columns. if any definition is found, @@ -50,9 +70,6 @@ def disambiguate_to_one(self, to_one: str) -> "Disambiguation": def disambiguate_to_many(self, to_many: str, record_index: int) -> "Disambiguation": ... -Disambiguation = Optional[DisambiguationInfo] - - class ScopedUploadable(Protocol): def disambiguate(self, disambiguation: Disambiguation) -> "ScopedUploadable": ... @@ -63,7 +80,7 @@ def bind(self, row: Row, uploadingAgentId: int, auditor: Auditor, cache: Optiona def get_treedefs(self) -> Set: ... - def apply_batch_edit_pack(self, batch_edit_pack: Optional[Dict[str, Any]]) -> "ScopedUploadable": + def apply_batch_edit_pack(self, batch_edit_pack: Optional[BatchEditJson]) -> "ScopedUploadable": ... class BoundUploadable(Protocol): diff --git a/specifyweb/workbench/views.py b/specifyweb/workbench/views.py index 30a6335d90f..a2373d86cea 100644 --- a/specifyweb/workbench/views.py +++ b/specifyweb/workbench/views.py @@ -16,13 +16,18 @@ from specifyweb.specify.views import login_maybe_required, openapi from specifyweb.specify.models import Recordset, Specifyuser from specifyweb.notifications.models import Message -from specifyweb.permissions.permissions import PermissionTarget, PermissionTargetAction, \ - check_permission_targets, check_table_permissions +from specifyweb.permissions.permissions import ( + PermissionTarget, + PermissionTargetAction, + check_permission_targets, + check_table_permissions, +) from . import models, tasks from .upload import upload as uploader, upload_plan_schema logger = logging.getLogger(__name__) + class DataSetPT(PermissionTarget): resource = "/workbench/dataset" create = PermissionTargetAction() @@ -34,25 +39,27 @@ class DataSetPT(PermissionTarget): transfer = PermissionTargetAction() create_recordset = PermissionTargetAction() -def regularize_rows(ncols: int, rows: List[List]) -> List[List[str]]: - n = ncols + 1 # extra row info such as disambiguation in hidden col at end + +def regularize_rows(ncols: int, rows: List[List], skip_empty=True) -> List[List[str]]: + n = ncols + 1 # extra row info such as disambiguation in hidden col at end def regularize(row: List) -> Optional[List]: - data = (row + ['']*n)[:n] # pad / trim row length to match columns - cleaned = ['' if v is None else str(v).strip() for v in data] # convert values to strings - return None if all(v == '' for v in cleaned[0:ncols]) else cleaned # skip empty rows + data = (row + [""] * n)[:n] # pad / trim row length to match columns + cleaned = [ + "" if v is None else str(v).strip() for v in data + ] # convert values to strings + return ( + None if (skip_empty and all(v == "" for v in cleaned[0:ncols])) else cleaned + ) # skip empty rows return [r for r in map(regularize, rows) if r is not None] open_api_components = { - 'schemas': { - 'wb_uploadresult': { + "schemas": { + "wb_uploadresult": { "oneOf": [ - { - "type": "string", - "example": "null" - }, + {"type": "string", "example": "null"}, { "type": "object", "properties": { @@ -63,9 +70,9 @@ def regularize(row: List) -> Optional[List]: "type": "string", "format": "datetime", "example": "2021-04-28T22:28:20.033117+00:00", - } - } - } + }, + }, + }, ] }, "wb_uploaderstatus": { @@ -73,8 +80,9 @@ def regularize(row: List) -> Optional[List]: { "type": "string", "example": "null", - "description": "Nothing to report" - }, { + "description": "Nothing to report", + }, + { "type": "object", "properties": { "taskinfo": { @@ -87,8 +95,8 @@ def regularize(row: List) -> Optional[List]: "total": { "type": "number", "example": 20, - } - } + }, + }, }, "taskstatus": { "type": "string", @@ -96,40 +104,33 @@ def regularize(row: List) -> Optional[List]: "PROGRESS", "PENDING", "FAILURE", - ] + ], }, "uploaderstatus": { "type": "object", "properties": { "operation": { "type": "string", - "enum": [ - 'validating', - 'uploading', - 'unuploading' - ] + "enum": ["validating", "uploading", "unuploading"], }, "taskid": { "type": "string", "maxLength": 36, "example": "7d34dbb2-6e57-4c4b-9546-1fe7bec1acca", - } - } + }, + }, }, }, - "description": "Status of the " + - "upload / un-upload / validation process", - } + "description": "Status of the " + + "upload / un-upload / validation process", + }, ] }, "wb_rows": { "type": "array", "items": { "type": "array", - "items": { - "type": "string", - "description": "Cell's value or null" - } + "items": {"type": "string", "description": "Cell's value or null"}, }, "description": "2D array of values", }, @@ -145,305 +146,341 @@ def regularize(row: List) -> Optional[List]: "type": "number", }, "description": "The order to show columns in", - } + }, ] }, "wb_uploadplan": { "type": "object", - "properties": { - }, - "description": "Upload Plan. Schema - " + - "https://github.com/specify/specify7/blob/5fb51a7d25d549248505aec141ae7f7cdc83e414/specifyweb/workbench/upload/upload_plan_schema.py#L14" + "properties": {}, + "description": "Upload Plan. Schema - " + + "https://github.com/specify/specify7/blob/5fb51a7d25d549248505aec141ae7f7cdc83e414/specifyweb/workbench/upload/upload_plan_schema.py#L14", }, "wb_validation_results": { "type": "object", "properties": {}, - "description": "Schema: " + - "https://github.com/specify/specify7/blob/19ebde3d86ef4276799feb63acec275ebde9b2f4/specifyweb/workbench/upload/validation_schema.py", + "description": "Schema: " + + "https://github.com/specify/specify7/blob/19ebde3d86ef4276799feb63acec275ebde9b2f4/specifyweb/workbench/upload/validation_schema.py", }, "wb_upload_results": { "type": "object", "properties": {}, - "description": "Schema: " + - "https://github.com/specify/specify7/blob/19ebde3d86ef4276799feb63acec275ebde9b2f4/specifyweb/workbench/upload/upload_results_schema.py", - } + "description": "Schema: " + + "https://github.com/specify/specify7/blob/19ebde3d86ef4276799feb63acec275ebde9b2f4/specifyweb/workbench/upload/upload_results_schema.py", + }, } } -@openapi(schema={ - "get": { - "parameters": [ - { - "name": "with_plan", - "in": "query", - "required": False, - "schema": { - "type": "string" - }, - "description": "If parameter is present, limit results to data sets with upload plans." - } - ], - "responses": { - "200": { - "description": "Data fetched successfully", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "number", - "minimum": 0, - "description": "Data Set ID", - }, - "name": { - "type": "string", - "description": "Data Set Name", - }, - "uploadresult": { - "$ref": "#/components/schemas/wb_uploadresult" - }, - "uploaderstatus": { - "$ref": "#/components/schemas/wb_uploaderstatus", - }, - "timestampcreated": { - "type": "string", - "format": "datetime", - "example": "2021-04-28T13:16:07.774" - }, - "timestampmodified": { - "type": "string", - "format": "datetime", - "example": "2021-04-28T13:50:41.710", - } - }, - 'required': ['id', 'name', 'uploadresult', 'uploaderstatus', 'timestampcreated', 'timestampmodified'], - 'additionalProperties': False - } - } - } + +@openapi( + schema={ + "get": { + "parameters": [ + { + "name": "with_plan", + "in": "query", + "required": False, + "schema": {"type": "string"}, + "description": "If parameter is present, limit results to data sets with upload plans.", } - } - } - }, - 'post': { - "requestBody": { - "required": True, - "description": "A JSON representation of a new Data Set", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Data Set name", - }, - "columns": { + ], + "responses": { + "200": { + "description": "Data fetched successfully", + "content": { + "application/json": { + "schema": { "type": "array", "items": { - "type": "string", - "description": "A name of the column", + "type": "object", + "properties": { + "id": { + "type": "number", + "minimum": 0, + "description": "Data Set ID", + }, + "name": { + "type": "string", + "description": "Data Set Name", + }, + "uploadresult": { + "$ref": "#/components/schemas/wb_uploadresult" + }, + "uploaderstatus": { + "$ref": "#/components/schemas/wb_uploaderstatus", + }, + "timestampcreated": { + "type": "string", + "format": "datetime", + "example": "2021-04-28T13:16:07.774", + }, + "timestampmodified": { + "type": "string", + "format": "datetime", + "example": "2021-04-28T13:50:41.710", + }, + }, + "required": [ + "id", + "name", + "uploadresult", + "uploaderstatus", + "timestampcreated", + "timestampmodified", + ], + "additionalProperties": False, }, - "description": "A unique array of strings", - }, - "rows": { - "$ref": "#/components/schemas/wb_rows", - }, - "importedfilename": { - "type": "string", - "description": "The name of the original file", } - }, - 'required': ['name', 'columns', 'rows', 'importedfilename'], - 'additionalProperties': False - } + } + }, } - } + }, }, - "responses": { - "201": { - "description": "Data created successfully", + "post": { + "requestBody": { + "required": True, + "description": "A JSON representation of a new Data Set", "content": { "application/json": { "schema": { "type": "object", "properties": { - "id": { - "type": "number", - "description": "Data Set ID", - }, "name": { "type": "string", - "description": - "Data Set name (may differ from the one " + - "in the request object as part of " + - "ensuring names are unique)" + "description": "Data Set name", + }, + "columns": { + "type": "array", + "items": { + "type": "string", + "description": "A name of the column", + }, + "description": "A unique array of strings", + }, + "rows": { + "$ref": "#/components/schemas/wb_rows", + }, + "importedfilename": { + "type": "string", + "description": "The name of the original file", }, }, - 'required': ['name', 'id'], - 'additionalProperties': False + "required": ["name", "columns", "rows", "importedfilename"], + "additionalProperties": False, } } + }, + }, + "responses": { + "201": { + "description": "Data created successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "number", + "description": "Data Set ID", + }, + "name": { + "type": "string", + "description": "Data Set name (may differ from the one " + + "in the request object as part of " + + "ensuring names are unique)", + }, + }, + "required": ["name", "id"], + "additionalProperties": False, + } + } + }, } - } - } - } -}, components=open_api_components) + }, + }, + }, + components=open_api_components, +) @login_maybe_required @require_http_methods(["GET", "POST"]) @transaction.atomic def datasets(request) -> http.HttpResponse: """RESTful list of user's WB datasets. POSTing will create a new dataset.""" if request.method == "POST": - check_permission_targets(request.specify_collection.id, request.specify_user.id, [DataSetPT.create]) + check_permission_targets( + request.specify_collection.id, request.specify_user.id, [DataSetPT.create] + ) data = json.load(request) - columns = data['columns'] - if any(not isinstance(c, str) for c in columns) or not isinstance(columns, list): - return http.HttpResponse(f"all column headers must be strings: {columns}", status=400) + columns = data["columns"] + if any(not isinstance(c, str) for c in columns) or not isinstance( + columns, list + ): + return http.HttpResponse( + f"all column headers must be strings: {columns}", status=400 + ) if len(set(columns)) != len(columns): - return http.HttpResponse(f"all column headers must be unique: {columns}", status=400) + return http.HttpResponse( + f"all column headers must be unique: {columns}", status=400 + ) - rows = regularize_rows(len(columns), data['rows']) + rows = regularize_rows(len(columns), data["rows"]) ds = models.Spdataset.objects.create( specifyuser=request.specify_user, collection=request.specify_collection, - name=data['name'], + name=data["name"], columns=columns, data=rows, - importedfilename=data['importedfilename'], + importedfilename=data["importedfilename"], createdbyagent=request.specify_user_agent, modifiedbyagent=request.specify_user_agent, ) return http.JsonResponse({"id": ds.id, "name": ds.name}, status=201) else: - return http.JsonResponse(models.Spdataset.get_meta_fields( - request, - ["uploadresult"], - { - **({'uploadplan__isnull':False} if request.GET.get('with_plan', 0) else {}), - # Defaults to false, to not have funny behaviour if frontend omits isupdate. - # That is, assume normal dataset is needed unless specifically told otherwise. - **({'isupdate': request.GET.get('isupdate', False)}), - **({'parent_id': None}) - } - ), safe=False) + return http.JsonResponse( + models.Spdataset.get_meta_fields( + request, + ["uploadresult"], + { + **( + {"uploadplan__isnull": False} + if request.GET.get("with_plan", 0) + else {} + ), + # Defaults to false, to not have funny behaviour if frontend omits isupdate. + # That is, assume normal dataset is needed unless specifically told otherwise. + **({"isupdate": request.GET.get("isupdate", False)}), + **({"parent_id": None}), + }, + ), + safe=False, + ) -@openapi(schema={ - "get": { - "responses": { - "200": { - "description": "Successful response", + +@openapi( + schema={ + "get": { + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "number", + "description": "Data Set ID", + }, + "name": { + "type": "string", + "description": "Data Set name", + }, + "columns": { + "type": "array", + "items": { + "type": "string", + "description": "A name of the column", + }, + "description": "A unique array of strings", + }, + "visualorder": { + "$ref": "#/components/schemas/wb_visualorder" + }, + "rows": {"$ref": "#/components/schemas/wb_rows"}, + "uploadplan": { + "$ref": "#/components/schemas/wb_uploadplan" + }, + "uploadresult": { + "$ref": "#/components/schemas/wb_uploadresult" + }, + "uploaderstatus": { + "$ref": "#/components/schemas/wb_uploaderstatus" + }, + "importedfilename": { + "type": "string", + "description": "The name of the original file", + }, + "remarks": { + "type": "string", + }, + "timestampcreated": { + "type": "string", + "format": "datetime", + "example": "2021-04-28T13:16:07.774", + }, + "timestampmodified": { + "type": "string", + "format": "datetime", + "example": "2021-04-28T13:50:41.710", + }, + }, + "required": [ + "id", + "name", + "columns", + "visualorder", + "rows", + "uploadplan", + "uploadresult", + "uploaderstatus", + "importedfilename", + "remarks", + "timestampcreated", + "timestampmodified", + ], + "additionalProperties": False, + } + } + }, + } + } + }, + "put": { + "requestBody": { + "required": True, + "description": "A JSON representation of updates to the data set", "content": { "application/json": { "schema": { "type": "object", "properties": { - "id": { - "type": "number", - "description": "Data Set ID", - }, "name": { "type": "string", "description": "Data Set name", }, - "columns": { - "type": "array", - "items": { - "type": "string", - "description": "A name of the column", - }, - "description": "A unique array of strings", + "remarks": { + "type": "string", }, "visualorder": { "$ref": "#/components/schemas/wb_visualorder" }, - "rows": { - "$ref": "#/components/schemas/wb_rows" - }, "uploadplan": { "$ref": "#/components/schemas/wb_uploadplan" }, - "uploadresult": { - "$ref": "#/components/schemas/wb_uploadresult" - }, - "uploaderstatus": { - "$ref": "#/components/schemas/wb_uploaderstatus" - }, - "importedfilename": { - "type": "string", - "description": "The name of the original file", - }, - "remarks": { - "type": "string", - }, - "timestampcreated": { - "type": "string", - "format": "datetime", - "example": "2021-04-28T13:16:07.774" - }, - "timestampmodified": { - "type": "string", - "format": "datetime", - "example": "2021-04-28T13:50:41.710", - } }, - 'required': ['id', 'name', 'columns', 'visualorder', 'rows', 'uploadplan', 'uploadresult', - 'uploaderstatus', 'importedfilename', 'remarks', 'timestampcreated', 'timestampmodified'], - 'additionalProperties': False + "additionalProperties": False, } } - } - } - } - }, - 'put': { - "requestBody": { - "required": True, - "description": "A JSON representation of updates to the data set", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Data Set name", - }, - "remarks": { - "type": "string", - }, - "visualorder": { - "$ref": "#/components/schemas/wb_visualorder" - }, - "uploadplan": { - "$ref": "#/components/schemas/wb_uploadplan" - }, - }, - 'additionalProperties': False - } - } + }, + }, + "responses": { + "204": {"description": "Data set updated."}, + "409": {"description": "Dataset in use by uploader."}, + }, + }, + "delete": { + "responses": { + "204": {"description": "Data set deleted."}, + "409": {"description": "Dataset in use by uploader"}, } }, - "responses": { - "204": {"description": "Data set updated."}, - "409": {"description": "Dataset in use by uploader."} - } }, - "delete": { - "responses": { - "204": {"description": "Data set deleted."}, - "409": {"description": "Dataset in use by uploader"} - } - } -}, components=open_api_components) + components=open_api_components, +) @login_maybe_required @require_http_methods(["GET", "PUT", "DELETE"]) @transaction.atomic @@ -457,39 +494,55 @@ def dataset(request, ds: models.Spdataset) -> http.HttpResponse: with transaction.atomic(): if request.method == "PUT": - check_permission_targets(request.specify_collection.id, request.specify_user.id, [DataSetPT.update]) + check_permission_targets( + request.specify_collection.id, + request.specify_user.id, + [DataSetPT.update], + ) attrs = json.load(request) - if 'name' in attrs: - ds.name = attrs['name'] + if "name" in attrs: + ds.name = attrs["name"] - if 'remarks' in attrs: - ds.remarks = attrs['remarks'] + if "remarks" in attrs: + ds.remarks = attrs["remarks"] - if 'visualorder' in attrs: - ds.visualorder = attrs['visualorder'] - assert ds.visualorder is None or (isinstance(ds.visualorder, list) and len(ds.visualorder) == len(ds.columns)) + if "visualorder" in attrs: + ds.visualorder = attrs["visualorder"] + assert ds.visualorder is None or ( + isinstance(ds.visualorder, list) + and len(ds.visualorder) == len(ds.columns) + ) - if 'uploadplan' in attrs: - plan = attrs['uploadplan'] + if "uploadplan" in attrs: + plan = attrs["uploadplan"] if ds.uploaderstatus is not None: - return http.HttpResponse('dataset in use by uploader', status=409) + return http.HttpResponse("dataset in use by uploader", status=409) if ds.was_uploaded(): - return http.HttpResponse('dataset has been uploaded. changing upload plan not allowed.', status=400) + return http.HttpResponse( + "dataset has been uploaded. changing upload plan not allowed.", + status=400, + ) if plan is not None: try: validate(plan, upload_plan_schema.schema) except ValidationError as e: - return http.HttpResponse(f"upload plan is invalid: {e}", status=400) + return http.HttpResponse( + f"upload plan is invalid: {e}", status=400 + ) - new_cols = upload_plan_schema.parse_plan(plan).get_cols() - set(ds.columns) + new_cols = upload_plan_schema.parse_plan(plan).get_cols() - set( + ds.columns + ) if new_cols: ncols = len(ds.columns) ds.columns += list(new_cols) for i, row in enumerate(ds.data): - ds.data[i] = row[:ncols] + [""]*len(new_cols) + row[ncols:] + ds.data[i] = ( + row[:ncols] + [""] * len(new_cols) + row[ncols:] + ) ds.uploadplan = json.dumps(plan) if plan is not None else None ds.rowresults = None @@ -499,20 +552,49 @@ def dataset(request, ds: models.Spdataset) -> http.HttpResponse: return http.HttpResponse(status=204) if request.method == "DELETE": - check_permission_targets(request.specify_collection.id, request.specify_user.id, [DataSetPT.delete]) + check_permission_targets( + request.specify_collection.id, + request.specify_user.id, + [DataSetPT.delete], + ) if ds.uploaderstatus is not None: - return http.HttpResponse('dataset in use by uploader', status=409) + return http.HttpResponse("dataset in use by uploader", status=409) ds.delete() return http.HttpResponse(status=204) assert False, "Unexpected HTTP method" -@openapi(schema={ - "get": { - "responses": { - "200": { - "description": "Successful response", +@openapi( + schema={ + "get": { + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "string", + "description": "Cell value", + }, + }, + "description": "2d array of cells. NOTE: last column would contain " + + "disambiguation results as a JSON object or be an " + + "empty string", + } + } + }, + } + } + }, + "put": { + "requestBody": { + "required": True, + "description": "A JSON representation of a spreadsheet", "content": { "application/json": { "schema": { @@ -521,48 +603,24 @@ def dataset(request, ds: models.Spdataset) -> http.HttpResponse: "type": "array", "items": { "type": "string", - "description": "Cell value" - } + "description": "Cell value", + }, }, - "description": - "2d array of cells. NOTE: last column would contain " + - "disambiguation results as a JSON object or be an " + - "empty string" + "description": "2d array of cells. NOTE: last column should contain " + + "disambiguation results as a JSON object or be an " + + "empty string", } } - } - } - } - }, - 'put': { - "requestBody": { - "required": True, - "description": "A JSON representation of a spreadsheet", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "array", - "items": { - "type": "string", - "description": "Cell value" - } - }, - "description": - "2d array of cells. NOTE: last column should contain " + - "disambiguation results as a JSON object or be an " + - "empty string" - } - } - } + }, + }, + "responses": { + "204": {"description": "Data set rows updated."}, + "409": {"description": "Dataset in use by uploader"}, + }, }, - "responses": { - "204": {"description": "Data set rows updated."}, - "409": {"description": "Dataset in use by uploader"} - } }, -}, components=open_api_components) + components=open_api_components, +) @login_maybe_required @require_http_methods(["GET", "PUT"]) @transaction.atomic @@ -571,11 +629,15 @@ def rows(request, ds) -> http.HttpResponse: """Returns (GET) or sets (PUT) the row data for dataset .""" if request.method == "PUT": - check_permission_targets(request.specify_collection.id, request.specify_user.id, [DataSetPT.update]) + check_permission_targets( + request.specify_collection.id, request.specify_user.id, [DataSetPT.update] + ) if ds.uploaderstatus is not None: - return http.HttpResponse('dataset in use by uploader.', status=409) + return http.HttpResponse("dataset in use by uploader.", status=409) if ds.was_uploaded(): - return http.HttpResponse('dataset has been uploaded. changing data not allowed.', status=400) + return http.HttpResponse( + "dataset has been uploaded. changing data not allowed.", status=400 + ) rows = regularize_rows(len(ds.columns), json.load(request)) @@ -586,29 +648,32 @@ def rows(request, ds) -> http.HttpResponse: ds.save() return http.HttpResponse(status=204) - else: # GET + else: # GET return http.JsonResponse(ds.data, safe=False) -@openapi(schema={ - 'post': { - "responses": { - "200": { - "description": "Returns a GUID (job ID)", - "content": { - "text/plain": { - "schema": { - "type": "string", - "maxLength": 36, - "example": "7d34dbb2-6e57-4c4b-9546-1fe7bec1acca", +@openapi( + schema={ + "post": { + "responses": { + "200": { + "description": "Returns a GUID (job ID)", + "content": { + "text/plain": { + "schema": { + "type": "string", + "maxLength": 36, + "example": "7d34dbb2-6e57-4c4b-9546-1fe7bec1acca", + } } - } - } - }, - "409": {"description": "Dataset in use by uploader"} - } + }, + }, + "409": {"description": "Dataset in use by uploader"}, + } + }, }, -}, components=open_api_components) + components=open_api_components, +) @login_maybe_required @require_POST @transaction.atomic() @@ -616,54 +681,64 @@ def rows(request, ds) -> http.HttpResponse: def upload(request, ds, no_commit: bool, allow_partial: bool) -> http.HttpResponse: "Initiates an upload or validation of dataset ." - check_permission_targets(request.specify_collection.id, request.specify_user.id, [ - DataSetPT.validate if no_commit else DataSetPT.upload - ]) + check_permission_targets( + request.specify_collection.id, + request.specify_user.id, + [DataSetPT.validate if no_commit else DataSetPT.upload], + ) with transaction.atomic(): if ds.uploaderstatus is not None: - return http.HttpResponse('dataset in use by uploader.', status=409) + return http.HttpResponse("dataset in use by uploader.", status=409) if ds.collection != request.specify_collection: - return http.HttpResponse('dataset belongs to a different collection.', status=400) + return http.HttpResponse( + "dataset belongs to a different collection.", status=400 + ) if ds.was_uploaded(): - return http.HttpResponse('dataset has already been uploaded.', status=400) + return http.HttpResponse("dataset has already been uploaded.", status=400) taskid = str(uuid4()) - async_result = tasks.upload.apply_async([ - request.specify_collection.id, - request.specify_user_agent.id, - ds.id, - no_commit, - allow_partial - ], task_id=taskid) + async_result = tasks.upload.apply_async( + [ + request.specify_collection.id, + request.specify_user_agent.id, + ds.id, + no_commit, + allow_partial, + ], + task_id=taskid, + ) ds.uploaderstatus = { - 'operation': "validating" if no_commit else "uploading", - 'taskid': taskid + "operation": "validating" if no_commit else "uploading", + "taskid": taskid, } - ds.save(update_fields=['uploaderstatus']) + ds.save(update_fields=["uploaderstatus"]) return http.JsonResponse(taskid, safe=False) -@openapi(schema={ - 'post': { - "responses": { - "200": { - "description": "Returns a GUID (job ID)", - "content": { - "text/plain": { - "schema": { - "type": "string", - "maxLength": 36, - "example": "7d34dbb2-6e57-4c4b-9546-1fe7bec1acca", +@openapi( + schema={ + "post": { + "responses": { + "200": { + "description": "Returns a GUID (job ID)", + "content": { + "text/plain": { + "schema": { + "type": "string", + "maxLength": 36, + "example": "7d34dbb2-6e57-4c4b-9546-1fe7bec1acca", + } } - } - } - }, - "409": {"description": "Dataset in use by uploader"} - } + }, + }, + "409": {"description": "Dataset in use by uploader"}, + } + }, }, -}, components=open_api_components) + components=open_api_components, +) @login_maybe_required @require_POST @transaction.atomic() @@ -671,43 +746,48 @@ def upload(request, ds, no_commit: bool, allow_partial: bool) -> http.HttpRespon def unupload(request, ds) -> http.HttpResponse: "Initiates an unupload of dataset ." - check_permission_targets(request.specify_collection.id, request.specify_user.id, [DataSetPT.unupload]) + check_permission_targets( + request.specify_collection.id, request.specify_user.id, [DataSetPT.unupload] + ) with transaction.atomic(): if ds.uploaderstatus is not None: - return http.HttpResponse('dataset in use by uploader.', status=409) + return http.HttpResponse("dataset in use by uploader.", status=409) if not ds.was_uploaded(): - return http.HttpResponse('dataset has not been uploaded.', status=400) + return http.HttpResponse("dataset has not been uploaded.", status=400) taskid = str(uuid4()) - async_result = tasks.unupload.apply_async([request.specify_collection.id, ds.id, request.specify_user_agent.id], task_id=taskid) - ds.uploaderstatus = { - 'operation': "unuploading", - 'taskid': taskid - } - ds.save(update_fields=['uploaderstatus']) + async_result = tasks.unupload.apply_async( + [request.specify_collection.id, ds.id, request.specify_user_agent.id], + task_id=taskid, + ) + ds.uploaderstatus = {"operation": "unuploading", "taskid": taskid} + ds.save(update_fields=["uploaderstatus"]) - return http.JsonResponse('w', safe=False) + return http.JsonResponse("w", safe=False) # @login_maybe_required -@openapi(schema={ - 'get': { - "responses": { - "200": { - "description": "Data fetched successfully", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/wb_uploaderstatus", +@openapi( + schema={ + "get": { + "responses": { + "200": { + "description": "Data fetched successfully", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/wb_uploaderstatus", + } } - } - } - }, - } + }, + }, + } + }, }, -}, components=open_api_components) + components=open_api_components, +) @require_GET def status(request, ds_id: int) -> http.HttpResponse: "Returns the uploader status for the dataset ." @@ -719,53 +799,50 @@ def status(request, ds_id: int) -> http.HttpResponse: return http.JsonResponse(None, safe=False) task = { - 'uploading': tasks.upload, - 'validating': tasks.upload, - 'unuploading': tasks.unupload, - }[ds.uploaderstatus['operation']] - result = task.AsyncResult(ds.uploaderstatus['taskid']) + "uploading": tasks.upload, + "validating": tasks.upload, + "unuploading": tasks.unupload, + }[ds.uploaderstatus["operation"]] + result = task.AsyncResult(ds.uploaderstatus["taskid"]) status = { - 'uploaderstatus': ds.uploaderstatus, - 'taskstatus': result.state, - 'taskinfo': result.info if isinstance(result.info, dict) else repr(result.info) + "uploaderstatus": ds.uploaderstatus, + "taskstatus": result.state, + "taskinfo": result.info if isinstance(result.info, dict) else repr(result.info), } return http.JsonResponse(status) -@openapi(schema={ - 'post': { - "responses": { - "200": { - "description": "Returns either 'ok' if a task is aborted " + - " or 'not running' if no task exists.", - "content": { - "text/plain": { - "schema": { - "type": "string", - "enum": [ - "ok", - "not running" - ] +@openapi( + schema={ + "post": { + "responses": { + "200": { + "description": "Returns either 'ok' if a task is aborted " + + " or 'not running' if no task exists.", + "content": { + "text/plain": { + "schema": {"type": "string", "enum": ["ok", "not running"]} } - } - } - }, - "503": { - "description": "Indicates the process could not be terminated.", - "content": { - "text/plain": { - "schema": { - "type": "string", - "enum": [ - 'timed out waiting for requested task to terminate' - ] + }, + }, + "503": { + "description": "Indicates the process could not be terminated.", + "content": { + "text/plain": { + "schema": { + "type": "string", + "enum": [ + "timed out waiting for requested task to terminate" + ], + } } - } - } - }, - } + }, + }, + } + }, }, -}, components=open_api_components) + components=open_api_components, +) @login_maybe_required @require_POST @models.Spdataset.validate_dataset_request(raise_404=True, lock_object=False) @@ -773,48 +850,54 @@ def abort(request, ds) -> http.HttpResponse: "Aborts any ongoing uploader operation for dataset ." if ds.uploaderstatus is None: - return http.HttpResponse('not running', content_type='text/plain') + return http.HttpResponse("not running", content_type="text/plain") task = { - 'uploading': tasks.upload, - 'validating': tasks.upload, - 'unuploading': tasks.unupload, - }[ds.uploaderstatus['operation']] - result = task.AsyncResult(ds.uploaderstatus['taskid']).revoke(terminate=True) + "uploading": tasks.upload, + "validating": tasks.upload, + "unuploading": tasks.unupload, + }[ds.uploaderstatus["operation"]] + result = task.AsyncResult(ds.uploaderstatus["taskid"]).revoke(terminate=True) try: models.Spdataset.objects.filter(id=ds.id).update(uploaderstatus=None) except OperationalError as e: - if e.args[0] == 1205: # (1205, 'Lock wait timeout exceeded; try restarting transaction') + if ( + e.args[0] == 1205 + ): # (1205, 'Lock wait timeout exceeded; try restarting transaction') return http.HttpResponse( - 'timed out waiting for requested task to terminate', + "timed out waiting for requested task to terminate", status=503, - content_type='text/plain' + content_type="text/plain", ) else: raise - return http.HttpResponse('ok', content_type='text/plain') + return http.HttpResponse("ok", content_type="text/plain") -@openapi(schema={ - 'get': { - "responses": { - "200": { - "description": "Successful operation", - "content": { - "text/plain": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/wb_upload_results", + +@openapi( + schema={ + "get": { + "responses": { + "200": { + "description": "Successful operation", + "content": { + "text/plain": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/wb_upload_results", + }, } } - } - } - }, - } + }, + }, + } + }, }, -}, components=open_api_components) + components=open_api_components, +) @login_maybe_required @require_GET @models.Spdataset.validate_dataset_request(raise_404=True, lock_object=False) @@ -828,52 +911,56 @@ def upload_results(request, ds) -> http.HttpResponse: if settings.DEBUG: from .upload.upload_results_schema import schema + validate(results, schema) return http.JsonResponse(results, safe=False) -@openapi(schema={ - 'post': { - "requestBody": { - "required": True, - "description": "A row to validate", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "string", - "description": "Cell value" - }, - } - } - } - }, - "responses": { - "200": { - "description": "Returns upload results for a single row.", + +@openapi( + schema={ + "post": { + "requestBody": { + "required": True, + "description": "A row to validate", "content": { - "text/plain": { + "application/json": { "schema": { - "type": "object", - "properties": { - "results": { - "$ref": "#/components/schemas/wb_upload_results" - }, - }, - 'required': ['results'], - 'additionalProperties': False + "type": "array", + "items": {"type": "string", "description": "Cell value"}, } } - } + }, }, - } + "responses": { + "200": { + "description": "Returns upload results for a single row.", + "content": { + "text/plain": { + "schema": { + "type": "object", + "properties": { + "results": { + "$ref": "#/components/schemas/wb_upload_results" + }, + }, + "required": ["results"], + "additionalProperties": False, + } + } + }, + }, + }, + }, }, -}, components=open_api_components) + components=open_api_components, +) @login_maybe_required @require_POST def validate_row(request, ds_id: str) -> http.HttpResponse: "Validates a single row for dataset . The row data is passed as POST parameters." - check_permission_targets(request.specify_collection.id, request.specify_user.id, [DataSetPT.validate]) + check_permission_targets( + request.specify_collection.id, request.specify_user.id, [DataSetPT.validate] + ) ds = get_object_or_404(models.Spdataset, id=ds_id) collection = request.specify_collection bt, upload_plan = uploader.get_ds_upload_plan(collection, ds) @@ -884,122 +971,147 @@ def validate_row(request, ds_id: str) -> http.HttpResponse: return http.JsonResponse(None, safe=False) row = rows[0] da = uploader.get_disambiguation_from_row(ncols, row) - result = uploader.validate_row(collection, upload_plan, request.specify_user_agent.id, dict(zip(ds.columns, row)), da) - return http.JsonResponse({'result': result.to_json()}) - -@openapi(schema={ - 'get': { - "responses": { - "200": { - "description": "Returns the upload plan schema, like defined here: " + - "https://github.com/specify/specify7/blob/19ebde3d86ef4276799feb63acec275ebde9b2f4/specifyweb/workbench/upload/upload_plan_schema.py", - "content": { - "text/plain": { - "schema": { - "type": "object", - "properties": {}, + result = uploader.validate_row( + collection, + upload_plan, + request.specify_user_agent.id, + dict(zip(ds.columns, row)), + da, + ) + return http.JsonResponse({"result": result.to_json()}) + + +@openapi( + schema={ + "get": { + "responses": { + "200": { + "description": "Returns the upload plan schema, like defined here: " + + "https://github.com/specify/specify7/blob/19ebde3d86ef4276799feb63acec275ebde9b2f4/specifyweb/workbench/upload/upload_plan_schema.py", + "content": { + "text/plain": { + "schema": { + "type": "object", + "properties": {}, + } } - } - } - }, - } + }, + }, + } + }, }, -}, components=open_api_components) + components=open_api_components, +) @require_GET def up_schema(request) -> http.HttpResponse: "Returns the upload plan schema." return http.JsonResponse(upload_plan_schema.schema) -@openapi(schema={ - 'post': { - "requestBody": { - "required": True, - "description": "User ID of the new owner", - "content": { - "application/x-www-form-urlencoded": { - "schema": { - "type": "object", - "properties": { - "specifyuserid": { - "type": "number", - "description": "User ID of the new owner" + +@openapi( + schema={ + "post": { + "requestBody": { + "required": True, + "description": "User ID of the new owner", + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "type": "object", + "properties": { + "specifyuserid": { + "type": "number", + "description": "User ID of the new owner", + }, }, - }, - 'required': ['specifyuserid'], - 'additionalProperties': False + "required": ["specifyuserid"], + "additionalProperties": False, + } } - } - } + }, + }, + "responses": { + "204": {"description": "Dataset transfer succeeded."}, + }, }, - "responses": { - "204": {"description": "Dataset transfer succeeded."}, - } }, -}, components=open_api_components) + components=open_api_components, +) @login_maybe_required @require_POST def transfer(request, ds_id: int) -> http.HttpResponse: """Transfer dataset's ownership to a different user.""" - if 'specifyuserid' not in request.POST: + if "specifyuserid" not in request.POST: return http.HttpResponseBadRequest("missing parameter: specifyuserid") - check_permission_targets(request.specify_collection.id, request.specify_user.id, [DataSetPT.transfer]) + check_permission_targets( + request.specify_collection.id, request.specify_user.id, [DataSetPT.transfer] + ) ds = get_object_or_404(models.Spdataset, id=ds_id) if ds.specifyuser != request.specify_user: return http.HttpResponseForbidden() try: - ds.specifyuser = Specifyuser.objects.get(id=request.POST['specifyuserid']) + ds.specifyuser = Specifyuser.objects.get(id=request.POST["specifyuserid"]) except Specifyuser.DoesNotExist: return http.HttpResponseBadRequest("the user does not exist") - Message.objects.create(user=ds.specifyuser, content=json.dumps({ - 'type': 'dataset-ownership-transferred', - 'previous-owner-name': request.specify_user.name, - 'dataset-name': ds.name, - 'dataset-id': ds_id, - })) + Message.objects.create( + user=ds.specifyuser, + content=json.dumps( + { + "type": "dataset-ownership-transferred", + "previous-owner-name": request.specify_user.name, + "dataset-name": ds.name, + "dataset-id": ds_id, + } + ), + ) ds.save() return http.HttpResponse(status=204) -@openapi(schema={ - 'post': { - "requestBody": { - "required": True, - "description": "The name of the record set to create.", - "content": { - "application/x-www-form-urlencoded": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Name to give the new record set." - }, - }, - 'required': ['name'], - 'additionalProperties': False - } - } - } - }, - "responses": { - "201": { - "description": "Record set created successfully.", + +@openapi( + schema={ + "post": { + "requestBody": { + "required": True, + "description": "The name of the record set to create.", "content": { - "application/json": { + "application/x-www-form-urlencoded": { "schema": { - "type": "number", - "description": "The database id of the created record set." + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Name to give the new record set.", + }, + }, + "required": ["name"], + "additionalProperties": False, } } - } + }, }, - } + "responses": { + "201": { + "description": "Record set created successfully.", + "content": { + "application/json": { + "schema": { + "type": "number", + "description": "The database id of the created record set.", + } + } + }, + }, + }, + }, }, -}, components=open_api_components) + components=open_api_components, +) @login_maybe_required @require_POST @models.Spdataset.validate_dataset_request(raise_404=True, lock_object=False) @@ -1010,16 +1122,22 @@ def create_recordset(request, ds) -> http.HttpResponse: if ds.rowresults is None: return http.HttpResponseBadRequest("data set is missing row upload results") - if 'name' not in request.POST: + if "name" not in request.POST: return http.HttpResponseBadRequest("missing parameter: name") - name = request.POST['name'] - max_length = Recordset._meta.get_field('name').max_length + name = request.POST["name"] + max_length = Recordset._meta.get_field("name").max_length if max_length is not None and len(name) > max_length: return http.HttpResponseBadRequest("name too long") - check_permission_targets(request.specify_collection.id, request.specify_user.id, [DataSetPT.create_recordset]) - check_table_permissions(request.specify_collection, request.specify_user, Recordset, "create") + check_permission_targets( + request.specify_collection.id, + request.specify_user.id, + [DataSetPT.create_recordset], + ) + check_table_permissions( + request.specify_collection, request.specify_user, Recordset, "create" + ) rs = uploader.create_recordset(ds, name) return http.JsonResponse(rs.id, status=201, safe=False) From b36aa1739fc0f6d7d2d42394525f1fa3c9b96131 Mon Sep 17 00:00:00 2001 From: realVinayak Date: Tue, 20 Aug 2024 12:06:44 -0500 Subject: [PATCH 31/63] (batch-edit): front-end resolves --- .../js_src/lib/components/Router/Routes.tsx | 1 - .../lib/components/Toolbar/WbsDialog.tsx | 4 - .../WbPlanView/__tests__/navigator.test.ts | 1774 +++++++++-------- .../components/WorkBench/batchEditHelpers.ts | 8 +- .../lib/components/WorkBench/handsontable.ts | 110 +- .../js_src/lib/components/WorkBench/index.tsx | 4 +- .../lib/tests/fixtures/uploadplan.1.json | 24 +- 7 files changed, 994 insertions(+), 931 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/Router/Routes.tsx b/specifyweb/frontend/js_src/lib/components/Router/Routes.tsx index e4dee266bc0..c935806356d 100644 --- a/specifyweb/frontend/js_src/lib/components/Router/Routes.tsx +++ b/specifyweb/frontend/js_src/lib/components/Router/Routes.tsx @@ -13,7 +13,6 @@ import { wbText } from '../../localization/workbench'; import type { RA } from '../../utils/types'; import { Redirect } from './Redirect'; import type { EnhancedRoute } from './RouterUtils'; -import { batchEditText } from '../../localization/batchEdit'; // FEATURE: go over non-dynamic routes in all routers to make sure they have titles /* eslint-disable @typescript-eslint/promise-function-async */ diff --git a/specifyweb/frontend/js_src/lib/components/Toolbar/WbsDialog.tsx b/specifyweb/frontend/js_src/lib/components/Toolbar/WbsDialog.tsx index 63c7c4d5b6e..c0a048c9659 100644 --- a/specifyweb/frontend/js_src/lib/components/Toolbar/WbsDialog.tsx +++ b/specifyweb/frontend/js_src/lib/components/Toolbar/WbsDialog.tsx @@ -132,10 +132,6 @@ function TableHeader({ ); } -type DataSetFilter = { - readonly with_plan: number; - readonly isupdate: number -} type WB_VARIANT = keyof Omit; diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/navigator.test.ts b/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/navigator.test.ts index 80e13ef0b52..ea886cecc85 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/navigator.test.ts +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/__tests__/navigator.test.ts @@ -20,396 +20,405 @@ theories(getMappingLineData, [ ], out: [ { - defaultValue: 'determinations', - customSelectSubtype: 'simple', - selectLabel: localized('Collection Object'), - fieldsData: { - catalogNumber: { - optionLabel: 'Cat #', - isEnabled: true, - isRequired: false, - isHidden: false, - isDefault: false, - isRelationship: false, - }, - catalogedDate: { - optionLabel: 'Cat Date', - isEnabled: true, - isRequired: false, - isHidden: false, - isDefault: false, - isRelationship: false, - }, - reservedText: { - optionLabel: 'CT Scan', - isEnabled: true, - isRequired: false, - isHidden: false, - isDefault: false, - isRelationship: false, - }, - guid: { - optionLabel: 'GUID', - isEnabled: true, - isRequired: false, - isHidden: false, - isDefault: false, - isRelationship: false, - }, - leftSideRels: { - isDefault: false, - isEnabled: true, - isHidden: false, - isRelationship: true, - isRequired: false, - optionLabel: 'Left Side Rels', - tableName: 'CollectionRelationship', - }, - altCatalogNumber: { - optionLabel: 'Prev/Exch #', - isEnabled: true, - isRequired: false, - isHidden: false, - isDefault: false, - isRelationship: false, - }, - projectNumber: { - optionLabel: 'Project Number', - isEnabled: true, - isRequired: false, - isHidden: false, - isDefault: false, - isRelationship: false, - }, - remarks: { - optionLabel: 'Remarks', - isEnabled: true, - isRequired: false, - isHidden: false, - isDefault: false, - isRelationship: false, - }, - reservedText2: { - optionLabel: 'Reserved Text2', - isEnabled: true, - isRequired: false, - isHidden: false, - isDefault: false, - isRelationship: false, - }, - rightSideRels: { - isDefault: false, - isEnabled: true, - isHidden: false, - isRelationship: true, - isRequired: false, - optionLabel: 'Right Side Rels', - tableName: 'CollectionRelationship', - }, - fieldNumber: { - optionLabel: 'Voucher', - isEnabled: true, - isRequired: false, - isHidden: false, - isDefault: false, - isRelationship: false, - }, - accession: { - optionLabel: 'Accession #', - isEnabled: true, - isRequired: false, - isHidden: false, - isDefault: false, - isRelationship: true, - tableName: 'Accession', - }, - cataloger: { - optionLabel: 'Cataloger', - isEnabled: true, - isRequired: false, - isHidden: false, - isDefault: false, - isRelationship: true, - tableName: 'Agent', - }, - collectionObjectAttribute: { - optionLabel: 'Col Obj Attribute', - isEnabled: true, - isRequired: false, - isHidden: false, - isDefault: false, - isRelationship: true, - tableName: 'CollectionObjectAttribute', - }, - collectionObjectCitations: { - optionLabel: 'Collection Object Citations', - isEnabled: true, - isRequired: false, - isHidden: false, - isDefault: false, - isRelationship: true, - tableName: 'CollectionObjectCitation', - }, - determinations: { - optionLabel: 'Determinations', - isEnabled: true, - isRequired: false, - isHidden: false, - isDefault: true, - isRelationship: true, - tableName: 'Determination', - }, - dnaSequences: { - optionLabel: 'DNA Sequences', - isEnabled: true, - isRequired: false, - isHidden: false, - isDefault: false, - isRelationship: true, - tableName: 'DNASequence', - }, - collectingEvent: { - optionLabel: 'Field No: Locality', - isEnabled: true, - isRequired: false, - isHidden: false, - isDefault: false, - isRelationship: true, - tableName: 'CollectingEvent', - }, - collection: { - isDefault: false, - isEnabled: true, - isHidden: false, - isRelationship: true, - isRequired: false, - optionLabel: 'Collection', - tableName: 'Collection', - }, - collectionObjectAttachments: { - isDefault: false, - isEnabled: true, - isHidden: false, - isRelationship: true, - isRequired: false, - optionLabel: 'Collection Object Attachments', - tableName: 'CollectionObjectAttachment', - }, - preparations: { - optionLabel: 'Preparations', - isEnabled: true, - isRequired: false, - isHidden: false, - isDefault: false, - isRelationship: true, - tableName: 'Preparation', - }, - voucherRelationships: { - optionLabel: 'Voucher Relationships', - isEnabled: true, - isRequired: false, - isHidden: false, - isDefault: false, - isRelationship: true, - tableName: 'VoucherRelationship', - }, + "customSelectSubtype": "simple", + "defaultValue": "determinations", + "selectLabel": localized("Collection Object"), + "fieldsData": { + "catalogNumber": { + "optionLabel": "Cat #", + "isEnabled": true, + "isRequired": false, + "isHidden": false, + "isDefault": false, + "isRelationship": false + }, + "catalogedDate": { + "optionLabel": "Cat Date", + "isEnabled": true, + "isRequired": false, + "isHidden": false, + "isDefault": false, + "isRelationship": false + }, + "reservedText": { + "optionLabel": "CT Scan", + "isEnabled": true, + "isRequired": false, + "isHidden": false, + "isDefault": false, + "isRelationship": false + }, + "guid": { + "optionLabel": "GUID", + "isEnabled": true, + "isRequired": false, + "isHidden": false, + "isDefault": false, + "isRelationship": false + }, + "altCatalogNumber": { + "optionLabel": "Prev/Exch #", + "isEnabled": true, + "isRequired": false, + "isHidden": false, + "isDefault": false, + "isRelationship": false + }, + "projectNumber": { + "optionLabel": "Project Number", + "isEnabled": true, + "isRequired": false, + "isHidden": false, + "isDefault": false, + "isRelationship": false + }, + "remarks": { + "optionLabel": "Remarks", + "isEnabled": true, + "isRequired": false, + "isHidden": false, + "isDefault": false, + "isRelationship": false + }, + "reservedText2": { + "optionLabel": "Reserved Text2", + "isEnabled": true, + "isRequired": false, + "isHidden": false, + "isDefault": false, + "isRelationship": false + }, + "fieldNumber": { + "optionLabel": "Voucher", + "isEnabled": true, + "isRequired": false, + "isHidden": false, + "isDefault": false, + "isRelationship": false + }, + "accession": { + "optionLabel": "Accession #", + "isEnabled": true, + "isRequired": false, + "isHidden": false, + "isDefault": false, + "isRelationship": true, + "tableName": "Accession" + }, + "cataloger": { + "optionLabel": "Cataloger", + "isEnabled": true, + "isRequired": false, + "isHidden": false, + "isDefault": false, + "isRelationship": true, + "tableName": "Agent" + }, + "collectionObjectAttribute": { + "optionLabel": "Col Obj Attribute", + "isEnabled": true, + "isRequired": false, + "isHidden": false, + "isDefault": false, + "isRelationship": true, + "tableName": "CollectionObjectAttribute" + }, + "collection": { + "optionLabel": "Collection", + "isEnabled": true, + "isRequired": false, + "isHidden": false, + "isDefault": false, + "isRelationship": true, + "tableName": "Collection" + }, + "collectionObjectAttachments": { + "optionLabel": "Collection Object Attachments", + "isEnabled": true, + "isRequired": false, + "isHidden": false, + "isDefault": false, + "isRelationship": true, + "tableName": "CollectionObjectAttachment" + }, + "collectionObjectCitations": { + "optionLabel": "Collection Object Citations", + "isEnabled": true, + "isRequired": false, + "isHidden": false, + "isDefault": false, + "isRelationship": true, + "tableName": "CollectionObjectCitation" + }, + "determinations": { + "optionLabel": "Determinations", + "isEnabled": true, + "isRequired": false, + "isHidden": false, + "isDefault": true, + "isRelationship": true, + "tableName": "Determination" + }, + "dnaSequences": { + "optionLabel": "DNA Sequences", + "isEnabled": true, + "isRequired": false, + "isHidden": false, + "isDefault": false, + "isRelationship": true, + "tableName": "DNASequence" + }, + "collectingEvent": { + "optionLabel": "Field No: Locality", + "isEnabled": true, + "isRequired": false, + "isHidden": false, + "isDefault": false, + "isRelationship": true, + "tableName": "CollectingEvent" + }, + "leftSideRels": { + "optionLabel": "Left Side Rels", + "isEnabled": true, + "isRequired": false, + "isHidden": false, + "isDefault": false, + "isRelationship": true, + "tableName": "CollectionRelationship" + }, + "preparations": { + "optionLabel": "Preparations", + "isEnabled": true, + "isRequired": false, + "isHidden": false, + "isDefault": false, + "isRelationship": true, + "tableName": "Preparation" + }, + "rightSideRels": { + "optionLabel": "Right Side Rels", + "isEnabled": true, + "isRequired": false, + "isHidden": false, + "isDefault": false, + "isRelationship": true, + "tableName": "CollectionRelationship" + }, + "voucherRelationships": { + "optionLabel": "Voucher Relationships", + "isEnabled": true, + "isRequired": false, + "isHidden": false, + "isDefault": false, + "isRelationship": true, + "tableName": "VoucherRelationship" + } }, - tableName: 'CollectionObject', + "tableName": "CollectionObject" }, { - defaultValue: '#1', - customSelectSubtype: 'toMany', - selectLabel: localized('Determination'), - fieldsData: { - '#1': { - optionLabel: '#1', - isRelationship: true, - isDefault: true, - tableName: 'Determination', - }, - '#2': { - optionLabel: 'Add', - isRelationship: true, - isDefault: false, - tableName: 'Determination', - }, + "customSelectSubtype": "toMany", + "defaultValue": "#1", + "selectLabel": localized("Determination"), + "fieldsData": { + "#1": { + "optionLabel": "#1", + "isRelationship": true, + "isDefault": true, + "tableName": "Determination" + }, + "#2": { + "optionLabel": "Add", + "isRelationship": true, + "isDefault": false, + "tableName": "Determination" + } }, - tableName: 'Determination', + "tableName": "Determination" }, { - defaultValue: 'taxon', - customSelectSubtype: 'simple', - selectLabel: localized('Determination'), - fieldsData: { - determinedDate: { - optionLabel: 'Date', - isEnabled: true, - isRequired: false, - isHidden: false, - isDefault: false, - isRelationship: false, - }, - guid: { - optionLabel: 'GUID', - isEnabled: true, - isRequired: false, - isHidden: false, - isDefault: false, - isRelationship: false, - }, - typeStatusName: { - optionLabel: 'Type Status', - isEnabled: true, - isRequired: false, - isHidden: false, - isDefault: false, - isRelationship: false, - }, - determiner: { - optionLabel: 'Determiner', - isEnabled: true, - isRequired: false, - isHidden: false, - isDefault: false, - isRelationship: true, - tableName: 'Agent', - }, - taxon: { - optionLabel: 'Taxon', - isEnabled: true, - isRequired: false, - isHidden: false, - isDefault: true, - isRelationship: true, - tableName: 'Taxon', - }, + "customSelectSubtype": "simple", + "defaultValue": "taxon", + "selectLabel": localized("Determination"), + "fieldsData": { + "determinedDate": { + "optionLabel": "Date", + "isEnabled": true, + "isRequired": false, + "isHidden": false, + "isDefault": false, + "isRelationship": false + }, + "guid": { + "optionLabel": "GUID", + "isEnabled": true, + "isRequired": false, + "isHidden": false, + "isDefault": false, + "isRelationship": false + }, + "typeStatusName": { + "optionLabel": "Type Status", + "isEnabled": true, + "isRequired": false, + "isHidden": false, + "isDefault": false, + "isRelationship": false + }, + "determiner": { + "optionLabel": "Determiner", + "isEnabled": true, + "isRequired": false, + "isHidden": false, + "isDefault": false, + "isRelationship": true, + "tableName": "Agent" + }, + "determiners": { + "optionLabel": "Determiners", + "isEnabled": true, + "isRequired": false, + "isHidden": false, + "isDefault": false, + "isRelationship": true, + "tableName": "Determiner" + }, + "taxon": { + "optionLabel": "Taxon", + "isEnabled": true, + "isRequired": false, + "isHidden": false, + "isDefault": true, + "isRelationship": true, + "tableName": "Taxon" + } }, - tableName: 'Determination', + "tableName": "Determination" }, { - defaultValue: '$Family', - customSelectSubtype: 'tree', - selectLabel: localized('Taxon'), - fieldsData: { - $Kingdom: { - optionLabel: 'Kingdom', - isRelationship: true, - isDefault: false, - tableName: 'Taxon', - }, - $Phylum: { - optionLabel: 'Phylum', - isRelationship: true, - isDefault: false, - tableName: 'Taxon', - }, - $Class: { - optionLabel: 'Class', - isRelationship: true, - isDefault: false, - tableName: 'Taxon', - }, - $Order: { - optionLabel: 'Order', - isRelationship: true, - isDefault: false, - tableName: 'Taxon', - }, - $Family: { - optionLabel: 'Family', - isRelationship: true, - isDefault: true, - tableName: 'Taxon', - }, - $Subfamily: { - optionLabel: 'Subfamily', - isRelationship: true, - isDefault: false, - tableName: 'Taxon', - }, - $Genus: { - optionLabel: 'Genus', - isRelationship: true, - isDefault: false, - tableName: 'Taxon', - }, - $Subgenus: { - optionLabel: 'Subgenus', - isRelationship: true, - isDefault: false, - tableName: 'Taxon', - }, - $Species: { - optionLabel: 'Species', - isRelationship: true, - isDefault: false, - tableName: 'Taxon', - }, - $Subspecies: { - optionLabel: 'Subspecies', - isRelationship: true, - isDefault: false, - tableName: 'Taxon', - }, + "customSelectSubtype": "tree", + "defaultValue": "$Family", + "selectLabel": localized("Taxon"), + "fieldsData": { + "$Kingdom": { + "optionLabel": "Kingdom", + "isRelationship": true, + "isDefault": false, + "tableName": "Taxon" + }, + "$Phylum": { + "optionLabel": "Phylum", + "isRelationship": true, + "isDefault": false, + "tableName": "Taxon" + }, + "$Class": { + "optionLabel": "Class", + "isRelationship": true, + "isDefault": false, + "tableName": "Taxon" + }, + "$Order": { + "optionLabel": "Order", + "isRelationship": true, + "isDefault": false, + "tableName": "Taxon" + }, + "$Family": { + "optionLabel": "Family", + "isRelationship": true, + "isDefault": true, + "tableName": "Taxon" + }, + "$Subfamily": { + "optionLabel": "Subfamily", + "isRelationship": true, + "isDefault": false, + "tableName": "Taxon" + }, + "$Genus": { + "optionLabel": "Genus", + "isRelationship": true, + "isDefault": false, + "tableName": "Taxon" + }, + "$Subgenus": { + "optionLabel": "Subgenus", + "isRelationship": true, + "isDefault": false, + "tableName": "Taxon" + }, + "$Species": { + "optionLabel": "Species", + "isRelationship": true, + "isDefault": false, + "tableName": "Taxon" + }, + "$Subspecies": { + "optionLabel": "Subspecies", + "isRelationship": true, + "isDefault": false, + "tableName": "Taxon" + } }, - tableName: 'Taxon', + "tableName": "Taxon" }, { - defaultValue: 'name', - customSelectSubtype: 'simple', - selectLabel: localized('Taxon'), - fieldsData: { - author: { - optionLabel: 'Author', - isEnabled: true, - isRequired: false, - isHidden: false, - isDefault: false, - isRelationship: false, - }, - commonName: { - optionLabel: 'Common Name', - isEnabled: true, - isRequired: false, - isHidden: false, - isDefault: false, - isRelationship: false, - }, - guid: { - optionLabel: 'GUID', - isEnabled: true, - isRequired: false, - isHidden: false, - isDefault: false, - isRelationship: false, - }, - name: { - optionLabel: 'Name', - isEnabled: true, - isRequired: true, - isHidden: false, - isDefault: true, - isRelationship: false, - }, - remarks: { - optionLabel: 'Remarks', - isEnabled: true, - isRequired: false, - isHidden: false, - isDefault: false, - isRelationship: false, - }, - source: { - optionLabel: 'Source', - isEnabled: true, - isRequired: false, - isHidden: false, - isDefault: false, - isRelationship: false, - }, + "customSelectSubtype": "simple", + "defaultValue": "name", + "selectLabel": localized("Taxon"), + "fieldsData": { + "author": { + "optionLabel": "Author", + "isEnabled": true, + "isRequired": false, + "isHidden": false, + "isDefault": false, + "isRelationship": false + }, + "commonName": { + "optionLabel": "Common Name", + "isEnabled": true, + "isRequired": false, + "isHidden": false, + "isDefault": false, + "isRelationship": false + }, + "guid": { + "optionLabel": "GUID", + "isEnabled": true, + "isRequired": false, + "isHidden": false, + "isDefault": false, + "isRelationship": false + }, + "name": { + "optionLabel": "Name", + "isEnabled": true, + "isRequired": true, + "isHidden": false, + "isDefault": true, + "isRelationship": false + }, + "remarks": { + "optionLabel": "Remarks", + "isEnabled": true, + "isRequired": false, + "isHidden": false, + "isDefault": false, + "isRelationship": false + }, + "source": { + "optionLabel": "Source", + "isEnabled": true, + "isRequired": false, + "isHidden": false, + "isDefault": false, + "isRelationship": false + } }, - tableName: 'Taxon', - }, + "tableName": "Taxon" + } ], }, { @@ -422,491 +431,546 @@ theories(getMappingLineData, [ spec: navigatorSpecs.queryBuilder, }, ], - out: [ - { - customSelectSubtype: 'simple', - defaultValue: 'determinations', - fieldsData: { - '-formatted': { - isDefault: false, - isEnabled: true, - isRelationship: false, - optionLabel: '(formatted)', - tableName: 'CollectionObject', - }, - accession: { - isDefault: false, - isEnabled: true, - isHidden: false, - isRelationship: true, - isRequired: false, - optionLabel: 'Accession #', - tableName: 'Accession', - }, - altCatalogNumber: { - isDefault: false, - isEnabled: true, - isHidden: false, - isRelationship: false, - isRequired: false, - optionLabel: 'Prev/Exch #', - }, - catalogNumber: { - isDefault: false, - isEnabled: true, - isHidden: false, - isRelationship: false, - isRequired: false, - optionLabel: 'Cat #', - }, - 'catalogedDate-day': { - isDefault: false, - isEnabled: true, - isHidden: false, - isRelationship: false, - isRequired: false, - optionLabel: 'Cat Date (Day)', - }, - 'catalogedDate-fullDate': { - isDefault: false, - isEnabled: true, - isHidden: false, - isRelationship: false, - isRequired: false, - optionLabel: 'Cat Date', - }, - 'catalogedDate-month': { - isDefault: false, - isEnabled: true, - isHidden: false, - isRelationship: false, - isRequired: false, - optionLabel: 'Cat Date (Month)', - }, - 'catalogedDate-year': { - isDefault: false, - isEnabled: true, - isHidden: false, - isRelationship: false, - isRequired: false, - optionLabel: 'Cat Date (Year)', - }, - cataloger: { - isDefault: false, - isEnabled: true, - isHidden: false, - isRelationship: true, - isRequired: false, - optionLabel: 'Cataloger', - tableName: 'Agent', - }, - collectingEvent: { - isDefault: false, - isEnabled: true, - isHidden: false, - isRelationship: true, - isRequired: false, - optionLabel: 'Field No: Locality', - tableName: 'CollectingEvent', - }, - collection: { - isDefault: false, - isEnabled: true, - isHidden: false, - isRelationship: true, - isRequired: false, - optionLabel: 'Collection', - tableName: 'Collection', - }, - collectionObjectAttachments: { - isDefault: false, - isEnabled: true, - isHidden: false, - isRelationship: true, - isRequired: false, - optionLabel: 'Collection Object Attachments', - tableName: 'CollectionObjectAttachment', - }, - collectionObjectAttribute: { - isDefault: false, - isEnabled: true, - isHidden: false, - isRelationship: true, - isRequired: false, - optionLabel: 'Col Obj Attribute', - tableName: 'CollectionObjectAttribute', - }, - collectionObjectCitations: { - isDefault: false, - isEnabled: true, - isHidden: false, - isRelationship: true, - isRequired: false, - optionLabel: 'Collection Object Citations', - tableName: 'CollectionObjectCitation', - }, - determinations: { - isDefault: true, - isEnabled: true, - isHidden: false, - isRelationship: true, - isRequired: false, - optionLabel: 'Determinations', - tableName: 'Determination', - }, - dnaSequences: { - isDefault: false, - isEnabled: true, - isHidden: false, - isRelationship: true, - isRequired: false, - optionLabel: 'DNA Sequences', - tableName: 'DNASequence', - }, - fieldNumber: { - isDefault: false, - isEnabled: true, - isHidden: false, - isRelationship: false, - isRequired: false, - optionLabel: 'Voucher', - }, - guid: { - isDefault: false, - isEnabled: true, - isHidden: false, - isRelationship: false, - isRequired: false, - optionLabel: 'GUID', - }, - leftSideRels: { - isDefault: false, - isEnabled: true, - isHidden: false, - isRelationship: true, - isRequired: false, - optionLabel: 'Left Side Rels', - tableName: 'CollectionRelationship', - }, - modifiedByAgent: { - isDefault: false, - isEnabled: true, - isHidden: false, - isRelationship: true, - isRequired: false, - optionLabel: 'Edited By', - tableName: 'Agent', - }, - preparations: { - isDefault: false, - isEnabled: true, - isHidden: false, - isRelationship: true, - isRequired: false, - optionLabel: 'Preparations', - tableName: 'Preparation', - }, - projectNumber: { - isDefault: false, - isEnabled: true, - isHidden: false, - isRelationship: false, - isRequired: false, - optionLabel: 'Project Number', - }, - remarks: { - isDefault: false, - isEnabled: true, - isHidden: false, - isRelationship: false, - isRequired: false, - optionLabel: 'Remarks', - }, - reservedText: { - isDefault: false, - isEnabled: true, - isHidden: false, - isRelationship: false, - isRequired: false, - optionLabel: 'CT Scan', - }, - reservedText2: { - isDefault: false, - isEnabled: true, - isHidden: false, - isRelationship: false, - isRequired: false, - optionLabel: 'Reserved Text2', - }, - rightSideRels: { - isDefault: false, - isEnabled: true, - isHidden: false, - isRelationship: true, - isRequired: false, - optionLabel: 'Right Side Rels', - tableName: 'CollectionRelationship', - }, - 'timestampModified-day': { - isDefault: false, - isEnabled: true, - isHidden: false, - isRelationship: false, - isRequired: false, - optionLabel: 'Date Edited (Day)', - }, - 'timestampModified-fullDate': { - isDefault: false, - isEnabled: true, - isHidden: false, - isRelationship: false, - isRequired: false, - optionLabel: 'Date Edited', - }, - 'timestampModified-month': { - isDefault: false, - isEnabled: true, - isHidden: false, - isRelationship: false, - isRequired: false, - optionLabel: 'Date Edited (Month)', - }, - 'timestampModified-year': { - isDefault: false, - isEnabled: true, - isHidden: false, - isRelationship: false, - isRequired: false, - optionLabel: 'Date Edited (Year)', - }, - voucherRelationships: { - isDefault: false, - isEnabled: true, - isHidden: false, - isRelationship: true, - isRequired: false, - optionLabel: 'Voucher Relationships', - tableName: 'VoucherRelationship', - }, + out: [{ + "customSelectSubtype": "simple", + "defaultValue": "determinations", + "selectLabel": localized("Collection Object"), + "fieldsData": { + "-formatted": { + "optionLabel": "(formatted)", + "tableName": "CollectionObject", + "isRelationship": false, + "isDefault": false, + "isEnabled": true + }, + "catalogNumber": { + "optionLabel": "Cat #", + "isEnabled": true, + "isRequired": false, + "isHidden": false, + "isDefault": false, + "isRelationship": false + }, + "catalogedDate-fullDate": { + "optionLabel": "Cat Date", + "isEnabled": true, + "isRequired": false, + "isHidden": false, + "isDefault": false, + "isRelationship": false + }, + "catalogedDate-day": { + "optionLabel": "Cat Date (Day)", + "isEnabled": true, + "isRequired": false, + "isHidden": false, + "isDefault": false, + "isRelationship": false + }, + "catalogedDate-month": { + "optionLabel": "Cat Date (Month)", + "isEnabled": true, + "isRequired": false, + "isHidden": false, + "isDefault": false, + "isRelationship": false + }, + "catalogedDate-year": { + "optionLabel": "Cat Date (Year)", + "isEnabled": true, + "isRequired": false, + "isHidden": false, + "isDefault": false, + "isRelationship": false + }, + "reservedText": { + "optionLabel": "CT Scan", + "isEnabled": true, + "isRequired": false, + "isHidden": false, + "isDefault": false, + "isRelationship": false + }, + "timestampModified-fullDate": { + "optionLabel": "Date Edited", + "isEnabled": true, + "isRequired": false, + "isHidden": false, + "isDefault": false, + "isRelationship": false + }, + "timestampModified-day": { + "optionLabel": "Date Edited (Day)", + "isEnabled": true, + "isRequired": false, + "isHidden": false, + "isDefault": false, + "isRelationship": false + }, + "timestampModified-month": { + "optionLabel": "Date Edited (Month)", + "isEnabled": true, + "isRequired": false, + "isHidden": false, + "isDefault": false, + "isRelationship": false + }, + "timestampModified-year": { + "optionLabel": "Date Edited (Year)", + "isEnabled": true, + "isRequired": false, + "isHidden": false, + "isDefault": false, + "isRelationship": false + }, + "guid": { + "optionLabel": "GUID", + "isEnabled": true, + "isRequired": false, + "isHidden": false, + "isDefault": false, + "isRelationship": false + }, + "altCatalogNumber": { + "optionLabel": "Prev/Exch #", + "isEnabled": true, + "isRequired": false, + "isHidden": false, + "isDefault": false, + "isRelationship": false + }, + "projectNumber": { + "optionLabel": "Project Number", + "isEnabled": true, + "isRequired": false, + "isHidden": false, + "isDefault": false, + "isRelationship": false + }, + "remarks": { + "optionLabel": "Remarks", + "isEnabled": true, + "isRequired": false, + "isHidden": false, + "isDefault": false, + "isRelationship": false + }, + "reservedText2": { + "optionLabel": "Reserved Text2", + "isEnabled": true, + "isRequired": false, + "isHidden": false, + "isDefault": false, + "isRelationship": false + }, + "fieldNumber": { + "optionLabel": "Voucher", + "isEnabled": true, + "isRequired": false, + "isHidden": false, + "isDefault": false, + "isRelationship": false + }, + "accession": { + "optionLabel": "Accession #", + "isEnabled": true, + "isRequired": false, + "isHidden": false, + "isDefault": false, + "isRelationship": true, + "tableName": "Accession" + }, + "cataloger": { + "optionLabel": "Cataloger", + "isEnabled": true, + "isRequired": false, + "isHidden": false, + "isDefault": false, + "isRelationship": true, + "tableName": "Agent" + }, + "collectionObjectAttribute": { + "optionLabel": "Col Obj Attribute", + "isEnabled": true, + "isRequired": false, + "isHidden": false, + "isDefault": false, + "isRelationship": true, + "tableName": "CollectionObjectAttribute" + }, + "collection": { + "optionLabel": "Collection", + "isEnabled": true, + "isRequired": false, + "isHidden": false, + "isDefault": false, + "isRelationship": true, + "tableName": "Collection" + }, + "collectionObjectAttachments": { + "optionLabel": "Collection Object Attachments", + "isEnabled": true, + "isRequired": false, + "isHidden": false, + "isDefault": false, + "isRelationship": true, + "tableName": "CollectionObjectAttachment" + }, + "collectionObjectCitations": { + "optionLabel": "Collection Object Citations", + "isEnabled": true, + "isRequired": false, + "isHidden": false, + "isDefault": false, + "isRelationship": true, + "tableName": "CollectionObjectCitation" + }, + "determinations": { + "optionLabel": "Determinations", + "isEnabled": true, + "isRequired": false, + "isHidden": false, + "isDefault": true, + "isRelationship": true, + "tableName": "Determination" + }, + "dnaSequences": { + "optionLabel": "DNA Sequences", + "isEnabled": true, + "isRequired": false, + "isHidden": false, + "isDefault": false, + "isRelationship": true, + "tableName": "DNASequence" + }, + "modifiedByAgent": { + "optionLabel": "Edited By", + "isEnabled": true, + "isRequired": false, + "isHidden": false, + "isDefault": false, + "isRelationship": true, + "tableName": "Agent" + }, + "collectingEvent": { + "optionLabel": "Field No: Locality", + "isEnabled": true, + "isRequired": false, + "isHidden": false, + "isDefault": false, + "isRelationship": true, + "tableName": "CollectingEvent" + }, + "leftSideRels": { + "optionLabel": "Left Side Rels", + "isEnabled": true, + "isRequired": false, + "isHidden": false, + "isDefault": false, + "isRelationship": true, + "tableName": "CollectionRelationship" + }, + "preparations": { + "optionLabel": "Preparations", + "isEnabled": true, + "isRequired": false, + "isHidden": false, + "isDefault": false, + "isRelationship": true, + "tableName": "Preparation" + }, + "rightSideRels": { + "optionLabel": "Right Side Rels", + "isEnabled": true, + "isRequired": false, + "isHidden": false, + "isDefault": false, + "isRelationship": true, + "tableName": "CollectionRelationship" + }, + "voucherRelationships": { + "optionLabel": "Voucher Relationships", + "isEnabled": true, + "isRequired": false, + "isHidden": false, + "isDefault": false, + "isRelationship": true, + "tableName": "VoucherRelationship" + } }, - selectLabel: localized('Collection Object'), - tableName: 'CollectionObject', + "tableName": "CollectionObject" }, { - customSelectSubtype: 'simple', - defaultValue: 'taxon', - fieldsData: { - '-formatted': { - isDefault: false, - isEnabled: true, - isRelationship: false, - optionLabel: '(aggregated)', - tableName: 'Determination', - }, - collectionObject: { - isDefault: false, - isEnabled: true, - isHidden: false, - isRelationship: true, - isRequired: false, - optionLabel: 'Collection Object', - tableName: 'CollectionObject', - }, - 'determinedDate-day': { - isDefault: false, - isEnabled: true, - isHidden: false, - isRelationship: false, - isRequired: false, - optionLabel: 'Date (Day)', - }, - 'determinedDate-fullDate': { - isDefault: false, - isEnabled: true, - isHidden: false, - isRelationship: false, - isRequired: false, - optionLabel: 'Date', - }, - 'determinedDate-month': { - isDefault: false, - isEnabled: true, - isHidden: false, - isRelationship: false, - isRequired: false, - optionLabel: 'Date (Month)', - }, - 'determinedDate-year': { - isDefault: false, - isEnabled: true, - isHidden: false, - isRelationship: false, - isRequired: false, - optionLabel: 'Date (Year)', - }, - determiner: { - isDefault: false, - isEnabled: true, - isHidden: false, - isRelationship: true, - isRequired: false, - optionLabel: 'Determiner', - tableName: 'Agent', - }, - determiners: { - isDefault: false, - isEnabled: true, - isHidden: false, - isRelationship: true, - isRequired: false, - optionLabel: 'Determiners', - tableName: 'Determiner', - }, - guid: { - isDefault: false, - isEnabled: true, - isHidden: false, - isRelationship: false, - isRequired: false, - optionLabel: 'GUID', - }, - isCurrent: { - isDefault: false, - isEnabled: true, - isHidden: false, - isRelationship: false, - isRequired: false, - optionLabel: 'Current', - }, - preferredTaxon: { - isDefault: false, - isEnabled: true, - isHidden: false, - isRelationship: true, - isRequired: false, - optionLabel: 'Preferred Taxon', - tableName: 'Taxon', - }, - taxon: { - isDefault: true, - isEnabled: true, - isHidden: false, - isRelationship: true, - isRequired: false, - optionLabel: 'Taxon', - tableName: 'Taxon', - }, - typeStatusName: { - isDefault: false, - isEnabled: true, - isHidden: false, - isRelationship: false, - isRequired: false, - optionLabel: 'Type Status', - }, + "customSelectSubtype": "simple", + "defaultValue": "taxon", + "selectLabel": localized("Determination"), + "fieldsData": { + "-formatted": { + "optionLabel": "(aggregated)", + "tableName": "Determination", + "isRelationship": false, + "isDefault": false, + "isEnabled": true + }, + "isCurrent": { + "optionLabel": "Current", + "isEnabled": true, + "isRequired": false, + "isHidden": false, + "isDefault": false, + "isRelationship": false + }, + "determinedDate-fullDate": { + "optionLabel": "Date", + "isEnabled": true, + "isRequired": false, + "isHidden": false, + "isDefault": false, + "isRelationship": false + }, + "determinedDate-day": { + "optionLabel": "Date (Day)", + "isEnabled": true, + "isRequired": false, + "isHidden": false, + "isDefault": false, + "isRelationship": false + }, + "determinedDate-month": { + "optionLabel": "Date (Month)", + "isEnabled": true, + "isRequired": false, + "isHidden": false, + "isDefault": false, + "isRelationship": false + }, + "determinedDate-year": { + "optionLabel": "Date (Year)", + "isEnabled": true, + "isRequired": false, + "isHidden": false, + "isDefault": false, + "isRelationship": false + }, + "guid": { + "optionLabel": "GUID", + "isEnabled": true, + "isRequired": false, + "isHidden": false, + "isDefault": false, + "isRelationship": false + }, + "typeStatusName": { + "optionLabel": "Type Status", + "isEnabled": true, + "isRequired": false, + "isHidden": false, + "isDefault": false, + "isRelationship": false + }, + "determiner": { + "optionLabel": "Determiner", + "isEnabled": true, + "isRequired": false, + "isHidden": false, + "isDefault": false, + "isRelationship": true, + "tableName": "Agent" + }, + "determiners": { + "optionLabel": "Determiners", + "isEnabled": true, + "isRequired": false, + "isHidden": false, + "isDefault": false, + "isRelationship": true, + "tableName": "Determiner" + }, + "preferredTaxon": { + "optionLabel": "Preferred Taxon", + "isEnabled": true, + "isRequired": false, + "isHidden": false, + "isDefault": false, + "isRelationship": true, + "tableName": "Taxon" + }, + "taxon": { + "optionLabel": "Taxon", + "isEnabled": true, + "isRequired": false, + "isHidden": false, + "isDefault": true, + "isRelationship": true, + "tableName": "Taxon" + } }, - selectLabel: localized('Determination'), - tableName: 'Determination', + "tableName": "Determination" }, { - customSelectSubtype: 'tree', - defaultValue: '$Family', - fieldsData: { - '$-any': { - isDefault: false, - isEnabled: true, - isRelationship: true, - optionLabel: '(any rank)', - tableName: 'Taxon', - }, - $Class: { - isDefault: false, - isRelationship: true, - optionLabel: 'Class', - tableName: 'Taxon', - }, - $Family: { - isDefault: true, - isRelationship: true, - optionLabel: 'Family', - tableName: 'Taxon', - }, - $Genus: { - isDefault: false, - isRelationship: true, - optionLabel: 'Genus', - tableName: 'Taxon', - }, - $Kingdom: { - isDefault: false, - isRelationship: true, - optionLabel: 'Kingdom', - tableName: 'Taxon', - }, - $Order: { - isDefault: false, - isRelationship: true, - optionLabel: 'Order', - tableName: 'Taxon', - }, - $Phylum: { - isDefault: false, - isRelationship: true, - optionLabel: 'Phylum', - tableName: 'Taxon', - }, - $Species: { - isDefault: false, - isRelationship: true, - optionLabel: 'Species', - tableName: 'Taxon', - }, - $Subfamily: { - isDefault: false, - isRelationship: true, - optionLabel: 'Subfamily', - tableName: 'Taxon', - }, - $Subgenus: { - isDefault: false, - isRelationship: true, - optionLabel: 'Subgenus', - tableName: 'Taxon', - }, - $Subspecies: { - isDefault: false, - isRelationship: true, - optionLabel: 'Subspecies', - tableName: 'Taxon', - }, + "customSelectSubtype": "tree", + "defaultValue": "$Family", + "selectLabel": localized("Taxon"), + "fieldsData": { + "$-any": { + "optionLabel": "(any rank)", + "isRelationship": true, + "isDefault": false, + "isEnabled": true, + "tableName": "Taxon" + }, + "$Kingdom": { + "optionLabel": "Kingdom", + "isRelationship": true, + "isDefault": false, + "tableName": "Taxon" + }, + "$Phylum": { + "optionLabel": "Phylum", + "isRelationship": true, + "isDefault": false, + "tableName": "Taxon" + }, + "$Class": { + "optionLabel": "Class", + "isRelationship": true, + "isDefault": false, + "tableName": "Taxon" + }, + "$Order": { + "optionLabel": "Order", + "isRelationship": true, + "isDefault": false, + "tableName": "Taxon" + }, + "$Family": { + "optionLabel": "Family", + "isRelationship": true, + "isDefault": true, + "tableName": "Taxon" + }, + "$Subfamily": { + "optionLabel": "Subfamily", + "isRelationship": true, + "isDefault": false, + "tableName": "Taxon" + }, + "$Genus": { + "optionLabel": "Genus", + "isRelationship": true, + "isDefault": false, + "tableName": "Taxon" + }, + "$Subgenus": { + "optionLabel": "Subgenus", + "isRelationship": true, + "isDefault": false, + "tableName": "Taxon" + }, + "$Species": { + "optionLabel": "Species", + "isRelationship": true, + "isDefault": false, + "tableName": "Taxon" + }, + "$Subspecies": { + "optionLabel": "Subspecies", + "isRelationship": true, + "isDefault": false, + "tableName": "Taxon" + } }, - selectLabel: localized('Taxon'), - tableName: 'Taxon', + "tableName": "Taxon" }, { - customSelectSubtype: 'simple', - defaultValue: 'name', - fieldsData: { - author: { - isDefault: false, - isEnabled: true, - isHidden: false, - isRelationship: false, - isRequired: false, - optionLabel: 'Author', - }, - fullName: { - isDefault: false, - isEnabled: true, - isHidden: false, - isRelationship: false, - isRequired: false, - optionLabel: 'Full Name', - }, + "customSelectSubtype": "simple", + "defaultValue": "name", + "selectLabel": localized("Taxon"), + "fieldsData": { + "author": { + "optionLabel": "Author", + "isEnabled": true, + "isRequired": false, + "isHidden": false, + "isDefault": false, + "isRelationship": false + }, + "commonName": { + "optionLabel": "Common Name", + "isEnabled": true, + "isRequired": false, + "isHidden": false, + "isDefault": false, + "isRelationship": false + }, + "fullName": { + "optionLabel": "Full Name", + "isEnabled": true, + "isRequired": false, + "isHidden": false, + "isDefault": false, + "isRelationship": false + }, + "guid": { + "optionLabel": "GUID", + "isEnabled": true, + "isRequired": false, + "isHidden": false, + "isDefault": false, + "isRelationship": false + }, + "isHybrid": { + "optionLabel": "Is Hybrid", + "isEnabled": true, + "isRequired": false, + "isHidden": false, + "isDefault": false, + "isRelationship": false + }, + "isAccepted": { + "optionLabel": "Is Preferred", + "isEnabled": true, + "isRequired": false, + "isHidden": false, + "isDefault": false, + "isRelationship": false + }, + "name": { + "optionLabel": "Name", + "isEnabled": true, + "isRequired": false, + "isHidden": false, + "isDefault": true, + "isRelationship": false + }, + "rankId": { + "optionLabel": "Rank ID", + "isEnabled": true, + "isRequired": false, + "isHidden": false, + "isDefault": false, + "isRelationship": false + }, + "remarks": { + "optionLabel": "Remarks", + "isEnabled": true, + "isRequired": false, + "isHidden": false, + "isDefault": false, + "isRelationship": false + }, + "source": { + "optionLabel": "Source", + "isEnabled": true, + "isRequired": false, + "isHidden": false, + "isDefault": false, + "isRelationship": false + } }, - selectLabel: localized('Taxon'), - tableName: 'Taxon', - }, - ], - }, -]); + "tableName": "Taxon" + } + ] + } +] +); diff --git a/specifyweb/frontend/js_src/lib/components/WorkBench/batchEditHelpers.ts b/specifyweb/frontend/js_src/lib/components/WorkBench/batchEditHelpers.ts index 639a1765dd2..95d22406b1b 100644 --- a/specifyweb/frontend/js_src/lib/components/WorkBench/batchEditHelpers.ts +++ b/specifyweb/frontend/js_src/lib/components/WorkBench/batchEditHelpers.ts @@ -4,13 +4,13 @@ import { isTreeTable } from "../InitialContext/treeRanks" import { MappingPath } from "../WbPlanView/Mapper" import { getNumberFromToManyIndex, relationshipIsToMany } from "../WbPlanView/mappingHelpers" -const NULL_RECORD = 'null_record'; +export const BATCH_EDIT_NULL_RECORD = "null_record"; // The key in the last column -export const BATCH_EDIT_KEY = 'batch_edit'; +export const BATCH_EDIT_KEY = "batch_edit" type BatchEditRecord = { - readonly id: typeof NULL_RECORD | number | undefined, + readonly id: typeof BATCH_EDIT_NULL_RECORD | number | undefined, readonly ordernumber: number | undefined, readonly version: number | undefined } @@ -23,7 +23,7 @@ export type BatchEditPack = { export const isBatchEditNullRecord = (batchEditPack: BatchEditPack | undefined, currentTable: SpecifyTable, mappingPath: MappingPath): boolean => { if (batchEditPack == undefined) return false; - if (mappingPath.length <= 1) return batchEditPack?.self?.id === NULL_RECORD; + if (mappingPath.length <= 1) return batchEditPack?.self?.id === BATCH_EDIT_NULL_RECORD; const [node, ...rest] = mappingPath; if (isTreeTable(currentTable.name)) return false; const relationship = defined(currentTable.getRelationship(node)); diff --git a/specifyweb/frontend/js_src/lib/components/WorkBench/handsontable.ts b/specifyweb/frontend/js_src/lib/components/WorkBench/handsontable.ts index 4277198f6e6..6d9d8ca5ad7 100644 --- a/specifyweb/frontend/js_src/lib/components/WorkBench/handsontable.ts +++ b/specifyweb/frontend/js_src/lib/components/WorkBench/handsontable.ts @@ -10,6 +10,7 @@ import type { WbMapping } from './mapping'; import type { WbPickLists } from './pickLists'; import { getPhysicalColToMappingCol } from './hotHelpers'; import { BATCH_EDIT_KEY, BatchEditPack, isBatchEditNullRecord } from './batchEditHelpers'; +import { CellProperties } from 'handsontable/settings'; export function configureHandsontable( hot: Handsontable, @@ -18,46 +19,10 @@ export function configureHandsontable( pickLists: WbPickLists ): void { identifyDefaultValues(hot, mappings); - identifyPickLists(hot, pickLists); + curryCells(hot, mappings, dataset, pickLists); setSort(hot, dataset); - makeUnmappedColumnsReadonly(hot, mappings, dataset); - makeNullRecordsReadOnly(hot, mappings, dataset); } -// TODO: Playaround with making this part of React -function makeUnmappedColumnsReadonly(hot: Handsontable, mappings: WbMapping | undefined, dataset: Dataset): void { - if (dataset.isupdate !== true || mappings === undefined) return; - const physicalColToMappingCol = getPhysicalColToMappingCol(mappings, dataset); - hot.updateSettings({ - // not sure if anything else is needeed.. - columns: (index) => ({readOnly: physicalColToMappingCol(index) === -1}) - }) -} - -function makeNullRecordsReadOnly(hot: Handsontable, mappings: WbMapping | undefined, dataset: Dataset): void { - if (dataset.isupdate !== true || mappings === undefined) return; - const physicalColToMappingCol = getPhysicalColToMappingCol(mappings, dataset); - hot.updateSettings({ - cells: (physicalRow, physicalCol, _property) => { - const mappingCol = physicalColToMappingCol(physicalCol); - const batchEditRaw: string | undefined = hot.getDataAtRow(physicalRow).at(-1) - const batchEditPack: BatchEditPack | undefined = batchEditRaw === undefined || batchEditRaw === null ? undefined : JSON.parse(batchEditRaw)[BATCH_EDIT_KEY]; - if (mappingCol !== -1 && mappingCol !== undefined && batchEditPack !== undefined){ - return { - readOnly: isBatchEditNullRecord(batchEditPack, mappings.baseTable, mappings.lines[mappingCol].mappingPath) - } - } - if (mappingCol === -1){ - return {readOnly: true} - } - return { - readOnly: false - } - } - }) -} - - export function identifyDefaultValues( hot: Handsontable, mappings: WbMapping | undefined @@ -68,26 +33,57 @@ export function identifyDefaultValues( }); } -function identifyPickLists(hot: Handsontable, pickLists: WbPickLists): void { - hot.updateSettings({ - cells: (_physicalRow, physicalCol, _property) => - physicalCol in pickLists - ? { - type: 'autocomplete', - source: writable(pickLists[physicalCol].items), - strict: pickLists[physicalCol].readOnly, - allowInvalid: true, - filter: - userPreferences.get('workBench', 'editor', 'filterPickLists') === - 'none', - filteringCaseSensitive: - userPreferences.get('workBench', 'editor', 'filterPickLists') === - 'case-sensitive', - sortByRelevance: false, - trimDropdown: false, - } - : { type: 'text' }, - }); +type GetProperty = (physicalRow: number, physicalCol: number, _property: string | number) => Partial; + +function curryCells(hot: Handsontable, + mappings: WbMapping | undefined, + dataset: Dataset, + pickLists: WbPickLists) : void { + const identifyPickLists = getPickListsIdentifier(pickLists); + const identifyNullRecords = getIdentifyNullRecords(hot, mappings, dataset); + hot.updateSettings({cells: (physicalRow, physicalColumn, property)=>{ + const pickListsResults = identifyPickLists?.(physicalRow, physicalColumn, property) ?? {}; + const nullRecordsResults = identifyNullRecords?.(physicalRow, physicalColumn, property) ?? {}; + return {...pickListsResults, ...nullRecordsResults} + }}) + } + +function getPickListsIdentifier(pickLists: WbPickLists): undefined | GetProperty { + const callback: GetProperty = (_physicalRow, physicalCol, _property) => physicalCol in pickLists ? ({ + type: 'autocomplete', + source: writable(pickLists[physicalCol].items), + strict: pickLists[physicalCol].readOnly, + allowInvalid: true, + filter: + userPreferences.get('workBench', 'editor', 'filterPickLists') === + 'none', + filteringCaseSensitive: + userPreferences.get('workBench', 'editor', 'filterPickLists') === + 'case-sensitive', + sortByRelevance: false, + trimDropdown: false, + }) + : ({ type: 'text' }); + return callback +} + +function getIdentifyNullRecords(hot: Handsontable, mappings: WbMapping | undefined, dataset: Dataset) : GetProperty | undefined { + if (dataset.isupdate !== true || mappings === undefined) return undefined; + const makeNullRecordsReadOnly: GetProperty = (physicalRow, physicalCol, _property) => { + const physicalColToMappingCol = getPhysicalColToMappingCol(mappings, dataset); + const mappingCol = physicalColToMappingCol(physicalCol); + const batchEditRaw: string | undefined = hot.getDataAtRow(physicalRow).at(-1) ?? undefined; + if (mappingCol === -1 || mappingCol === undefined){ + // Definitely don't need to anything, not even mapped + return {readOnly: true}; + } + if (batchEditRaw === undefined){ + return {readOnly: false}; + } + const batchEditPack: BatchEditPack | undefined = JSON.parse(batchEditRaw)[BATCH_EDIT_KEY]; + return { readOnly: isBatchEditNullRecord(batchEditPack, mappings.baseTable, mappings.lines[mappingCol].mappingPath)}; + } + return makeNullRecordsReadOnly } function setSort(hot: Handsontable, dataset: Dataset): void { diff --git a/specifyweb/frontend/js_src/lib/components/WorkBench/index.tsx b/specifyweb/frontend/js_src/lib/components/WorkBench/index.tsx index 225a03119b5..7246bbd6ce5 100644 --- a/specifyweb/frontend/js_src/lib/components/WorkBench/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/WorkBench/index.tsx @@ -16,14 +16,14 @@ import { NotFoundView } from '../Router/NotFoundView'; import type { Dataset } from '../WbPlanView/Wrapped'; import { WbView } from './WbView'; -export function WorkBench(): JSX.Element | undefined { +export function WorkBench(): JSX.Element { const { id } = useParams(); const datasetId = f.parseInt(id); const [dataset, setDataset] = useDataset(datasetId); return datasetId === undefined ? : - dataset === undefined ? undefined : ; + dataset === undefined ? : ; } export function WorkBenchSafe({getSetDataset}: {readonly getSetDataset: GetSet}): JSX.Element { diff --git a/specifyweb/frontend/js_src/lib/tests/fixtures/uploadplan.1.json b/specifyweb/frontend/js_src/lib/tests/fixtures/uploadplan.1.json index f0cc682997a..dd2c0af3a5e 100644 --- a/specifyweb/frontend/js_src/lib/tests/fixtures/uploadplan.1.json +++ b/specifyweb/frontend/js_src/lib/tests/fixtures/uploadplan.1.json @@ -160,7 +160,8 @@ "toMany": {} } } - } + }, + "toMany":{} }, { "wbcols": {}, @@ -179,7 +180,8 @@ "toMany": {} } } - } + }, + "toMany": {} }, { "wbcols": {}, @@ -198,7 +200,8 @@ "toMany": {} } } - } + }, + "toMany": {} }, { "wbcols": {}, @@ -217,7 +220,8 @@ "toMany": {} } } - } + }, + "toMany": {} } ] } @@ -246,7 +250,8 @@ "toMany": {} } } - } + }, + "toMany": {} } ] } @@ -328,7 +333,8 @@ "toMany": {} } } - } + }, + "toMany": {} }, { "wbcols": { @@ -346,7 +352,8 @@ "toMany": {} } } - } + }, + "toMany": {} } ], "preparations": [ @@ -364,7 +371,8 @@ "toMany": {} } } - } + }, + "toMany": {} } ] } From 4de007a072f27888e08fe09278dded0cbe5ebe83 Mon Sep 17 00:00:00 2001 From: realVinayak Date: Tue, 20 Aug 2024 12:15:27 -0500 Subject: [PATCH 32/63] (tests): localization resolves --- .../js_src/lib/localization/workbench.ts | 34 +------------------ 1 file changed, 1 insertion(+), 33 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/localization/workbench.ts b/specifyweb/frontend/js_src/lib/localization/workbench.ts index 4eadf29ffc4..6e8cb0462cc 100644 --- a/specifyweb/frontend/js_src/lib/localization/workbench.ts +++ b/specifyweb/frontend/js_src/lib/localization/workbench.ts @@ -971,14 +971,6 @@ export const wbText = createDictionary({ 'fr-fr': 'Télécharger le forfait', 'uk-ua': 'План завантаження', }, - potentialUploadResults: { - 'en-us': 'Potential Upload Results', - 'ru-ru': 'Возможные результаты загрузки', - 'es-es': 'Resultados potenciales de la carga', - 'fr-fr': 'Résultats potentiels du téléchargement', - 'uk-ua': 'Потенційні результати завантаження', - 'de-ch': 'Mögliche Upload-Ergebnisse', - }, noUploadResultsAvailable: { 'en-us': 'No upload results are available for this cell', 'ru-ru': 'Для этой ячейки нет результатов загрузки', @@ -986,28 +978,7 @@ export const wbText = createDictionary({ 'fr-fr': "Aucun résultat de téléchargement n'est disponible pour cette cellule", 'uk-ua': 'Для цієї клітинки немає результатів завантаження', - 'de-ch': 'Für diese Zelle sind keine Upload-Ergebnisse verfügbar', - }, - wbUploadedDescription: { - 'en-us': 'Number of new records created in each table:', - 'ru-ru': 'Количество новых записей, созданных в каждой таблице:', - 'es-es': 'Número de registros nuevos creados en cada tabla:', - 'fr-fr': 'Nombre de nouveaux enregistrements créés dans chaque table :', - 'uk-ua': 'Кількість нових записів, створених у кожній таблиці:', - 'de-ch': 'Anzahl der in jeder Tabelle erstellten neuen Datensätze:', - }, - wbUploadedPotentialDescription: { - 'en-us': 'Number of new records that would be created in each table:', - 'ru-ru': - 'Количество новых записей, которые будут созданы в каждой таблице:', - 'es-es': 'Número de registros nuevos que se crearían en cada tabla:', - 'fr-fr': ` - Nombre de nouveaux enregistrements qui seraient créés dans chaque table : - `, - 'uk-ua': 'Кількість нових записів, які будуть створені в кожній таблиці:', - 'de-ch': ` - Anzahl der neuen Datensätze, die in jeder Tabelle erstellt werden würden: - `, + 'de-ch': 'Für diese Zelle sind keine Uploasd-Ergebnisse verfügbar', }, navigationOptions: { 'en-us': 'Navigation Options', @@ -1593,9 +1564,6 @@ export const wbText = createDictionary({ updateResults: { 'en-us': 'Update Results' }, - potentialUpdateResults: { - 'en-us': 'Potential Update Results' - }, affectedResults: { 'en-us': "Records affected" }, From 2fd6e6ee4b5e6470e2b0abaedbbf75c211110d95 Mon Sep 17 00:00:00 2001 From: realVinayak Date: Tue, 20 Aug 2024 12:56:15 -0500 Subject: [PATCH 33/63] (tests): Resolve tests + clear results on rollback --- .../lib/components/Toolbar/WbsDialog.tsx | 2 +- .../js_src/lib/localization/batchEdit.ts | 2 +- .../js_src/lib/localization/workbench.ts | 3 -- specifyweb/specify/tests/test_api.py | 6 +-- specifyweb/stored_queries/batch_edit.py | 45 ++++++++++--------- specifyweb/workbench/upload/upload.py | 7 +-- 6 files changed, 31 insertions(+), 34 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/Toolbar/WbsDialog.tsx b/specifyweb/frontend/js_src/lib/components/Toolbar/WbsDialog.tsx index c0a048c9659..1777d62ae66 100644 --- a/specifyweb/frontend/js_src/lib/components/Toolbar/WbsDialog.tsx +++ b/specifyweb/frontend/js_src/lib/components/Toolbar/WbsDialog.tsx @@ -299,7 +299,7 @@ export const datasetVariants = { // Cannot import via the header canImport: ()=>false, header: (count: number)=>commonText.countLine({resource: wbText.dataSets({variant: batchEditText.batchEdit()}), count}), - onEmpty: ()=>`${wbText.wbsDialogEmpty()} ${hasPermission('/workbench/dataset', 'create') ? batchEditText.createDataSetInstructions() : ''}`, + onEmpty: ()=>`${wbText.wbsDialogEmpty()} ${hasPermission('/workbench/dataset', 'create') ? batchEditText.createUpdateDataSetInstructions() : ''}`, canEdit: ()=>hasPermission('/workbench/dataset', 'update'), route: baseWbVariant.route, metaRoute: baseWbVariant.metaRoute diff --git a/specifyweb/frontend/js_src/lib/localization/batchEdit.ts b/specifyweb/frontend/js_src/lib/localization/batchEdit.ts index 3fa411a86ec..0f76dd56daa 100644 --- a/specifyweb/frontend/js_src/lib/localization/batchEdit.ts +++ b/specifyweb/frontend/js_src/lib/localization/batchEdit.ts @@ -25,7 +25,7 @@ export const batchEditText = createDictionary({ errorInQuery: { 'en-us': "Following errors were found in the query" }, - createDataSetInstructions: { + createUpdateDataSetInstructions: { 'en-us': "Use the query builder to make a new batch edit dataset" } } as const) \ No newline at end of file diff --git a/specifyweb/frontend/js_src/lib/localization/workbench.ts b/specifyweb/frontend/js_src/lib/localization/workbench.ts index 6e8cb0462cc..3dd62f103cf 100644 --- a/specifyweb/frontend/js_src/lib/localization/workbench.ts +++ b/specifyweb/frontend/js_src/lib/localization/workbench.ts @@ -1561,9 +1561,6 @@ export const wbText = createDictionary({ deletedCells: { 'en-us': "Deleted Cells" }, - updateResults: { - 'en-us': 'Update Results' - }, affectedResults: { 'en-us': "Records affected" }, diff --git a/specifyweb/specify/tests/test_api.py b/specifyweb/specify/tests/test_api.py index b882429993b..c7d590cc7df 100644 --- a/specifyweb/specify/tests/test_api.py +++ b/specifyweb/specify/tests/test_api.py @@ -72,8 +72,7 @@ def setUp(self): isloggedin=False, isloggedinreport=False, name="testuser", - password="205C0D906445E1C71CA77C6D714109EB6D582B03A5493E4C", - ) # testuser + password="205C0D906445E1C71CA77C6D714109EB6D582B03A5493E4C") # testuser UserPolicy.objects.create( collection=None, @@ -732,8 +731,7 @@ def test_set_user_agents_in_use_exception(self): isloggedin=False, isloggedinreport=False, name="testuser2", - password="205C0D906445E1C71CA77C6D714109EB6D582B03A5493E4C", - ) # testuser + password="205C0D906445E1C71CA77C6D714109EB6D582B03A5493E4C") # testuser c = Client() c.force_login(self.specifyuser) diff --git a/specifyweb/stored_queries/batch_edit.py b/specifyweb/stored_queries/batch_edit.py index 27dab116201..528bea2e102 100644 --- a/specifyweb/stored_queries/batch_edit.py +++ b/specifyweb/stored_queries/batch_edit.py @@ -173,10 +173,12 @@ def is_part_of_tree(self, query_fields: List[QueryField]) -> bool: return False return isinstance(join_path[-2], TreeRankQuery) + # These constants are purely for memory optimization, no code depends and/or cares if this is constant. EMPTY_FIELD = BatchEditFieldPack() EMPTY_PACK = BatchEditPack(id=EMPTY_FIELD, order=EMPTY_FIELD, version=EMPTY_FIELD) + # FUTURE: this already supports nested-to-many for most part # wb plan, but contains query fields along with indexes to look-up in a result row. # TODO: see if it can be moved + combined with front-end logic. I kept all parsing on backend, but there might be possible beneft in doing this @@ -189,38 +191,37 @@ class RowPlanMap(NamedTuple): has_filters: bool = False @staticmethod - def _merge(has_filters: bool): - def _merger( - current: Dict[str, "RowPlanMap"], other: Tuple[str, "RowPlanMap"] - ) -> Dict[str, "RowPlanMap"]: - key, other_plan = other - return { - **current, - # merge if other is also found in ours - key: ( + def _merge( + current: Dict[str, "RowPlanMap"], other: Tuple[str, "RowPlanMap"] + ) -> Dict[str, "RowPlanMap"]: + key, other_plan = other + return { + **current, + # merge if other is also found in ours + key: ( + other_plan + if key not in current + else current[key].merge( other_plan - if key not in current - else current[key].merge( - other_plan, has_filter_on_parent=has_filters - ) - ), - } + ) + ), + } - return _merger # takes two row plans, combines them together. Adjusts has_filters. def merge( - self: "RowPlanMap", other: "RowPlanMap", has_filter_on_parent=False + self: "RowPlanMap", other: "RowPlanMap" ) -> "RowPlanMap": new_columns = [*self.columns, *other.columns] batch_edit_pack = other.batch_edit_pack or self.batch_edit_pack - has_self_filters = has_filter_on_parent or self.has_filters or other.has_filters + has_self_filters = self.has_filters or other.has_filters to_one = reduce( - RowPlanMap._merge(has_self_filters), other.to_one.items(), self.to_one + RowPlanMap._merge, other.to_one.items(), self.to_one ) - to_many = reduce(RowPlanMap._merge(False), other.to_many.items(), self.to_many) + to_many = reduce(RowPlanMap._merge, other.to_many.items(), self.to_many) + any_filter = any(node.has_filters for node in [*to_one.values(), *to_many.values()]) return RowPlanMap( - batch_edit_pack, new_columns, to_one, to_many, has_filters=has_self_filters + batch_edit_pack, new_columns, to_one, to_many, has_filters=has_self_filters or any_filter ) @staticmethod @@ -366,7 +367,7 @@ def get_row_plan(fields: List[QueryField]) -> "RowPlanMap": for field in fields ] return reduce( - lambda current, other: current.merge(other, has_filter_on_parent=False), + lambda current, other: current.merge(other), iter, RowPlanMap(batch_edit_pack=EMPTY_PACK), ) diff --git a/specifyweb/workbench/upload/upload.py b/specifyweb/workbench/upload/upload.py index 4890f119da9..2d09de85d34 100644 --- a/specifyweb/workbench/upload/upload.py +++ b/specifyweb/workbench/upload/upload.py @@ -391,10 +391,11 @@ def _commit_uploader(result): unupload_dataset(parent, agent, progress) - parent.rowresults = json.dumps([r.to_json() for r in results]) - parent.save(update_fields=['rowresults']) + # parent.rowresults = json.dumps([r.to_json() for r in results]) + # parent.save(update_fields=['rowresults']) - + parent.rowresults = None + parent.save(update_fields=['rowresults']) From 8b2433f33658d755e875440ab637121451d339e1 Mon Sep 17 00:00:00 2001 From: realVinayak Date: Thu, 22 Aug 2024 09:38:21 -0500 Subject: [PATCH 34/63] (batch-edit): refactors, and unit tests --- .../AttachmentsBulkImport/Upload.tsx | 4 +- .../js_src/lib/components/BatchEdit/index.tsx | 17 +- .../lib/components/DataModel/resource.ts | 2 + .../components/DataModel/uniqueFields.json | 1 + .../lib/components/Permissions/definitions.ts | 10 + .../Preferences/UserDefinitions.tsx | 14 + .../lib/components/QueryBuilder/Wrapped.tsx | 3 +- .../lib/components/Toolbar/WbsDialog.tsx | 85 +- .../components/WbActions/WbNoUploadPlan.tsx | 1 + .../lib/components/WbActions/WbRollback.tsx | 15 +- .../lib/components/WbActions/WbUpload.tsx | 11 +- .../js_src/lib/components/WbActions/index.tsx | 37 +- .../lib/components/WbPlanView/index.tsx | 5 +- .../components/WbPlanView/navigatorSpecs.ts | 4 - .../js_src/lib/components/WbToolkit/index.tsx | 9 +- .../lib/components/WorkBench/Status.tsx | 9 +- .../lib/components/WorkBench/WbView.tsx | 4 +- .../components/WorkBench/batchEditHelpers.ts | 1 + .../lib/components/WorkBench/handsontable.ts | 8 +- .../lib/components/WorkBench/helpers.ts | 2 +- .../lib/components/WorkBench/hotProps.tsx | 6 +- .../js_src/lib/localization/batchEdit.ts | 28 + .../js_src/lib/localization/workbench.ts | 79 +- specifyweb/specify/models.py | 9 +- specifyweb/stored_queries/batch_edit.py | 140 ++-- specifyweb/stored_queries/format.py | 7 +- specifyweb/stored_queries/queryfield.py | 64 +- specifyweb/stored_queries/queryfieldspec.py | 5 +- .../stored_queries/tests/base_format.py | 83 ++ .../tests/static/co_query_row_plan.py | 134 ++- .../stored_queries/tests/test_batch_edit.py | 42 +- .../stored_queries/tests/test_format.py | 68 +- .../stored_queries/tests/test_row_plan_map.py | 233 ++++++ specifyweb/workbench/upload/clone.py | 96 ++- .../upload/tests/test_batch_edit_table.py | 783 +++++++++++++++--- specifyweb/workbench/upload/treerecord.py | 449 +++++++--- specifyweb/workbench/upload/upload_table.py | 22 +- specifyweb/workbench/upload/uploadable.py | 5 - specifyweb/workbench/views.py | 56 +- 39 files changed, 1945 insertions(+), 606 deletions(-) create mode 100644 specifyweb/stored_queries/tests/base_format.py create mode 100644 specifyweb/stored_queries/tests/test_row_plan_map.py diff --git a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/Upload.tsx b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/Upload.tsx index 654d2bbe6a1..9363075f5da 100644 --- a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/Upload.tsx +++ b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/Upload.tsx @@ -106,8 +106,8 @@ async function prepareForUpload( const dialogText = { onAction: wbText.uploading(), - onCancelled: wbText.uploadCanceled(), - onCancelledDescription: wbText.uploadCanceledDescription(), + onCancelled: wbText.uploadCanceled({type: wbText.upload()}), + onCancelledDescription: wbText.uploadCanceledDescription({type: wbText.upload()}), } as const; export function AttachmentUpload({ diff --git a/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx b/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx index 923181b398e..e8b354be9bb 100644 --- a/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx @@ -12,7 +12,6 @@ import { generateMappingPathPreview } from '../WbPlanView/mappingPreview'; import { batchEditText } from '../../localization/batchEdit'; import { uniquifyDataSetName } from '../WbImport/helpers'; import { QueryFieldSpec } from '../QueryBuilder/fieldSpec'; -import { isNestedToMany } from '../WbPlanView/modelHelpers'; import {isTreeTable, strictGetTreeDefinitionItems, treeRanksPromise } from '../InitialContext/treeRanks'; import { AnyTree, SerializedResource } from '../DataModel/helperTypes'; import { f } from '../../utils/functools'; @@ -23,16 +22,20 @@ import { dialogIcons } from '../Atoms/Icons'; import { userPreferences } from '../Preferences/userPreferences'; import { SpecifyTable } from '../DataModel/specifyTable'; import { H2, H3 } from '../Atoms'; +import { TableIcon } from '../Molecules/TableIcon'; +import { relationshipIsToMany } from '../WbPlanView/mappingHelpers'; import { strictGetTable } from '../DataModel/tables'; export function BatchEditFromQuery({ query, fields, baseTableName, + recordSetId }: { readonly query: SpecifyResource; readonly fields: RA; - readonly baseTableName: keyof Tables + readonly baseTableName: keyof Tables; + readonly recordSetId?: number }) { const navigate = useNavigate(); const post = (dataSetName: string) => @@ -45,6 +48,7 @@ export function BatchEditFromQuery({ ...serializeResource(query), captions: fields.filter(({isDisplay})=>isDisplay).map(({mappingPath})=>generateMappingPathPreview(baseTableName, mappingPath)), name: dataSetName, + recordSetId, limit: userPreferences.get('batchEdit', 'query', 'limit') }), }); @@ -92,11 +96,8 @@ type QueryError = { function containsFaultyNestedToMany(queryFieldSpec: QueryFieldSpec) : undefined | string { const joinPath = queryFieldSpec.joinPath if (joinPath.length <= 1) return undefined; - const hasNestedToMany = joinPath.some((currentField, id)=>{ - const nextField = joinPath[id+1]; - return nextField !== undefined && currentField.isRelationship && nextField.isRelationship && isNestedToMany(currentField, nextField); - }); - return hasNestedToMany ? (generateMappingPathPreview(queryFieldSpec.baseTable.name, queryFieldSpec.toMappingPath())) : undefined + const nestedToManyCount = joinPath.filter((relationship)=>relationship.isRelationship && relationshipIsToMany(relationship)); + return nestedToManyCount.length > 1 ? (generateMappingPathPreview(queryFieldSpec.baseTable.name, queryFieldSpec.toMappingPath())) : undefined } const getTreeDefFromName = (rankName: string, treeDefItems: RA>)=>defined(treeDefItems.find((treeRank)=>treeRank.name.toLowerCase() === rankName.toLowerCase())); @@ -137,5 +138,5 @@ function ShowInvalidFields({error}: {readonly error: QueryError['invalidFields'] function ShowMissingRanks({error}: {readonly error: QueryError['missingRanks']}) { const hasMissing = Object.values(error).some((rank)=>rank.length > 0); - return hasMissing ?

    {batchEditText.addTreeRank()}

    {Object.entries(error).map(([treeTable, ranks])=>
    {strictGetTable(treeTable).label}
    {ranks.map((rank)=>

    {rank}

    )}
    )}
    : null + return hasMissing ?

    {batchEditText.addTreeRank()}

    {Object.entries(error).map(([treeTable, ranks])=>

    {strictGetTable(treeTable).label}

    {ranks.map((rank)=>

    {rank}

    )}
    )}
    : null } \ No newline at end of file diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/resource.ts b/specifyweb/frontend/js_src/lib/components/DataModel/resource.ts index 1d133fa4761..0e40805b706 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/resource.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/resource.ts @@ -318,6 +318,8 @@ export const getUniqueFields = (table: SpecifyTable, schemaAware: boolean =true) ) ) .map(({ name }) => name), + // Don't clone specifyuser. + ...(table.name === 'Agent' ? table.relationships.filter(({relatedTable})=>relatedTable.name ==='SpecifyUser').map(({name})=>name) : []), ...filterArray( uniqueFields.map((fieldName) => table.getField(fieldName)?.name) ), diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/uniqueFields.json b/specifyweb/frontend/js_src/lib/components/DataModel/uniqueFields.json index 949877f50c6..3e2ba4fcd67 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/uniqueFields.json +++ b/specifyweb/frontend/js_src/lib/components/DataModel/uniqueFields.json @@ -40,6 +40,7 @@ ], "agent": [ "agentAttachments", + "specifyUser", "guid", "timestampCreated", "version", diff --git a/specifyweb/frontend/js_src/lib/components/Permissions/definitions.ts b/specifyweb/frontend/js_src/lib/components/Permissions/definitions.ts index f8b056eea95..c75fbce141f 100644 --- a/specifyweb/frontend/js_src/lib/components/Permissions/definitions.ts +++ b/specifyweb/frontend/js_src/lib/components/Permissions/definitions.ts @@ -76,6 +76,16 @@ export const operationPolicies = { 'upload', 'rollback', ], + '/batch_edit/dataset': [ + 'create', + 'update', + 'delete', + 'commit', + 'rollback', + 'validate', + 'transfer', + 'create_recordset', + ] } as const; /** diff --git a/specifyweb/frontend/js_src/lib/components/Preferences/UserDefinitions.tsx b/specifyweb/frontend/js_src/lib/components/Preferences/UserDefinitions.tsx index f69fd0882a0..275aadbdd3b 100644 --- a/specifyweb/frontend/js_src/lib/components/Preferences/UserDefinitions.tsx +++ b/specifyweb/frontend/js_src/lib/components/Preferences/UserDefinitions.tsx @@ -1954,7 +1954,21 @@ export const userPreferenceDefinitions = { } }) } + }, + editor: { + title: preferencesText.general(), + items: { + showRollback: definePref({ + title: batchEditText.showRollback(), + requiresReload: false, + defaultValue: true, + type: "java.lang.Boolean", + visible: true, + description: batchEditText.showRollbackDescription() + }) + } } + } } } as const; diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx index 289392fc39d..6411cb932ab 100644 --- a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx +++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx @@ -53,6 +53,7 @@ import type { QueryResultRow } from './Results'; import { QueryResultsWrapper } from './ResultsWrapper'; import { QueryToolbar } from './Toolbar'; import { BatchEditFromQuery } from '../BatchEdit'; +import { datasetVariants } from '../Toolbar/WbsDialog'; const fetchTreeRanks = async (): Promise => treeRanksPromise.then(f.true); @@ -590,7 +591,7 @@ function Wrapped({ } extraButtons={ <> - + { datasetVariants.batchEdit.canCreate() && } {query.countOnly ? undefined : ( createEmptyDataSet( @@ -135,6 +136,8 @@ function TableHeader({ type WB_VARIANT = keyof Omit; +export type WbVariantUiSpec = typeof datasetVariants.workbench.uiSpec.viewer; + export function GenericDataSetsDialog({ onClose: handleClose, onDataSetSelect: handleDataSetSelect, @@ -144,15 +147,15 @@ export function GenericDataSetsDialog({ readonly onClose: () => void; readonly onDataSetSelect?: (id: number) => void; }): JSX.Element | null { - const variant = datasetVariants[wbVariant]; + const {fetchUrl, sortConfig: sortConfigSpec, canEdit, uiSpec, route, metaRoute, canImport} = datasetVariants[wbVariant]; const [unsortedDatasets] = useAsyncState( React.useCallback( - async () => ajax>(formatUrl(variant.fetchUrl, {}), { headers: { Accept: 'application/json' } }).then(({data})=>data), + async () => ajax>(formatUrl(fetchUrl, {}), { headers: { Accept: 'application/json' } }).then(({data})=>data), [wbVariant] ), true ); - const [sortConfig, handleSort, applySortConfig] = useSortConfig(variant.sortConfig.key, variant.sortConfig.field, false); + const [sortConfig, handleSort, applySortConfig] = useSortConfig(sortConfigSpec.key, sortConfigSpec.field, false); const datasets = Array.isArray(unsortedDatasets) ? applySortConfig( unsortedDatasets, ({ name, timestampcreated, uploadresult }) => @@ -165,13 +168,11 @@ export function GenericDataSetsDialog({ const navigate = useNavigate(); const loading = React.useContext(LoadingContext); - const {canImport, canEdit, header, onEmpty, route, metaRoute} = variant; - return Array.isArray(datasets) ? {commonText.cancel()} - {variant.canImport() && ( + {canImport() && ( <> {wbText.importFile()} @@ -195,13 +196,13 @@ export function GenericDataSetsDialog({ container: dialogClassNames.wideContainer, }} dimensionsKey="DataSetsDialog" - header={header(datasets.length)} + header={uiSpec.datasetsDialog.header(datasets.length)} icon={icons.table} onClose={handleClose} > {datasets.length === 0 ? (

    - {onEmpty(canImport())} + {uiSpec.datasetsDialog.empty()}

    ) : (
    ); } diff --git a/specifyweb/frontend/js_src/lib/components/WbActions/WbUpload.tsx b/specifyweb/frontend/js_src/lib/components/WbActions/WbUpload.tsx index 4823c588881..8889b0388ae 100644 --- a/specifyweb/frontend/js_src/lib/components/WbActions/WbUpload.tsx +++ b/specifyweb/frontend/js_src/lib/components/WbActions/WbUpload.tsx @@ -8,6 +8,7 @@ import { Dialog } from '../Molecules/Dialog'; import type { WbCellCounts } from '../WorkBench/CellMeta'; import type { WbMapping } from '../WorkBench/mapping'; import type { WbStatus } from '../WorkBench/WbView'; +import { WbVariantUiSpec } from '../Toolbar/WbsDialog'; export function WbUpload({ hasUnsavedChanges, @@ -15,12 +16,14 @@ export function WbUpload({ openNoUploadPlan, startUpload, cellCounts, + uiSpec }: { readonly hasUnsavedChanges: boolean; readonly mappings: WbMapping | undefined; readonly openNoUploadPlan: () => void; readonly startUpload: (mode: WbStatus) => void; readonly cellCounts: WbCellCounts; + readonly uiSpec: WbVariantUiSpec }): JSX.Element { const [showUpload, openUpload, closeUpload] = useBooleanState(); @@ -51,7 +54,7 @@ export function WbUpload({ } onClick={handleUpload} > - {wbText.upload()} + {uiSpec.do} {showUpload && ( {commonText.cancel()} - {wbText.upload()} + {uiSpec.do} } - header={wbText.startUpload()} + header={wbText.startUpload({type: uiSpec.do})} onClose={closeUpload} > - {wbText.startUploadDescription()} + {uiSpec.doStartDescription} )} diff --git a/specifyweb/frontend/js_src/lib/components/WbActions/index.tsx b/specifyweb/frontend/js_src/lib/components/WbActions/index.tsx index 6349a50e37e..8465be513a9 100644 --- a/specifyweb/frontend/js_src/lib/components/WbActions/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/WbActions/index.tsx @@ -10,7 +10,6 @@ import { Button } from '../Atoms/Button'; import { LoadingContext } from '../Core/Contexts'; import { ErrorBoundary } from '../Errors/ErrorBoundary'; import { Dialog } from '../Molecules/Dialog'; -import { hasPermission } from '../Permissions/helpers'; import type { Dataset, Status } from '../WbPlanView/Wrapped'; import type { WbCellCounts } from '../WorkBench/CellMeta'; import type { WbMapping } from '../WorkBench/mapping'; @@ -23,6 +22,7 @@ import { WbRollback } from './WbRollback'; import { WbSave } from './WbSave'; import { WbUpload } from './WbUpload'; import { WbValidate } from './WbValidate'; +import { resolveVariantFromDataset, WbVariantUiSpec } from '../Toolbar/WbsDialog'; export function WbActions({ dataset, @@ -65,8 +65,11 @@ export function WbActions({ onOpenStatus: openStatus, workbench, }); + + const variant = resolveVariantFromDataset(dataset); + const uiSpec = variant.uiSpec.viewer; - const message = mode === undefined ? undefined : getMessage(cellCounts, mode); + const message = mode === undefined ? undefined : getMessage(cellCounts, mode, uiSpec); const isMapped = mappings !== undefined; @@ -85,7 +88,7 @@ export function WbActions({ onCloseNoUploadPlan={closeNoUploadPlan} onOpenNoUploadPlan={openNoUploadPlan} /> - {!isUploaded && hasPermission('/workbench/dataset', 'validate') ? ( + {!isUploaded && variant.canValidate() ? ( - {isUploaded && hasPermission('/workbench/dataset', 'unupload') ? ( + {isUploaded && variant.canUndo() ? ( ) : undefined} - {!isUploaded && hasPermission('/workbench/dataset', 'upload') ? ( + {!isUploaded && variant.canDo() ? ( ) : undefined} - {!isUploaded && hasPermission('/workbench/dataset', 'update') ? ( + {!isUploaded && variant.canEdit() ? ( <> @@ -221,7 +226,8 @@ export function WbActions({ ? wbText.validationCanceledDescription() : mode === 'unupload' ? wbText.rollbackCanceledDescription() - : wbText.uploadCanceledDescription()} + : wbText.uploadCanceledDescription({type: uiSpec.do}) + }
    )} @@ -289,7 +295,8 @@ function useWbActions({ function getMessage( cellCounts: WbCellCounts, - mode: WbStatus + mode: WbStatus, + uiSpec: WbVariantUiSpec ): { readonly header: LocalizedString; readonly message: JSX.Element | LocalizedString; @@ -322,23 +329,23 @@ function getMessage( upload: cellCounts.invalidCells === 0 ? { - header: wbText.uploadSuccessful(), - message: wbText.uploadSuccessfulDescription(), + header: wbText.uploadSuccessful({type: uiSpec.do}), + message: uiSpec.doSuccessfulDescription, } : { - header: wbText.uploadErrors(), + header: wbText.uploadErrors({type: uiSpec.do}), message: ( <> - {wbText.uploadErrorsDescription()} + {wbText.uploadErrorsDescription({type: uiSpec.do})}

    - {wbText.uploadErrorsSecondDescription()} + {wbText.uploadErrorsSecondDescription({type: uiSpec.do})} ), }, unupload: { header: wbText.dataSetRollback(), - message: wbText.dataSetRollbackDescription(), + message: uiSpec.undoFinishedDescription, }, }; diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/index.tsx b/specifyweb/frontend/js_src/lib/components/WbPlanView/index.tsx index f5972b1ac09..e8d758e2840 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/index.tsx @@ -15,10 +15,10 @@ import { f } from '../../utils/functools'; import { ReadOnlyContext } from '../Core/Contexts'; import { useMenuItem } from '../Header/MenuContext'; import { treeRanksPromise } from '../InitialContext/treeRanks'; -import { hasPermission } from '../Permissions/helpers'; import { NotFoundView } from '../Router/NotFoundView'; import type { Dataset } from './Wrapped'; import { WbPlanView } from './Wrapped'; +import { resolveVariantFromDataset } from '../Toolbar/WbsDialog'; const fetchTreeRanks = async (): Promise => treeRanksPromise.then(f.true); @@ -45,10 +45,9 @@ function WbPlanViewSafe({dataSet}:{readonly dataSet: Dataset}): JSX.Element | nu const [treeRanksLoaded = false] = useAsyncState(fetchTreeRanks, true); useMenuItem(dataSet.isupdate ? 'batchEdit' : 'workBench'); useErrorContext('dataSet', dataSet); - const isReadOnly = React.useContext(ReadOnlyContext) || - !hasPermission('/workbench/dataset', 'update') || + !resolveVariantFromDataset(dataSet).canEdit() || typeof dataSet !== 'object' || dataSet.uploadresult?.success === true || // FEATURE: Remove this diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/navigatorSpecs.ts b/specifyweb/frontend/js_src/lib/components/WbPlanView/navigatorSpecs.ts index c0a1d1cf17b..a129ba92649 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/navigatorSpecs.ts +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/navigatorSpecs.ts @@ -44,10 +44,6 @@ const wbPlanView: NavigatorSpec = { includeRootFormattedAggregated: false, allowTransientToMany: true, useSchemaOverrides: true, - /* - * Hide nested -to-many relationships as they are not - * supported by the WorkBench - */ allowNestedToMany: true, ensurePermission: () => userPreferences.get('workBench', 'wbPlanView', 'showNoAccessTables') diff --git a/specifyweb/frontend/js_src/lib/components/WbToolkit/index.tsx b/specifyweb/frontend/js_src/lib/components/WbToolkit/index.tsx index 7186f342011..e3a83ee6504 100644 --- a/specifyweb/frontend/js_src/lib/components/WbToolkit/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/WbToolkit/index.tsx @@ -7,7 +7,7 @@ import type { RA } from '../../utils/types'; import { Button } from '../Atoms/Button'; import { raise } from '../Errors/Crash'; import { ErrorBoundary } from '../Errors/ErrorBoundary'; -import { hasPermission, hasTablePermission } from '../Permissions/helpers'; +import { hasTablePermission } from '../Permissions/helpers'; import { userPreferences } from '../Preferences/userPreferences'; import type { Dataset } from '../WbPlanView/Wrapped'; import { downloadDataSet } from '../WorkBench/helpers'; @@ -17,6 +17,7 @@ import { WbConvertCoordinates } from './CoordinateConverter'; import { WbRawPlan } from './DevShowPlan'; import { WbGeoLocate } from './GeoLocate'; import { WbLeafletMap } from './WbLeafletMap'; +import { resolveVariantFromDataset } from '../Toolbar/WbsDialog'; export function WbToolkit({ dataset, @@ -57,13 +58,15 @@ export function WbToolkit({ const hasLocality = mappings === undefined ? false : mappings.localityColumns.length > 0; + const variant = resolveVariantFromDataset(dataset); + return (
    - {hasPermission('/workbench/dataset', 'transfer') && + {variant.canTransfer() && hasTablePermission('SpecifyUser', 'read') ? ( - {hasPermission('/workbench/dataset', 'update') && ( + {variant.canUpdate() && ( <> (dataset.uploaderstatus); const [aborted, setAborted] = React.useState( false @@ -63,7 +66,7 @@ export function WbStatus({ const title = { validating: wbText.wbStatusValidation(), - uploading: wbText.wbStatusUpload(), + uploading: wbText.wbStatusUpload({type: uiSpec.do}), unuploading: wbText.wbStatusUnupload(), }[status.uploaderstatus.operation]; @@ -72,13 +75,13 @@ export function WbStatus({ const mappedOperation = { validating: wbText.validation(), - uploading: wbText.upload(), + uploading: uiSpec.do, unuploading: wbText.rollback(), }[status.uploaderstatus.operation]; const standardizedOperation = { validating: wbText.validating(), - uploading: wbText.uploading(), + uploading: uiSpec.doing, unuploading: wbText.rollingBack(), }[status.uploaderstatus.operation]; diff --git a/specifyweb/frontend/js_src/lib/components/WorkBench/WbView.tsx b/specifyweb/frontend/js_src/lib/components/WorkBench/WbView.tsx index 13754a0c75d..25d12435fc7 100644 --- a/specifyweb/frontend/js_src/lib/components/WorkBench/WbView.tsx +++ b/specifyweb/frontend/js_src/lib/components/WorkBench/WbView.tsx @@ -29,7 +29,6 @@ import { Button } from '../Atoms/Button'; import { className } from '../Atoms/className'; import { Link } from '../Atoms/Link'; import { ReadOnlyContext } from '../Core/Contexts'; -import { hasPermission } from '../Permissions/helpers'; import { WbActions } from '../WbActions'; import { useResults } from '../WbActions/useResults'; import type { Dataset } from '../WbPlanView/Wrapped'; @@ -46,6 +45,7 @@ import { WbUploaded } from './Results'; import { useDisambiguationDialog } from './useDisambiguationDialog'; import { WbSpreadsheet } from './WbSpreadsheet'; import { WbValidation } from './WbValidation'; +import { resolveVariantFromDataset } from '../Toolbar/WbsDialog'; export type WbStatus = 'unupload' | 'upload' | 'validate'; @@ -147,7 +147,7 @@ export function WbView({ }, []); const isMapped = mappings !== undefined; - const canUpdate = hasPermission('/workbench/dataset', 'update'); + const canUpdate = resolveVariantFromDataset(workbench.dataset).canEdit(); const [showToolkit, _openToolkit, _closeToolkit, toggleToolkit] = useBooleanState(); diff --git a/specifyweb/frontend/js_src/lib/components/WorkBench/batchEditHelpers.ts b/specifyweb/frontend/js_src/lib/components/WorkBench/batchEditHelpers.ts index 95d22406b1b..5a721ce6e4c 100644 --- a/specifyweb/frontend/js_src/lib/components/WorkBench/batchEditHelpers.ts +++ b/specifyweb/frontend/js_src/lib/components/WorkBench/batchEditHelpers.ts @@ -21,6 +21,7 @@ export type BatchEditPack = { readonly to_many?: R> } + export const isBatchEditNullRecord = (batchEditPack: BatchEditPack | undefined, currentTable: SpecifyTable, mappingPath: MappingPath): boolean => { if (batchEditPack == undefined) return false; if (mappingPath.length <= 1) return batchEditPack?.self?.id === BATCH_EDIT_NULL_RECORD; diff --git a/specifyweb/frontend/js_src/lib/components/WorkBench/handsontable.ts b/specifyweb/frontend/js_src/lib/components/WorkBench/handsontable.ts index 6d9d8ca5ad7..5db3f8ac2ce 100644 --- a/specifyweb/frontend/js_src/lib/components/WorkBench/handsontable.ts +++ b/specifyweb/frontend/js_src/lib/components/WorkBench/handsontable.ts @@ -72,12 +72,16 @@ function getIdentifyNullRecords(hot: Handsontable, mappings: WbMapping | undefin const makeNullRecordsReadOnly: GetProperty = (physicalRow, physicalCol, _property) => { const physicalColToMappingCol = getPhysicalColToMappingCol(mappings, dataset); const mappingCol = physicalColToMappingCol(physicalCol); - const batchEditRaw: string | undefined = hot.getDataAtRow(physicalRow).at(-1) ?? undefined; if (mappingCol === -1 || mappingCol === undefined){ // Definitely don't need to anything, not even mapped return {readOnly: true}; } - if (batchEditRaw === undefined){ + + const batchEditRaw: string | undefined = hot.getDataAtRow(hot.toVisualRow(physicalRow)).at(-1) ?? undefined; + if ((batchEditRaw === undefined) || ( + // will happen for new rows + rows auto-added at the bottom. + batchEditRaw.trim() === '' + )){ return {readOnly: false}; } const batchEditPack: BatchEditPack | undefined = JSON.parse(batchEditRaw)[BATCH_EDIT_KEY]; diff --git a/specifyweb/frontend/js_src/lib/components/WorkBench/helpers.ts b/specifyweb/frontend/js_src/lib/components/WorkBench/helpers.ts index 78a88b5d1ad..dbfa3e5d65e 100644 --- a/specifyweb/frontend/js_src/lib/components/WorkBench/helpers.ts +++ b/specifyweb/frontend/js_src/lib/components/WorkBench/helpers.ts @@ -23,4 +23,4 @@ export const downloadDataSet = async ( else reject(error); } ) - ); + ) \ No newline at end of file diff --git a/specifyweb/frontend/js_src/lib/components/WorkBench/hotProps.tsx b/specifyweb/frontend/js_src/lib/components/WorkBench/hotProps.tsx index 4d5fb304f63..29b2ac0134c 100644 --- a/specifyweb/frontend/js_src/lib/components/WorkBench/hotProps.tsx +++ b/specifyweb/frontend/js_src/lib/components/WorkBench/hotProps.tsx @@ -46,7 +46,8 @@ export function useHotProps({ { length: dataset.columns.length + 1 }, (_, physicalCol) => ({ // Get data from nth column for nth column - data: physicalCol + data: physicalCol, + readOnly: ([-1, undefined].includes(physicalColToMappingCol(physicalCol))) }) ), [dataset.columns.length] @@ -116,6 +117,7 @@ export function useHotProps({ const tabMoves = tabMovesPref === 'col' ? { col: 1, row: 0 } : { col: 0, row: 1 }; + const adjustedMinRows = dataset.isupdate ? 0 : minSpareRows; return { autoWrapCol, autoWrapRow, @@ -125,7 +127,7 @@ export function useHotProps({ enterBeginsEditing, hiddenRows, hiddenColumns, - minSpareRows, + minSpareRows:adjustedMinRows, tabMoves, comments, }; diff --git a/specifyweb/frontend/js_src/lib/localization/batchEdit.ts b/specifyweb/frontend/js_src/lib/localization/batchEdit.ts index 0f76dd56daa..4bc643c437d 100644 --- a/specifyweb/frontend/js_src/lib/localization/batchEdit.ts +++ b/specifyweb/frontend/js_src/lib/localization/batchEdit.ts @@ -27,5 +27,33 @@ export const batchEditText = createDictionary({ }, createUpdateDataSetInstructions: { 'en-us': "Use the query builder to make a new batch edit dataset" + }, + showRollback: { + 'en-us': "Show revert button" + }, + showRollbackDescription: { + 'en-us': "Revert is currently an experimental feature. This preference will hide the button" + }, + commit: { + 'en-us': 'Commit' + }, + startCommitDescription: { + 'en-us': 'Commiting the Data Set will update, add, and delete the data from the spreadsheet to the Specify database.', + }, + startRevertDescription: { + 'en-us': "Rolling back the dataset will re-update the values, delete created records, and create new records" + }, + commitSuccessfulDescription: { + 'en-us': `Click on the "Results" button to see the number of records affected in each database table`, + }, + dateSetRevertDescription: { + 'en-us': `This Rolledback Data Set is saved, however, it cannot be edit. Please re-run the query` + }, + committing: { + 'en-us': 'Committing' + }, + nullRecord: { + 'en-us': "(Not included in the query results)" } + } as const) \ No newline at end of file diff --git a/specifyweb/frontend/js_src/lib/localization/workbench.ts b/specifyweb/frontend/js_src/lib/localization/workbench.ts index 3dd62f103cf..9722f63d059 100644 --- a/specifyweb/frontend/js_src/lib/localization/workbench.ts +++ b/specifyweb/frontend/js_src/lib/localization/workbench.ts @@ -383,12 +383,7 @@ export const wbText = createDictionary({ `, }, startUpload: { - 'en-us': 'Begin Data Set Upload?', - 'ru-ru': 'Начать загрузку набора данных?', - 'es-es': '¿Comenzar carga de conjunto de datos?', - 'fr-fr': "Commencer le téléchargement de l'ensemble de données ?", - 'uk-ua': 'Почати завантаження набору даних?', - 'de-ch': 'Hochladen des Datensatzes beginnen?', + 'en-us': 'Begin Data Set {type:string}?', }, startUploadDescription: { 'en-us': @@ -643,12 +638,7 @@ export const wbText = createDictionary({ 'de-ch': 'Bei der Validierung wurden Fehler im Datensatz gefunden.', }, uploadSuccessful: { - 'en-us': 'Upload Completed with No Errors', - 'ru-ru': 'Загрузка завершена без ошибок', - 'es-es': 'Carga completada sin errores', - 'fr-fr': 'Téléchargement terminé sans erreur', - 'uk-ua': 'Завантаження завершено без помилок', - 'de-ch': 'Upload ohne Fehler abgeschlossen', + 'en-us': '{type:string} Completed with No Errors', }, uploadSuccessfulDescription: { 'en-us': ` @@ -677,58 +667,15 @@ export const wbText = createDictionary({ `, }, uploadErrors: { - 'en-us': 'Upload Failed due to Error Cells', - 'ru-ru': 'Ошибка загрузки из-за ошибок', - 'es-es': 'Carga fallida debido a celdas de error', - 'fr-fr': "Échec du téléchargement en raison de cellules d'erreur", - 'uk-ua': 'Помилка завантаження через клітинки помилок', - 'de-ch': 'Der Upload ist aufgrund fehlerhafter Zellen fehlgeschlagen', + 'en-us': '{type:string} Failed due to Error Cells', }, uploadErrorsDescription: { - 'en-us': 'The upload failed due to one or more cell value errors.', - 'ru-ru': - 'Загрузка не удалась из-за одной или нескольких ошибок значений ячеек.', - 'es-es': 'La carga falló debido a uno o más errores de valor de celda.', - 'fr-fr': ` - Le téléchargement a échoué en raison d'une ou plusieurs erreurs de valeur - de cellule. - `, - 'uk-ua': - 'Помилка завантаження через одну або кілька помилок значення клітинки.', - 'de-ch': ` - Der Upload ist aufgrund eines oder mehrerer Zellenwertfehler - fehlgeschlagen. - `, + 'en-us': 'The {type:string} failed due to one or more cell value errors.', }, uploadErrorsSecondDescription: { 'en-us': ` Validate the Data Set and review the mouseover hints for each error cell, - then make the appropriate corrections. Save and retry the Upload. - `, - 'ru-ru': ` - Проверте набор данных и наведите указатель мыши на каждую ячейку с - ошибкой, затем сделайте соответствующие исправления, сохраните и повторите - попытку. - `, - 'es-es': ` - Valide el conjunto de datos y revise las sugerencias del mouseover para - cada celda de error, luego haga las correcciones apropiadas. Guarde y - vuelva a intentar la carga. - `, - 'fr-fr': ` - Validez l'ensemble de données et examinez les conseils de passage de la - souris pour chaque cellule d'erreur, puis apportez les corrections - appropriées. Enregistrez et réessayez le téléchargement. - `, - 'uk-ua': ` - Перевірте набір даних і перегляньте підказки для кожної клітинки помилки, - а потім внесіть відповідні виправлення. Збережіть і повторіть спробу - завантаження. - `, - 'de-ch': ` - Validieren Sie den Datensatz und überprüfen Sie die Mouseover-Hinweise für - jede Fehlerzelle. Nehmen Sie dann die entsprechenden Korrekturen vor. - Speichern Sie und versuchen Sie den Upload erneut. + then make the appropriate corrections. Save and retry the {type:string}. `, }, dataSetRollback: { @@ -796,20 +743,10 @@ export const wbText = createDictionary({ 'de-ch': 'Datensatz-Rollback abgebrochen.', }, uploadCanceled: { - 'en-us': 'Upload Cancelled', - 'ru-ru': 'Загрузка отменена', - 'es-es': 'Subida cancelada', - 'de-ch': 'Datensatzvalidierung abgebrochen.', - 'fr-fr': 'Téléchargement annulé', - 'uk-ua': 'Завантаження скасовано', + 'en-us': '{type:string} Cancelled', }, uploadCanceledDescription: { - 'en-us': 'Data Set Upload cancelled.', - 'ru-ru': 'Загрузка набора данных отменена.', - 'es-es': 'Carga de conjunto de datos cancelada.', - 'fr-fr': "Téléchargement de l'ensemble de données annulé.", - 'uk-ua': 'Завантаження набору даних скасовано.', - 'de-ch': 'Der Upload des Datensatzes wurde abgebrochen.', + 'en-us': 'Data Set {type:string} cancelled.', }, coordinateConverter: { 'en-us': 'Geocoordinate Format', @@ -1382,7 +1319,7 @@ export const wbText = createDictionary({ 'de-ch': 'Datensatz-Rollbackstatus', }, wbStatusUpload: { - 'en-us': 'Data Set Upload Status', + 'en-us': 'Data Set {type:string} Status', 'ru-ru': 'Состояние загрузки набора данных', 'es-es': 'Estado de carga del conjunto de datos', 'fr-fr': "Une erreur s'est produite pendant [X22X]", diff --git a/specifyweb/specify/models.py b/specifyweb/specify/models.py index 664407505f0..64f03418eb4 100644 --- a/specifyweb/specify/models.py +++ b/specifyweb/specify/models.py @@ -4,7 +4,7 @@ from specifyweb.businessrules.exceptions import AbortSave from specifyweb.specify.model_timestamp import save_auto_timestamp_field_with_override from specifyweb.specify import model_extras -from .datamodel import datamodel +from .datamodel import datamodel, Table import logging logger = logging.getLogger(__name__) @@ -23,7 +23,12 @@ def custom_save(self, *args, **kwargs): # Handle AbortSave exception as needed logger.error("Save operation aborted: %s", e) return - + +# TODO: Use this everywhere +class ModelWithTable(models.Model): + specify_model: Table + class Meta: + abstract = True # These Django model classes were generated by the specifyweb.specify.sp7_build_models.py script. # The original models in this file are based on the Specify 6.8.03 datamodel schema. diff --git a/specifyweb/stored_queries/batch_edit.py b/specifyweb/stored_queries/batch_edit.py index 528bea2e102..9eb67303260 100644 --- a/specifyweb/stored_queries/batch_edit.py +++ b/specifyweb/stored_queries/batch_edit.py @@ -25,6 +25,9 @@ from . import models import json +from specifyweb.workbench.upload.upload_plan_schema import schema +from jsonschema import validate + from django.db import transaction @@ -97,6 +100,13 @@ def _callback(field): } return BatchEditPack(**new_field_specs) + def merge(self, other: "BatchEditPack") -> "BatchEditPack": + return BatchEditPack( + id=self.id if self.id.field is not None else other.id, + version=self.version if self.version.field is not None else other.version, + order=self.order if self.order.field is not None else other.order, + ) + # a basic query field spec to field @staticmethod def _query_field(field_spec: QueryFieldSpec, sort_type: int): @@ -144,13 +154,13 @@ def index_plan(self, start_index=0) -> Tuple["BatchEditPack", List[QueryField]]: def bind(self, row: Tuple[Any]): return BatchEditPack( - id=BatchEditFieldPack( - value=row[self.id.idx] if self.id.idx is not None else None + id=self.id._replace( + value=row[self.id.idx] if self.id.idx is not None else None, ), - order=BatchEditFieldPack( + order=self.order._replace( value=row[self.order.idx] if self.order.idx is not None else None ), - version=BatchEditFieldPack( + version=self.version._replace( value=row[self.version.idx] if self.version.idx is not None else None ), ) @@ -164,7 +174,7 @@ def to_json(self) -> Dict[str, Any]: # we not only care that it is part of tree, but also care that there is rank to tree def is_part_of_tree(self, query_fields: List[QueryField]) -> bool: - if self.id is None or self.id.idx is None: + if self.id is None or self.id.value is None or self.id.value == NULL_RECORD: return False id_field = self.id.idx field = query_fields[id_field - 1] @@ -188,40 +198,51 @@ class RowPlanMap(NamedTuple): columns: List[BatchEditFieldPack] = [] to_one: Dict[str, "RowPlanMap"] = {} to_many: Dict[str, "RowPlanMap"] = {} - has_filters: bool = False + is_naive: bool = True @staticmethod - def _merge( - current: Dict[str, "RowPlanMap"], other: Tuple[str, "RowPlanMap"] - ) -> Dict[str, "RowPlanMap"]: - key, other_plan = other - return { - **current, - # merge if other is also found in ours - key: ( - other_plan - if key not in current - else current[key].merge( + def _merge(force_naive=False): + def _merge( + current: Dict[str, "RowPlanMap"], other: Tuple[str, "RowPlanMap"] + ) -> Dict[str, "RowPlanMap"]: + key, other_plan = other + return { + **current, + # merge if other is also found in ours + key: ( other_plan - ) - ), - } + if key not in current + else current[key].merge(other_plan, force_naive) + ), + } + return _merge - # takes two row plans, combines them together. Adjusts has_filters. + # takes two row plans, combines them together. Adjusts is_naive. def merge( - self: "RowPlanMap", other: "RowPlanMap" + self: "RowPlanMap", other: "RowPlanMap", force_naive=False ) -> "RowPlanMap": new_columns = [*self.columns, *other.columns] - batch_edit_pack = other.batch_edit_pack or self.batch_edit_pack - has_self_filters = self.has_filters or other.has_filters - to_one = reduce( - RowPlanMap._merge, other.to_one.items(), self.to_one + batch_edit_pack = other.batch_edit_pack.merge(self.batch_edit_pack) + is_self_naive = self.is_naive and other.is_naive + # BUG: Handle this more gracefully for to-ones. + to_one = reduce(RowPlanMap._merge(), other.to_one.items(), self.to_one) + adjusted_to_one = { + key: value._replace(is_naive=True) for (key, value) in to_one.items() + } + to_many = reduce( + RowPlanMap._merge(force_naive), other.to_many.items(), self.to_many ) - to_many = reduce(RowPlanMap._merge, other.to_many.items(), self.to_many) - any_filter = any(node.has_filters for node in [*to_one.values(), *to_many.values()]) + adjusted_naive = ( + is_self_naive + and not (any(not _to_one.is_naive for _to_one in to_one.values())) + ) or (force_naive and (batch_edit_pack.order.field is None)) return RowPlanMap( - batch_edit_pack, new_columns, to_one, to_many, has_filters=has_self_filters or any_filter + batch_edit_pack, + new_columns, + adjusted_to_one, + to_many, + is_naive=adjusted_naive, ) @staticmethod @@ -271,7 +292,7 @@ def index_plan(self, start_index=1) -> Tuple["RowPlanMap", List[QueryField]]: to_one=_to_one, to_many=_to_many, batch_edit_pack=_batch_indexed, - has_filters=self.has_filters, + is_naive=self.is_naive, ), [*column_fields, *_batch_fields, *to_one_fields, *to_many_fields], ) @@ -306,19 +327,17 @@ def _recur_row_plan( node = next_path[0] rest = next_path[1:] - # we can't edit relationships's formatted/aggregated anyways. - batch_edit_pack = ( - EMPTY_PACK - if original_field_spec.needs_formatted() - else BatchEditPack.from_field_spec(partial_field_spec) - ) + # Meh, simplifies other stuff going on in other places + # that is, we'll include the pack of CO if query is like CO -> (formatted) or CO -> CE (formatted). + # No, this doesn't mean IDs of the formatted/aggregated are including (that is impossible) + batch_edit_pack = BatchEditPack.from_field_spec(partial_field_spec) if node is None or (len(rest) == 0): # we are at the end return RowPlanMap( columns=[BatchEditFieldPack(field=original_field)], batch_edit_pack=batch_edit_pack, - has_filters=(original_field.op_num != 8), + is_naive=(original_field.op_num == 8), ) assert isinstance(node, TreeRankQuery) or isinstance( @@ -366,11 +385,25 @@ def get_row_plan(fields: List[QueryField]) -> "RowPlanMap": ) for field in fields ] - return reduce( - lambda current, other: current.merge(other), + + raw_plan = reduce( + lambda current, other: current.merge(other, True), iter, RowPlanMap(batch_edit_pack=EMPTY_PACK), ) + return raw_plan.skim_plan() + + def skim_plan(self: "RowPlanMap", parent_is_naive=True) -> "RowPlanMap": + is_current_naive = parent_is_naive and self.is_naive + to_one = { + key: value.skim_plan(is_current_naive) + for (key, value) in self.to_one.items() + } + to_many = { + key: value.skim_plan(is_current_naive) + for (key, value) in self.to_many.items() + } + return self._replace(to_one=to_one, to_many=to_many, is_naive=is_current_naive) @staticmethod def _bind_null(value: "RowPlanCanonical") -> List["RowPlanCanonical"]: @@ -395,16 +428,15 @@ def bind(self, row: Tuple[Any]) -> "RowPlanCanonical": # gets a null record to fill-out empty space # doesn't support nested-to-many's yet - complicated - def nullify(self) -> "RowPlanCanonical": - columns = [ - pack._replace( - value="(Not included in results)" if self.has_filters else None - ) - for pack in self.columns - ] - to_ones = {key: value.nullify() for (key, value) in self.to_one.items()} + def nullify(self, ignore_naive=False) -> "RowPlanCanonical": + # since is_naive is set, + is_null = (not self.is_naive) and not ignore_naive + columns = [pack._replace(value=None) for pack in self.columns] + to_ones = { + key: value.nullify(not is_null) for (key, value) in self.to_one.items() + } batch_edit_pack = BatchEditPack( - id=BatchEditFieldPack(value=(NULL_RECORD if self.has_filters else None)), + id=BatchEditFieldPack(value=(NULL_RECORD if is_null else None)), order=EMPTY_FIELD, version=EMPTY_FIELD, ) @@ -729,7 +761,8 @@ def to_upload_plan( ) -> Tuple[List[Tuple[Tuple[int, int], str]], Uploadable]: # Yuk, finally. - # Whether we are something like [det-> (T -- what we are) -> tree]. Set break points in handle_tree_field in query_construct.py to figure out what this means. + # Whether we are something like [det-> (T -- what we are) -> tree]. + # Set break points in handle_tree_field in query_construct.py to figure out what this means. intermediary_to_tree = any( canonical.batch_edit_pack is not None and canonical.batch_edit_pack.is_part_of_tree(query_fields) @@ -740,7 +773,7 @@ def _lookup_in_fields(_id: Optional[int]): assert _id is not None, "invalid lookup used!" field = query_fields[ _id - 1 - ] # Need to go off by 1, bc we added 1 to account for distinct + ] # Need to go off by 1, bc we added 1 to account for id fields string_id = field.fieldspec.to_stringid() localized_label = localization_dump.get( string_id, naive_field_format(field.fieldspec) @@ -758,7 +791,9 @@ def _lookup_in_fields(_id: Optional[int]): fieldspec.needs_formatted() or intermediary_to_tree or (fieldspec.is_temporal() and fieldspec.date_part != "Full Date") - or fieldspec.get_field().name.lower() == "fullname" + # TODO: Refactor after merge with production + or fieldspec.get_field().name.lower() + in ["fullname", "nodenumber", "highestchildnodenumber"] ) id_in_original_fields = get_column_id(string_id) return ( @@ -833,6 +868,7 @@ def _to_upload_plan(rel_name: str, _self: "RowPlanCanonical"): overrideScope=None, wbcols=wb_cols, static={}, + # FEAT: Remove this restriction to allow adding brand new data anywhere toOne=Func.remove_keys(to_one_upload_tables, Func.is_not_empty), toMany=Func.remove_keys(to_many_upload_tables, Func.is_not_empty), ) @@ -866,6 +902,7 @@ def run_batch_edit(collection, user, spquery, agent): mapped_raws = [ [*row, json.dumps({"batch_edit": pack})] for (row, pack) in zip(rows, packs) ] + # Skipping empty because we can have a funny case where all the query fields don't contain any data regularized_rows = regularize_rows(len(headers), mapped_raws, skip_empty=False) return make_dataset( user=user, @@ -999,6 +1036,7 @@ def _get_orig_column(string_id: str): headers = Func.second(key_and_headers) json_upload_plan = upload_plan.unparse() + validate(json_upload_plan, schema) return ( headers, diff --git a/specifyweb/stored_queries/format.py b/specifyweb/stored_queries/format.py index 05ae6326f74..20263777bc2 100644 --- a/specifyweb/stored_queries/format.py +++ b/specifyweb/stored_queries/format.py @@ -15,7 +15,7 @@ from typing import Tuple, Optional, Union -from specifyweb.context.app_resource import get_app_resource +import specifyweb.context.app_resource as app_resource from specifyweb.context.remote_prefs import get_remote_prefs from specifyweb.specify.agent_types import agent_types @@ -40,7 +40,7 @@ class ObjectFormatter(object): def __init__(self, collection, user, replace_nulls, format_agent_type=False): - formattersXML, _, __ = get_app_resource(collection, user, 'DataObjFormatters') + formattersXML, _, __ = app_resource.get_app_resource(collection, user, 'DataObjFormatters') self.formattersDom = ElementTree.fromstring(formattersXML) self.date_format = get_date_format() self.date_format_year = MYSQL_TO_YEAR.get(self.date_format) @@ -182,8 +182,7 @@ def make_expr(self, else: new_query, table, model, specify_field = query.build_join( specify_model, orm_table, formatter_field_spec.join_path) - new_expr = self._fieldformat(formatter_field_spec.get_field(), - getattr(table, specify_field.name)) + new_expr = getattr(table, specify_field.name) if 'format' in fieldNodeAttrib: new_expr = self.pseudo_sprintf(fieldNodeAttrib['format'], new_expr) diff --git a/specifyweb/stored_queries/queryfield.py b/specifyweb/stored_queries/queryfield.py index 60344979d6c..79136529ff1 100644 --- a/specifyweb/stored_queries/queryfield.py +++ b/specifyweb/stored_queries/queryfield.py @@ -7,21 +7,31 @@ logger = logging.getLogger(__name__) -EphemeralField = namedtuple('EphemeralField', "stringId isRelFld operStart startValue isNot isDisplay sortType formatName") +EphemeralField = namedtuple( + "EphemeralField", + "stringId isRelFld operStart startValue isNot isDisplay sortType formatName", +) -def fields_from_json(json_fields) -> List['QueryField']: + +def fields_from_json(json_fields) -> List["QueryField"]: """Given deserialized json data representing an array of SpQueryField records, return an array of QueryField objects that can build the corresponding sqlalchemy query. """ + def ephemeral_field_from_json(json: Dict[str, Any]): - return EphemeralField(**{field: json.get(field.lower(), None) for field in EphemeralField._fields}) + return EphemeralField( + **{field: json.get(field.lower(), None) for field in EphemeralField._fields} + ) - field_specs = [QueryField.from_spqueryfield(ephemeral_field_from_json(data)) - for data in sorted(json_fields, key=lambda field: field['position'])] + field_specs = [ + QueryField.from_spqueryfield(ephemeral_field_from_json(data)) + for data in sorted(json_fields, key=lambda field: field["position"]) + ] return field_specs + class QueryField(NamedTuple): fieldspec: QueryFieldSpec op_num: int @@ -33,34 +43,42 @@ class QueryField(NamedTuple): @classmethod def from_spqueryfield(cls, field: EphemeralField, value=None): - logger.info('processing field from %r', field) + logger.info("processing field from %r", field) fieldspec = QueryFieldSpec.from_stringid(field.stringId, field.isRelFld) if field.isRelFld: # force no filtering on formatted / aggregated fields value = "" - return cls(fieldspec = fieldspec, - op_num = field.operStart, - value = field.startValue if value is None else value, - negate = field.isNot, - display = field.isDisplay, - format_name = field.formatName, - sort_type = field.sortType) + return cls( + fieldspec=fieldspec, + op_num=field.operStart, + value=field.startValue if value is None else value, + negate=field.isNot, + display=field.isDisplay, + format_name=field.formatName, + sort_type=field.sortType, + ) def add_to_query(self, query, no_filter=False, formatauditobjs=False): logger.info("adding field %s", self) value_required_for_filter = QueryOps.OPERATIONS[self.op_num] not in ( - 'op_true', # 6 - 'op_false', # 7 - 'op_empty', # 12 - 'op_trueornull', # 13 - 'op_falseornull', # 14 + "op_true", # 6 + "op_false", # 7 + "op_empty", # 12 + "op_trueornull", # 13 + "op_falseornull", # 14 ) - no_filter = no_filter or (self.value == '' - and value_required_for_filter - and not self.negate) - - return self.fieldspec.add_to_query(query, value=self.value, op_num=None if no_filter else self.op_num, negate=self.negate, formatter=self.format_name, formatauditobjs=formatauditobjs) + no_filter = no_filter or ( + self.value == "" and value_required_for_filter and not self.negate + ) + return self.fieldspec.add_to_query( + query, + value=self.value, + op_num=None if no_filter else self.op_num, + negate=self.negate, + formatter=self.format_name, + formatauditobjs=formatauditobjs, + ) diff --git a/specifyweb/stored_queries/queryfieldspec.py b/specifyweb/stored_queries/queryfieldspec.py index 6c6872940a8..8872f9b13b0 100644 --- a/specifyweb/stored_queries/queryfieldspec.py +++ b/specifyweb/stored_queries/queryfieldspec.py @@ -52,7 +52,7 @@ def field_to_elem(field): def make_tree_fieldnames(table: Table, reverse=False): - mapping = {"ID": table.idFieldName.lower(), "": "name"} + mapping = {"ID": table.idFieldName.lower(), "": "fullname"} if reverse: return {value: key for (key, value) in mapping.items()} return mapping @@ -170,7 +170,8 @@ def from_stringid(cls, stringid, is_relation): tree_rank.relatedModelName = node.name tree_rank.type = "many-to-one" join_path.append(tree_rank) - field = node.get_field(field or "name") # to replicate 6 for now. + assert field is not None + field = node.get_field(field) if field is not None: join_path.append(field) diff --git a/specifyweb/stored_queries/tests/base_format.py b/specifyweb/stored_queries/tests/base_format.py new file mode 100644 index 00000000000..c860c9b801c --- /dev/null +++ b/specifyweb/stored_queries/tests/base_format.py @@ -0,0 +1,83 @@ +SIMPLE_DEF = """ + + + + + accessionNumber + + + + + + + agent + role + + + + + + + lastName + + + lastName + firstName + middleInitial + + + lastName + + + lastName + + + + + + + + stationFieldNumber + startDate + locality.geography.fullName + locality.localityName + locality.latitude1 + locality.longitude1 + + + + + + + + """ \ No newline at end of file diff --git a/specifyweb/stored_queries/tests/static/co_query_row_plan.py b/specifyweb/stored_queries/tests/static/co_query_row_plan.py index e128737659f..fd83e149aaa 100644 --- a/specifyweb/stored_queries/tests/static/co_query_row_plan.py +++ b/specifyweb/stored_queries/tests/static/co_query_row_plan.py @@ -37,7 +37,7 @@ ], to_one={}, to_many={}, - has_filters=False, + is_naive=True, ), "collectingevent": RowPlanMap( batch_edit_pack=BatchEditPack( @@ -98,7 +98,7 @@ ], to_one={}, to_many={}, - has_filters=False, + is_naive=True, ), "Country": RowPlanMap( batch_edit_pack=BatchEditPack( @@ -119,7 +119,7 @@ ], to_one={}, to_many={}, - has_filters=False, + is_naive=True, ), "County": RowPlanMap( batch_edit_pack=BatchEditPack( @@ -140,7 +140,7 @@ ], to_one={}, to_many={}, - has_filters=False, + is_naive=True, ), "Province": RowPlanMap( batch_edit_pack=BatchEditPack( @@ -161,19 +161,19 @@ ], to_one={}, to_many={}, - has_filters=False, + is_naive=True, ), }, to_many={}, - has_filters=False, + is_naive=True, ) }, to_many={}, - has_filters=False, + is_naive=True, ) }, to_many={}, - has_filters=False, + is_naive=True, ), }, to_many={ @@ -208,7 +208,7 @@ ], to_one={}, to_many={}, - has_filters=False, + is_naive=True, ), "Species": RowPlanMap( batch_edit_pack=BatchEditPack( @@ -225,7 +225,7 @@ ], to_one={}, to_many={}, - has_filters=False, + is_naive=True, ), "Subspecies": RowPlanMap( batch_edit_pack=BatchEditPack( @@ -242,15 +242,15 @@ ], to_one={}, to_many={}, - has_filters=False, + is_naive=True, ), }, to_many={}, - has_filters=False, + is_naive=True, ) }, to_many={}, - has_filters=False, + is_naive=True, ), "preparations": RowPlanMap( batch_edit_pack=BatchEditPack( @@ -261,8 +261,112 @@ columns=[BatchEditFieldPack(field=None, idx=61, value=None)], to_one={}, to_many={}, - has_filters=False, + is_naive=True, ), }, - has_filters=False, + is_naive=True, +) + +RowPlanMap( + batch_edit_pack=BatchEditPack( + id=BatchEditFieldPack(field=None, idx=2, value=None), + order=BatchEditFieldPack(field=None, idx=None, value=None), + version=BatchEditFieldPack(field=None, idx=3, value=None), + ), + columns=[BatchEditFieldPack(field=None, idx=1, value=None)], + to_one={}, + to_many={ + "collectionobjects": RowPlanMap( + batch_edit_pack=BatchEditPack( + id=BatchEditFieldPack(field=None, idx=5, value=None), + order=BatchEditFieldPack(field=None, idx=None, value=None), + version=BatchEditFieldPack(field=None, idx=6, value=None), + ), + columns=[BatchEditFieldPack(field=None, idx=4, value=None)], + to_one={ + "cataloger": RowPlanMap( + batch_edit_pack=BatchEditPack( + id=BatchEditFieldPack(field=None, idx=8, value=None), + order=BatchEditFieldPack(field=None, idx=None, value=None), + version=BatchEditFieldPack(field=None, idx=9, value=None), + ), + columns=[BatchEditFieldPack(field=None, idx=7, value=None)], + to_one={}, + to_many={ + "addresses": RowPlanMap( + batch_edit_pack=BatchEditPack( + id=BatchEditFieldPack(field=None, idx=11, value=None), + order=BatchEditFieldPack( + field=None, idx=None, value=None + ), + version=BatchEditFieldPack( + field=None, idx=12, value=None + ), + ), + columns=[ + BatchEditFieldPack(field=None, idx=10, value=None) + ], + to_one={}, + to_many={}, + is_naive=True, + ), + "agentattachments": RowPlanMap( + batch_edit_pack=BatchEditPack( + id=BatchEditFieldPack(field=None, idx=14, value=None), + order=BatchEditFieldPack( + field=None, idx=None, value=None + ), + version=BatchEditFieldPack( + field=None, idx=15, value=None + ), + ), + columns=[ + BatchEditFieldPack(field=None, idx=13, value=None) + ], + to_one={ + "attachment": RowPlanMap( + batch_edit_pack=BatchEditPack( + id=BatchEditFieldPack( + field=None, idx=17, value=None + ), + order=BatchEditFieldPack( + field=None, idx=None, value=None + ), + version=BatchEditFieldPack( + field=None, idx=18, value=None + ), + ), + columns=[ + BatchEditFieldPack( + field=None, idx=16, value=None + ) + ], + to_one={}, + to_many={}, + is_naive=True, + ) + }, + to_many={}, + is_naive=False, + ), + }, + is_naive=True, + ) + }, + to_many={}, + is_naive=True, + ), + "collectors": RowPlanMap( + batch_edit_pack=BatchEditPack( + id=BatchEditFieldPack(field=None, idx=20, value=None), + order=BatchEditFieldPack(field=None, idx=22, value=None), + version=BatchEditFieldPack(field=None, idx=21, value=None), + ), + columns=[BatchEditFieldPack(field=None, idx=19, value=None)], + to_one={}, + to_many={}, + is_naive=False, + ), + }, + is_naive=True, ) diff --git a/specifyweb/stored_queries/tests/test_batch_edit.py b/specifyweb/stored_queries/tests/test_batch_edit.py index 6f87734bab8..778e7284a94 100644 --- a/specifyweb/stored_queries/tests/test_batch_edit.py +++ b/specifyweb/stored_queries/tests/test_batch_edit.py @@ -1,4 +1,5 @@ import json +from unittest.mock import patch from specifyweb.stored_queries.batch_edit import ( BatchEditFieldPack, @@ -10,6 +11,7 @@ from specifyweb.stored_queries.queryfield import fields_from_json from specifyweb.stored_queries.queryfieldspec import QueryFieldSpec +from specifyweb.stored_queries.tests.base_format import SIMPLE_DEF from specifyweb.stored_queries.tests.tests import SQLAlchemySetup from specifyweb.stored_queries.tests.static import test_plan @@ -41,6 +43,10 @@ def _builder(query_fields, base_table): return _builder +def fake_obj_formatter(*args, **kwargs): + return (SIMPLE_DEF, None, None) + +OBJ_FORMATTER_PATH = 'specifyweb.context.app_resource.get_app_resource' # NOTES: Yes, it is more convenient to hard code ids (instead of defining variables.). # But, using variables can make bugs apparent @@ -48,6 +54,7 @@ def _builder(query_fields, base_table): class QueryConstructionTests(SQLAlchemySetup): def setUp(self): super().setUp() + agents = [ {"firstname": "Test1", "lastname": "LastName"}, {"firstname": "Test2", "lastname": "LastNameAsTest"}, @@ -83,6 +90,8 @@ def test_query_construction(self): self.assertEqual(plan, row_plan_map) + + @patch(OBJ_FORMATTER_PATH, new=fake_obj_formatter) def test_basic_run(self): base_table = "collectionobject" query_paths = [ @@ -150,12 +159,12 @@ def test_basic_run(self): ) correct_rows = [ - ["num-0", None, "", "", "Test1", "LastName", None], - ["num-1", None, "", "", "Test1", "LastName", None], - ["num-2", 99, "", "", "Test2", "LastNameAsTest", None], - ["num-3", None, "", "", "Test2", "LastNameAsTest", None], - ["num-4", 229, "", "", "Test2", "LastNameAsTest", None], - ] + ['num-0', None, 'LastName', '', 'Test1', 'LastName', None], + ['num-1', None, 'LastName', '', 'Test1', 'LastName', None], + ['num-2', 99, 'LastNameAsTest', '', 'Test2', 'LastNameAsTest', None], + ['num-3', None, 'LastNameAsTest', '', 'Test2', 'LastNameAsTest', None], + ['num-4', 229, 'LastNameAsTest', '', 'Test2', 'LastNameAsTest', None] + ] self.assertEqual(correct_rows, rows) @@ -244,7 +253,7 @@ def test_basic_run(self): self.assertEqual(correct_packs, packs) - plan = { + correct_plan = { "baseTableName": "Collectionobject", "uploadable": { "uploadTable": { @@ -290,8 +299,9 @@ def test_basic_run(self): }, } - validate(plan, schema) + self.assertDictEqual(correct_plan, plan) + @patch(OBJ_FORMATTER_PATH, new=fake_obj_formatter) def test_duplicates_flattened(self): base_table = "collectionobject" query_paths = [ @@ -574,7 +584,7 @@ def test_duplicates_flattened(self): { "CollectionObject catalogNumber": "num-0", "CollectionObject integer1": 99, - "Agent (formatted)": "", + "Agent (formatted)": "LastName", "Agent firstName": "Test1", "Agent lastName": "LastName", "AgentSpecialty specialtyName": "agent1-testspecialty", @@ -640,7 +650,7 @@ def test_duplicates_flattened(self): { "CollectionObject catalogNumber": "num-2", "CollectionObject integer1": 412, - "Agent (formatted)": "", + "Agent (formatted)": "LastNameTest4", "Agent firstName": "Test4", "Agent lastName": "LastNameTest4", "AgentSpecialty specialtyName": None, @@ -673,7 +683,7 @@ def test_duplicates_flattened(self): { "CollectionObject catalogNumber": "num-3", "CollectionObject integer1": 322, - "Agent (formatted)": "", + "Agent (formatted)": "LastNameAsTest", "Agent firstName": "Test2", "Agent lastName": "LastNameAsTest", "AgentSpecialty specialtyName": "agent2-testspecialty", @@ -1209,6 +1219,7 @@ def test_duplicates_flattened(self): self.assertEqual(packs, correct_packs) self.assertDictEqual(plan, test_plan.plan) + @patch(OBJ_FORMATTER_PATH, new=fake_obj_formatter) def test_stalls_within_to_many(self): base_table = "collectionobject" query_paths = [ @@ -1460,8 +1471,9 @@ def test_stalls_within_to_many(self): ], ) - self.assertEqual(packs, correct_packs) - + self.assertEqual(packs, correct_packs) + + @patch(OBJ_FORMATTER_PATH, new=fake_obj_formatter) def test_to_one_does_not_stall_if_not_to_many(self): base_table = "collectionobject" query_paths = [ @@ -1634,6 +1646,7 @@ def test_to_one_does_not_stall_if_not_to_many(self): ], ) + @patch(OBJ_FORMATTER_PATH, new=fake_obj_formatter) def test_to_one_stalls_within(self): # Something like collectionobject -> cataloger -> agent specialty and agent -> agent address self stalls base_table = "collectionobject" @@ -1898,6 +1911,7 @@ def test_to_one_stalls_within(self): ] self.assertEqual(correct_packs, packs) + @patch(OBJ_FORMATTER_PATH, new=fake_obj_formatter) def test_to_one_stalls_to_many(self): # To ensure that to-many on to-one side stalls naive to-manys @@ -2278,4 +2292,4 @@ def test_to_one_stalls_to_many(self): }, ] - self.assertEqual(correct_packs, packs) + self.assertEqual(correct_packs, packs) \ No newline at end of file diff --git a/specifyweb/stored_queries/tests/test_format.py b/specifyweb/stored_queries/tests/test_format.py index 3cd8c3f2498..db72872087d 100644 --- a/specifyweb/stored_queries/tests/test_format.py +++ b/specifyweb/stored_queries/tests/test_format.py @@ -1,5 +1,6 @@ from specifyweb.stored_queries.format import ObjectFormatter from specifyweb.stored_queries.query_construct import QueryConstruct +from specifyweb.stored_queries.tests.base_format import SIMPLE_DEF from specifyweb.stored_queries.tests.tests import SQLAlchemySetup from xml.etree import ElementTree import specifyweb.specify.models as spmodels @@ -8,7 +9,6 @@ # Used for pretty-formatting sql code for testing import sqlparse - class FormatterAggregatorTests(SQLAlchemySetup): def setUp(self): @@ -21,71 +21,7 @@ def _get_formatter(formatter_def): def test_basic_formatters(self): - formatter_def = """ - - - - - accessionNumber - - - - - - - agent - role - - - - - - - lastName - - - lastName - firstName - middleInitial - - - lastName - - - lastName - - - - - - - - """ + formatter_def = SIMPLE_DEF object_formatter = self.get_formatter(formatter_def) diff --git a/specifyweb/stored_queries/tests/test_row_plan_map.py b/specifyweb/stored_queries/tests/test_row_plan_map.py new file mode 100644 index 00000000000..58c23db4558 --- /dev/null +++ b/specifyweb/stored_queries/tests/test_row_plan_map.py @@ -0,0 +1,233 @@ +from unittest import TestCase + +import json + +from specifyweb.stored_queries.batch_edit import ( + BatchEditFieldPack, + BatchEditPack, + RowPlanMap, +) +from specifyweb.stored_queries.queryfield import fields_from_json + +from specifyweb.stored_queries.tests.static.co_query_row_plan import row_plan_map + + +class TestRowPlanMaps(TestCase): + + def test_query_construction(self): + query = json.load(open("specifyweb/stored_queries/tests/static/co_query.json")) + query_fields = fields_from_json(query["fields"]) + visible_fields = [field for field in query_fields if field.display] + row_plan = RowPlanMap.get_row_plan(visible_fields) + plan, fields = row_plan.index_plan() + self.assertEqual(plan, row_plan_map) + + def test_complicated_query_construction_filters(self): + + fields = [ + { + "tablelist": "10", + "stringid": "10.collectingevent.text4", + "fieldname": "text4", + "isrelfld": False, + "sorttype": 0, + "position": 0, + "isdisplay": True, + "operstart": 8, + "startvalue": "", + "isnot": False, + }, + { + "tablelist": "10,30-collectors", + "stringid": "10,30-collectors.collector.isPrimary", + "fieldname": "isPrimary", + "isrelfld": False, + "sorttype": 0, + "position": 1, + "isdisplay": True, + "operstart": 6, + "startvalue": "", + "isnot": False, + }, + { + "tablelist": "10,1-collectionObjects", + "stringid": "10,1-collectionObjects.collectionobject.catalogNumber", + "fieldname": "catalogNumber", + "isrelfld": False, + "sorttype": 0, + "position": 2, + "isdisplay": True, + "operstart": 1, + "startvalue": "707070", + "isnot": False, + }, + { + "tablelist": "10,1-collectionObjects,5-cataloger", + "stringid": "10,1-collectionObjects,5-cataloger.agent.email", + "fieldname": "email", + "isrelfld": False, + "sorttype": 0, + "position": 3, + "isdisplay": True, + "operstart": 1, + "startvalue": "testmail", + "isnot": False, + }, + { + "tablelist": "10,1-collectionObjects,5-cataloger,8-addresses", + "stringid": "10,1-collectionObjects,5-cataloger,8-addresses.address.address", + "fieldname": "address", + "isrelfld": False, + "sorttype": 0, + "position": 4, + "isdisplay": True, + "operstart": 8, + "startvalue": "", + "isnot": False, + }, + { + "tablelist": "10,1-collectionObjects,5-cataloger,109-agentAttachments", + "stringid": "10,1-collectionObjects,5-cataloger,109-agentAttachments.agentattachment.remarks", + "fieldname": "remarks", + "isrelfld": False, + "sorttype": 0, + "position": 5, + "isdisplay": True, + "operstart": 1, + "startvalue": "Test Here", + "isnot": False, + }, + { + "tablelist": "10,1-collectionObjects,5-cataloger,109-agentAttachments,41", + "stringid": "10,1-collectionObjects,5-cataloger,109-agentAttachments,41.attachment.guid", + "fieldname": "guid", + "isrelfld": False, + "sorttype": 0, + "position": 6, + "isdisplay": True, + "operstart": 8, + "startvalue": "", + "isnot": False, + }, + ] + + query_fields = fields_from_json(fields) + visible_fields = [field for field in query_fields if field.display] + row_plan = RowPlanMap.get_row_plan(visible_fields) + plan, fields = row_plan.index_plan() + correct_plan = RowPlanMap( + batch_edit_pack=BatchEditPack( + id=BatchEditFieldPack(field=None, idx=2, value=None), + order=BatchEditFieldPack(field=None, idx=None, value=None), + version=BatchEditFieldPack(field=None, idx=3, value=None), + ), + columns=[BatchEditFieldPack(field=None, idx=1, value=None)], + to_one={}, + to_many={ + "collectionobjects": RowPlanMap( + batch_edit_pack=BatchEditPack( + id=BatchEditFieldPack(field=None, idx=5, value=None), + order=BatchEditFieldPack(field=None, idx=None, value=None), + version=BatchEditFieldPack(field=None, idx=6, value=None), + ), + columns=[BatchEditFieldPack(field=None, idx=4, value=None)], + to_one={ + "cataloger": RowPlanMap( + batch_edit_pack=BatchEditPack( + id=BatchEditFieldPack(field=None, idx=8, value=None), + order=BatchEditFieldPack( + field=None, idx=None, value=None + ), + version=BatchEditFieldPack( + field=None, idx=9, value=None + ), + ), + columns=[BatchEditFieldPack(field=None, idx=7, value=None)], + to_one={}, + to_many={ + "addresses": RowPlanMap( + batch_edit_pack=BatchEditPack( + id=BatchEditFieldPack( + field=None, idx=11, value=None + ), + order=BatchEditFieldPack( + field=None, idx=None, value=None + ), + version=BatchEditFieldPack( + field=None, idx=12, value=None + ), + ), + columns=[ + BatchEditFieldPack( + field=None, idx=10, value=None + ) + ], + to_one={}, + to_many={}, + is_naive=True, + ), + "agentattachments": RowPlanMap( + batch_edit_pack=BatchEditPack( + id=BatchEditFieldPack( + field=None, idx=14, value=None + ), + order=BatchEditFieldPack( + field=None, idx=None, value=None + ), + version=BatchEditFieldPack( + field=None, idx=15, value=None + ), + ), + columns=[ + BatchEditFieldPack( + field=None, idx=13, value=None + ) + ], + to_one={ + "attachment": RowPlanMap( + batch_edit_pack=BatchEditPack( + id=BatchEditFieldPack( + field=None, idx=17, value=None + ), + order=BatchEditFieldPack( + field=None, idx=None, value=None + ), + version=BatchEditFieldPack( + field=None, idx=18, value=None + ), + ), + columns=[ + BatchEditFieldPack( + field=None, idx=16, value=None + ) + ], + to_one={}, + to_many={}, + is_naive=False, + ) + }, + to_many={}, + is_naive=False, + ), + }, + is_naive=True, + ) + }, + to_many={}, + is_naive=True, + ), + "collectors": RowPlanMap( + batch_edit_pack=BatchEditPack( + id=BatchEditFieldPack(field=None, idx=20, value=None), + order=BatchEditFieldPack(field=None, idx=22, value=None), + version=BatchEditFieldPack(field=None, idx=21, value=None), + ), + columns=[BatchEditFieldPack(field=None, idx=19, value=None)], + to_one={}, + to_many={}, + is_naive=False, + ), + }, + is_naive=True, + ) + self.assertEqual(plan, correct_plan) diff --git a/specifyweb/workbench/upload/clone.py b/specifyweb/workbench/upload/clone.py index c03fd63a0b3..614d2243e5f 100644 --- a/specifyweb/workbench/upload/clone.py +++ b/specifyweb/workbench/upload/clone.py @@ -8,60 +8,96 @@ from specifyweb.specify.func import Func from specifyweb.specify.load_datamodel import Table +from specifyweb.specify.models import ModelWithTable FIELDS_TO_NOT_CLONE: Dict[str, List[str]] = json.load( - open('specifyweb/frontend/js_src/lib/components/DataModel/uniqueFields.json')) + open("specifyweb/frontend/js_src/lib/components/DataModel/uniqueFields.json") +) -# These fields are system fields. This is a bit different than uniqueFields on frontend at DataModel/resource.ts in that we don't want to skip all those fields -# when checking whether the record is null. Those fields would be, skipped, during cloning, but not when checking whether record is null. +# These fields are system fields. This is a bit different than uniqueFields on frontend at DataModel/resource.ts in that we don't want to skip all those fields +# when checking whether the record is null. Those fields would be, skipped, during cloning, but not when checking whether record is null. # TODO: See if we have enough reason to just directly take those fields... GENERIC_FIELDS_TO_SKIP = [ - 'timestampcreated', - 'timestampmodified', - 'version', - 'id', - 'createdbyagent_id', - 'modifiedbyagent_id', - 'guid' - ] + "timestampcreated", + "timestampmodified", + "version", + "id", + "createdbyagent_id", + "modifiedbyagent_id", + "guid", +] + @transaction.atomic() -def clone_record(reference_record, inserter: Callable[[Model, Dict[str, Any]], Model], one_to_ones={}, to_ignore: List[str] = [], override_attrs={}) -> Model: - model: Model = type(reference_record) # type: ignore +def clone_record( + reference_record, + inserter: Callable[[ModelWithTable, Dict[str, Any]], ModelWithTable], + one_to_ones={}, + to_ignore: List[str] = [], + override_attrs={}, +) -> ModelWithTable: + model: ModelWithTable = type(reference_record) # type: ignore model_name = model._meta.model_name assert model_name is not None - specify_model: Table = model.specify_model # type: ignore + specify_model = model.specify_model # We could be smarter here, and make our own list, but this is indication that there were new tables or something, and we can't assume our schema version is correct. - assert model_name.lower() in FIELDS_TO_NOT_CLONE, f"Schema mismatch detected at {model_name}" + assert ( + model_name.lower() in FIELDS_TO_NOT_CLONE + ), f"Schema mismatch detected at {model_name}" - fields_to_ignore = [*[field.lower() for field in FIELDS_TO_NOT_CLONE[model_name.lower()]], *to_ignore, *GENERIC_FIELDS_TO_SKIP] + fields_to_ignore = [ + *[field.lower() for field in FIELDS_TO_NOT_CLONE[model_name.lower()]], + *to_ignore, + *GENERIC_FIELDS_TO_SKIP, + ] - all_fields = [field for field in model._meta.get_fields() if field.name not in fields_to_ignore] + all_fields = [ + field + for field in model._meta.get_fields() + if field.name not in fields_to_ignore + ] - marked = [(field, (field.is_relation and specify_model.get_relationship(field.name).dependent or field.name.lower() in one_to_ones.get(model_name.lower(), []) and field.name is not None)) for field in all_fields] + marked = [ + ( + field, + ( + field.is_relation + and specify_model.get_relationship(field.name).dependent + or field.name.lower() in one_to_ones.get(model_name.lower(), []) + and field.name is not None + ), + ) + for field in all_fields + ] def _cloned(value, field, is_dependent): if not is_dependent: return value - return clone_record(getattr(reference_record, field.name), inserter, one_to_ones).pk - + return clone_record( + getattr(reference_record, field.name), inserter, one_to_ones + ).pk + attrs = { - field.attname: Func.maybe(getattr(reference_record, field.attname), lambda obj: _cloned(obj, field, is_dependent)) # type: ignore + field.attname: Func.maybe(getattr(reference_record, field.attname), lambda obj: _cloned(obj, field, is_dependent)) # type: ignore for (field, is_dependent) in marked # This will handle many-to-ones + one-to-ones if field.concrete } - attrs = { - **attrs, - **override_attrs - } + attrs = {**attrs, **override_attrs} inserted = inserter(model, attrs) - to_many_cloned = [[clone_record(to_many_record, inserter, one_to_ones, override_attrs = {field.remote_field.attname: inserted.pk}) # type: ignore - for to_many_record in getattr(reference_record, field.name).all() # Clone all records separatetly - ] for (field, is_dependent) in marked if is_dependent and not field.concrete] # Should be a relationship, but not on our side - - return inserted \ No newline at end of file + to_many_cloned = [ + [ + clone_record(to_many_record, inserter, one_to_ones, override_attrs={field.remote_field.attname: inserted.pk}) # type: ignore + for to_many_record in getattr( + reference_record, field.name + ).all() # Clone all records separatetly + ] + for (field, is_dependent) in marked + if is_dependent and not field.concrete + ] # Should be a relationship, but not on our side + + return inserted diff --git a/specifyweb/workbench/upload/tests/test_batch_edit_table.py b/specifyweb/workbench/upload/tests/test_batch_edit_table.py index ed998ae13cd..14c6436e903 100644 --- a/specifyweb/workbench/upload/tests/test_batch_edit_table.py +++ b/specifyweb/workbench/upload/tests/test_batch_edit_table.py @@ -2,8 +2,8 @@ from unittest.mock import patch from specifyweb.specify.func import Func from specifyweb.specify.tests.test_api import get_table -from specifyweb.stored_queries.batch_edit import run_batch_edit_query # type: ignore -from specifyweb.stored_queries.queryfield import QueryField +from specifyweb.stored_queries.batch_edit import run_batch_edit_query # type: ignore +from specifyweb.stored_queries.queryfield import QueryField, fields_from_json from specifyweb.stored_queries.queryfieldspec import QueryFieldSpec from specifyweb.stored_queries.tests.test_batch_edit import props_builder from specifyweb.stored_queries.tests.tests import SQLAlchemySetup @@ -41,6 +41,7 @@ Collectingevent, Address, Agentspecialty, + Collector, ) lookup_in_auditlog = lambda model, _id: get_table("Spauditlog").objects.filter( @@ -250,15 +251,24 @@ def test_one_to_one_updates(self): catalognumber="88".zfill(9), ) - data = [*data, {"catno": "88", "number": "902"}] - batch_edit_pack = [*batch_edit_pack, {"self": {"id": co_to_create.id}}] + co_to_create_2 = get_table("Collectionobject").objects.create( + collection=self.collection, + catalognumber="42".zfill(9), + ) + + data = [*data, {"catno": "88", "number": "902"}, {"catno": "42", "number": ""}] + batch_edit_pack = [ + *batch_edit_pack, + {"self": {"id": co_to_create.id}}, + {"self": {"id": co_to_create_2.id}}, + ] results = do_upload( self.collection, data, plan, self.agent.id, batch_edit_packs=batch_edit_pack ) correct = [ (NoChange, coa_result) - for coa_result in [Updated, NoChange, Updated, Uploaded] + for coa_result in [Updated, NoChange, Updated, Uploaded, NullRecord] ] for _id, result in enumerate(zip(results, correct)): @@ -410,7 +420,8 @@ class SQLUploadTests(SQLAlchemySetup, UploadTestsBase): def setUp(self): super().setUp() self.build_props = props_builder(self, SQLUploadTests.test_session_context) - get_table("Collectionobject").objects.all().delete() + Collectionobject.objects.all().delete() + Collectingevent.objects.all().delete() self.test_agent_1 = Agent.objects.create( firstname="John", lastname="Doe", division=self.division, agenttype=0 ) @@ -508,7 +519,7 @@ def setUp(self): def _build_props(self, query_fields, base_table): raw = self.build_props(query_fields, base_table) - raw["session_maker"] = SQLUploadTests.test_session_context + raw["session_maker"] = self.__class__.test_session_context return raw def make_query(self, field_spec, sort_type): @@ -570,24 +581,31 @@ def test_no_op(self): # We didn't change anything, nothing should change. verify just that list([self.enforcer(result) for result in results]) - def enforce_in_log(self, record_id, table, audit_code: Union[Literal['INSERT'], Literal['UPDATE'], Literal['REMOVE']]): + def enforce_in_log( + self, + record_id, + table, + audit_code: Union[Literal["INSERT"], Literal["UPDATE"], Literal["REMOVE"]], + ): entries = lookup_in_auditlog(table, record_id) self.assertEqual(1, entries.count()) entry = entries.first() self.assertEqual(entry.action, getattr(auditcodes, audit_code)) - def query_to_results(self, base_table, query_paths): + def make_query_fields(self, base_table, query_paths): added = [(base_table, *path) for path in query_paths] query_fields = [ self.make_query(QueryFieldSpec.from_path(path), 0) for path in added ] + return base_table, query_fields + + def query_to_results(self, base_table, query_fields): props = self._build_props(query_fields, base_table) (headers, rows, packs, plan_json, _) = run_batch_edit_query(props) - validate(plan_json, schema) plan = parse_plan(plan_json) regularized_rows = regularize_rows(len(headers), rows) @@ -659,9 +677,9 @@ def test_to_one_cloned(self): ce_created.collectingeventattribute.integer1, self.cea_1.integer1 ) - self.enforce_in_log(ce_created_id, "collectingevent", 'INSERT') + self.enforce_in_log(ce_created_id, "collectingevent", "INSERT") self.enforce_in_log( - ce_created.collectingeventattribute.id, "collectingeventattribute", 'INSERT' + ce_created.collectingeventattribute.id, "collectingeventattribute", "INSERT" ) def _run_matching_test(self): @@ -679,7 +697,7 @@ def _run_matching_test(self): ] (headers, rows, pack, plan) = self.query_to_results( - "collectionobject", query_paths + *self.make_query_fields("collectionobject", query_paths) ) dicted = [dict(zip(headers, row)) for row in rows] @@ -778,7 +796,8 @@ def test_update_to_many_without_defer(self): defer = make_defer( # These reulsts below should how true for all these cases - match=False, null=False + match=False, + null=False, ) query_paths = [ @@ -792,7 +811,7 @@ def test_update_to_many_without_defer(self): ] (headers, rows, pack, plan) = self.query_to_results( - "collectionobject", query_paths + *self.make_query_fields("collectionobject", query_paths) ) dicted = [dict(zip(headers, row)) for row in rows] @@ -863,135 +882,235 @@ def test_update_to_many_without_defer(self): results = do_upload( self.collection, data, plan, self.agent.id, batch_edit_packs=pack ) - + result_map = {} def wrap_is_instance(result, instance): self.assertIsInstance(result, instance) is_success = isinstance(result.get_id(), int) if is_success: - result_map[instance] = [*result_map.get(instance, []), (result.info.tableName, result.get_id())] + result_map[instance] = [ + *result_map.get(instance, []), + (result.info.tableName, result.get_id()), + ] wrap_is_instance(results[0].record_result, Updated) - self.assertEqual(['CollectionObject integer1'], results[0].record_result.info.columns) + self.assertEqual( + ["CollectionObject integer1"], results[0].record_result.info.columns + ) - self.assertIsInstance(results[0].toOne['cataloger'].record_result, Matched) + self.assertIsInstance(results[0].toOne["cataloger"].record_result, Matched) - co_1_prep_1 = results[0].toMany['preparations'][0] + co_1_prep_1 = results[0].toMany["preparations"][0] wrap_is_instance(co_1_prep_1.record_result, Updated) self.assertEqual(["Preparation text1"], co_1_prep_1.record_result.info.columns) - - self.assertIsInstance(co_1_prep_1.toOne['preptype'].record_result, Uploaded) - co_1_prep_2 = results[0].toMany['preparations'][1] + self.assertIsInstance(co_1_prep_1.toOne["preptype"].record_result, Uploaded) + + co_1_prep_2 = results[0].toMany["preparations"][1] wrap_is_instance(co_1_prep_2.record_result, Updated) - self.assertCountEqual(co_1_prep_2.record_result.info.columns, ["Preparation text1 #2", "Preparation countAmt #2"]) - self.assertIsInstance(co_1_prep_2.toOne['preptype'].record_result, Matched) + self.assertCountEqual( + co_1_prep_2.record_result.info.columns, + ["Preparation text1 #2", "Preparation countAmt #2"], + ) + self.assertIsInstance(co_1_prep_2.toOne["preptype"].record_result, Matched) - co_1_prep_3 = results[0].toMany['preparations'][2] + co_1_prep_3 = results[0].toMany["preparations"][2] wrap_is_instance(co_1_prep_3.record_result, Updated) - self.assertCountEqual(co_1_prep_3.record_result.info.columns, ["Preparation text1 #3", "Preparation countAmt #3"]) - self.assertIsInstance(co_1_prep_3.toOne['preptype'].record_result, Matched) + self.assertCountEqual( + co_1_prep_3.record_result.info.columns, + ["Preparation text1 #3", "Preparation countAmt #3"], + ) + self.assertIsInstance(co_1_prep_3.toOne["preptype"].record_result, Matched) - self.assertEqual(co_1_prep_3.toOne['preptype'].record_result.get_id(), self.preptype.id) - self.assertEqual(co_1_prep_2.toOne['preptype'].record_result.get_id(), self.preptype.id) + self.assertEqual( + co_1_prep_3.toOne["preptype"].record_result.get_id(), self.preptype.id + ) + self.assertEqual( + co_1_prep_2.toOne["preptype"].record_result.get_id(), self.preptype.id + ) self.assertIsInstance(results[1].record_result, NoChange) - self.assertIsInstance(results[1].toOne['cataloger'].record_result, Matched) + self.assertIsInstance(results[1].toOne["cataloger"].record_result, Matched) - wrap_is_instance(results[1].toMany['preparations'][0].record_result, Updated) - self.assertCountEqual(results[1].toMany['preparations'][0].record_result.info.columns, ["Preparation countAmt", "Preparation text1"]) + wrap_is_instance(results[1].toMany["preparations"][0].record_result, Updated) + self.assertCountEqual( + results[1].toMany["preparations"][0].record_result.info.columns, + ["Preparation countAmt", "Preparation text1"], + ) - self.assertIsInstance(results[1].toMany['preparations'][1].record_result, NoChange) + self.assertIsInstance( + results[1].toMany["preparations"][1].record_result, NoChange + ) - self.assertIsInstance(results[1].toMany['preparations'][2].record_result, NullRecord) + self.assertIsInstance( + results[1].toMany["preparations"][2].record_result, NullRecord + ) self.assertIsInstance(results[2].record_result, NoChange) - co_3_preps = results[2].toMany['preparations'] + co_3_preps = results[2].toMany["preparations"] wrap_is_instance(co_3_preps[0].record_result, Deleted) wrap_is_instance(co_3_preps[1].record_result, Uploaded) wrap_is_instance(co_3_preps[2].record_result, Uploaded) - self.assertIsInstance(co_3_preps[1].toOne['preptype'].record_result, Matched) - self.assertEqual(co_3_preps[1].toOne['preptype'].record_result.get_id(), co_1_prep_1.toOne['preptype'].record_result.get_id()) + self.assertIsInstance(co_3_preps[1].toOne["preptype"].record_result, Matched) + self.assertEqual( + co_3_preps[1].toOne["preptype"].record_result.get_id(), + co_1_prep_1.toOne["preptype"].record_result.get_id(), + ) - self.assertEqual(co_3_preps[2].toOne['preptype'].record_result.get_id(), self.preptype.id) - self.assertFalse(Preparation.objects.filter(id=results[2].toMany['preparations'][0].record_result.get_id()).exists()) + self.assertEqual( + co_3_preps[2].toOne["preptype"].record_result.get_id(), self.preptype.id + ) + self.assertFalse( + Preparation.objects.filter( + id=results[2].toMany["preparations"][0].record_result.get_id() + ).exists() + ) - [self.enforce_in_log(result_id, table, ('INSERT' if _type == Uploaded else ('REMOVE' if _type == Deleted else 'UPDATE'))) - for (_type, results) in result_map.items() - for (table, result_id) in results] + [ + self.enforce_in_log( + result_id, + table, + ( + "INSERT" + if _type == Uploaded + else ("REMOVE" if _type == Deleted else "UPDATE") + ), + ) + for (_type, results) in result_map.items() + for (table, result_id) in results + ] def _run_with_defer(self): query_paths = [ - ['integer1'], - ['cataloger', 'firstname'], - ['preparations', 'countAmt'] + ["integer1"], + ["cataloger", "firstname"], + ["preparations", "countAmt"], ] - (headers, rows, pack, plan) = self.query_to_results('collectionobject', query_paths) + (headers, rows, pack, plan) = self.query_to_results( + *self.make_query_fields("collectionobject", query_paths) + ) data = [ - {'CollectionObject integer1': '', 'Agent firstName': 'John', 'Preparation countAmt': '', 'Preparation countAmt #2': '', 'Preparation countAmt #3': ''}, - {'CollectionObject integer1': '', 'Agent firstName': 'John', 'Preparation countAmt': '', 'Preparation countAmt #2': '', 'Preparation countAmt #3': ''}, - {'CollectionObject integer1': '', 'Agent firstName': 'Jame', 'Preparation countAmt': '', 'Preparation countAmt #2': '', 'Preparation countAmt #3': ''} - ] + { + "CollectionObject integer1": "", + "Agent firstName": "John", + "Preparation countAmt": "", + "Preparation countAmt #2": "", + "Preparation countAmt #3": "", + }, + { + "CollectionObject integer1": "", + "Agent firstName": "John", + "Preparation countAmt": "", + "Preparation countAmt #2": "", + "Preparation countAmt #3": "", + }, + { + "CollectionObject integer1": "", + "Agent firstName": "Jame", + "Preparation countAmt": "", + "Preparation countAmt #2": "", + "Preparation countAmt #3": "", + }, + ] return (headers, data, pack, plan) - + def test_update_to_many_with_defer(self): with patch( - "specifyweb.workbench.upload.preferences.should_defer_fields", new=make_defer(match=True, null=False) + "specifyweb.workbench.upload.preferences.should_defer_fields", + new=make_defer(match=True, null=False), ): (headers, data, pack, plan) = self._run_with_defer() - results = do_upload(self.collection, data, plan, self.agent.id, batch_edit_packs=pack) + results = do_upload( + self.collection, data, plan, self.agent.id, batch_edit_packs=pack + ) - self.assertIsInstance(results[0].toMany['preparations'][0].record_result, Updated) - self.assertIsInstance(results[0].toMany['preparations'][1].record_result, Updated) - self.assertIsInstance(results[0].toMany['preparations'][2].record_result, Updated) + self.assertIsInstance( + results[0].toMany["preparations"][0].record_result, Updated + ) + self.assertIsInstance( + results[0].toMany["preparations"][1].record_result, Updated + ) + self.assertIsInstance( + results[0].toMany["preparations"][2].record_result, Updated + ) - self.assertIsInstance(results[1].toMany['preparations'][0].record_result, Updated) - self.assertIsInstance(results[1].toMany['preparations'][1].record_result, Updated) - self.assertIsInstance(results[1].toMany['preparations'][2].record_result, NullRecord) + self.assertIsInstance( + results[1].toMany["preparations"][0].record_result, Updated + ) + self.assertIsInstance( + results[1].toMany["preparations"][1].record_result, Updated + ) + self.assertIsInstance( + results[1].toMany["preparations"][2].record_result, NullRecord + ) - self.assertIsInstance(results[2].toMany['preparations'][0].record_result, NoChange) - self.assertIsInstance(results[2].toMany['preparations'][1].record_result, NullRecord) - self.assertIsInstance(results[2].toMany['preparations'][2].record_result, NullRecord) + self.assertIsInstance( + results[2].toMany["preparations"][0].record_result, NoChange + ) + self.assertIsInstance( + results[2].toMany["preparations"][1].record_result, NullRecord + ) + self.assertIsInstance( + results[2].toMany["preparations"][2].record_result, NullRecord + ) - with patch("specifyweb.workbench.upload.preferences.should_defer_fields", new=make_defer - (match=False, - null=True, - force='match' - )): + with patch( + "specifyweb.workbench.upload.preferences.should_defer_fields", + new=make_defer(match=False, null=True, force="match"), + ): (headers, data, pack, plan) = self._run_with_defer() - results = do_upload(self.collection, data, plan, self.agent.id, batch_edit_packs=pack) + results = do_upload( + self.collection, data, plan, self.agent.id, batch_edit_packs=pack + ) - self.assertIsInstance(results[0].toMany['preparations'][0].record_result, Deleted) - self.assertIsInstance(results[0].toMany['preparations'][1].record_result, Deleted) - self.assertIsInstance(results[0].toMany['preparations'][2].record_result, Deleted) + self.assertIsInstance( + results[0].toMany["preparations"][0].record_result, Deleted + ) + self.assertIsInstance( + results[0].toMany["preparations"][1].record_result, Deleted + ) + self.assertIsInstance( + results[0].toMany["preparations"][2].record_result, Deleted + ) - self.assertIsInstance(results[1].toMany['preparations'][0].record_result, Deleted) - self.assertIsInstance(results[1].toMany['preparations'][1].record_result, Deleted) - self.assertIsInstance(results[1].toMany['preparations'][2].record_result, NullRecord) + self.assertIsInstance( + results[1].toMany["preparations"][0].record_result, Deleted + ) + self.assertIsInstance( + results[1].toMany["preparations"][1].record_result, Deleted + ) + self.assertIsInstance( + results[1].toMany["preparations"][2].record_result, NullRecord + ) - self.assertIsInstance(results[2].toMany['preparations'][0].record_result, Deleted) - self.assertIsInstance(results[2].toMany['preparations'][1].record_result, NullRecord) - self.assertIsInstance(results[2].toMany['preparations'][2].record_result, NullRecord) + self.assertIsInstance( + results[2].toMany["preparations"][0].record_result, Deleted + ) + self.assertIsInstance( + results[2].toMany["preparations"][1].record_result, NullRecord + ) + self.assertIsInstance( + results[2].toMany["preparations"][2].record_result, NullRecord + ) # original_data = [ # {'CollectionObject integer1': '', 'Agent firstName': 'John', 'Preparation countAmt': '20', 'Preparation countAmt #2': '5', 'Preparation countAmt #3': '88'}, # {'CollectionObject integer1': '', 'Agent firstName': 'John', 'Preparation countAmt': '89', 'Preparation countAmt #2': '27', 'Preparation countAmt #3': ''}, # {'CollectionObject integer1': '', 'Agent firstName': 'Jame', 'Preparation countAmt': '', 'Preparation countAmt #2': '', 'Preparation countAmt #3': ''} # ] - def test_bidirectional_to_many(self): - self._update(self.test_agent_1, {'remarks': 'changed for dup testing'}) + self._update(self.test_agent_1, {"remarks": "changed for dup testing"}) self.co_4 = Collectionobject.objects.create( catalognumber="84".zfill(9), cataloger=self.test_agent_2, @@ -1018,14 +1137,14 @@ def test_bidirectional_to_many(self): ["cataloger", "firstname"], ["cataloger", "lastname"], ["cataloger", "addresses", "address"], - ['cataloger', 'agenttype'], + ["cataloger", "agenttype"], ["preparations", "countamt"], ["preparations", "text1"], ["preparations", "preptype", "name"], ] (headers, rows, pack, plan) = self.query_to_results( - "collectionobject", query_paths + *self.make_query_fields("collectionobject", query_paths) ) dicted = [dict(zip(headers, row)) for row in rows] @@ -1038,58 +1157,174 @@ def test_bidirectional_to_many(self): # ] data = [ - {'CollectionObject integer1': '', 'Agent firstName': 'John', 'Agent lastName': 'Dew', 'Agent agentType': 'Organization', 'Address address': 'testaddress1 changed', 'Address address #2': 'testaddress2', 'Preparation countAmt': '288', 'Preparation text1': 'Value for preparation', 'PrepType name': 'testPrepType', 'Preparation countAmt #2': '5', 'Preparation text1 #2': 'Second value for preparation', 'PrepType name #2': 'testPrepType', 'Preparation countAmt #3': '88', 'Preparation text1 #3': 'Third value for preparation', 'PrepType name #3': 'testPrepType'}, - {'CollectionObject integer1': '', 'Agent firstName': 'John', 'Agent lastName': 'Dew', 'Agent agentType': 'Organization', 'Address address': 'testaddress1 changed', 'Address address #2': 'testaddress2', 'Preparation countAmt': '892', 'Preparation text1': 'Value for preparation for second CO', 'PrepType name': 'testPrepType', 'Preparation countAmt #2': '27', 'Preparation text1 #2': '', 'PrepType name #2': 'testPrepType', 'Preparation countAmt #3': '', 'Preparation text1 #3': '', 'PrepType name #3': ''}, - {'CollectionObject integer1': '', 'Agent firstName': '', 'Agent lastName': '', 'Agent agentType': '', 'Address address': '', 'Address address #2': '', 'Preparation countAmt': '', 'Preparation text1': 'Needs to be deleted', 'PrepType name': 'testPrepType', 'Preparation countAmt #2': '', 'Preparation text1 #2': '', 'PrepType name #2': '', 'Preparation countAmt #3': '', 'Preparation text1 #3': '', 'PrepType name #3': ''}, - ] - + { + "CollectionObject integer1": "", + "Agent firstName": "John", + "Agent lastName": "Dew", + "Agent agentType": "Organization", + "Address address": "testaddress1 changed", + "Address address #2": "testaddress2", + "Preparation countAmt": "288", + "Preparation text1": "Value for preparation", + "PrepType name": "testPrepType", + "Preparation countAmt #2": "5", + "Preparation text1 #2": "Second value for preparation", + "PrepType name #2": "testPrepType", + "Preparation countAmt #3": "88", + "Preparation text1 #3": "Third value for preparation", + "PrepType name #3": "testPrepType", + }, + { + "CollectionObject integer1": "", + "Agent firstName": "John", + "Agent lastName": "Dew", + "Agent agentType": "Organization", + "Address address": "testaddress1 changed", + "Address address #2": "testaddress2", + "Preparation countAmt": "892", + "Preparation text1": "Value for preparation for second CO", + "PrepType name": "testPrepType", + "Preparation countAmt #2": "27", + "Preparation text1 #2": "", + "PrepType name #2": "testPrepType", + "Preparation countAmt #3": "", + "Preparation text1 #3": "", + "PrepType name #3": "", + }, + { + "CollectionObject integer1": "", + "Agent firstName": "", + "Agent lastName": "", + "Agent agentType": "", + "Address address": "", + "Address address #2": "", + "Preparation countAmt": "", + "Preparation text1": "Needs to be deleted", + "PrepType name": "testPrepType", + "Preparation countAmt #2": "", + "Preparation text1 #2": "", + "PrepType name #2": "", + "Preparation countAmt #3": "", + "Preparation text1 #3": "", + "PrepType name #3": "", + }, + ] + results = do_upload( self.collection, data, plan, self.agent.id, batch_edit_packs=pack ) self.assertIsInstance(results[0].record_result, NoChange) - self.assertIsInstance(results[0].toOne['cataloger'].record_result, Uploaded) - [self.enforcer(record_result, [Uploaded]) for record_result in results[0].toOne['cataloger'].toMany['addresses']] - self.assertIsInstance(results[0].toMany['preparations'][0].record_result, Updated) - self.assertCountEqual(results[0].toMany['preparations'][0].record_result.info.columns, ['Preparation countAmt']) - [self.enforcer(record_result, [NoChange]) for record_result in results[0].toMany['preparations'][1:]] + self.assertIsInstance(results[0].toOne["cataloger"].record_result, Uploaded) + [ + self.enforcer(record_result, [Uploaded]) + for record_result in results[0].toOne["cataloger"].toMany["addresses"] + ] + self.assertIsInstance( + results[0].toMany["preparations"][0].record_result, Updated + ) + self.assertCountEqual( + results[0].toMany["preparations"][0].record_result.info.columns, + ["Preparation countAmt"], + ) + [ + self.enforcer(record_result, [NoChange]) + for record_result in results[0].toMany["preparations"][1:] + ] self.co_1.refresh_from_db() - self.assertEqual(self.co_1.cataloger_id, results[0].toOne['cataloger'].record_result.get_id()) + self.assertEqual( + self.co_1.cataloger_id, results[0].toOne["cataloger"].record_result.get_id() + ) cataloger_created = self.co_1.cataloger # All assertions below check if clone was correct - self.assertEqual(cataloger_created.addresses.all().count(), self.test_agent_1.addresses.all().count()) - self.assertEqual(cataloger_created.agentspecialties.all().count(), self.test_agent_1.agentspecialties.all().count()) - self.assertEqual(cataloger_created.addresses.all().filter(address='testaddress1 changed').count(), 1) - self.assertEqual(cataloger_created.addresses.all().filter(address='testaddress2').count(), 1) - self.assertEqual(cataloger_created.agentspecialties.all().filter(specialtyname='specialty1').count(), 1) - self.assertEqual(cataloger_created.agentspecialties.all().filter(specialtyname='specialty2').count(), 1) + self.assertEqual( + cataloger_created.addresses.all().count(), + self.test_agent_1.addresses.all().count(), + ) + self.assertEqual( + cataloger_created.agentspecialties.all().count(), + self.test_agent_1.agentspecialties.all().count(), + ) + self.assertEqual( + cataloger_created.addresses.all() + .filter(address="testaddress1 changed") + .count(), + 1, + ) + self.assertEqual( + cataloger_created.addresses.all().filter(address="testaddress2").count(), 1 + ) + self.assertEqual( + cataloger_created.agentspecialties.all() + .filter(specialtyname="specialty1") + .count(), + 1, + ) + self.assertEqual( + cataloger_created.agentspecialties.all() + .filter(specialtyname="specialty2") + .count(), + 1, + ) self.assertEqual(cataloger_created.remarks, self.test_agent_1.remarks) self.assertIsInstance(results[1].record_result, NoChange) - self.assertIsInstance(results[1].toOne['cataloger'].record_result, MatchedAndChanged) - self.assertEqual(results[1].toOne['cataloger'].record_result.get_id(), cataloger_created.id) - self.assertIsInstance(results[1].toMany['preparations'][0].record_result, Updated) - self.assertCountEqual(results[1].toMany['preparations'][0].record_result.info.columns, ['Preparation countAmt']) - self.assertIsInstance(results[1].toMany['preparations'][1].record_result, NoChange) - self.assertIsInstance(results[1].toMany['preparations'][2].record_result, NullRecord) - + self.assertIsInstance( + results[1].toOne["cataloger"].record_result, MatchedAndChanged + ) + self.assertEqual( + results[1].toOne["cataloger"].record_result.get_id(), cataloger_created.id + ) + self.assertIsInstance( + results[1].toMany["preparations"][0].record_result, Updated + ) + self.assertCountEqual( + results[1].toMany["preparations"][0].record_result.info.columns, + ["Preparation countAmt"], + ) + self.assertIsInstance( + results[1].toMany["preparations"][1].record_result, NoChange + ) + self.assertIsInstance( + results[1].toMany["preparations"][2].record_result, NullRecord + ) self.assertIsInstance(results[2].record_result, NoChange) - self.assertIsInstance(results[2].toOne['cataloger'].record_result, NullRecord) - - self.assertIsInstance(results[2].toMany['preparations'][0].record_result, NoChange) - self.assertIsInstance(results[2].toMany['preparations'][1].record_result, NullRecord) - self.assertIsInstance(results[2].toMany['preparations'][2].record_result, NullRecord) + self.assertIsInstance(results[2].toOne["cataloger"].record_result, NullRecord) + + self.assertIsInstance( + results[2].toMany["preparations"][0].record_result, NoChange + ) + self.assertIsInstance( + results[2].toMany["preparations"][1].record_result, NullRecord + ) + self.assertIsInstance( + results[2].toMany["preparations"][2].record_result, NullRecord + ) # Make sure stuff was audited (creating clone is audited) - [self.enforce_in_log(record.pk, 'address', 'INSERT') for record in cataloger_created.addresses.all()] - [self.enforce_in_log(record.pk, 'agentspecialty', 'INSERT') for record in cataloger_created.agentspecialties.all()] - self.enforce_in_log(cataloger_created.pk, 'agent', 'INSERT') + [ + self.enforce_in_log(record.pk, "address", "INSERT") + for record in cataloger_created.addresses.all() + ] + [ + self.enforce_in_log(record.pk, "agentspecialty", "INSERT") + for record in cataloger_created.agentspecialties.all() + ] + self.enforce_in_log(cataloger_created.pk, "agent", "INSERT") - self.enforce_in_log(results[1].toMany['preparations'][0].record_result.get_id(), 'preparation', 'UPDATE') - self.enforce_in_log(results[0].toMany['preparations'][0].record_result.get_id(), 'preparation', 'UPDATE') + self.enforce_in_log( + results[1].toMany["preparations"][0].record_result.get_id(), + "preparation", + "UPDATE", + ) + self.enforce_in_log( + results[0].toMany["preparations"][0].record_result.get_id(), + "preparation", + "UPDATE", + ) def test_to_many_match_is_possible(self): @@ -1118,7 +1353,7 @@ def test_to_many_match_is_possible(self): ] (headers, rows, pack, plan) = self.query_to_results( - "collectionobject", query_paths + *self.make_query_fields("collectionobject", query_paths) ) dicted = [dict(zip(headers, row)) for row in rows] @@ -1174,3 +1409,317 @@ def test_to_many_match_is_possible(self): self.assertEqual( results[2].toOne["cataloger"].record_result.get_id(), self.test_agent_1.id ) + + def batch_edit_filtering(self, filtering=False): + + self._update(self.test_agent_1, {"integer1": 20}) + self._update(self.test_agent_2, {"integer1": 21}) + self._update(self.test_agent_3, {"integer1": 20}) + + self.ce_3 = Collectingevent.objects.create( + stationfieldnumber="3", + discipline=self.discipline, + ) + + self.ce_4 = Collectingevent.objects.create( + stationfieldnumber="4", + discipline=self.discipline, + ) + + ce_1_coll_1 = Collector.objects.create( + collectingevent=self.ce_1, + agent=self.test_agent_1, + remarks="coll_1", + division=self.test_agent_1.division, + ) + ce_1_coll_2 = Collector.objects.create( + collectingevent=self.ce_1, + agent=self.test_agent_2, + remarks="coll_2", + division=self.test_agent_2.division, + ) + ce_1_coll_3 = Collector.objects.create( + collectingevent=self.ce_1, + agent=self.test_agent_3, + remarks="coll_1", + division=self.test_agent_3.division, + ) + + ce_2_coll_1 = Collector.objects.create( + collectingevent=self.ce_2, + agent=self.test_agent_1, + remarks="coll_1", + division=self.test_agent_1.division, + ) + ce_2_coll_2 = Collector.objects.create( + collectingevent=self.ce_2, + agent=self.test_agent_2, + division=self.test_agent_2.division, + ) + ce_2_coll_3 = Collector.objects.create( + collectingevent=self.ce_2, + agent=self.test_agent_3, + division=self.test_agent_3.division, + ) + + ce_3_coll_1 = Collector.objects.create( + collectingevent=self.ce_3, + agent=self.test_agent_1, + division=self.test_agent_1.division, + ) + ce_3_coll_2 = Collector.objects.create( + collectingevent=self.ce_3, + agent=self.test_agent_2, + remarks="coll_1", + division=self.test_agent_2.division, + ) + ce_3_coll_3 = Collector.objects.create( + collectingevent=self.ce_3, + agent=self.test_agent_3, + division=self.test_agent_3.division, + ) + + ce_4_coll_1 = Collector.objects.create( + collectingevent=self.ce_4, + agent=self.test_agent_1, + division=self.test_agent_1.division, + ) + ce_4_coll_2 = Collector.objects.create( + collectingevent=self.ce_4, + agent=self.test_agent_2, + division=self.test_agent_2.division, + ) + ce_4_coll_3 = Collector.objects.create( + collectingevent=self.ce_4, + agent=self.test_agent_3, + remarks="coll_1", + division=self.test_agent_3.division, + ) + + query_fields = [ + { + "tablelist": "10", + "stringid": "10.collectingevent.text4", + "fieldname": "text4", + "isrelfld": False, + "sorttype": 0, + "position": 0, + "isdisplay": True, + "operstart": 8, + "startvalue": "", + "isnot": False, + }, + { + "tablelist": "10,30-collectors,5", + "stringid": "10,30-collectors,5.agent.firstName", + "fieldname": "firstName", + "isrelfld": False, + "sorttype": 0, + "position": 1, + "isdisplay": True, + "operstart": 8, + "startvalue": "", + "isnot": False, + }, + { + "tablelist": "10,30-collectors,5", + "stringid": "10,30-collectors,5.agent.lastName", + "fieldname": "lastName", + "isrelfld": False, + "sorttype": 0, + "position": 2, + "isdisplay": True, + "operstart": 8, + "startvalue": "", + "isnot": False, + }, + { + "tablelist": "10,30-collectors", + "stringid": "10,30-collectors.collector.remarks", + "fieldname": "remarks", + "isrelfld": False, + "sorttype": 0, + "position": 3, + "isdisplay": True, + "operstart": 1 if filtering else 8, + "startvalue": "coll_1", + "isnot": False, + }, + ] + + return self.query_to_results("collectingevent", fields_from_json(query_fields)) + + def test_batch_edit_no_filtering(self): + (headers, rows, pack, plan) = self.batch_edit_filtering() + + rows = [dict(zip(headers, row)) for row in rows] + + data = [ + { + "CollectingEvent text4": "", + "Collector remarks": "coll_1", + "Agent firstName": "John", + "Agent lastName": "Doe", + "Collector remarks #2": "coll_2", + "Agent firstName #2": "Jame", + "Agent lastName #2": "", + "Collector remarks #3": "coll_1", + "Agent firstName #3": "Jame", + "Agent lastName #3": "Blo", + }, + { + "CollectingEvent text4": "", + "Collector remarks": "coll_1", + "Agent firstName": "John", + "Agent lastName": "Doe", + "Collector remarks #2": "", + "Agent firstName #2": "Jame", + "Agent lastName #2": "", + "Collector remarks #3": "", + "Agent firstName #3": "Jame", + "Agent lastName #3": "Blo", + }, + { + "CollectingEvent text4": "", + "Collector remarks": "", + "Agent firstName": "John", + "Agent lastName": "Doe", + "Collector remarks #2": "coll_1", + "Agent firstName #2": "Jame", + "Agent lastName #2": "", + "Collector remarks #3": "", + "Agent firstName #3": "Jame", + "Agent lastName #3": "Blo", + }, + { + "CollectingEvent text4": "", + "Collector remarks": "", + "Agent firstName": "John", + "Agent lastName": "Doe", + "Collector remarks #2": "", + "Agent firstName #2": "Jame", + "Agent lastName #2": "", + "Collector remarks #3": "coll_1", + "Agent firstName #3": "Jame", + "Agent lastName #3": "Blo", + }, + ] + + results = do_upload( + self.collection, rows, plan, self.agent.id, batch_edit_packs=pack + ) + + list([self.enforcer(result) for result in results]) + + def test_batch_edit_filtering(self): + (headers, rows, pack, plan) = self.batch_edit_filtering(True) + + rows = [dict(zip(headers, row)) for row in rows] + + rows = [ + { + "CollectingEvent text4": "", + "Collector remarks": "coll_1", + "Agent firstName": "John", + "Agent lastName": "Doe", + "Collector remarks #2": "Changes here are ignored", + "Agent firstName #2": "", + "Agent lastName #2": "", + "Collector remarks #3": "This change is not ignored", + "Agent firstName #3": "Jame", + "Agent lastName #3": "Blo", + }, + { + "CollectingEvent text4": "", + "Collector remarks": "coll_1", + "Agent firstName": "John", + "Agent lastName": "Doe", + "Collector remarks #2": "This changee is also ignoreed", + "Agent firstName #2": "", + "Agent lastName #2": "", + "Collector remarks #3": "", + "Agent firstName #3": "And so is this one", + "Agent lastName #3": "", + }, + { + "CollectingEvent text4": "This will be saved", + "Collector remarks": "This will not be saved", + "Agent firstName": "", + "Agent lastName": "", + "Collector remarks #2": "coll_1", + "Agent firstName #2": "Jame", + "Agent lastName #2": "", + "Collector remarks #3": "", + "Agent firstName #3": "", + "Agent lastName #3": "change ignored", + }, + { + "CollectingEvent text4": "", + "Collector remarks": "", + "Agent firstName": "", + "Agent lastName": "", + "Collector remarks #2": "", + "Agent firstName #2": "", + "Agent lastName #2": "", + "Collector remarks #3": "Change not ignored", + "Agent firstName #3": "Jame", + "Agent lastName #3": "Blo", + }, + ] + + results = do_upload( + self.collection, rows, plan, self.agent.id, batch_edit_packs=pack + ) + + self.assertIsInstance(results[0].record_result, NoChange) + self.assertIsInstance(results[1].record_result, NoChange) + self.assertIsInstance(results[2].record_result, Updated) + self.assertEqual( + results[2].record_result.info.columns, ["CollectingEvent text4"] + ) + self.assertIsInstance(results[3].record_result, NoChange) + + self.assertIsInstance( + results[0].toMany["collectors"][0].record_result, NoChange + ) + self.assertIsInstance( + results[0].toMany["collectors"][0].toOne["agent"].record_result, Matched + ) + self.assertIsInstance( + results[0].toMany["collectors"][1].record_result, NoChange + ) + self.assertIsInstance(results[0].toMany["collectors"][2].record_result, Updated) + self.assertIsInstance( + results[0].toMany["collectors"][2].toOne["agent"].record_result, Matched + ) + + self.assertIsInstance( + results[1].toMany["collectors"][0].record_result, NoChange + ) + self.assertIsInstance( + results[1].toMany["collectors"][0].toOne["agent"].record_result, Matched + ) + self.assertIsInstance( + results[1].toMany["collectors"][1].record_result, NoChange + ) + self.assertIsInstance( + results[1].toMany["collectors"][1].record_result, NoChange + ) + + self.assertIsInstance( + results[2].toMany["collectors"][0].record_result, NoChange + ) + self.assertIsInstance( + results[2].toMany["collectors"][1].record_result, NoChange + ) + self.assertIsInstance( + results[2].toMany["collectors"][2].record_result, NoChange + ) + + self.assertIsInstance( + results[3].toMany["collectors"][0].record_result, NoChange + ) + self.assertIsInstance( + results[3].toMany["collectors"][1].record_result, NoChange + ) + self.assertIsInstance(results[3].toMany["collectors"][2].record_result, Updated) diff --git a/specifyweb/workbench/upload/treerecord.py b/specifyweb/workbench/upload/treerecord.py index 5c54ace9785..516da3859e0 100644 --- a/specifyweb/workbench/upload/treerecord.py +++ b/specifyweb/workbench/upload/treerecord.py @@ -11,15 +11,43 @@ from specifyweb.businessrules.exceptions import BusinessRuleException from specifyweb.specify import models from specifyweb.workbench.upload.clone import clone_record -from specifyweb.workbench.upload.predicates import ContetRef, DjangoPredicates, SkippablePredicate, ToRemove, resolve_reference_attributes, safe_fetch +from specifyweb.workbench.upload.predicates import ( + ContetRef, + DjangoPredicates, + SkippablePredicate, + ToRemove, + resolve_reference_attributes, + safe_fetch, +) import specifyweb.workbench.upload.preferences as defer_preference from .column_options import ColumnOptions, ExtendedColumnOptions -from .parsing import ParseResult, WorkBenchParseFailure, parse_many, filter_and_upload, Filter -from .upload_result import UploadResult, NullRecord, NoMatch, Matched, \ - MatchedMultiple, Uploaded, ParseFailures, FailedBusinessRule, ReportInfo, \ - TreeInfo -from .uploadable import Row, Disambiguation as DA, Auditor, ScopeGenerator, BatchEditJson +from .parsing import ( + ParseResult, + WorkBenchParseFailure, + parse_many, + filter_and_upload, + Filter, +) +from .upload_result import ( + UploadResult, + NullRecord, + NoMatch, + Matched, + MatchedMultiple, + Uploaded, + ParseFailures, + FailedBusinessRule, + ReportInfo, + TreeInfo, +) +from .uploadable import ( + Row, + Disambiguation as DA, + Auditor, + ScopeGenerator, + BatchEditJson, +) logger = logging.getLogger(__name__) @@ -28,8 +56,11 @@ class TreeRecord(NamedTuple): name: str ranks: Dict[str, Dict[str, ColumnOptions]] - def apply_scoping(self, collection, generator: ScopeGenerator = None, row=None) -> "ScopedTreeRecord": + def apply_scoping( + self, collection, generator: ScopeGenerator = None, row=None + ) -> "ScopedTreeRecord": from .scoping import apply_scoping_to_treerecord as apply_scoping + return apply_scoping(self, collection) def get_cols(self) -> Set[str]: @@ -37,15 +68,20 @@ def get_cols(self) -> Set[str]: def to_json(self) -> Dict: result = { - 'ranks': { - rank: cols['name'].to_json() if len(cols) == 1 else dict(treeNodeCols={k: v.to_json() for k, v in cols.items()}) + "ranks": { + rank: ( + cols["name"].to_json() + if len(cols) == 1 + else dict(treeNodeCols={k: v.to_json() for k, v in cols.items()}) + ) for rank, cols in self.ranks.items() }, } - return { 'treeRecord': result } + return {"treeRecord": result} def unparse(self) -> Dict: - return { 'baseTableName': self.name, 'uploadable': self.to_json() } + return {"baseTableName": self.name, "uploadable": self.to_json()} + class ScopedTreeRecord(NamedTuple): name: str @@ -57,30 +93,50 @@ class ScopedTreeRecord(NamedTuple): batch_edit_pack: Optional[Dict[str, Any]] def disambiguate(self, disambiguation: DA) -> "ScopedTreeRecord": - return self._replace(disambiguation=disambiguation.disambiguate_tree()) if disambiguation is not None else self + return ( + self._replace(disambiguation=disambiguation.disambiguate_tree()) + if disambiguation is not None + else self + ) - def apply_batch_edit_pack(self, batch_edit_pack: Optional[BatchEditJson]) -> "ScopedTreeRecord": + def apply_batch_edit_pack( + self, batch_edit_pack: Optional[BatchEditJson] + ) -> "ScopedTreeRecord": if batch_edit_pack is None: return self # batch-edit considers ranks as self-relationships, and are trivially stored in to-one - rank_from_pack = batch_edit_pack.get('to_one', {}) - return self._replace(batch_edit_pack={rank: pack['self'] for (rank, pack) in rank_from_pack.items()}) + rank_from_pack = batch_edit_pack.get("to_one", {}) + return self._replace( + batch_edit_pack={ + rank: pack["self"] for (rank, pack) in rank_from_pack.items() + } + ) def get_treedefs(self) -> Set: return {self.treedef} - def bind(self, row: Row, uploadingAgentId: Optional[int], auditor: Auditor, cache: Optional[Dict]=None) -> Union["BoundTreeRecord", ParseFailures]: + def bind( + self, + row: Row, + uploadingAgentId: Optional[int], + auditor: Auditor, + cache: Optional[Dict] = None, + ) -> Union["BoundTreeRecord", ParseFailures]: parsedFields: Dict[str, List[ParseResult]] = {} parseFails: List[WorkBenchParseFailure] = [] for rank, cols in self.ranks.items(): - nameColumn = cols['name'] + nameColumn = cols["name"] presults, pfails = parse_many(self.name, cols, row) parsedFields[rank] = presults parseFails += pfails filters = {k: v for result in presults for k, v in result.filter_on.items()} - if filters.get('name', None) is None: + if filters.get("name", None) is None: parseFails += [ - WorkBenchParseFailure('invalidPartialRecord',{'column':nameColumn.column}, result.column) + WorkBenchParseFailure( + "invalidPartialRecord", + {"column": nameColumn.column}, + result.column, + ) for result in presults if any(v is not None for v in result.filter_on.values()) ] @@ -98,31 +154,54 @@ def bind(self, row: Row, uploadingAgentId: Optional[int], auditor: Auditor, cach uploadingAgentId=uploadingAgentId, auditor=auditor, cache=cache, - batch_edit_pack=self.batch_edit_pack + batch_edit_pack=self.batch_edit_pack, ) + class MustMatchTreeRecord(TreeRecord): - def apply_scoping(self, collection, generator: ScopeGenerator=None, row=None) -> "ScopedMustMatchTreeRecord": - s = super().apply_scoping(collection) - return ScopedMustMatchTreeRecord(*s) + def apply_scoping( + self, collection, generator: ScopeGenerator = None, row=None + ) -> "ScopedMustMatchTreeRecord": + s = super().apply_scoping(collection) + return ScopedMustMatchTreeRecord(*s) + class ScopedMustMatchTreeRecord(ScopedTreeRecord): - def bind(self, row: Row, uploadingAgentId: Optional[int], auditor: Auditor, cache: Optional[Dict]=None) -> Union["BoundMustMatchTreeRecord", ParseFailures]: + def bind( + self, + row: Row, + uploadingAgentId: Optional[int], + auditor: Auditor, + cache: Optional[Dict] = None, + ) -> Union["BoundMustMatchTreeRecord", ParseFailures]: b = super().bind(row, uploadingAgentId, auditor, cache) return b if isinstance(b, ParseFailures) else BoundMustMatchTreeRecord(*b) + class TreeDefItemWithParseResults(NamedTuple): treedefitem: Any results: List[ParseResult] def match_key(self) -> str: - return repr((self.treedefitem.id, sorted(pr.match_key() for pr in self.results))) + return repr( + (self.treedefitem.id, sorted(pr.match_key() for pr in self.results)) + ) + MatchResult = Union[NoMatch, Matched, MatchedMultiple] -MatchInfo = TypedDict('MatchInfo', {'id': int, 'name': str, 'definitionitem__name': str, 'definitionitem__rankid': int}) +MatchInfo = TypedDict( + "MatchInfo", + { + "id": int, + "name": str, + "definitionitem__name": str, + "definitionitem__rankid": int, + }, +) + +FETCHED_ATTRS = ["id", "name", "definitionitem__name", "definitionitem__rankid"] -FETCHED_ATTRS = ['id', 'name', 'definitionitem__name', 'definitionitem__rankid'] class BoundTreeRecord(NamedTuple): name: str @@ -142,9 +221,19 @@ def is_one_to_one(self) -> bool: def must_match(self) -> bool: return False - def get_django_predicates(self, should_defer_match: bool, to_one_override: Dict[str, UploadResult]={}) -> DjangoPredicates: + def get_django_predicates( + self, should_defer_match: bool, to_one_override: Dict[str, UploadResult] = {} + ) -> DjangoPredicates: + # Everything is so complicated around here. In an initial implementation, I naively returned SkippablePredicates, + # but that'll potentially cause null records to be actually processed. (although, there doesn't seem to be a realizable user mapping to do it) + # So, the best we can really do, is to check if this entire tree record is null or not. + # It it is, we return a null django predicate (which will correctly get processed for to-one). + # Otherwise, we simply return a SkippablePredicate which will then correctly be handled (by not matching on it) + (is_null, _, __) = self._is_null() + if is_null: + return DjangoPredicates() return SkippablePredicate() - + def can_save(self) -> bool: return False @@ -159,26 +248,44 @@ def process_row(self) -> UploadResult: def save_row(self, force=False) -> UploadResult: raise NotImplementedError() - + def get_to_remove(self) -> ToRemove: raise NotImplementedError() - - def _handle_row(self, must_match: bool) -> UploadResult: + + def _is_null( + self, + ) -> Tuple[ + Optional[UploadResult], + List[TreeDefItemWithParseResults], + Optional[Dict[str, Any]], + ]: references = self._get_reference() tdiwprs = self._to_match(references) if not tdiwprs: columns = [pr.column for prs in self.parsedFields.values() for pr in prs] info = ReportInfo(tableName=self.name, columns=columns, treeInfo=None) - return UploadResult(NullRecord(info), {}, {}) + return (UploadResult(NullRecord(info), {}, {}), [], {}) + + return (None, tdiwprs, references) + + def _handle_row(self, must_match: bool) -> UploadResult: + + is_null, tdiwprs, references = self._is_null() + if is_null: + return is_null unmatched, match_result = self._match(tdiwprs, references) if isinstance(match_result, MatchedMultiple): return UploadResult(match_result, {}, {}) - if unmatched: # incomplete match + if unmatched: # incomplete match if must_match: - info = ReportInfo(tableName=self.name, columns=[r.column for tdiwpr in unmatched for r in tdiwpr.results], treeInfo=None) + info = ReportInfo( + tableName=self.name, + columns=[r.column for tdiwpr in unmatched for r in tdiwpr.results], + treeInfo=None, + ) return UploadResult(NoMatch(info), {}, {}) else: return self._upload(unmatched, match_result, references) @@ -189,12 +296,25 @@ def _to_match(self, references=None) -> List[TreeDefItemWithParseResults]: return [ TreeDefItemWithParseResults(tdi, self.parsedFields[tdi.name]) for tdi in self.treedefitems - if tdi.name in self.parsedFields - and (any(v is not None for r in self.parsedFields[tdi.name] for v in r.filter_on.values()) - and ((references is None) or (tdi.name not in references) or (references[tdi.name] is None) or (any(v is not None for v in references[tdi.name]['attrs'])))) + if tdi.name in self.parsedFields + and ( + any( + v is not None + for r in self.parsedFields[tdi.name] + for v in r.filter_on.values() + ) + and ( + (references is None) + or (tdi.name not in references) + or (references[tdi.name] is None) + or (any(v is not None for v in references[tdi.name]["attrs"])) + ) + ) ] - def _match(self, tdiwprs: List[TreeDefItemWithParseResults], references=None) -> Tuple[List[TreeDefItemWithParseResults], MatchResult]: + def _match( + self, tdiwprs: List[TreeDefItemWithParseResults], references=None + ) -> Tuple[List[TreeDefItemWithParseResults], MatchResult]: assert tdiwprs, "There has to be something to match." model = getattr(models, self.name) @@ -212,7 +332,15 @@ def _match(self, tdiwprs: List[TreeDefItemWithParseResults], references=None) -> matches = list(model.objects.filter(id=da).values(*FETCHED_ATTRS)[:10]) if not matches: - matches = self._find_matching_descendent(parent, to_match, None if references is None else references.get(to_match.treedefitem.name)) + matches = self._find_matching_descendent( + parent, + to_match, + ( + None + if references is None + else references.get(to_match.treedefitem.name) + ), + ) if len(matches) != 1: # matching failed at to_match level @@ -223,8 +351,12 @@ def _match(self, tdiwprs: List[TreeDefItemWithParseResults], references=None) -> if not tdiwprs: # found a complete match matched = matches[0] - info = ReportInfo(tableName=self.name, columns=matched_cols, treeInfo=TreeInfo(matched['definitionitem__name'], matched['name'])) - return [], Matched(matched['id'], info) + info = ReportInfo( + tableName=self.name, + columns=matched_cols, + treeInfo=TreeInfo(matched["definitionitem__name"], matched["name"]), + ) + return [], Matched(matched["id"], info) parent = matches[0] @@ -234,33 +366,70 @@ def _match(self, tdiwprs: List[TreeDefItemWithParseResults], references=None) -> info = ReportInfo( tableName=self.name, columns=[r.column for r in to_match.results], - treeInfo=TreeInfo(to_match.treedefitem.name, "") + treeInfo=TreeInfo(to_match.treedefitem.name, ""), ) - ids = [m['id'] for m in matches] + ids = [m["id"] for m in matches] key = repr(sorted(tdiwpr.match_key() for tdiwpr in tried_to_match)) return tdiwprs, MatchedMultiple(ids, key, info) else: - assert n_matches == 0, f"More than one match found when matching '{tdiwprs}' in '{model}'" + assert ( + n_matches == 0 + ), f"More than one match found when matching '{tdiwprs}' in '{model}'" if parent is not None: - info = ReportInfo(tableName=self.name, columns=matched_cols, treeInfo=TreeInfo(parent['definitionitem__name'], parent['name'])) - return tdiwprs, Matched(parent['id'], info) # partial match + info = ReportInfo( + tableName=self.name, + columns=matched_cols, + treeInfo=TreeInfo(parent["definitionitem__name"], parent["name"]), + ) + return tdiwprs, Matched(parent["id"], info) # partial match else: - info = ReportInfo(tableName=self.name, columns=matched_cols + [r.column for r in to_match.results], treeInfo=None) - return tdiwprs, NoMatch(info) # no levels matched at all - - def _find_matching_descendent(self, parent: Optional[MatchInfo], to_match: TreeDefItemWithParseResults, reference=None) -> List[MatchInfo]: - steps = sum(1 for tdi in self.treedefitems if parent['definitionitem__rankid'] < tdi.rankid <= to_match.treedefitem.rankid) \ - if parent is not None else 1 + info = ReportInfo( + tableName=self.name, + columns=matched_cols + [r.column for r in to_match.results], + treeInfo=None, + ) + return tdiwprs, NoMatch(info) # no levels matched at all + + def _find_matching_descendent( + self, + parent: Optional[MatchInfo], + to_match: TreeDefItemWithParseResults, + reference=None, + ) -> List[MatchInfo]: + steps = ( + sum( + 1 + for tdi in self.treedefitems + if parent["definitionitem__rankid"] + < tdi.rankid + <= to_match.treedefitem.rankid + ) + if parent is not None + else 1 + ) assert steps > 0, (parent, to_match) - filters = {field: value for r in to_match.results for field, value in r.filter_on.items()} + filters = { + field: value + for r in to_match.results + for field, value in r.filter_on.items() + } - reference_id = None if reference is None else reference['ref'].pk + reference_id = None if reference is None else reference["ref"].pk # Just adding the id of the reference is enough here - cache_key = (self.name, steps, parent and parent['id'], to_match.treedefitem.id, tuple(sorted(filters.items())), reference_id) + cache_key = ( + self.name, + steps, + parent and parent["id"], + to_match.treedefitem.id, + tuple(sorted(filters.items())), + reference_id, + ) - cached: Optional[List[MatchInfo]] = self.cache.get(cache_key, None) if self.cache is not None else None + cached: Optional[List[MatchInfo]] = ( + self.cache.get(cache_key, None) if self.cache is not None else None + ) if cached is not None: return cached @@ -268,12 +437,16 @@ def _find_matching_descendent(self, parent: Optional[MatchInfo], to_match: TreeD for d in range(steps): _filter = { - **(reference['attrs'] if reference is not None else {}), - **filters, - **({'__'.join(["parent_id"]*(d+1)): parent['id']} if parent is not None else {}), - **{'definitionitem_id': to_match.treedefitem.id} - } - + **(reference["attrs"] if reference is not None else {}), + **filters, + **( + {"__".join(["parent_id"] * (d + 1)): parent["id"]} + if parent is not None + else {} + ), + **{"definitionitem_id": to_match.treedefitem.id}, + } + query = model.objects.filter(**_filter).values(*FETCHED_ATTRS) matches: List[MatchInfo] = [] @@ -281,7 +454,7 @@ def _find_matching_descendent(self, parent: Optional[MatchInfo], to_match: TreeD if reference_id is not None: query_with_id = query.filter(id=reference_id) matches = list(query_with_id[:10]) - + if not matches: matches = list(query[:10]) @@ -292,34 +465,53 @@ def _find_matching_descendent(self, parent: Optional[MatchInfo], to_match: TreeD return matches - def _upload(self, to_upload: List[TreeDefItemWithParseResults], matched: Union[Matched, NoMatch], references=None) -> UploadResult: - assert to_upload, f"Invalid Error: {to_upload}, can not upload matched resluts: {matched}" + def _upload( + self, + to_upload: List[TreeDefItemWithParseResults], + matched: Union[Matched, NoMatch], + references=None, + ) -> UploadResult: + assert ( + to_upload + ), f"Invalid Error: {to_upload}, can not upload matched resluts: {matched}" model = getattr(models, self.name) parent_info: Optional[Dict] if isinstance(matched, Matched): parent_info = model.objects.values(*FETCHED_ATTRS).get(id=matched.id) - parent_result = {'parent': UploadResult(matched, {}, {})} + parent_result = {"parent": UploadResult(matched, {}, {})} else: parent_info = None parent_result = {} root_name = self.root.name if self.root else "Uploaded" placeholders = [ - TreeDefItemWithParseResults(tdi, [filter_and_upload({'name': root_name if tdi.rankid == 0 else "Uploaded"}, "")]) + TreeDefItemWithParseResults( + tdi, + [ + filter_and_upload( + {"name": root_name if tdi.rankid == 0 else "Uploaded"}, "" + ) + ], + ) for tdi in self.treedefitems - if tdi.rankid < to_upload[0].treedefitem.rankid + if tdi.rankid < to_upload[0].treedefitem.rankid and (tdi.rankid == 0 or tdi.isenforced) ] if placeholders: # dummy values were added above the nodes we want to upload # rerun the match in case those dummy values already exist - unmatched, new_match_result = self._match(placeholders + to_upload, references) + unmatched, new_match_result = self._match( + placeholders + to_upload, references + ) if isinstance(new_match_result, MatchedMultiple): return UploadResult( - FailedBusinessRule('invalidTreeStructure', {}, new_match_result.info), - {}, {} + FailedBusinessRule( + "invalidTreeStructure", {}, new_match_result.info + ), + {}, + {}, ) return self._upload(unmatched, new_match_result, references) @@ -328,22 +520,32 @@ def _upload(self, to_upload: List[TreeDefItemWithParseResults], matched: Union[M tdi for tdi in self.treedefitems if tdi.isenforced - and tdi.rankid > (parent_info['definitionitem__rankid'] if parent_info else 0) + and tdi.rankid + > (parent_info["definitionitem__rankid"] if parent_info else 0) and tdi.rankid < uploading_rankids[-1] and tdi.rankid not in uploading_rankids ] if skipped_enforced: names = [tdi.title if tdi.title else tdi.name for tdi in skipped_enforced] - after_skipped = [u for u in to_upload if u.treedefitem.rankid > skipped_enforced[-1].rankid] - info = ReportInfo(tableName=self.name, columns=[r.column for r in after_skipped[0].results], treeInfo=None) + after_skipped = [ + u + for u in to_upload + if u.treedefitem.rankid > skipped_enforced[-1].rankid + ] + info = ReportInfo( + tableName=self.name, + columns=[r.column for r in after_skipped[0].results], + treeInfo=None, + ) return UploadResult( FailedBusinessRule( - 'missingRequiredTreeParent', - {'names':names}, # {'names':repr(names)}, - info + "missingRequiredTreeParent", + {"names": names}, # {'names':repr(names)}, + info, ), - {}, {} + {}, + {}, ) missing_requireds = [ @@ -363,26 +565,30 @@ def _upload(self, to_upload: List[TreeDefItemWithParseResults], matched: Union[M info = ReportInfo( tableName=self.name, columns=[pr.column for pr in tdiwpr.results], - treeInfo=TreeInfo(tdiwpr.treedefitem.name, attrs.get('name', "")) + treeInfo=TreeInfo(tdiwpr.treedefitem.name, attrs.get("name", "")), ) new_attrs = dict( - createdbyagent_id=self.uploadingAgentId, - definitionitem=tdiwpr.treedefitem, - rankid=tdiwpr.treedefitem.rankid, - definition=self.treedef, - parent_id=parent_info and parent_info['id'], - ) - - reference_payload = None if references is None else references.get(tdiwpr.treedefitem.name, None) - + createdbyagent_id=self.uploadingAgentId, + definitionitem=tdiwpr.treedefitem, + rankid=tdiwpr.treedefitem.rankid, + definition=self.treedef, + parent_id=parent_info and parent_info["id"], + ) + + reference_payload = ( + None + if references is None + else references.get(tdiwpr.treedefitem.name, None) + ) + new_attrs = { - **(reference_payload['attrs'] if reference_payload is not None else {}), + **(reference_payload["attrs"] if reference_payload is not None else {}), **attrs, **new_attrs, } - ref = None if reference_payload is None else reference_payload['ref'] + ref = None if reference_payload is None else reference_payload["ref"] with transaction.atomic(): try: @@ -391,50 +597,56 @@ def _upload(self, to_upload: List[TreeDefItemWithParseResults], matched: Union[M else: obj = self._do_insert(model, **new_attrs) except (BusinessRuleException, IntegrityError) as e: - return UploadResult(FailedBusinessRule(str(e), {}, info), parent_result, {}) + return UploadResult( + FailedBusinessRule(str(e), {}, info), parent_result, {} + ) result = UploadResult(Uploaded(obj.id, info, []), parent_result, {}) - parent_info = {'id': obj.id, 'definitionitem__rankid': obj.definitionitem.rankid} - parent_result = {'parent': result} + parent_info = { + "id": obj.id, + "definitionitem__rankid": obj.definitionitem.rankid, + } + parent_result = {"parent": result} return result def _do_insert(self, model, **kwargs): _inserter = self._get_inserter() return _inserter(model, kwargs) - + def _get_inserter(self): def _inserter(model, attrs): obj = model(**attrs) - if model.specify_model.get_field('nodenumber'): + if model.specify_model.get_field("nodenumber"): obj.save(skip_tree_extras=True) else: obj.save(force_insert=True) - self.auditor.insert(obj, None) + self.auditor.insert(obj, None) return obj + return _inserter - + def _do_clone(self, ref, attrs): _inserter = self._get_inserter() return clone_record(ref, _inserter, {}, [], attrs) - + def force_upload_row(self) -> UploadResult: raise NotImplementedError() def _get_reference(self) -> Optional[Dict[str, Any]]: - - FIELDS_TO_SKIP = ['nodenumber', 'highestchildnodenumber', 'parent_id'] - # Much simpler than uploadTable. Just fetch all rank's references. Since we also require name to be not null, + FIELDS_TO_SKIP = ["nodenumber", "highestchildnodenumber", "parent_id"] + + # Much simpler than uploadTable. Just fetch all rank's references. Since we also require name to be not null, # the "deferForNull" is redundant. We, do, however need to look at deferForMatch, and we are done. if self.batch_edit_pack is None: return None - + model = getattr(models, self.name) - should_defer = defer_preference.should_defer_fields('match') + should_defer = defer_preference.should_defer_fields("match") references = {} @@ -446,23 +658,40 @@ def _get_reference(self) -> Optional[Dict[str, Any]]: info = ReportInfo(tableName=self.name, columns=columns, treeInfo=None) pack = self.batch_edit_pack[tdi.name] try: - reference = safe_fetch(model, {'id': pack['id']}, pack.get('version', None)) - if previous_parent_id is not None and previous_parent_id != reference.pk: - raise BusinessRuleException("Tree structure changed, please re-run the query") + reference = safe_fetch( + model, {"id": pack["id"]}, pack.get("version", None) + ) + if ( + previous_parent_id is not None + and previous_parent_id != reference.pk + ): + raise BusinessRuleException( + "Tree structure changed, please re-run the query" + ) except (ContetRef, BusinessRuleException) as e: raise BusinessRuleException(str(e), {}, info) - + previous_parent_id = reference.parent_id - references[tdi.name] = None if should_defer else {'ref': reference, 'attrs': resolve_reference_attributes(FIELDS_TO_SKIP, model, reference)} + references[tdi.name] = ( + None + if should_defer + else { + "ref": reference, + "attrs": resolve_reference_attributes( + FIELDS_TO_SKIP, model, reference + ), + } + ) return references + class BoundMustMatchTreeRecord(BoundTreeRecord): def must_match(self) -> bool: return True def force_upload_row(self) -> UploadResult: - raise Exception('trying to force upload of must-match table') + raise Exception("trying to force upload of must-match table") def process_row(self) -> UploadResult: return self._handle_row(must_match=True) diff --git a/specifyweb/workbench/upload/upload_table.py b/specifyweb/workbench/upload/upload_table.py index 4a0fd1a9a5b..f4632453eeb 100644 --- a/specifyweb/workbench/upload/upload_table.py +++ b/specifyweb/workbench/upload/upload_table.py @@ -40,7 +40,6 @@ ) from .uploadable import ( NULL_RECORD, - ModelWithTable, Row, ScopeGenerator, Uploadable, @@ -331,7 +330,7 @@ class BoundUploadTable(NamedTuple): match_payload: Optional[BatchEditSelf] strong_ignore: List[ str - ] # fields to stricly ignore for anything. unfortunately, depends needs parent-backref. See ctest_to_many_match_is_possibleomment in "test_batch_edit_table.py/test_to_many_match_is_possible" + ] # fields to stricly ignore for anything. unfortunately, depends needs parent-backref. See comment in "test_batch_edit_table.py/test_to_many_match_is_possible" @property def current_id(self): @@ -355,7 +354,7 @@ def can_save(self) -> bool: return isinstance(self.current_id, int) @property - def django_model(self) -> ModelWithTable: + def django_model(self) -> models.ModelWithTable: return getattr(models, self.name.capitalize()) @property @@ -381,6 +380,11 @@ def get_django_predicates( if model.objects.filter(id=self.disambiguation).exists(): return DjangoPredicates(filters={"id": self.disambiguation}) + # here's why the line below matters: If some records were added during record epansion, we cannot _filter_ or _eclude_ on them. + # Yes, this also means it will ONLY happen if there is a filter on that side. Something like CO (base) -> Cataloger -> Addresses. + # If there's no filter on addresses, below line wouldn't matter (bc presense of no record actually means there is none). If there's a filter, + # there could be a hidden record. This is because all we know is that we didnt "see" it, doesn't mean it's actually not there. + # See unittest, which covers both branches of this. if self.current_id == NULL_RECORD: return SkippablePredicate() @@ -458,8 +462,8 @@ def save_row(self, force=False) -> UploadResult: else update_table.process_row_with_null() ) - def _get_reference(self, should_cache=True) -> Optional[ModelWithTable]: - model: ModelWithTable = self.django_model + def _get_reference(self, should_cache=True) -> Optional[models.ModelWithTable]: + model: models.ModelWithTable = self.django_model current_id = self.current_id if current_id is None: @@ -631,7 +635,7 @@ def _check_missing_required(self) -> Optional[ParseFailures]: def _do_upload( self, - model: ModelWithTable, + model: models.ModelWithTable, to_one_results: Dict[str, UploadResult], info: ReportInfo, ) -> UploadResult: @@ -700,7 +704,9 @@ def _do_upload( return UploadResult(record, to_one_results, to_many_results) - def _handle_to_many(self, update: bool, parent_id: int, model: ModelWithTable): + def _handle_to_many( + self, update: bool, parent_id: int, model: models.ModelWithTable + ): return { fieldname: _upload_to_manys( model, @@ -914,7 +920,7 @@ def _do_upload( ): # I don't know what else to do. I don't think this will ever get raised. I don't know what I'll need to debug this, so showing everything. raise Exception( - f"Attempting to change the scope of the record: {reference_record} at {self}" + f"Attempting to change the scope of the record: {reference_record} at {self}. \n\n Diff: {concrete_field_changes}" ) to_one_changes = BoundUpdateTable._field_changed(reference_record, to_one_ids) diff --git a/specifyweb/workbench/upload/uploadable.py b/specifyweb/workbench/upload/uploadable.py index fca822e9314..dab13876bce 100644 --- a/specifyweb/workbench/upload/uploadable.py +++ b/specifyweb/workbench/upload/uploadable.py @@ -35,11 +35,6 @@ class Extra(TypedDict): Filter = Dict[str, Any] -# TODO: Use this everywhere -class ModelWithTable(Model): - specify_model: Table - class Meta: - abstract = True class Uploadable(Protocol): # also returns if the scoped table returned can be cached or not. # depends on whether scope depends on other columns. if any definition is found, diff --git a/specifyweb/workbench/views.py b/specifyweb/workbench/views.py index a2373d86cea..daf69aa12ae 100644 --- a/specifyweb/workbench/views.py +++ b/specifyweb/workbench/views.py @@ -1,6 +1,6 @@ import json import logging -from typing import List, Optional +from typing import List, Optional, Type, Union from uuid import uuid4 from django import http @@ -40,6 +40,24 @@ class DataSetPT(PermissionTarget): create_recordset = PermissionTargetAction() +class BatchEditDataSet(PermissionTarget): + resource = "/batch_edit/dataset" + create = PermissionTargetAction() + update = PermissionTargetAction() + delete = PermissionTargetAction() + commit = PermissionTargetAction() + rollback = PermissionTargetAction() + validate = PermissionTargetAction() + transfer = PermissionTargetAction() + create_recordset = PermissionTargetAction() + + +def resolve_permission( + dataset: models.Spdataset, +) -> Union[Type[DataSetPT], Type[BatchEditDataSet]]: + return BatchEditDataSet if dataset.isupdate else DataSetPT + + def regularize_rows(ncols: int, rows: List[List], skip_empty=True) -> List[List[str]]: n = ncols + 1 # extra row info such as disambiguation in hidden col at end @@ -497,7 +515,7 @@ def dataset(request, ds: models.Spdataset) -> http.HttpResponse: check_permission_targets( request.specify_collection.id, request.specify_user.id, - [DataSetPT.update], + [resolve_permission(ds).update], ) attrs = json.load(request) @@ -555,7 +573,7 @@ def dataset(request, ds: models.Spdataset) -> http.HttpResponse: check_permission_targets( request.specify_collection.id, request.specify_user.id, - [DataSetPT.delete], + [resolve_permission(ds).delete], ) if ds.uploaderstatus is not None: return http.HttpResponse("dataset in use by uploader", status=409) @@ -630,7 +648,9 @@ def rows(request, ds) -> http.HttpResponse: if request.method == "PUT": check_permission_targets( - request.specify_collection.id, request.specify_user.id, [DataSetPT.update] + request.specify_collection.id, + request.specify_user.id, + [resolve_permission(ds).update], ) if ds.uploaderstatus is not None: return http.HttpResponse("dataset in use by uploader.", status=409) @@ -639,7 +659,9 @@ def rows(request, ds) -> http.HttpResponse: "dataset has been uploaded. changing data not allowed.", status=400 ) - rows = regularize_rows(len(ds.columns), json.load(request)) + rows = regularize_rows( + len(ds.columns), json.load(request), skip_empty=(not ds.isupdate) + ) ds.data = rows ds.rowresults = None @@ -681,10 +703,12 @@ def rows(request, ds) -> http.HttpResponse: def upload(request, ds, no_commit: bool, allow_partial: bool) -> http.HttpResponse: "Initiates an upload or validation of dataset ." + do_permission = BatchEditDataSet.commit if ds.isupdate else DataSetPT.upload + check_permission_targets( request.specify_collection.id, request.specify_user.id, - [DataSetPT.validate if no_commit else DataSetPT.upload], + [resolve_permission(ds).validate if no_commit else do_permission], ) with transaction.atomic(): @@ -745,9 +769,9 @@ def upload(request, ds, no_commit: bool, allow_partial: bool) -> http.HttpRespon @models.Spdataset.validate_dataset_request(raise_404=True, lock_object=True) def unupload(request, ds) -> http.HttpResponse: "Initiates an unupload of dataset ." - + permission = BatchEditDataSet.rollback if ds.isupdate else DataSetPT.unupload check_permission_targets( - request.specify_collection.id, request.specify_user.id, [DataSetPT.unupload] + request.specify_collection.id, request.specify_user.id, [permission] ) with transaction.atomic(): @@ -958,15 +982,17 @@ def upload_results(request, ds) -> http.HttpResponse: @require_POST def validate_row(request, ds_id: str) -> http.HttpResponse: "Validates a single row for dataset . The row data is passed as POST parameters." + ds = get_object_or_404(models.Spdataset, id=ds_id) check_permission_targets( - request.specify_collection.id, request.specify_user.id, [DataSetPT.validate] + request.specify_collection.id, + request.specify_user.id, + [resolve_permission(ds).validate], ) - ds = get_object_or_404(models.Spdataset, id=ds_id) collection = request.specify_collection bt, upload_plan = uploader.get_ds_upload_plan(collection, ds) row = json.loads(request.body) ncols = len(ds.columns) - rows = regularize_rows(ncols, [row]) + rows = regularize_rows(ncols, [row], skip_empty=(not ds.isupdate)) if not rows: return http.JsonResponse(None, safe=False) row = rows[0] @@ -1044,11 +1070,13 @@ def transfer(request, ds_id: int) -> http.HttpResponse: if "specifyuserid" not in request.POST: return http.HttpResponseBadRequest("missing parameter: specifyuserid") + ds = get_object_or_404(models.Spdataset, id=ds_id) check_permission_targets( - request.specify_collection.id, request.specify_user.id, [DataSetPT.transfer] + request.specify_collection.id, + request.specify_user.id, + [resolve_permission(ds).transfer], ) - ds = get_object_or_404(models.Spdataset, id=ds_id) if ds.specifyuser != request.specify_user: return http.HttpResponseForbidden() @@ -1133,7 +1161,7 @@ def create_recordset(request, ds) -> http.HttpResponse: check_permission_targets( request.specify_collection.id, request.specify_user.id, - [DataSetPT.create_recordset], + [resolve_permission(ds).create_recordset], ) check_table_permissions( request.specify_collection, request.specify_user, Recordset, "create" From f3913c53375746437ad836a42d40f3a128968b5b Mon Sep 17 00:00:00 2001 From: realVinayak Date: Thu, 22 Aug 2024 09:57:13 -0500 Subject: [PATCH 35/63] (batch-edit): UI changes --- specifyweb/frontend/js_src/css/main.css | 2 +- .../frontend/js_src/lib/components/WbUtils/index.tsx | 8 +++++++- .../frontend/js_src/lib/components/WorkBench/Results.tsx | 4 ++-- .../js_src/lib/components/WorkBench/WbValidation.tsx | 3 +++ .../frontend/js_src/lib/components/WorkBench/WbView.tsx | 1 + 5 files changed, 14 insertions(+), 4 deletions(-) diff --git a/specifyweb/frontend/js_src/css/main.css b/specifyweb/frontend/js_src/css/main.css index 43d23842262..0015bfaa2df 100644 --- a/specifyweb/frontend/js_src/css/main.css +++ b/specifyweb/frontend/js_src/css/main.css @@ -256,7 +256,7 @@ --search-result: theme('colors.green.300'); --updated-cell: theme('colors.cyan.200'); --deleted-cell: theme('colors.brand.100'); - --matched-and-changed-cell: theme('colors.gray.200'); + --matched-and-changed-cell: theme('colors.blue.200'); @apply dark:[--invalid-cell:theme('colors.red.900')] dark:[--modified-cell:theme('colors.yellow.900')] dark:[--new-cell:theme('colors.indigo.900')] diff --git a/specifyweb/frontend/js_src/lib/components/WbUtils/index.tsx b/specifyweb/frontend/js_src/lib/components/WbUtils/index.tsx index cde752f5a46..9ab9f0ae0f5 100644 --- a/specifyweb/frontend/js_src/lib/components/WbUtils/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/WbUtils/index.tsx @@ -14,6 +14,7 @@ import type { WbUtils } from './Utils'; export function WbUtilsComponent({ isUploaded, + isUpdate, cellCounts, utils, cells, @@ -21,6 +22,7 @@ export function WbUtilsComponent({ searchRef, }: { readonly isUploaded: boolean; + readonly isUpdate: boolean; readonly cellCounts: WbCellCounts; readonly utils: WbUtils; readonly cells: WbCellMeta; @@ -127,7 +129,9 @@ export function WbUtilsComponent({ totalCount={cellCounts.newCells} utils={utils} /> - + + } {!isUploaded && (
      - {Object.entries(recordCounts).sort(sortFunction(([value])=>value)).map( + {Object.entries(recordCounts).sort(sortFunction(([value])=>RecordCountPriority.indexOf(value))).map( ([resultType, recordsPerType], id)=>)}
    diff --git a/specifyweb/frontend/js_src/lib/components/WorkBench/WbValidation.tsx b/specifyweb/frontend/js_src/lib/components/WorkBench/WbValidation.tsx index 05ee35f733b..a93c4b4e0ae 100644 --- a/specifyweb/frontend/js_src/lib/components/WorkBench/WbValidation.tsx +++ b/specifyweb/frontend/js_src/lib/components/WorkBench/WbValidation.tsx @@ -35,6 +35,9 @@ type RecordCountsKey = keyof Pick, number>>>>; +export const RecordCountPriority: RA = ['Updated', 'Uploaded', 'MatchedAndChanged', 'Deleted']; + + type UploadResults = { readonly ambiguousMatches: WritableArray< WritableArray<{ diff --git a/specifyweb/frontend/js_src/lib/components/WorkBench/WbView.tsx b/specifyweb/frontend/js_src/lib/components/WorkBench/WbView.tsx index 25d12435fc7..0693476e263 100644 --- a/specifyweb/frontend/js_src/lib/components/WorkBench/WbView.tsx +++ b/specifyweb/frontend/js_src/lib/components/WorkBench/WbView.tsx @@ -251,6 +251,7 @@ export function WbView({
    {disambiguationDialogs} Date: Thu, 22 Aug 2024 10:02:48 -0500 Subject: [PATCH 36/63] (test): localization test resolve --- specifyweb/frontend/js_src/lib/localization/batchEdit.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/localization/batchEdit.ts b/specifyweb/frontend/js_src/lib/localization/batchEdit.ts index 4bc643c437d..231e62dba18 100644 --- a/specifyweb/frontend/js_src/lib/localization/batchEdit.ts +++ b/specifyweb/frontend/js_src/lib/localization/batchEdit.ts @@ -52,8 +52,5 @@ export const batchEditText = createDictionary({ committing: { 'en-us': 'Committing' }, - nullRecord: { - 'en-us': "(Not included in the query results)" - } } as const) \ No newline at end of file From aca7b6636e7a08ff75ae6ee9b94a34af85d0a0d2 Mon Sep 17 00:00:00 2001 From: realVinayak Date: Thu, 22 Aug 2024 15:06:34 +0000 Subject: [PATCH 37/63] Lint code with ESLint and Prettier Triggered by c09ad245a8db46c673eb3c8e9cceb087389de3b4 on branch refs/heads/wb_improvements --- .../js_src/lib/localization/batchEdit.ts | 99 ++++++++++--------- 1 file changed, 51 insertions(+), 48 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/localization/batchEdit.ts b/specifyweb/frontend/js_src/lib/localization/batchEdit.ts index 231e62dba18..e836d80319d 100644 --- a/specifyweb/frontend/js_src/lib/localization/batchEdit.ts +++ b/specifyweb/frontend/js_src/lib/localization/batchEdit.ts @@ -4,53 +4,56 @@ * @module */ -import { createDictionary } from "./utils"; +import { createDictionary } from './utils'; export const batchEditText = createDictionary({ - batchEdit: { - 'en-us': "Batch Edit" - }, - numberOfRecords: { - 'en-us': "Number of records selected from the query" - }, - removeField: { - 'en-us': "Field not supported for batch edit. Either remove the field, or make it hidden." - }, - addTreeRank: { - 'en-us': "Please add the following missing rank to the query", - }, - datasetName: { - 'en-us': "{queryName:string} {datePart:string}" - }, - errorInQuery: { - 'en-us': "Following errors were found in the query" - }, - createUpdateDataSetInstructions: { - 'en-us': "Use the query builder to make a new batch edit dataset" - }, - showRollback: { - 'en-us': "Show revert button" - }, - showRollbackDescription: { - 'en-us': "Revert is currently an experimental feature. This preference will hide the button" - }, - commit: { - 'en-us': 'Commit' - }, - startCommitDescription: { - 'en-us': 'Commiting the Data Set will update, add, and delete the data from the spreadsheet to the Specify database.', - }, - startRevertDescription: { - 'en-us': "Rolling back the dataset will re-update the values, delete created records, and create new records" - }, - commitSuccessfulDescription: { - 'en-us': `Click on the "Results" button to see the number of records affected in each database table`, - }, - dateSetRevertDescription: { - 'en-us': `This Rolledback Data Set is saved, however, it cannot be edit. Please re-run the query` - }, - committing: { - 'en-us': 'Committing' - }, - -} as const) \ No newline at end of file + batchEdit: { + 'en-us': 'Batch Edit', + }, + numberOfRecords: { + 'en-us': 'Number of records selected from the query', + }, + removeField: { + 'en-us': + 'Field not supported for batch edit. Either remove the field, or make it hidden.', + }, + addTreeRank: { + 'en-us': 'Please add the following missing rank to the query', + }, + datasetName: { + 'en-us': '{queryName:string} {datePart:string}', + }, + errorInQuery: { + 'en-us': 'Following errors were found in the query', + }, + createUpdateDataSetInstructions: { + 'en-us': 'Use the query builder to make a new batch edit dataset', + }, + showRollback: { + 'en-us': 'Show revert button', + }, + showRollbackDescription: { + 'en-us': + 'Revert is currently an experimental feature. This preference will hide the button', + }, + commit: { + 'en-us': 'Commit', + }, + startCommitDescription: { + 'en-us': + 'Commiting the Data Set will update, add, and delete the data from the spreadsheet to the Specify database.', + }, + startRevertDescription: { + 'en-us': + 'Rolling back the dataset will re-update the values, delete created records, and create new records', + }, + commitSuccessfulDescription: { + 'en-us': `Click on the "Results" button to see the number of records affected in each database table`, + }, + dateSetRevertDescription: { + 'en-us': `This Rolledback Data Set is saved, however, it cannot be edit. Please re-run the query`, + }, + committing: { + 'en-us': 'Committing', + }, +} as const); From c0f26b6e7bba65a41605c13511bc1067d8a38b5e Mon Sep 17 00:00:00 2001 From: realVinayak Date: Thu, 22 Aug 2024 14:57:58 -0500 Subject: [PATCH 38/63] (build): Add back changed build file --- .github/workflows/docker.yml | 133 +++++++++++++++++++++-------------- 1 file changed, 82 insertions(+), 51 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index b8628446084..f96b7b17b15 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -4,8 +4,11 @@ on: push: pull_request: +env: + REGISTRY_IMAGE: specifyconsortium/specify7-service + jobs: - dockerize: + build: runs-on: ubuntu-latest # "push" event is not triggered for forks, so need to listen for # pull_requests, but only for external ones, so as not to run the action @@ -13,16 +16,17 @@ jobs: if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == true + strategy: + fail-fast: false + matrix: + platform: + - linux/amd64 + - linux/arm64 steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Prepare id: prep - env: - DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} run: | - DOCKER_IMAGE=specifyconsortium/specify7-service + DOCKER_IMAGE=${{ env.REGISTRY_IMAGE }} VERSION=noop if [ "${{ github.event_name }}" = "schedule" ]; then VERSION=nightly @@ -54,53 +58,80 @@ jobs: echo "version=${VERSION}" >> $GITHUB_OUTPUT echo "tags=${TAGS}" >> $GITHUB_OUTPUT echo "created=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> $GITHUB_OUTPUT - - # Determine the platforms based on the branch - PLATFORMS="linux/amd64" - REF_NAME=$(echo "${GITHUB_REF#refs/*/}") - if [[ $GITHUB_REF == refs/tags/* || "$REF_NAME" = "$DEFAULT_BRANCH" ]]; then - PLATFORMS="linux/amd64,linux/arm64" - elif [[ "$REF_NAME" == *arm ]]; then - PLATFORMS="linux/arm64" - fi - echo "platforms=${PLATFORMS}" >> $GITHUB_ENV - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 - - - name: Cache Docker layers - uses: actions/cache@v3 + + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 with: - path: /tmp/.buildx-cache - key: ${{ runner.os }}-buildx-${{ github.sha }} - restore-keys: | - ${{ runner.os }}-buildx- - - - name: Login to DockerHub - uses: docker/login-action@v2 + images: ${{ env.REGISTRY_IMAGE }} + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Docker Hub + uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push by digest + id: build + uses: docker/build-push-action@v6 + with: + platforms: ${{ matrix.platform }} + labels: ${{ steps.meta.outputs.labels }} + outputs: type=image,name=${{ env.REGISTRY_IMAGE }},push-by-digest=true,name-canonical=true,push=true + + - name: Export digest + run: | + mkdir -p /tmp/digests + digest="${{ steps.build.outputs.digest }}" + touch "/tmp/digests/${digest#sha256:}" + + - name: Upload digest + uses: actions/upload-artifact@v4 + with: + name: digests-${{ strategy.job-index }} + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 - - name: Build and push - uses: docker/build-push-action@v3 + merge: + runs-on: ubuntu-latest + needs: + - build + steps: + - name: Download digests + uses: actions/download-artifact@v4 + with: + path: /tmp/digests + pattern: digests-* + merge-multiple: true + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 with: - context: . - file: ./Dockerfile - build-args: | - BUILD_VERSION=${{ steps.prep.outputs.version }} - GIT_SHA=${{ github.sha }} - push: true - cache-from: type=local,src=/tmp/.buildx-cache - cache-to: type=local,dest=/tmp/.buildx-cache - tags: ${{ steps.prep.outputs.tags }} - labels: | - org.opencontainers.image.title=${{ github.event.repository.name }} - org.opencontainers.image.description=${{ github.event.repository.description }} - org.opencontainers.image.url=${{ github.event.repository.html_url }} - org.opencontainers.image.source=${{ github.event.repository.clone_url }} - org.opencontainers.image.version=${{ steps.prep.outputs.version }} - org.opencontainers.image.created=${{ steps.prep.outputs.created }} - org.opencontainers.image.revision=${{ github.sha }} - org.opencontainers.image.licenses=${{ github.event.repository.license.spdx_id }} - platforms: ${{ env.platforms }} + images: ${{ env.REGISTRY_IMAGE }} + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Create manifest list and push + working-directory: /tmp/digests + run: | + docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ + $(printf '${{ env.REGISTRY_IMAGE }}@sha256:%s ' *) + + - name: Inspect image + run: | + docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:${{ steps.meta.outputs.version }} \ No newline at end of file From 3dcb5ceb346d109ce4d6ab67f1b63cf5bbfd82e4 Mon Sep 17 00:00:00 2001 From: realVinayak Date: Fri, 23 Aug 2024 09:05:25 -0500 Subject: [PATCH 39/63] (batch-edit): make tree query more robust --- .../js_src/lib/components/BatchEdit/index.tsx | 8 ++++---- specifyweb/stored_queries/batch_edit.py | 12 ++++++------ 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx b/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx index e8b354be9bb..206188a592d 100644 --- a/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx @@ -23,7 +23,7 @@ import { userPreferences } from '../Preferences/userPreferences'; import { SpecifyTable } from '../DataModel/specifyTable'; import { H2, H3 } from '../Atoms'; import { TableIcon } from '../Molecules/TableIcon'; -import { relationshipIsToMany } from '../WbPlanView/mappingHelpers'; +import { anyTreeRank, relationshipIsToMany } from '../WbPlanView/mappingHelpers'; import { strictGetTable } from '../DataModel/tables'; export function BatchEditFromQuery({ @@ -103,12 +103,12 @@ function containsFaultyNestedToMany(queryFieldSpec: QueryFieldSpec) : undefined const getTreeDefFromName = (rankName: string, treeDefItems: RA>)=>defined(treeDefItems.find((treeRank)=>treeRank.name.toLowerCase() === rankName.toLowerCase())); function findAllMissing(queryFieldSpecs: RA): QueryError['missingRanks'] { - const treeFieldSpecs = group(queryFieldSpecs.filter((fieldSpec)=>isTreeTable(fieldSpec.table.name)).map((spec)=>[spec.table, spec.treeRank])); + const treeFieldSpecs = group(queryFieldSpecs.filter((fieldSpec)=>isTreeTable(fieldSpec.table.name) && fieldSpec.treeRank !== anyTreeRank).map((spec)=>[spec.table, spec.treeRank])); return Object.fromEntries(treeFieldSpecs.map(([treeTable, treeRanks])=>[treeTable.name, findMissingRanks(treeTable, treeRanks)])) } -function findMissingRanks(treeTable: SpecifyTable, treeRanks: RA) { - if (treeRanks.every((rank)=>(rank === undefined))) {}; +function findMissingRanks(treeTable: SpecifyTable, treeRanks: RA): RA { + if (treeRanks.every((rank)=>(rank === undefined))) return []; const allTreeDefItems = strictGetTreeDefinitionItems(treeTable.name as "Geography", false); diff --git a/specifyweb/stored_queries/batch_edit.py b/specifyweb/stored_queries/batch_edit.py index 9eb67303260..2c5bcfb1522 100644 --- a/specifyweb/stored_queries/batch_edit.py +++ b/specifyweb/stored_queries/batch_edit.py @@ -174,7 +174,7 @@ def to_json(self) -> Dict[str, Any]: # we not only care that it is part of tree, but also care that there is rank to tree def is_part_of_tree(self, query_fields: List[QueryField]) -> bool: - if self.id is None or self.id.value is None or self.id.value == NULL_RECORD: + if self.id.idx is None: return False id_field = self.id.idx field = query_fields[id_field - 1] @@ -332,7 +332,7 @@ def _recur_row_plan( # No, this doesn't mean IDs of the formatted/aggregated are including (that is impossible) batch_edit_pack = BatchEditPack.from_field_spec(partial_field_spec) - if node is None or (len(rest) == 0): + if len(rest) == 0: # we are at the end return RowPlanMap( columns=[BatchEditFieldPack(field=original_field)], @@ -435,10 +435,10 @@ def nullify(self, ignore_naive=False) -> "RowPlanCanonical": to_ones = { key: value.nullify(not is_null) for (key, value) in self.to_one.items() } - batch_edit_pack = BatchEditPack( - id=BatchEditFieldPack(value=(NULL_RECORD if is_null else None)), - order=EMPTY_FIELD, - version=EMPTY_FIELD, + batch_edit_pack = self.batch_edit_pack._replace( + id=self.batch_edit_pack.id._replace( + value=(NULL_RECORD if is_null else None) + ) ) return RowPlanCanonical(batch_edit_pack, columns, to_ones) From afc63620867dd46abdde9b677e02cc851e01c425 Mon Sep 17 00:00:00 2001 From: realVinayak Date: Fri, 23 Aug 2024 14:09:17 +0000 Subject: [PATCH 40/63] Lint code with ESLint and Prettier Triggered by 3dcb5ceb346d109ce4d6ab67f1b63cf5bbfd82e4 on branch refs/heads/wb_improvements --- .../js_src/lib/components/BatchEdit/index.tsx | 307 +++++++++++++----- 1 file changed, 218 insertions(+), 89 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx b/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx index 206188a592d..e937ae39848 100644 --- a/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx @@ -1,142 +1,271 @@ import React from 'react'; -import { SpecifyResource } from '../DataModel/legacyTypes'; -import { GeographyTreeDefItem, SpQuery, Tables } from '../DataModel/types'; -import { Button } from '../Atoms/Button'; import { useNavigate } from 'react-router-dom'; -import { keysToLowerCase, sortFunction, group } from '../../utils/utils'; -import { serializeResource } from '../DataModel/serializers'; -import { ajax } from '../../utils/ajax'; -import { QueryField } from '../QueryBuilder/helpers'; -import { defined, filterArray, RA } from '../../utils/types'; -import { generateMappingPathPreview } from '../WbPlanView/mappingPreview'; + import { batchEditText } from '../../localization/batchEdit'; -import { uniquifyDataSetName } from '../WbImport/helpers'; -import { QueryFieldSpec } from '../QueryBuilder/fieldSpec'; -import {isTreeTable, strictGetTreeDefinitionItems, treeRanksPromise } from '../InitialContext/treeRanks'; -import { AnyTree, SerializedResource } from '../DataModel/helperTypes'; +import { commonText } from '../../localization/common'; +import { ajax } from '../../utils/ajax'; import { f } from '../../utils/functools'; +import type { RA } from '../../utils/types'; +import { defined, filterArray } from '../../utils/types'; +import { group, keysToLowerCase, sortFunction } from '../../utils/utils'; +import { H2, H3 } from '../Atoms'; +import { Button } from '../Atoms/Button'; +import { dialogIcons } from '../Atoms/Icons'; import { LoadingContext } from '../Core/Contexts'; -import { commonText } from '../../localization/common'; +import type { AnyTree, SerializedResource } from '../DataModel/helperTypes'; +import type { SpecifyResource } from '../DataModel/legacyTypes'; +import { serializeResource } from '../DataModel/serializers'; +import type { SpecifyTable } from '../DataModel/specifyTable'; +import { strictGetTable } from '../DataModel/tables'; +import type { GeographyTreeDefItem, SpQuery, Tables } from '../DataModel/types'; +import { + isTreeTable, + strictGetTreeDefinitionItems, + treeRanksPromise, +} from '../InitialContext/treeRanks'; import { Dialog } from '../Molecules/Dialog'; -import { dialogIcons } from '../Atoms/Icons'; -import { userPreferences } from '../Preferences/userPreferences'; -import { SpecifyTable } from '../DataModel/specifyTable'; -import { H2, H3 } from '../Atoms'; import { TableIcon } from '../Molecules/TableIcon'; -import { anyTreeRank, relationshipIsToMany } from '../WbPlanView/mappingHelpers'; -import { strictGetTable } from '../DataModel/tables'; +import { userPreferences } from '../Preferences/userPreferences'; +import { QueryFieldSpec } from '../QueryBuilder/fieldSpec'; +import type { QueryField } from '../QueryBuilder/helpers'; +import { uniquifyDataSetName } from '../WbImport/helpers'; +import { + anyTreeRank, + relationshipIsToMany, +} from '../WbPlanView/mappingHelpers'; +import { generateMappingPathPreview } from '../WbPlanView/mappingPreview'; export function BatchEditFromQuery({ query, fields, baseTableName, - recordSetId + recordSetId, }: { readonly query: SpecifyResource; readonly fields: RA; readonly baseTableName: keyof Tables; - readonly recordSetId?: number + readonly recordSetId?: number; }) { const navigate = useNavigate(); - const post = (dataSetName: string) => - ajax<{ id: number }>('/stored_query/batch_edit/', { + const post = async (dataSetName: string) => + ajax<{ readonly id: number }>('/stored_query/batch_edit/', { method: 'POST', errorMode: 'dismissible', headers: { Accept: 'application/json' }, - body: keysToLowerCase( - { - ...serializeResource(query), - captions: fields.filter(({isDisplay})=>isDisplay).map(({mappingPath})=>generateMappingPathPreview(baseTableName, mappingPath)), - name: dataSetName, - recordSetId, - limit: userPreferences.get('batchEdit', 'query', 'limit') - }), + body: keysToLowerCase({ + ...serializeResource(query), + captions: fields + .filter(({ isDisplay }) => isDisplay) + .map(({ mappingPath }) => + generateMappingPathPreview(baseTableName, mappingPath) + ), + name: dataSetName, + recordSetId, + limit: userPreferences.get('batchEdit', 'query', 'limit'), + }), }); - const [errors, setErrors] = React.useState(undefined); + const [errors, setErrors] = React.useState(undefined); const loading = React.useContext(LoadingContext); return ( <> - { - loading( - treeRanksPromise.then(()=>{ - const queryFieldSpecs = filterArray(fields.map((field)=>field.isDisplay ? QueryFieldSpec.fromPath(baseTableName, field.mappingPath) : undefined)); - const missingRanks = findAllMissing(queryFieldSpecs); - const invalidFields = filterArray(queryFieldSpecs.map(containsFaultyNestedToMany)); - - const hasErrors = (Object.values(missingRanks).some((ranks)=>ranks.length > 0) || (invalidFields.length > 0)); - - if (hasErrors) { - setErrors({missingRanks, invalidFields}); - return - } - - const newName = batchEditText.datasetName({queryName: query.get('name'), datePart: new Date().toDateString()}); - return uniquifyDataSetName(newName, undefined, 'batchEdit').then((name)=>post(name).then(({ data }) => navigate(`/specify/workbench/${data.id}`))) - }) - ) - } - } - > - <>{batchEditText.batchEdit()} - - {errors !== undefined && setErrors(undefined)}/>} + { + loading( + treeRanksPromise.then(async () => { + const queryFieldSpecs = filterArray( + fields.map((field) => + field.isDisplay + ? QueryFieldSpec.fromPath(baseTableName, field.mappingPath) + : undefined + ) + ); + const missingRanks = findAllMissing(queryFieldSpecs); + const invalidFields = filterArray( + queryFieldSpecs.map(containsFaultyNestedToMany) + ); + + const hasErrors = + Object.values(missingRanks).some((ranks) => ranks.length > 0) || + invalidFields.length > 0; + + if (hasErrors) { + setErrors({ missingRanks, invalidFields }); + return; + } + + const newName = batchEditText.datasetName({ + queryName: query.get('name'), + datePart: new Date().toDateString(), + }); + return uniquifyDataSetName(newName, undefined, 'batchEdit').then( + async (name) => + post(name).then(({ data }) => + navigate(`/specify/workbench/${data.id}`) + ) + ); + }) + ); + }} + > + <>{batchEditText.batchEdit()} + + {errors !== undefined && ( + setErrors(undefined)} /> + )} ); } type QueryError = { readonly missingRanks: { - // Query can contain relationship to multiple trees - readonly [KEY in AnyTree['tableName']]: RA - }, - readonly invalidFields: RA + readonly // Query can contain relationship to multiple trees + [KEY in AnyTree['tableName']]: RA; + }; + readonly invalidFields: RA; }; -function containsFaultyNestedToMany(queryFieldSpec: QueryFieldSpec) : undefined | string { - const joinPath = queryFieldSpec.joinPath +function containsFaultyNestedToMany( + queryFieldSpec: QueryFieldSpec +): string | undefined { + const joinPath = queryFieldSpec.joinPath; if (joinPath.length <= 1) return undefined; - const nestedToManyCount = joinPath.filter((relationship)=>relationship.isRelationship && relationshipIsToMany(relationship)); - return nestedToManyCount.length > 1 ? (generateMappingPathPreview(queryFieldSpec.baseTable.name, queryFieldSpec.toMappingPath())) : undefined -} + const nestedToManyCount = joinPath.filter( + (relationship) => + relationship.isRelationship && relationshipIsToMany(relationship) + ); + return nestedToManyCount.length > 1 + ? generateMappingPathPreview( + queryFieldSpec.baseTable.name, + queryFieldSpec.toMappingPath() + ) + : undefined; +} -const getTreeDefFromName = (rankName: string, treeDefItems: RA>)=>defined(treeDefItems.find((treeRank)=>treeRank.name.toLowerCase() === rankName.toLowerCase())); +const getTreeDefFromName = ( + rankName: string, + treeDefItems: RA> +) => + defined( + treeDefItems.find( + (treeRank) => treeRank.name.toLowerCase() === rankName.toLowerCase() + ) + ); -function findAllMissing(queryFieldSpecs: RA): QueryError['missingRanks'] { - const treeFieldSpecs = group(queryFieldSpecs.filter((fieldSpec)=>isTreeTable(fieldSpec.table.name) && fieldSpec.treeRank !== anyTreeRank).map((spec)=>[spec.table, spec.treeRank])); - return Object.fromEntries(treeFieldSpecs.map(([treeTable, treeRanks])=>[treeTable.name, findMissingRanks(treeTable, treeRanks)])) +function findAllMissing( + queryFieldSpecs: RA +): QueryError['missingRanks'] { + const treeFieldSpecs = group( + queryFieldSpecs + .filter( + (fieldSpec) => + isTreeTable(fieldSpec.table.name) && + fieldSpec.treeRank !== anyTreeRank + ) + .map((spec) => [spec.table, spec.treeRank]) + ); + return Object.fromEntries( + treeFieldSpecs.map(([treeTable, treeRanks]) => [ + treeTable.name, + findMissingRanks(treeTable, treeRanks), + ]) + ); } -function findMissingRanks(treeTable: SpecifyTable, treeRanks: RA): RA { - if (treeRanks.every((rank)=>(rank === undefined))) return []; +function findMissingRanks( + treeTable: SpecifyTable, + treeRanks: RA +): RA { + if (treeRanks.every((rank) => rank === undefined)) return []; - const allTreeDefItems = strictGetTreeDefinitionItems(treeTable.name as "Geography", false); + const allTreeDefItems = strictGetTreeDefinitionItems( + treeTable.name as 'Geography', + false + ); // Duplicates don't affect any logic here - const currentTreeRanks = filterArray(treeRanks.map((treeRank)=>f.maybe(treeRank, (name)=>getTreeDefFromName(name, allTreeDefItems)))); + const currentTreeRanks = filterArray( + treeRanks.map((treeRank) => + f.maybe(treeRank, (name) => getTreeDefFromName(name, allTreeDefItems)) + ) + ); - const currentRanksSorted = [...currentTreeRanks].sort(sortFunction(({rankId})=>rankId, true)); + const currentRanksSorted = Array.from(currentTreeRanks).sort( + sortFunction(({ rankId }) => rankId, true) + ); const highestRank = currentRanksSorted[0]; - const ranksBelow = allTreeDefItems.filter(({rankId, name})=>rankId > highestRank.rankId && !currentTreeRanks.find((rank)=>rank.name === name)); + const ranksBelow = allTreeDefItems.filter( + ({ rankId, name }) => + rankId > highestRank.rankId && + !currentTreeRanks.find((rank) => rank.name === name) + ); - return ranksBelow.map((rank)=>rank.name) + return ranksBelow.map((rank) => rank.name); } -function ErrorsDialog({errors, onClose: handleClose}:{readonly errors: QueryError; readonly onClose: ()=>void }): JSX.Element { - return +function ErrorsDialog({ + errors, + onClose: handleClose, +}: { + readonly errors: QueryError; + readonly onClose: () => void; +}): JSX.Element { + return ( + - - + + + ); } -function ShowInvalidFields({error}: {readonly error: QueryError['invalidFields']}){ +function ShowInvalidFields({ + error, +}: { + readonly error: QueryError['invalidFields']; +}) { const hasErrors = error.length > 0; - return hasErrors ?

    {batchEditText.removeField()}

    {error.map((singleError)=>

    {singleError}

    )}
    : null + return hasErrors ? ( +
    +
    +

    {batchEditText.removeField()}

    +
    + {error.map((singleError) => ( +

    {singleError}

    + ))} +
    + ) : null; +} + +function ShowMissingRanks({ + error, +}: { + readonly error: QueryError['missingRanks']; +}) { + const hasMissing = Object.values(error).some((rank) => rank.length > 0); + return hasMissing ? ( +
    +
    +

    {batchEditText.addTreeRank()}

    +
    + {Object.entries(error).map(([treeTable, ranks]) => ( +
    +
    + +

    {strictGetTable(treeTable).label}

    +
    +
    + {ranks.map((rank) => ( +

    {rank}

    + ))} +
    +
    + ))} +
    + ) : null; } - -function ShowMissingRanks({error}: {readonly error: QueryError['missingRanks']}) { - const hasMissing = Object.values(error).some((rank)=>rank.length > 0); - return hasMissing ?

    {batchEditText.addTreeRank()}

    {Object.entries(error).map(([treeTable, ranks])=>

    {strictGetTable(treeTable).label}

    {ranks.map((rank)=>

    {rank}

    )}
    )}
    : null -} \ No newline at end of file From 28b823b0f716dd73e5459448e6b884783db94eb8 Mon Sep 17 00:00:00 2001 From: realVinayak Date: Fri, 23 Aug 2024 16:09:45 -0500 Subject: [PATCH 41/63] (batch-edit): enforce name + resolve wrong order --- .../js_src/lib/components/BatchEdit/index.tsx | 28 +++++++++++-------- specifyweb/workbench/upload/treerecord.py | 4 +-- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx b/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx index e937ae39848..b30415b2b85 100644 --- a/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx @@ -34,6 +34,7 @@ import { relationshipIsToMany, } from '../WbPlanView/mappingHelpers'; import { generateMappingPathPreview } from '../WbPlanView/mappingPreview'; +import { LiteralField, Relationship } from '../DataModel/specifyField'; export function BatchEditFromQuery({ query, @@ -155,14 +156,16 @@ function findAllMissing( queryFieldSpecs: RA ): QueryError['missingRanks'] { const treeFieldSpecs = group( + filterArray( queryFieldSpecs - .filter( + .map( (fieldSpec) => isTreeTable(fieldSpec.table.name) && - fieldSpec.treeRank !== anyTreeRank + fieldSpec.treeRank !== anyTreeRank && fieldSpec.treeRank !== undefined ? + [fieldSpec.table, {rank: fieldSpec.treeRank, field: fieldSpec.getField()}] : undefined ) - .map((spec) => [spec.table, spec.treeRank]) - ); + )); + return Object.fromEntries( treeFieldSpecs.map(([treeTable, treeRanks]) => [ treeTable.name, @@ -171,11 +174,13 @@ function findAllMissing( ); } +// TODO: discuss if we need to add more of them, and if we need to add more of them for other table. +const requiredFields = ['name']; + function findMissingRanks( treeTable: SpecifyTable, - treeRanks: RA + treeRanks: RA<{rank: string, field?: Relationship | LiteralField} | undefined> ): RA { - if (treeRanks.every((rank) => rank === undefined)) return []; const allTreeDefItems = strictGetTreeDefinitionItems( treeTable.name as 'Geography', @@ -185,23 +190,22 @@ function findMissingRanks( // Duplicates don't affect any logic here const currentTreeRanks = filterArray( treeRanks.map((treeRank) => - f.maybe(treeRank, (name) => getTreeDefFromName(name, allTreeDefItems)) + f.maybe(treeRank, ({rank, field}) => ({specifyRank: getTreeDefFromName(rank, allTreeDefItems), field})) ) ); const currentRanksSorted = Array.from(currentTreeRanks).sort( - sortFunction(({ rankId }) => rankId, true) + sortFunction(({ specifyRank: {rankId} }) => rankId) ); const highestRank = currentRanksSorted[0]; const ranksBelow = allTreeDefItems.filter( ({ rankId, name }) => - rankId > highestRank.rankId && - !currentTreeRanks.find((rank) => rank.name === name) - ); + rankId >= highestRank.specifyRank.rankId && + !currentTreeRanks.some((rank)=> rank.specifyRank.name === name && rank.field !== undefined && requiredFields.includes(rank.field.name))) - return ranksBelow.map((rank) => rank.name); + return ranksBelow.map((rank) => `${rank.name} ${defined(strictGetTable(treeTable.name).getField('name')).label}`); } function ErrorsDialog({ diff --git a/specifyweb/workbench/upload/treerecord.py b/specifyweb/workbench/upload/treerecord.py index 516da3859e0..901dd825cd9 100644 --- a/specifyweb/workbench/upload/treerecord.py +++ b/specifyweb/workbench/upload/treerecord.py @@ -651,7 +651,7 @@ def _get_reference(self) -> Optional[Dict[str, Any]]: references = {} previous_parent_id = None - for tdi in self.treedefitems: + for tdi in self.treedefitems[::-1]: if tdi.name not in self.batch_edit_pack: continue columns = [pr.column for pr in self.parsedFields[tdi.name]] @@ -666,7 +666,7 @@ def _get_reference(self) -> Optional[Dict[str, Any]]: and previous_parent_id != reference.pk ): raise BusinessRuleException( - "Tree structure changed, please re-run the query" + f"Tree structure changed, please re-run the query. Expected: {previous_parent_id}, got {reference}" ) except (ContetRef, BusinessRuleException) as e: raise BusinessRuleException(str(e), {}, info) From 6a0b7110555a1f850bfccdd1464421e53821b6f6 Mon Sep 17 00:00:00 2001 From: realVinayak Date: Fri, 23 Aug 2024 19:00:42 -0500 Subject: [PATCH 42/63] (batch-edit): minor refactor --- .../js_src/lib/components/BatchEdit/index.tsx | 11 ++++------- .../js_src/lib/components/WorkBench/DataSetMeta.tsx | 2 +- specifyweb/workbench/upload/treerecord.py | 10 +++++++++- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx b/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx index b30415b2b85..26529893bdc 100644 --- a/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx @@ -175,7 +175,7 @@ function findAllMissing( } // TODO: discuss if we need to add more of them, and if we need to add more of them for other table. -const requiredFields = ['name']; +const requiredTreeFields: RA = ['name'] as const; function findMissingRanks( treeTable: SpecifyTable, @@ -200,13 +200,10 @@ function findMissingRanks( const highestRank = currentRanksSorted[0]; - const ranksBelow = allTreeDefItems.filter( + return allTreeDefItems.flatMap( ({ rankId, name }) => - rankId >= highestRank.specifyRank.rankId && - !currentTreeRanks.some((rank)=> rank.specifyRank.name === name && rank.field !== undefined && requiredFields.includes(rank.field.name))) - - return ranksBelow.map((rank) => `${rank.name} ${defined(strictGetTable(treeTable.name).getField('name')).label}`); -} + rankId < highestRank.specifyRank.rankId ? [] : filterArray(requiredTreeFields.map((requiredField)=>!currentTreeRanks.some((rank)=>rank.specifyRank.name === name && rank.field !== undefined && requiredField === rank.field.name) ? `${name} ${defined(strictGetTable(treeTable.name).getField(requiredField)).label}` : undefined))); + } function ErrorsDialog({ errors, diff --git a/specifyweb/frontend/js_src/lib/components/WorkBench/DataSetMeta.tsx b/specifyweb/frontend/js_src/lib/components/WorkBench/DataSetMeta.tsx index d6cf20ab21e..cb5534efff7 100644 --- a/specifyweb/frontend/js_src/lib/components/WorkBench/DataSetMeta.tsx +++ b/specifyweb/frontend/js_src/lib/components/WorkBench/DataSetMeta.tsx @@ -137,7 +137,7 @@ export function DataSetMeta({ { loading( - ping(`${datasetUrl}${dataset.id}/`, { + ping(`${datasetUrl.fetchUrl}${dataset.id}/`, { method: 'DELETE', errorMode: 'dismissible', expectedErrors: [Http.NOT_FOUND, Http.NO_CONTENT], diff --git a/specifyweb/workbench/upload/treerecord.py b/specifyweb/workbench/upload/treerecord.py index 901dd825cd9..b8ea322f13b 100644 --- a/specifyweb/workbench/upload/treerecord.py +++ b/specifyweb/workbench/upload/treerecord.py @@ -618,6 +618,7 @@ def _do_insert(self, model, **kwargs): def _get_inserter(self): def _inserter(model, attrs): obj = model(**attrs) + # TODO: Refactor after merge with production, directly check if table is tree or not. if model.specify_model.get_field("nodenumber"): obj.save(skip_tree_extras=True) else: @@ -636,7 +637,14 @@ def force_upload_row(self) -> UploadResult: def _get_reference(self) -> Optional[Dict[str, Any]]: - FIELDS_TO_SKIP = ["nodenumber", "highestchildnodenumber", "parent_id"] + FIELDS_TO_SKIP = [ + "nodenumber", + "highestchildnodenumber", + "parent_id", + # TODO: Test fullname. Depends on use-cases I guess. + # Skipping them currently because we won't be able to match across branches, without disabling all database fields lookup + "fullname", + ] # Much simpler than uploadTable. Just fetch all rank's references. Since we also require name to be not null, # the "deferForNull" is redundant. We, do, however need to look at deferForMatch, and we are done. From 7244e448944b142869f62bee84b7ee1b7b365dc9 Mon Sep 17 00:00:00 2001 From: realVinayak Date: Tue, 27 Aug 2024 22:22:12 -0500 Subject: [PATCH 43/63] (batch-edit): make deletes more sensible, simplify naiveness --- specifyweb/express_search/related.py | 141 +++++---- .../components/WorkBench/batchEditHelpers.ts | 28 +- .../lib/components/WorkBench/handsontable.ts | 2 +- specifyweb/specify/datamodel.py | 4 + specifyweb/specify/func.py | 4 +- specifyweb/stored_queries/batch_edit.py | 213 ++++++++----- .../stored_queries/tests/test_batch_edit.py | 25 +- .../stored_queries/tests/test_row_plan_map.py | 96 +++++- specifyweb/workbench/upload/predicates.py | 283 ++++++++++++------ specifyweb/workbench/upload/scoping.py | 197 ++++++++---- specifyweb/workbench/upload/treerecord.py | 19 +- specifyweb/workbench/upload/upload_table.py | 79 ++++- specifyweb/workbench/upload/uploadable.py | 101 ++++--- specifyweb/workbench/views.py | 28 +- 14 files changed, 827 insertions(+), 393 deletions(-) diff --git a/specifyweb/express_search/related.py b/specifyweb/express_search/related.py index 4b25ee29736..8d3e3da1288 100644 --- a/specifyweb/express_search/related.py +++ b/specifyweb/express_search/related.py @@ -5,64 +5,76 @@ import logging from ..specify.models import datamodel -from ..stored_queries.execution import build_query +from ..stored_queries.execution import BuildQueryProps, build_query from ..stored_queries.query_ops import QueryOps from ..stored_queries.queryfield import QueryField from ..stored_queries.queryfieldspec import QueryFieldSpec logger = logging.getLogger(__name__) + class F(str): pass + class RelatedSearchMeta(type): def __new__(cls, name, bases, dict): Rs = super(RelatedSearchMeta, cls).__new__(cls, name, bases, dict) if Rs.definitions is None: return Rs - root_table_name = Rs.definitions[0].split('.')[0] + root_table_name = Rs.definitions[0].split(".")[0] Rs.root = datamodel.get_table(root_table_name, strict=True) def col_to_fs(col, add_id=False): - return QueryFieldSpec.from_path( [root_table_name] + col.split('.'), add_id ) + return QueryFieldSpec.from_path([root_table_name] + col.split("."), add_id) Rs.display_fields = [ - QueryField(fieldspec=col_to_fs(col), - op_num=1, - value="", - negate=False, - display=True, - format_name=None, - sort_type=0) - for col in Rs.columns] - - if Rs.link: - Rs.display_fields.append(QueryField( - fieldspec=col_to_fs(Rs.link, add_id=True), + QueryField( + fieldspec=col_to_fs(col), op_num=1, value="", negate=False, display=True, format_name=None, - sort_type=0)) + sort_type=0, + ) + for col in Rs.columns + ] + + if Rs.link: + Rs.display_fields.append( + QueryField( + fieldspec=col_to_fs(Rs.link, add_id=True), + op_num=1, + value="", + negate=False, + display=True, + format_name=None, + sort_type=0, + ) + ) def make_filter(f, negate): field, op, val = f - return QueryField(fieldspec=col_to_fs(field), - op_num=QueryOps.OPERATIONS.index(op.__name__), - value=col_to_fs(val) if isinstance(val, F) else val, - negate=negate, - display=False, - format_name=None, - sort_type=0) + return QueryField( + fieldspec=col_to_fs(field), + op_num=QueryOps.OPERATIONS.index(op.__name__), + value=col_to_fs(val) if isinstance(val, F) else val, + negate=negate, + display=False, + format_name=None, + sort_type=0, + ) - Rs.filter_fields = [make_filter(f, False) for f in Rs.filters] + \ - [make_filter(f, True) for f in Rs.excludes] + Rs.filter_fields = [make_filter(f, False) for f in Rs.filters] + [ + make_filter(f, True) for f in Rs.excludes + ] return Rs + class RelatedSearch(object, metaclass=RelatedSearchMeta): distinct = False filters = [] @@ -73,9 +85,14 @@ class RelatedSearch(object, metaclass=RelatedSearchMeta): @classmethod def execute(cls, session, config, terms, collection, user, limit, offset): - queries = [_f for _f in ( - cls(defn).build_related_query(session, config, terms, collection, user) - for defn in cls.definitions) if _f] + queries = [ + _f + for _f in ( + cls(defn).build_related_query(session, config, terms, collection, user) + for defn in cls.definitions + ) + if _f + ] if len(queries) > 0: query = queries[0].union(*queries[1:]) @@ -86,52 +103,67 @@ def execute(cls, session, config, terms, collection, user, limit, offset): results = [] return { - 'totalCount': count, - 'results': results, - 'definition': { - 'name': cls.__name__, - 'root': cls.root.name, - 'link': cls.link, - 'columns': cls.columns, - 'fieldSpecs': [{'stringId': fs.to_stringid(), 'isRelationship': fs.is_relationship()} - for field in cls.display_fields - for fs in [field.fieldspec]]}} + "totalCount": count, + "results": results, + "definition": { + "name": cls.__name__, + "root": cls.root.name, + "link": cls.link, + "columns": cls.columns, + "fieldSpecs": [ + { + "stringId": fs.to_stringid(), + "isRelationship": fs.is_relationship(), + } + for field in cls.display_fields + for fs in [field.fieldspec] + ], + }, + } def __init__(self, definition): self.definition = definition def build_related_query(self, session, config, terms, collection, user): - logger.info('%s: building related query using definition: %s', - self.__class__.__name__, self.definition) + logger.info( + "%s: building related query using definition: %s", + self.__class__.__name__, + self.definition, + ) from .views import build_primary_query - primary_fieldspec = QueryFieldSpec.from_path(self.definition.split('.'), add_id=True) + primary_fieldspec = QueryFieldSpec.from_path( + self.definition.split("."), add_id=True + ) pivot = primary_fieldspec.table - logger.debug('pivoting on: %s', pivot) - for searchtable in config.findall('tables/searchtable'): - if searchtable.find('tableName').text == pivot.name: + logger.debug("pivoting on: %s", pivot) + for searchtable in config.findall("tables/searchtable"): + if searchtable.find("tableName").text == pivot.name: break else: return None - logger.debug('using %s for primary search', searchtable.find('tableName').text) - primary_query = build_primary_query(session, searchtable, terms, collection, user, as_scalar=True) + logger.debug("using %s for primary search", searchtable.find("tableName").text) + primary_query = build_primary_query( + session, searchtable, terms, collection, user, as_scalar=True + ) if primary_query is None: return None - logger.debug('primary query: %s', primary_query) + logger.debug("primary query: %s", primary_query) primary_field = QueryField( fieldspec=primary_fieldspec, - op_num=QueryOps.OPERATIONS.index('op_in'), + op_num=QueryOps.OPERATIONS.index("op_in"), value=primary_query, negate=False, display=False, format_name=None, - sort_type=0) + sort_type=0, + ) logger.debug("primary queryfield: %s", primary_field) logger.debug("display queryfields: %s", self.display_fields) @@ -139,10 +171,17 @@ def build_related_query(self, session, config, terms, collection, user): queryfields = self.display_fields + self.filter_fields + [primary_field] - related_query, _ = build_query(session, collection, user, self.root.tableId, queryfields, implicit_or=False) + related_query, _ = build_query( + session, + collection, + user, + self.root.tableId, + queryfields, + props=BuildQueryProps(implicit_or=True), + ) if self.distinct: related_query = related_query.distinct() - logger.debug('related query: %s', related_query) + logger.debug("related query: %s", related_query) return related_query diff --git a/specifyweb/frontend/js_src/lib/components/WorkBench/batchEditHelpers.ts b/specifyweb/frontend/js_src/lib/components/WorkBench/batchEditHelpers.ts index 5a721ce6e4c..5a228ec9a22 100644 --- a/specifyweb/frontend/js_src/lib/components/WorkBench/batchEditHelpers.ts +++ b/specifyweb/frontend/js_src/lib/components/WorkBench/batchEditHelpers.ts @@ -1,8 +1,6 @@ -import { defined, R, RA } from "../../utils/types" -import { SpecifyTable } from "../DataModel/specifyTable" -import { isTreeTable } from "../InitialContext/treeRanks" +import { R, RA } from "../../utils/types"; import { MappingPath } from "../WbPlanView/Mapper" -import { getNumberFromToManyIndex, relationshipIsToMany } from "../WbPlanView/mappingHelpers" +import { getNumberFromToManyIndex, valueIsToManyIndex, valueIsTreeRank } from "../WbPlanView/mappingHelpers" export const BATCH_EDIT_NULL_RECORD = "null_record"; @@ -22,19 +20,23 @@ export type BatchEditPack = { } -export const isBatchEditNullRecord = (batchEditPack: BatchEditPack | undefined, currentTable: SpecifyTable, mappingPath: MappingPath): boolean => { +export const isBatchEditNullRecord = (batchEditPack: BatchEditPack | undefined, mappingPath: MappingPath): boolean => { if (batchEditPack == undefined) return false; if (mappingPath.length <= 1) return batchEditPack?.self?.id === BATCH_EDIT_NULL_RECORD; const [node, ...rest] = mappingPath; - if (isTreeTable(currentTable.name)) return false; - const relationship = defined(currentTable.getRelationship(node)); - const relatedTable = relationship.relatedTable; - const name = node.toLowerCase(); - if (relationshipIsToMany(relationship)){ + // FEAT: Remove this + if (valueIsTreeRank(node)) return false; + + // it may actually not be a to-many + const isToMany = rest[0] !== undefined && valueIsToManyIndex(rest[0]); + + // batch-edit pack is strictly lower-case + const lookUpNode = node.toLowerCase(); + if (isToMany){ // id starts with 1... const toManyId = getNumberFromToManyIndex(rest[0]) - 1; - const toMany = batchEditPack?.to_many?.[name][toManyId]; - return toMany !== undefined && isBatchEditNullRecord(toMany, relatedTable, rest.slice(1)); + const toMany = batchEditPack?.to_many?.[lookUpNode]?.[toManyId]; + return isBatchEditNullRecord(toMany, rest.slice(1)); } - return isBatchEditNullRecord(batchEditPack?.to_one?.[name], relatedTable, rest); + return isBatchEditNullRecord(batchEditPack?.to_one?.[lookUpNode], rest); } \ No newline at end of file diff --git a/specifyweb/frontend/js_src/lib/components/WorkBench/handsontable.ts b/specifyweb/frontend/js_src/lib/components/WorkBench/handsontable.ts index 5db3f8ac2ce..968bfa03f3b 100644 --- a/specifyweb/frontend/js_src/lib/components/WorkBench/handsontable.ts +++ b/specifyweb/frontend/js_src/lib/components/WorkBench/handsontable.ts @@ -85,7 +85,7 @@ function getIdentifyNullRecords(hot: Handsontable, mappings: WbMapping | undefin return {readOnly: false}; } const batchEditPack: BatchEditPack | undefined = JSON.parse(batchEditRaw)[BATCH_EDIT_KEY]; - return { readOnly: isBatchEditNullRecord(batchEditPack, mappings.baseTable, mappings.lines[mappingCol].mappingPath)}; + return { readOnly: isBatchEditNullRecord(batchEditPack, mappings.lines[mappingCol].mappingPath)}; } return makeNullRecordsReadOnly } diff --git a/specifyweb/specify/datamodel.py b/specifyweb/specify/datamodel.py index 5411088bcba..33908d99dfb 100644 --- a/specifyweb/specify/datamodel.py +++ b/specifyweb/specify/datamodel.py @@ -4,6 +4,10 @@ # These datamodel classes were generated by the specifyweb.specify.sp7_build_datamodel.py script. # The original models in this file are based on the Specify 6.8.03 datamodel schema. +# TODO: Refactor after merge with production +def is_tree_table(table: Table): + return table.get_field('nodenumber') is not None + datamodel = Datamodel(tables=[ Table( classname='edu.ku.brc.specify.datamodel.Accession', diff --git a/specifyweb/specify/func.py b/specifyweb/specify/func.py index 0cdcc2b56b8..a543c91589a 100644 --- a/specifyweb/specify/func.py +++ b/specifyweb/specify/func.py @@ -51,10 +51,10 @@ def tap_call( @staticmethod def remove_keys(source: Dict[I, O], callback: Callable[[O], bool]) -> Dict[I, O]: - return {key: value for key, value in source.items() if callback(value)} + return {key: value for key, value in source.items() if callback(key, value)} @staticmethod - def is_not_empty(val): + def is_not_empty(key, val): return val @staticmethod diff --git a/specifyweb/stored_queries/batch_edit.py b/specifyweb/stored_queries/batch_edit.py index 2c5bcfb1522..c05cf8ff667 100644 --- a/specifyweb/stored_queries/batch_edit.py +++ b/specifyweb/stored_queries/batch_edit.py @@ -5,9 +5,21 @@ # However, using 1.11 makes things slower in other files. from functools import reduce -from typing import Any, Callable, Dict, List, NamedTuple, Optional, Tuple, TypedDict +from typing import ( + Any, + Callable, + Dict, + List, + NamedTuple, + Optional, + Tuple, + TypedDict, + Union, + Literal, +) from specifyweb.specify.models import datamodel from specifyweb.specify.load_datamodel import Field, Relationship, Table +from specifyweb.specify.datamodel import is_tree_table from specifyweb.stored_queries.execution import execute from specifyweb.stored_queries.queryfield import QueryField, fields_from_json from specifyweb.stored_queries.queryfieldspec import ( @@ -29,7 +41,7 @@ from jsonschema import validate from django.db import transaction - +from decimal import Decimal MaybeField = Callable[[QueryFieldSpec], Optional[Field]] @@ -39,6 +51,36 @@ # - seemed complicated to merge upload plan from the frontend # - need to place id markers at correct level, so need to follow upload plan anyways. +# TODO: Play-around with localizing +NULL_RECORD_DESCRIPTION = "(Not included in the query results)" + + +SHARED_READONLY_FIELDS = [ + "timestampcreated", + "timestampmodified", + "version", + "modifiedbyagent", + "createdbyagent", + "nodenumber", + "highestchildnodenumber", + "rankid", + "fullname", +] + + +def get_readonly_fields(table: Table): + fields = [*SHARED_READONLY_FIELDS, table.idFieldName.lower()] + relationships = [] + if table.name.lower() == "determination": + relationships = ["preferredtaxon"] + return fields, relationships + + +def parse(value: Optional[Any]) -> Any: + if isinstance(value, Decimal): + return float(value) + return value + def _get_nested_order(field_spec: QueryFieldSpec): # don't care about ordernumber if it ain't nested @@ -201,48 +243,31 @@ class RowPlanMap(NamedTuple): is_naive: bool = True @staticmethod - def _merge(force_naive=False): - def _merge( - current: Dict[str, "RowPlanMap"], other: Tuple[str, "RowPlanMap"] - ) -> Dict[str, "RowPlanMap"]: - key, other_plan = other - return { - **current, - # merge if other is also found in ours - key: ( - other_plan - if key not in current - else current[key].merge(other_plan, force_naive) - ), - } - - return _merge + def _merge( + current: Dict[str, "RowPlanMap"], other: Tuple[str, "RowPlanMap"] + ) -> Dict[str, "RowPlanMap"]: + key, other_plan = other + return { + **current, + # merge if other is also found in ours + key: (other_plan if key not in current else current[key].merge(other_plan)), + } # takes two row plans, combines them together. Adjusts is_naive. - def merge( - self: "RowPlanMap", other: "RowPlanMap", force_naive=False - ) -> "RowPlanMap": + def merge(self: "RowPlanMap", other: "RowPlanMap") -> "RowPlanMap": new_columns = [*self.columns, *other.columns] batch_edit_pack = other.batch_edit_pack.merge(self.batch_edit_pack) is_self_naive = self.is_naive and other.is_naive # BUG: Handle this more gracefully for to-ones. - to_one = reduce(RowPlanMap._merge(), other.to_one.items(), self.to_one) - adjusted_to_one = { - key: value._replace(is_naive=True) for (key, value) in to_one.items() - } - to_many = reduce( - RowPlanMap._merge(force_naive), other.to_many.items(), self.to_many - ) - adjusted_naive = ( - is_self_naive - and not (any(not _to_one.is_naive for _to_one in to_one.values())) - ) or (force_naive and (batch_edit_pack.order.field is None)) + # That is, we'll currently incorrectly disallow making new ones. Fine for now. + to_one = reduce(RowPlanMap._merge, other.to_one.items(), self.to_one) + to_many = reduce(RowPlanMap._merge, other.to_many.items(), self.to_many) return RowPlanMap( batch_edit_pack, new_columns, - adjusted_to_one, + to_one, to_many, - is_naive=adjusted_naive, + is_naive=is_self_naive, ) @staticmethod @@ -354,21 +379,42 @@ def _recur_row_plan( node.name.lower() if not isinstance(node, TreeRankQuery) else node.name ) - rest_plan = { - rel_name: RowPlanMap._recur_row_plan( - [*running_path, node], - rest, - datamodel.get_table_strict(node.relatedModelName), - original_field, - ) - } + remaining_map = RowPlanMap._recur_row_plan( + [*running_path, node], + rest, + datamodel.get_table_strict(node.relatedModelName), + original_field, + ) boiler = RowPlanMap(columns=[], batch_edit_pack=batch_edit_pack) - return ( - boiler._replace(to_one=rest_plan) - if rel_type == "to_one" - else boiler._replace(to_many=rest_plan) - ) + + def _augment_is_naive(rel_type: Union[Literal["to_one"], Literal["to_many"]]): + + rest_plan = {rel_name: remaining_map} + if rel_type == "to_one": + # Propagate is_naive up + return boiler._replace( + is_naive=remaining_map.is_naive, to_one=rest_plan + ) + + # bc the user eperience guys want to be able to make new dets/preps one hop away + # but, we can't allow it for ordernumber when filtering. pretty annoying. + # and definitely not naive for any tree, well, technically it is possible, but for user's sake. + is_naive = not is_tree_table(next_table) and ( + ( + len(running_path) == 0 + and (remaining_map.batch_edit_pack.order.field is None) + ) + or remaining_map.is_naive + ) + return boiler._replace( + to_many={ + # to force-naiveness + rel_name: remaining_map._replace(is_naive=is_naive) + } + ) + + return _augment_is_naive(rel_type) # generates multiple row plan maps, and merges them into one # this doesn't index the row plan, bc that is complicated. @@ -386,24 +432,24 @@ def get_row_plan(fields: List[QueryField]) -> "RowPlanMap": for field in fields ] - raw_plan = reduce( - lambda current, other: current.merge(other, True), + plan = reduce( + lambda current, other: current.merge(other), iter, RowPlanMap(batch_edit_pack=EMPTY_PACK), ) - return raw_plan.skim_plan() - - def skim_plan(self: "RowPlanMap", parent_is_naive=True) -> "RowPlanMap": - is_current_naive = parent_is_naive and self.is_naive - to_one = { - key: value.skim_plan(is_current_naive) - for (key, value) in self.to_one.items() - } - to_many = { - key: value.skim_plan(is_current_naive) - for (key, value) in self.to_many.items() - } - return self._replace(to_one=to_one, to_many=to_many, is_naive=is_current_naive) + return plan + + # def skim_plan(self: "RowPlanMap", parent_is_naive=True) -> "RowPlanMap": + # is_current_naive = parent_is_naive and self.is_naive + # to_one = { + # key: value.skim_plan(is_current_naive) + # for (key, value) in self.to_one.items() + # } + # to_many = { + # key: value.skim_plan(is_current_naive) + # for (key, value) in self.to_many.items() + # } + # return self._replace(to_one=to_one, to_many=to_many, is_naive=is_current_naive) @staticmethod def _bind_null(value: "RowPlanCanonical") -> List["RowPlanCanonical"]: @@ -413,7 +459,7 @@ def _bind_null(value: "RowPlanCanonical") -> List["RowPlanCanonical"]: def bind(self, row: Tuple[Any]) -> "RowPlanCanonical": columns = [ - column._replace(value=row[column.idx], field=None) + column._replace(value=parse(row[column.idx]), field=None) for column in self.columns # Careful: this can be 0, so not doing "if not column.idx" if column.idx is not None @@ -428,16 +474,19 @@ def bind(self, row: Tuple[Any]) -> "RowPlanCanonical": # gets a null record to fill-out empty space # doesn't support nested-to-many's yet - complicated - def nullify(self, ignore_naive=False) -> "RowPlanCanonical": + def nullify(self, parent_is_phantom=False) -> "RowPlanCanonical": # since is_naive is set, - is_null = (not self.is_naive) and not ignore_naive - columns = [pack._replace(value=None) for pack in self.columns] + is_phantom = parent_is_phantom or not self.is_naive + columns = [ + pack._replace(value=NULL_RECORD_DESCRIPTION if is_phantom else None) + for pack in self.columns + ] to_ones = { - key: value.nullify(not is_null) for (key, value) in self.to_one.items() + key: value.nullify(is_phantom) for (key, value) in self.to_one.items() } batch_edit_pack = self.batch_edit_pack._replace( id=self.batch_edit_pack.id._replace( - value=(NULL_RECORD if is_null else None) + value=(NULL_RECORD if is_phantom else None) ) ) return RowPlanCanonical(batch_edit_pack, columns, to_ones) @@ -740,8 +789,8 @@ def _flatten(_: str, _self: "RowPlanCanonical"): "to_one": Func.remove_keys(to_ones[1], Func.is_not_empty), "to_many": Func.remove_keys( to_many[1], - lambda records: any( - Func.is_not_empty(record) for record in records + lambda key, records: any( + Func.is_not_empty(key, record) for record in records ), ), }, @@ -769,7 +818,7 @@ def to_upload_plan( for canonical in self.to_one.values() ) - def _lookup_in_fields(_id: Optional[int]): + def _lookup_in_fields(_id: Optional[int], readonly_fields: List[str]): assert _id is not None, "invalid lookup used!" field = query_fields[ _id - 1 @@ -783,17 +832,12 @@ def _lookup_in_fields(_id: Optional[int]): if _count > 1: localized_label += f" #{_count}" fieldspec = field.fieldspec - # Couple of special fields are not editable. TODO: See if more needs to be added here. - # 1. Partial dates - # 2. Formatted/Aggregated - # three. Fullname in trees + is_null = ( fieldspec.needs_formatted() or intermediary_to_tree or (fieldspec.is_temporal() and fieldspec.date_part != "Full Date") - # TODO: Refactor after merge with production - or fieldspec.get_field().name.lower() - in ["fullname", "nodenumber", "highestchildnodenumber"] + or fieldspec.get_field().name.lower() in readonly_fields ) id_in_original_fields = get_column_id(string_id) return ( @@ -802,8 +846,9 @@ def _lookup_in_fields(_id: Optional[int]): localized_label, ) + readonly_fields, readonly_rels = get_readonly_fields(base_table) key_and_fields_and_headers = [ - _lookup_in_fields(column.idx) for column in self.columns + _lookup_in_fields(column.idx, readonly_fields) for column in self.columns ] wb_cols = { @@ -853,6 +898,9 @@ def _to_upload_plan(rel_name: str, _self: "RowPlanCanonical"): ] all_headers = [*raw_headers, *to_one_headers, *to_many_headers] + def _relationship_is_editable(name, value): + return Func.is_not_empty(name, value) and name not in readonly_rels + if intermediary_to_tree: assert len(to_many_upload_tables) == 0, "Found to-many for tree!" upload_plan: Uploadable = TreeRecord( @@ -869,8 +917,11 @@ def _to_upload_plan(rel_name: str, _self: "RowPlanCanonical"): wbcols=wb_cols, static={}, # FEAT: Remove this restriction to allow adding brand new data anywhere - toOne=Func.remove_keys(to_one_upload_tables, Func.is_not_empty), - toMany=Func.remove_keys(to_many_upload_tables, Func.is_not_empty), + # that's about the best we can do, to make relationships readonly. we can't really omit them during headers finding, because they are "still" there + toOne=Func.remove_keys(to_one_upload_tables, _relationship_is_editable), + toMany=Func.remove_keys( + to_many_upload_tables, _relationship_is_editable + ), ) return all_headers, upload_plan diff --git a/specifyweb/stored_queries/tests/test_batch_edit.py b/specifyweb/stored_queries/tests/test_batch_edit.py index 778e7284a94..86d3368a176 100644 --- a/specifyweb/stored_queries/tests/test_batch_edit.py +++ b/specifyweb/stored_queries/tests/test_batch_edit.py @@ -2,7 +2,6 @@ from unittest.mock import patch from specifyweb.stored_queries.batch_edit import ( - BatchEditFieldPack, BatchEditPack, BatchEditProps, RowPlanMap, @@ -43,10 +42,13 @@ def _builder(query_fields, base_table): return _builder + def fake_obj_formatter(*args, **kwargs): return (SIMPLE_DEF, None, None) -OBJ_FORMATTER_PATH = 'specifyweb.context.app_resource.get_app_resource' + +OBJ_FORMATTER_PATH = "specifyweb.context.app_resource.get_app_resource" + # NOTES: Yes, it is more convenient to hard code ids (instead of defining variables.). # But, using variables can make bugs apparent @@ -90,7 +92,6 @@ def test_query_construction(self): self.assertEqual(plan, row_plan_map) - @patch(OBJ_FORMATTER_PATH, new=fake_obj_formatter) def test_basic_run(self): base_table = "collectionobject" @@ -159,12 +160,12 @@ def test_basic_run(self): ) correct_rows = [ - ['num-0', None, 'LastName', '', 'Test1', 'LastName', None], - ['num-1', None, 'LastName', '', 'Test1', 'LastName', None], - ['num-2', 99, 'LastNameAsTest', '', 'Test2', 'LastNameAsTest', None], - ['num-3', None, 'LastNameAsTest', '', 'Test2', 'LastNameAsTest', None], - ['num-4', 229, 'LastNameAsTest', '', 'Test2', 'LastNameAsTest', None] - ] + ["num-0", None, "LastName", "", "Test1", "LastName", None], + ["num-1", None, "LastName", "", "Test1", "LastName", None], + ["num-2", 99, "LastNameAsTest", "", "Test2", "LastNameAsTest", None], + ["num-3", None, "LastNameAsTest", "", "Test2", "LastNameAsTest", None], + ["num-4", 229, "LastNameAsTest", "", "Test2", "LastNameAsTest", None], + ] self.assertEqual(correct_rows, rows) @@ -1471,8 +1472,8 @@ def test_stalls_within_to_many(self): ], ) - self.assertEqual(packs, correct_packs) - + self.assertEqual(packs, correct_packs) + @patch(OBJ_FORMATTER_PATH, new=fake_obj_formatter) def test_to_one_does_not_stall_if_not_to_many(self): base_table = "collectionobject" @@ -2292,4 +2293,4 @@ def test_to_one_stalls_to_many(self): }, ] - self.assertEqual(correct_packs, packs) \ No newline at end of file + self.assertEqual(correct_packs, packs) diff --git a/specifyweb/stored_queries/tests/test_row_plan_map.py b/specifyweb/stored_queries/tests/test_row_plan_map.py index 58c23db4558..9efa2b7177d 100644 --- a/specifyweb/stored_queries/tests/test_row_plan_map.py +++ b/specifyweb/stored_queries/tests/test_row_plan_map.py @@ -115,6 +115,7 @@ def test_complicated_query_construction_filters(self): visible_fields = [field for field in query_fields if field.display] row_plan = RowPlanMap.get_row_plan(visible_fields) plan, fields = row_plan.index_plan() + print(plan) correct_plan = RowPlanMap( batch_edit_pack=BatchEditPack( id=BatchEditFieldPack(field=None, idx=2, value=None), @@ -203,14 +204,14 @@ def test_complicated_query_construction_filters(self): ], to_one={}, to_many={}, - is_naive=False, + is_naive=True, ) }, to_many={}, is_naive=False, ), }, - is_naive=True, + is_naive=False, ) }, to_many={}, @@ -231,3 +232,94 @@ def test_complicated_query_construction_filters(self): is_naive=True, ) self.assertEqual(plan, correct_plan) + + def test_first_hop_always_naive(self): + fields = [ + { + "tablelist": "10,1-collectionObjects", + "stringid": "10,1-collectionObjects.collectionobject.catalogNumber", + "fieldname": "catalogNumber", + "isrelfld": False, + "sorttype": 0, + "position": 2, + "isdisplay": True, + "operstart": 1, + "startvalue": "707070", + "isnot": False, + } + ] + query_fields = fields_from_json(fields) + row_plan = RowPlanMap.get_row_plan(query_fields) + plan, fields = row_plan.index_plan() + self.assertTrue(plan.to_many["collectionobjects"].is_naive) + + def test_first_hop_order_naive(self): + filter_fields = [ + { + "tablelist": "10,30-collectors", + "stringid": "10,30-collectors.collector.isPrimary", + "fieldname": "isPrimary", + "isrelfld": False, + "sorttype": 0, + "position": 1, + "isdisplay": True, + "operstart": 6, + "startvalue": "", + "isnot": False, + }, + ] + query_fields = fields_from_json(filter_fields) + row_plan = RowPlanMap.get_row_plan(query_fields) + plan, fields = row_plan.index_plan() + self.assertFalse(plan.to_many["collectors"].is_naive) + + no_filter_fields = [{**filter_fields[0], "operstart": 8}] + query_fields = fields_from_json(no_filter_fields) + row_plan = RowPlanMap.get_row_plan(query_fields) + plan, fields = row_plan.index_plan() + self.assertTrue(plan.to_many["collectors"].is_naive) + + def test_distant_to_many_naive(self): + filter_fields = [ + { + "tablelist": "1,5-cataloger,8-addresses", + "stringid": "1,5-cataloger,8-addresses.address.address", + "fieldname": "address", + "isrelfld": False, + "sorttype": 0, + "position": 4, + "isdisplay": True, + "operstart": 1, + "startvalue": "test", + "isnot": False, + } + ] + query_fields = fields_from_json(filter_fields) + row_plan = RowPlanMap.get_row_plan(query_fields) + plan, fields = row_plan.index_plan() + self.assertFalse(plan.to_one["cataloger"].to_many["addresses"].is_naive) + + def test_tree_is_not_naive(self): + filter_fields = [ + { + "tablelist": "1,9-determinations,4,4-acceptedChildren", + "stringid": "1,9-determinations,4,4-acceptedChildren.taxon.author", + "fieldname": "author", + "isrelfld": False, + "sorttype": 0, + "position": 0, + "isdisplay": True, + "operstart": 8, + "startvalue": "", + "isnot": False, + } + ] + query_fields = fields_from_json(filter_fields) + row_plan = RowPlanMap.get_row_plan(query_fields) + plan, fields = row_plan.index_plan() + self.assertFalse( + plan.to_many["determinations"] + .to_one["taxon"] + .to_many["acceptedchildren"] + .is_naive + ) diff --git a/specifyweb/workbench/upload/predicates.py b/specifyweb/workbench/upload/predicates.py index a71ac256888..3a0a2284cca 100644 --- a/specifyweb/workbench/upload/predicates.py +++ b/specifyweb/workbench/upload/predicates.py @@ -1,6 +1,15 @@ - from functools import reduce -from typing import Callable, Dict, NamedTuple, Optional, Any, Generator, List, Tuple, Union +from typing import ( + Callable, + Dict, + NamedTuple, + Optional, + Any, + Generator, + List, + Tuple, + Union, +) from typing_extensions import TypedDict from django.db.models import QuerySet, Q, F, Model, Exists, OuterRef @@ -16,25 +25,26 @@ Value = Optional[Union[str, int, F]] + class ToRemoveMatchee(TypedDict): filter_on: Filter - # It is possible that the node we need to filter on may be present. In this case, we'll remove valid entries. + # It is possible that the node we need to filter on may be present. In this case, we'll remove valid entries. # To avoid that, we track the present ones too. I can't think why this might need more cont, so making it Q remove: Optional[Q] + ToRemoveNode = Dict[str, List[ToRemoveMatchee]] get_model = lambda model_name: getattr(spmodels, model_name.lower().capitalize()) + def add_to_remove_node(previous: ToRemoveNode, new_node: ToRemoveNode) -> ToRemoveNode: return { **previous, - **{ - key: [*previous.get(key, []), *values] - for key, values in new_node.items() - } + **{key: [*previous.get(key, []), *values] for key, values in new_node.items()}, } + class ToRemove(NamedTuple): model_name: str filter_on: Filter @@ -42,25 +52,27 @@ class ToRemove(NamedTuple): def to_cache_key(self): return repr((self.model_name, filter_match_key(self.filter_on))) + class DjangoPredicates(NamedTuple): - filters: Dict[str, Union[Value, List[Any]]] = {} # type: ignore + filters: Dict[str, Union[Value, List[Any]]] = {} # type: ignore to_remove: Optional[ToRemove] = None def reduce_for_to_one(self): - if not self.filters and not self.to_remove and not isinstance(self, SkippablePredicate): + if ( + not self.filters + and not self.to_remove + and not isinstance(self, SkippablePredicate) + ): # If we get here, we know that we can directly reduce to-one - # to an empty none match, and don't need to bother matching + # to an empty none match, and don't need to bother matching # more. return None return [self] - - def reduce_for_to_many( - self, - uploadable: Any # type: ignore - ): + + def reduce_for_to_many(self, uploadable: Any): # type: ignore if self.filters or isinstance(self, SkippablePredicate): return self - + # nested excludes don't make sense and complicates everything. # this avoids it (while keeping semantics same). # That is, if we hit a "to_remove", we're done, and don't need to go any deeper. @@ -72,34 +84,47 @@ def _map_reduce(values): if not isinstance(values, list): return values is None return all(value.is_reducible() for value in values) - + def is_reducible(self): if not self.filters: return True - return all(DjangoPredicates._map_reduce(values) for values in self.filters.values()) - - def get_cache_key(self, basetable_name: str=None) -> str: + return all( + DjangoPredicates._map_reduce(values) for values in self.filters.values() + ) + + def get_cache_key(self, basetable_name: str = None) -> str: filters = [ # we don't care about table past the first one, since rels will uniquely denote the related table.. - (key, tuple([value.get_cache_key() for value in values]) - if isinstance(values, list) else repr(values)) - for key, values in Func.sort_by_key(self.filters)] + ( + key, + ( + tuple([value.get_cache_key() for value in values]) + if isinstance(values, list) + else repr(values) + ), + ) + for key, values in Func.sort_by_key(self.filters) + ] to_remove = None if self.to_remove is None else self.to_remove.to_cache_key() return repr((basetable_name, tuple(filters), to_remove)) - + def _smart_apply( - self, - query: QuerySet, - get_unique_alias: Generator[str, None, None], - current_model: Model, - path: Optional[str] = None, - aliases: List[Tuple[str, str]] = [], - to_remove_node: 'ToRemoveNode' = {} - ): - _get_field_name = lambda raw_name: raw_name if path is None else (path + '__' + raw_name) + self, + query: QuerySet, + get_unique_alias: Generator[str, None, None], + current_model: Model, + path: Optional[str] = None, + aliases: List[Tuple[str, str]] = [], + to_remove_node: "ToRemoveNode" = {}, + ): + _get_field_name = lambda raw_name: ( + raw_name if path is None else (path + "__" + raw_name) + ) # it's useless to match on nothing. caller should be aware of that - assert self.filters is not None and self.filters, "trying to force match on nothing" + assert ( + self.filters is not None and self.filters + ), "trying to force match on nothing" base_predicates = { _get_field_name(field_name): value @@ -109,59 +134,109 @@ def _smart_apply( filtered = { **base_predicates, - **{name: F(alias) for (name, alias) in aliases[1:]} + **{name: F(alias) for (name, alias) in aliases[1:]}, } unique_alias = next(get_unique_alias) - alias_path = _get_field_name('id') + alias_path = _get_field_name("id") query = query.filter(**filtered).alias(**{unique_alias: F(alias_path)}) - aliases = [ - *aliases, - (alias_path, unique_alias) - ] + aliases = [*aliases, (alias_path, unique_alias)] def _reduce_by_key(rel_name: str): # mypy isn't able to infer types correctly - new_model: Model = current_model._meta.get_field(rel_name).related_model # type: ignore + new_model: Model = current_model._meta.get_field(rel_name).related_model # type: ignore assert new_model is not None - def _reduce(previous: Tuple[QuerySet, ToRemoveNode, List[Any]], current: DjangoPredicates): + + def _reduce( + previous: Tuple[QuerySet, ToRemoveNode, List[Any]], + current: DjangoPredicates, + ): # Don't do anything if isinstance(current, SkippablePredicate): return previous - + previous_query, previous_to_remove_node, internal_exclude = previous - if (current.to_remove): - assert not current.filters, "Hmm, we are trying to filter on something we'll remove. How??" - reverse_side: str = current_model._meta.get_field(rel_name).remote_field.name # type: ignore - model_name: str = new_model._meta.model_name # type: ignore + if current.to_remove: + assert ( + not current.filters + ), "Hmm, we are trying to filter on something we'll remove. How??" + reverse_side: str = current_model._meta.get_field(rel_name).remote_field.name # type: ignore + model_name: str = new_model._meta.model_name # type: ignore assert reverse_side is not None - to_remove_node = add_to_remove_node(previous_to_remove_node, { - model_name: [{ - 'filter_on': {**current.to_remove.filter_on, reverse_side: OuterRef(unique_alias)}, - 'remove': None if len(internal_exclude) == 0 else Func.make_ors([Q(id=OuterRef(prev)) for prev in internal_exclude]) - }]}) + to_remove_node = add_to_remove_node( + previous_to_remove_node, + { + model_name: [ + { + "filter_on": { + **current.to_remove.filter_on, + reverse_side: OuterRef(unique_alias), + }, + "remove": ( + None + if len(internal_exclude) == 0 + else Func.make_ors( + [ + Q(id=OuterRef(prev)) + for prev in internal_exclude + ] + ) + ), + } + ] + }, + ) return previous_query, to_remove_node, internal_exclude - + new_path = _get_field_name(rel_name) - - new_query, node_to_remove, alias_of_child = current._smart_apply(previous_query, get_unique_alias, new_model, new_path, aliases, previous_to_remove_node) - record_aligned: QuerySet = reduce(lambda _query, _to_remove: _query.exclude(**{alias_of_child: F(_to_remove)}), internal_exclude, new_query) - return record_aligned, node_to_remove, [*internal_exclude, alias_of_child] - + + new_query, node_to_remove, alias_of_child = current._smart_apply( + previous_query, + get_unique_alias, + new_model, + new_path, + aliases, + previous_to_remove_node, + ) + record_aligned: QuerySet = reduce( + lambda _query, _to_remove: _query.exclude( + **{alias_of_child: F(_to_remove)} + ), + internal_exclude, + new_query, + ) + return ( + record_aligned, + node_to_remove, + [*internal_exclude, alias_of_child], + ) + return _reduce - - def _reduce_by_rel(accum: Tuple[QuerySet, ToRemoveNode], by_rels: List[DjangoPredicates], rel_name: str): - modified_query, modified_to_remove, _ = reduce(_reduce_by_key(rel_name), sorted(by_rels, key=lambda pred: int(pred.to_remove is not None)), (accum[0], accum[1], [])) + + def _reduce_by_rel( + accum: Tuple[QuerySet, ToRemoveNode], + by_rels: List[DjangoPredicates], + rel_name: str, + ): + modified_query, modified_to_remove, _ = reduce( + _reduce_by_key(rel_name), + sorted(by_rels, key=lambda pred: int(pred.to_remove is not None)), + (accum[0], accum[1], []), + ) return modified_query, modified_to_remove - - rels = [(key, values) for (key, values) in self.filters.items() if isinstance(values, list)] + + rels = [ + (key, values) + for (key, values) in self.filters.items() + if isinstance(values, list) + ] query, to_remove = reduce( - lambda accum, current: _reduce_by_rel(accum, current[1], current[0]), - rels, - (query, to_remove_node) - ) + lambda accum, current: _reduce_by_rel(accum, current[1], current[0]), + rels, + (query, to_remove_node), + ) return query, to_remove, unique_alias def apply_to_query(self, base_name: str) -> QuerySet: @@ -170,11 +245,13 @@ def apply_to_query(self, base_name: str) -> QuerySet: getter = get_unique_predicate() if not self.filters: return query - query, to_remove, alias = self._smart_apply(query, getter, base_model, None, aliases=[]) + query, to_remove, alias = self._smart_apply( + query, getter, base_model, None, aliases=[] + ) if to_remove: query = query.filter(canonicalize_remove_node(to_remove)) return query - + # Use this in places where we need to guarantee unique values. We could use random numbers, but using this # makes things sane for debugging. @@ -184,57 +261,86 @@ def get_unique_predicate(pre="predicate-") -> Generator[str, None, None]: yield f"{pre}{_id}" _id += 1 + # This predicates is a magic predicate, and is completely ignored during query-building. If a uploadable returns this, it won't be considered for matching # NOTE: There's a difference between Skipping and returning a null filter (if within to-ones, null will really - correctly - filter for null). class SkippablePredicate(DjangoPredicates): def get_cache_key(self, basetable_name: str = None) -> str: - return repr(('Skippable', basetable_name)) + return repr(("Skippable", basetable_name)) def apply_to_query(self, base_name: str) -> QuerySet: raise Exception("Attempting to apply skippable predicates to a query!") - + def is_reducible(self): # Don't reduce it. Doesn't make sense for top-level. But does if in rels return False + def filter_match_key(f: Filter) -> str: return repr(sorted(f.items())) - + + def canonicalize_remove_node(node: ToRemoveNode) -> Q: all_exists = [Q(_map_matchee(matchee, name)) for name, matchee in node.items()] all_or = Func.make_ors(all_exists) # Don't miss the negation below! return ~all_or + def _map_matchee(matchee: List[ToRemoveMatchee], model_name: str) -> Exists: model: Model = get_model(model_name) - qs = [Q(**match['filter_on']) for match in matchee] + qs = [Q(**match["filter_on"]) for match in matchee] qs_or = Func.make_ors(qs) query = model.objects.filter(qs_or) - to_remove = [match['remove'] for match in matchee if match['remove'] is not None] + to_remove = [match["remove"] for match in matchee if match["remove"] is not None] if to_remove: query = query.exclude(Func.make_ors(to_remove)) return Exists(query) + class ContetRef(Exception): pass + +# These fields are ignored during cloning, and during matching. +SPECIAL_TREE_FIELDS_TO_SKIP = [ + "nodenumber", + "highestchildnodenumber", + # Otherwise, we'd always be restricted to a subtree... + "parent_id", + # TODO: Test fullname. Depends on use-cases I guess. + # Skipping them currently because we won't be able to match across branches, without disabling all database fields lookup + "fullname", +] + + def safe_fetch(model: Model, filters, version): if filters is None: return None try: reference_record = model.objects.select_for_update().get(**filters) except ObjectDoesNotExist: - raise ContetRef(f"Object {filters} at {model._meta.model_name} is no longer present in the database") - - incoming_version = getattr(reference_record, 'version', None) + raise ContetRef( + f"Object {filters} at {model._meta.model_name} is no longer present in the database" + ) + + incoming_version = getattr(reference_record, "version", None) + + if ( + incoming_version is not None + and version is not None + and version != incoming_version + ): + raise ContetRef( + f"Object {filters} of {model._meta.model_name} is out of date. Please re-run the query" + ) - if incoming_version is not None and version is not None and version != incoming_version: - raise ContetRef(f"Object {filters} of {model._meta.model_name} is out of date. Please re-run the query") - return reference_record -def resolve_reference_attributes(fields_to_skip, model, reference_record) -> Dict[str, Any]: + +def resolve_reference_attributes( + fields_to_skip, model, reference_record +) -> Dict[str, Any]: if reference_record is None: return {} @@ -245,13 +351,12 @@ def resolve_reference_attributes(fields_to_skip, model, reference_record) -> Dic ] all_fields = [ - field.attname for field in model._meta.get_fields(include_hidden=True) - if field.concrete and (field.attname not in fields_to_skip and field.name not in fields_to_skip) - ] + field.attname + for field in model._meta.get_fields(include_hidden=True) + if field.concrete + and (field.attname not in fields_to_skip and field.name not in fields_to_skip) + ] - clone_attrs = { - field: getattr(reference_record, field) - for field in all_fields - } + clone_attrs = {field: getattr(reference_record, field) for field in all_fields} - return clone_attrs \ No newline at end of file + return clone_attrs diff --git a/specifyweb/workbench/upload/scoping.py b/specifyweb/workbench/upload/scoping.py index eb1d3628bc0..a31ef76624c 100644 --- a/specifyweb/workbench/upload/scoping.py +++ b/specifyweb/workbench/upload/scoping.py @@ -1,10 +1,11 @@ from functools import reduce from typing import Dict, Any, Generator, Tuple, Callable, List -from specifyweb.specify.datamodel import datamodel, Table +from specifyweb.specify.datamodel import datamodel, Table, is_tree_table from specifyweb.specify.load_datamodel import DoesNotExistError from specifyweb.specify import models from specifyweb.specify.uiformatters import get_uiformatter from specifyweb.stored_queries.format import get_date_format +from specifyweb.workbench.upload.predicates import SPECIAL_TREE_FIELDS_TO_SKIP from .uploadable import ScopeGenerator, ScopedUploadable from .upload_table import UploadTable, ScopedUploadTable, ScopedOneToOneTable @@ -34,62 +35,77 @@ """ DEFERRED_SCOPING: Dict[Tuple[str, str], Tuple[str, str, str]] = { - ("Collectionrelationship", "rightside"): ('collectionreltype', 'name', 'rightsidecollection'), - ("Collectionrelationship", "leftside"): ('collectionreltype', 'name', 'leftsidecollection'), - } + ("Collectionrelationship", "rightside"): ( + "collectionreltype", + "name", + "rightsidecollection", + ), + ("Collectionrelationship", "leftside"): ( + "collectionreltype", + "name", + "leftsidecollection", + ), +} + def scoping_relationships(collection, table: Table) -> Dict[str, int]: extra_static: Dict[str, int] = {} try: - table.get_field_strict('collectionmemberid') - extra_static['collectionmemberid'] = collection.id + table.get_field_strict("collectionmemberid") + extra_static["collectionmemberid"] = collection.id except DoesNotExistError: pass try: - table.get_relationship('collection') - extra_static['collection_id'] = collection.id + table.get_relationship("collection") + extra_static["collection_id"] = collection.id except DoesNotExistError: pass try: - table.get_relationship('discipline') - extra_static['discipline_id'] = collection.discipline.id + table.get_relationship("discipline") + extra_static["discipline_id"] = collection.discipline.id except DoesNotExistError: pass try: - table.get_relationship('division') - extra_static['division_id'] = collection.discipline.division.id + table.get_relationship("division") + extra_static["division_id"] = collection.discipline.division.id except DoesNotExistError: pass try: - table.get_relationship('institution') - extra_static['institution_id'] = collection.discipline.division.institution.id + table.get_relationship("institution") + extra_static["institution_id"] = collection.discipline.division.institution.id except DoesNotExistError: pass return extra_static + AdjustToOnes = Callable[[ScopedUploadable, str], ScopedUploadable] + def _make_one_to_one(fieldname: str, rest: AdjustToOnes) -> AdjustToOnes: def adjust_to_ones(u: ScopedUploadable, f: str) -> ScopedUploadable: if f == fieldname and isinstance(u, ScopedUploadTable): return rest(ScopedOneToOneTable(*u), f) else: return rest(u, f) + return adjust_to_ones -def extend_columnoptions(colopts: ColumnOptions, collection, tablename: str, fieldname: str) -> ExtendedColumnOptions: +def extend_columnoptions( + colopts: ColumnOptions, collection, tablename: str, fieldname: str +) -> ExtendedColumnOptions: schema_items = models.Splocalecontaineritem.objects.filter( container__discipline=collection.discipline, container__schematype=0, container__name=tablename.lower(), - name=fieldname.lower()) + name=fieldname.lower(), + ) schemaitem = schema_items and schema_items[0] picklistname = schemaitem and schemaitem.picklistname @@ -99,25 +115,41 @@ def extend_columnoptions(colopts: ColumnOptions, collection, tablename: str, fie else: picklists = models.Picklist.objects.filter(name=picklistname) collection_picklists = picklists.filter(collection=collection) - - picklist = picklists[0] if len(collection_picklists) == 0 else collection_picklists[0] + + picklist = ( + picklists[0] if len(collection_picklists) == 0 else collection_picklists[0] + ) ui_formatter = get_uiformatter(collection, tablename, fieldname) - scoped_formatter = None if ui_formatter is None else ui_formatter.apply_scope(collection) - friendly_repr = f'{tablename}-{fieldname}-{collection}' + scoped_formatter = ( + None if ui_formatter is None else ui_formatter.apply_scope(collection) + ) + friendly_repr = f"{tablename}-{fieldname}-{collection}" return ExtendedColumnOptions( column=colopts.column, matchBehavior=colopts.matchBehavior, nullAllowed=colopts.nullAllowed, default=colopts.default, schemaitem=schemaitem, - uiformatter=None if scoped_formatter is None else CustomRepr(scoped_formatter, friendly_repr), + uiformatter=( + None + if scoped_formatter is None + else CustomRepr(scoped_formatter, friendly_repr) + ), picklist=picklist, - dateformat=get_date_format() + dateformat=get_date_format(), ) -def get_deferred_scoping(key: str, table_name: str, uploadable: UploadTable, row: Dict[str, Any], base_ut, generator: ScopeGenerator): - deferred_key = (table_name, key) + +def get_deferred_scoping( + key: str, + table_name: str, + uploadable: UploadTable, + row: Dict[str, Any], + base_ut, + generator: ScopeGenerator, +): + deferred_key = (table_name, key) deferred_scoping = DEFERRED_SCOPING.get(deferred_key, None) if deferred_scoping is None: @@ -132,7 +164,9 @@ def get_deferred_scoping(key: str, table_name: str, uploadable: UploadTable, row filter_value = row[related_column_name] filter_search = {filter_field: filter_value} related_table = datamodel.get_table_strict(related_key) - related = getattr(models, related_table.django_name).objects.get(**filter_search) + related = getattr(models, related_table.django_name).objects.get( + **filter_search + ) collection_id = getattr(related, relationship_name).id else: # meh, would just go to the original collection @@ -140,19 +174,28 @@ def get_deferred_scoping(key: str, table_name: str, uploadable: UploadTable, row # don't cache anymore, since values can be dependent on rows. if generator is not None: - next(generator) # a bit hacky - return uploadable._replace(overrideScope = {'collection': collection_id}) + next(generator) # a bit hacky + return uploadable._replace(overrideScope={"collection": collection_id}) + -def apply_scoping_to_uploadtable(ut: UploadTable, collection, generator: ScopeGenerator = None, row=None) -> ScopedUploadTable: +def apply_scoping_to_uploadtable( + ut: UploadTable, collection, generator: ScopeGenerator = None, row=None +) -> ScopedUploadTable: table = datamodel.get_table_strict(ut.name) - if ut.overrideScope is not None and isinstance(ut.overrideScope['collection'], int): - collection = models.Collection.objects.get(id=ut.overrideScope['collection']) - + if ut.overrideScope is not None and isinstance(ut.overrideScope["collection"], int): + collection = models.Collection.objects.get(id=ut.overrideScope["collection"]) + to_one_fields = get_to_one_fields(collection) - adjuster = reduce(lambda accum, curr: _make_one_to_one(curr, accum), to_one_fields.get(table.name.lower(), []), lambda u, f: u) + adjuster = reduce( + lambda accum, curr: _make_one_to_one(curr, accum), + to_one_fields.get(table.name.lower(), []), + lambda u, f: u, + ) - apply_scoping = lambda key, value: get_deferred_scoping(key, table.django_name, value, row, ut, generator).apply_scoping(collection, generator, row) + apply_scoping = lambda key, value: get_deferred_scoping( + key, table.django_name, value, row, ut, generator + ).apply_scoping(collection, generator, row) to_ones = { key: adjuster(apply_scoping(key, value), key) @@ -165,77 +208,109 @@ def _backref(key): return model._meta.get_field(key).remote_field.attname to_many = { - key: [set_order_number(i, apply_scoping(key, record))._replace(strong_ignore=[_backref(key)]) for i, record in enumerate(records)] + key: [ + set_order_number(i, apply_scoping(key, record), [_backref(key)]) + for i, record in enumerate(records) + ] for (key, records) in ut.toMany.items() } scoped_table = ScopedUploadTable( name=ut.name, - wbcols={f: extend_columnoptions(colopts, collection, table.name, f) for f, colopts in ut.wbcols.items()}, + wbcols={ + f: extend_columnoptions(colopts, collection, table.name, f) + for f, colopts in ut.wbcols.items() + }, static=ut.static, toOne=to_ones, - toMany=to_many, #type: ignore + toMany=to_many, # type: ignore scopingAttrs=scoping_relationships(collection, table), disambiguation=None, # Often, we'll need to recur down to clone (nested one-to-ones). Having this entire is handy in such a case - to_one_fields = to_one_fields, + to_one_fields=to_one_fields, match_payload=None, - strong_ignore=[] + # Ignore stuff like parent_id and such for matching, BUT, preserve it for cloning. + # This is done to allow matching to different parts of the tree, but not make a faulty tree if user doesn't select values (during clone). + strong_ignore=(SPECIAL_TREE_FIELDS_TO_SKIP if is_tree_table(table) else []), ) return scoped_table -def get_to_one_fields(collection) -> Dict[str, List['str']]: + +def get_to_one_fields(collection) -> Dict[str, List["str"]]: return { - 'collectionobject': [*(['collectingevent'] if collection.isembeddedcollectingevent else []), 'collectionobjectattribute'], - 'collectingevent': ['collectingeventattribute'], - 'attachment': ['attachmentimageattribute'], - 'collectingtrip': ['collectingtripattribute'], - 'preparation': ['preparationattribute'], - **({collection.discipline.paleocontextchildtable.lower(): ['paleocontext']} if collection.discipline.ispaleocontextembedded else {}) + "collectionobject": [ + *(["collectingevent"] if collection.isembeddedcollectingevent else []), + "collectionobjectattribute", + ], + "collectingevent": ["collectingeventattribute"], + "attachment": ["attachmentimageattribute"], + "collectingtrip": ["collectingtripattribute"], + "preparation": ["preparationattribute"], + **( + {collection.discipline.paleocontextchildtable.lower(): ["paleocontext"]} + if collection.discipline.ispaleocontextembedded + else {} + ), } -def set_order_number(i: int, tmr: ScopedUploadTable) -> ScopedUploadTable: + +def set_order_number( + i: int, tmr: ScopedUploadTable, to_ignore: List[str] +) -> ScopedUploadTable: table = datamodel.get_table_strict(tmr.name) - if table.get_field('ordernumber'): - return tmr._replace(scopingAttrs={**tmr.scopingAttrs, 'ordernumber': i}) - return tmr + if table.get_field("ordernumber"): + return tmr._replace(scopingAttrs={**tmr.scopingAttrs, "ordernumber": i}) + return tmr._replace(strong_ignore=[*tmr.strong_ignore, *to_ignore]) + def apply_scoping_to_treerecord(tr: TreeRecord, collection) -> ScopedTreeRecord: table = datamodel.get_table_strict(tr.name) - if table.name == 'Taxon': + if table.name == "Taxon": treedef = collection.discipline.taxontreedef - elif table.name == 'Geography': + elif table.name == "Geography": treedef = collection.discipline.geographytreedef - elif table.name == 'LithoStrat': + elif table.name == "LithoStrat": treedef = collection.discipline.lithostrattreedef - elif table.name == 'GeologicTimePeriod': + elif table.name == "GeologicTimePeriod": treedef = collection.discipline.geologictimeperiodtreedef - elif table.name == 'Storage': + elif table.name == "Storage": treedef = collection.discipline.division.institution.storagetreedef else: - raise Exception(f'unexpected tree type: {table.name}') + raise Exception(f"unexpected tree type: {table.name}") - treedefitems = list(treedef.treedefitems.order_by('rankid')) + treedefitems = list(treedef.treedefitems.order_by("rankid")) treedef_ranks = [tdi.name for tdi in treedefitems] for rank in tr.ranks: if rank not in treedef_ranks: - raise Exception(f'"{rank}" not among {table.name} tree ranks: {treedef_ranks}') + raise Exception( + f'"{rank}" not among {table.name} tree ranks: {treedef_ranks}' + ) - root = list(getattr(models, table.name.capitalize()).objects.filter(definitionitem=treedefitems[0])[:1]) # assume there is only one + root = list( + getattr(models, table.name.capitalize()).objects.filter( + definitionitem=treedefitems[0] + )[:1] + ) # assume there is only one return ScopedTreeRecord( name=tr.name, - ranks={r: {f: extend_columnoptions(colopts, collection, table.name, f) for f, colopts in cols.items()} for r, cols in tr.ranks.items()}, + ranks={ + r: { + f: extend_columnoptions(colopts, collection, table.name, f) + for f, colopts in cols.items() + } + for r, cols in tr.ranks.items() + }, treedef=treedef, - treedefitems=list(treedef.treedefitems.order_by('rankid')), + treedefitems=list(treedef.treedefitems.order_by("rankid")), root=root[0] if root else None, disambiguation={}, - batch_edit_pack=None + batch_edit_pack=None, ) diff --git a/specifyweb/workbench/upload/treerecord.py b/specifyweb/workbench/upload/treerecord.py index b8ea322f13b..5ec01aba407 100644 --- a/specifyweb/workbench/upload/treerecord.py +++ b/specifyweb/workbench/upload/treerecord.py @@ -12,6 +12,7 @@ from specifyweb.specify import models from specifyweb.workbench.upload.clone import clone_record from specifyweb.workbench.upload.predicates import ( + SPECIAL_TREE_FIELDS_TO_SKIP, ContetRef, DjangoPredicates, SkippablePredicate, @@ -222,7 +223,10 @@ def must_match(self) -> bool: return False def get_django_predicates( - self, should_defer_match: bool, to_one_override: Dict[str, UploadResult] = {} + self, + should_defer_match: bool, + to_one_override: Dict[str, UploadResult] = {}, + consider_dependents=False, ) -> DjangoPredicates: # Everything is so complicated around here. In an initial implementation, I naively returned SkippablePredicates, # but that'll potentially cause null records to be actually processed. (although, there doesn't seem to be a realizable user mapping to do it) @@ -237,7 +241,7 @@ def get_django_predicates( def can_save(self) -> bool: return False - def delete_row(self, info, parent_obj=None) -> UploadResult: + def delete_row(self, parent_obj=None) -> UploadResult: raise NotImplementedError() def match_row(self) -> UploadResult: @@ -637,15 +641,6 @@ def force_upload_row(self) -> UploadResult: def _get_reference(self) -> Optional[Dict[str, Any]]: - FIELDS_TO_SKIP = [ - "nodenumber", - "highestchildnodenumber", - "parent_id", - # TODO: Test fullname. Depends on use-cases I guess. - # Skipping them currently because we won't be able to match across branches, without disabling all database fields lookup - "fullname", - ] - # Much simpler than uploadTable. Just fetch all rank's references. Since we also require name to be not null, # the "deferForNull" is redundant. We, do, however need to look at deferForMatch, and we are done. @@ -686,7 +681,7 @@ def _get_reference(self) -> Optional[Dict[str, Any]]: else { "ref": reference, "attrs": resolve_reference_attributes( - FIELDS_TO_SKIP, model, reference + SPECIAL_TREE_FIELDS_TO_SKIP, model, reference ), } ) diff --git a/specifyweb/workbench/upload/upload_table.py b/specifyweb/workbench/upload/upload_table.py index f4632453eeb..a8e400d673a 100644 --- a/specifyweb/workbench/upload/upload_table.py +++ b/specifyweb/workbench/upload/upload_table.py @@ -5,7 +5,6 @@ from specifyweb.businessrules.exceptions import BusinessRuleException from specifyweb.specify import models -from specifyweb.specify.api import delete_obj from specifyweb.specify.func import Func from specifyweb.specify.field_change_info import FieldChangeInfo from specifyweb.workbench.upload.clone import clone_record @@ -371,7 +370,10 @@ def _should_defer_match(self): return defer_preference.should_defer_fields("match") def get_django_predicates( - self, should_defer_match: bool, to_one_override: Dict[str, UploadResult] = {} + self, + should_defer_match: bool, + to_one_override: Dict[str, UploadResult] = {}, + consider_dependents=False, ) -> DjangoPredicates: model = self.django_model @@ -408,7 +410,8 @@ def get_django_predicates( if key in to_one_override # For simplicity in typing, to-ones are also considered as a list else value.get_django_predicates( - should_defer_match=should_defer_match + should_defer_match=should_defer_match, + consider_dependents=consider_dependents, ).reduce_for_to_one() ) for key, value in self.toOne.items() @@ -417,7 +420,8 @@ def get_django_predicates( to_many = { key: [ value.get_django_predicates( - should_defer_match=should_defer_match + should_defer_match=should_defer_match, + consider_dependents=consider_dependents, ).reduce_for_to_many(value) for value in values ] @@ -429,6 +433,29 @@ def get_django_predicates( ) if combined_filters.is_reducible(): + # This is a very hot path, being called for every object on a row. We'd need to be very minimal in what we consider when considering dependents. + # So, we: + # 1. ^ all other attrs are null (otherwise, we won't delete this obj anyways, don't need to waste time looking at deps) + # 2. only look at unmapped dependents, otherwise, it is redundant again. + # 3. just make sure it is present + if consider_dependents and record_ref is not None: + current_rels = [*self.toOne.keys(), *self.toMany.keys()] + for field in record_ref._meta.get_fields(): + if field in current_rels or not ( + field.is_relation + and self._relationship_is_dependent(field.name) + ): + continue + if field.many_to_one or field.one_to_one: + attname: str = field.attname # type: ignore + hit = getattr(record_ref, attname) != None + else: + hit = getattr(record_ref, field.name).exists() + if hit: + # returning this makes this agnostic to implementation above, it really shouldn't be used for matching or anything + return DjangoPredicates( + filters={field.name: [SkippablePredicate()]} + ) return DjangoPredicates() combined_filters = combined_filters._replace( @@ -542,6 +569,7 @@ def _handle_row(self, skip_match: bool, allow_null: bool) -> UploadResult: filter_predicate = self.get_django_predicates( should_defer_match=self._should_defer_match, to_one_override=to_one_results, + consider_dependents=isinstance(self, BoundUpdateTable), ) except ContetRef as e: # Not sure if there is a better way for this. Consider moving this to binding. @@ -759,7 +787,14 @@ def _do_picklist_additions(self) -> List[PicklistAddition]: ) return added_picklist_items - def delete_row(self, info, parent_obj=None) -> UploadResult: + def delete_row(self, parent_obj=None) -> UploadResult: + + info = ReportInfo( + tableName=self.name, + columns=[pr.column for pr in self.parsedFields], + treeInfo=None, + ) + if self.current_id is None: return UploadResult(NullRecord(info), {}, {}) # By the time we are here, we know if we can't have a not null to-one or to-many mapping. @@ -768,17 +803,39 @@ def delete_row(self, info, parent_obj=None) -> UploadResult: reference_record = self._get_reference( should_cache=False # Need to evict the last copy, in case someone tries accessing it, we'll then get a stale record ) + + assert reference_record is not None + result: Optional[Union[Deleted, FailedBusinessRule]] = None + + to_many_deleted: Dict[str, List[UploadResult]] = { + key: [record.delete_row() for record in records] + for (key, records) in self.toMany.items() + if self._relationship_is_dependent(key) + } + if any( + isinstance(result.record_result, Deleted) + for (results_per_key) in to_many_deleted.values() + for result in results_per_key + ): + return UploadResult(PropagatedFailure(), {}, to_many_deleted) + with transaction.atomic(): try: - delete_obj( - reference_record, parent_obj=parent_obj, deleter=self.auditor.delete - ) + # we don't care about deleting dependents, because if we get here, we either don't have any dependents OR we mapped all of them + self.auditor.delete(reference_record, parent_obj) + reference_record.delete() result = Deleted(self.current_id, info) except (BusinessRuleException, IntegrityError) as e: result = FailedBusinessRule(str(e), {}, info) + + to_one_deleted: Dict[str, UploadResult] = { + key: value.delete_row() + for (key, value) in self.toOne.items() + if self._relationship_is_dependent(key) + } assert result is not None - return UploadResult(result, {}, {}) + return UploadResult(result, to_one_deleted, to_many_deleted) def _relationship_is_dependent(self, field_name) -> bool: django_model = self.django_model @@ -1017,7 +1074,7 @@ def _clean_up_fks( ) -> Tuple[Dict[str, UploadResult], Dict[str, List[UploadResult]]]: to_one_deleted = { - key: uploadable.delete_row(to_one_results[key].record_result.info) # type: ignore + key: uploadable.delete_row() # type: ignore for (key, uploadable) in self.toOne.items() if self._relationship_is_dependent(key) and isinstance(to_one_results[key].record_result, NullRecord) @@ -1026,7 +1083,7 @@ def _clean_up_fks( to_many_deleted = { key: [ ( - uploadable.delete_row(result.record_result.info) + uploadable.delete_row() if isinstance(result.record_result, NullRecord) else result ) diff --git a/specifyweb/workbench/upload/uploadable.py b/specifyweb/workbench/upload/uploadable.py index dab13876bce..eaaa135e769 100644 --- a/specifyweb/workbench/upload/uploadable.py +++ b/specifyweb/workbench/upload/uploadable.py @@ -9,23 +9,27 @@ from .upload_result import UploadResult, ParseFailures from .auditor import Auditor + class BatchEditSelf(TypedDict): id: int ordernumber: Optional[int] version: Optional[int] - + + class BatchEditJson(TypedDict): self: BatchEditSelf to_one: Dict[str, Any] to_many: Dict[str, List[Any]] + class Extra(TypedDict): batch_edit: Optional[BatchEditJson] disambiguation: Dict[str, int] + Disambiguation = Optional["DisambiguationInfo"] -NULL_RECORD = 'null_record' +NULL_RECORD = "null_record" ScopeGenerator = Optional[Generator[int, None, None]] @@ -35,77 +39,76 @@ class Extra(TypedDict): Filter = Dict[str, Any] + class Uploadable(Protocol): # also returns if the scoped table returned can be cached or not. # depends on whether scope depends on other columns. if any definition is found, # we cannot cache. well, we can make this more complicated by recursviely caching # static parts of even a non-entirely-cachable uploadable. - def apply_scoping(self, collection, generator: ScopeGenerator = None, row=None) -> "ScopedUploadable": - ... + def apply_scoping( + self, collection, generator: ScopeGenerator = None, row=None + ) -> "ScopedUploadable": ... - def get_cols(self) -> Set[str]: - ... + def get_cols(self) -> Set[str]: ... - def to_json(self) -> Dict: - ... + def to_json(self) -> Dict: ... + + def unparse(self) -> Dict: ... - def unparse(self) -> Dict: - ... class DisambiguationInfo(Protocol): - def disambiguate(self) -> Optional[int]: - ... + def disambiguate(self) -> Optional[int]: ... + + def disambiguate_tree(self) -> Dict[str, int]: ... - def disambiguate_tree(self) -> Dict[str, int]: - ... + def disambiguate_to_one(self, to_one: str) -> "Disambiguation": ... - def disambiguate_to_one(self, to_one: str) -> "Disambiguation": - ... + def disambiguate_to_many( + self, to_many: str, record_index: int + ) -> "Disambiguation": ... - def disambiguate_to_many(self, to_many: str, record_index: int) -> "Disambiguation": - ... class ScopedUploadable(Protocol): - def disambiguate(self, disambiguation: Disambiguation) -> "ScopedUploadable": - ... + def disambiguate(self, disambiguation: Disambiguation) -> "ScopedUploadable": ... + + def bind( + self, + row: Row, + uploadingAgentId: int, + auditor: Auditor, + cache: Optional[Dict] = None, + ) -> Union["BoundUploadable", ParseFailures]: ... - def bind(self, row: Row, uploadingAgentId: int, auditor: Auditor, cache: Optional[Dict]=None) -> Union["BoundUploadable", ParseFailures]: - ... + def get_treedefs(self) -> Set: ... + + def apply_batch_edit_pack( + self, batch_edit_pack: Optional[BatchEditJson] + ) -> "ScopedUploadable": ... - def get_treedefs(self) -> Set: - ... - - def apply_batch_edit_pack(self, batch_edit_pack: Optional[BatchEditJson]) -> "ScopedUploadable": - ... class BoundUploadable(Protocol): - def is_one_to_one(self) -> bool: - ... + def is_one_to_one(self) -> bool: ... + + def must_match(self) -> bool: ... + + def get_django_predicates( + self, + should_defer_match: bool, + to_one_override: Dict[str, UploadResult] = {}, + consider_dependents=False, + ) -> DjangoPredicates: ... - def must_match(self) -> bool: - ... - - def get_django_predicates(self, should_defer_match: bool, to_one_override: Dict[str, UploadResult] = {}) -> DjangoPredicates: - ... + def get_to_remove(self) -> ToRemove: ... - def get_to_remove(self) -> ToRemove: - ... + def match_row(self) -> UploadResult: ... - def match_row(self) -> UploadResult: - ... + def process_row(self) -> UploadResult: ... - def process_row(self) -> UploadResult: - ... + def force_upload_row(self) -> UploadResult: ... - def force_upload_row(self) -> UploadResult: - ... - - def save_row(self, force=False) -> UploadResult: - ... + def save_row(self, force=False) -> UploadResult: ... # I don't want to use dataset's isupdate=True, so using this. That is, the entire "batch edit" can work perfectly fine using workbench. - def can_save(self) -> bool: - ... + def can_save(self) -> bool: ... - def delete_row(self, info, parent_obj=None) -> UploadResult: - ... \ No newline at end of file + def delete_row(self, parent_obj=None) -> UploadResult: ... diff --git a/specifyweb/workbench/views.py b/specifyweb/workbench/views.py index daf69aa12ae..f9df6acf372 100644 --- a/specifyweb/workbench/views.py +++ b/specifyweb/workbench/views.py @@ -702,6 +702,7 @@ def rows(request, ds) -> http.HttpResponse: @models.Spdataset.validate_dataset_request(raise_404=True, lock_object=True) def upload(request, ds, no_commit: bool, allow_partial: bool) -> http.HttpResponse: "Initiates an upload or validation of dataset ." + from .upload.upload import do_upload_dataset do_permission = BatchEditDataSet.commit if ds.isupdate else DataSetPT.upload @@ -723,16 +724,25 @@ def upload(request, ds, no_commit: bool, allow_partial: bool) -> http.HttpRespon return http.HttpResponse("dataset has already been uploaded.", status=400) taskid = str(uuid4()) - async_result = tasks.upload.apply_async( - [ - request.specify_collection.id, - request.specify_user_agent.id, - ds.id, - no_commit, - allow_partial, - ], - task_id=taskid, + # async_result = tasks.upload.apply_async( + # [ + # request.specify_collection.id, + # request.specify_user_agent.id, + # ds.id, + # no_commit, + # allow_partial, + # ], + # task_id=taskid, + # ) + do_upload_dataset( + request.specify_collection, + request.specify_user_agent.id, + ds, + no_commit, + allow_partial, + None, ) + async_result = "ss" ds.uploaderstatus = { "operation": "validating" if no_commit else "uploading", "taskid": taskid, From 15e0d14bcaa6b0cb0a10203c4a33dd251fa320fd Mon Sep 17 00:00:00 2001 From: realVinayak Date: Tue, 27 Aug 2024 22:25:21 -0500 Subject: [PATCH 44/63] (batch-edit): revert workbench views --- specifyweb/workbench/views.py | 27 +++++++++------------------ 1 file changed, 9 insertions(+), 18 deletions(-) diff --git a/specifyweb/workbench/views.py b/specifyweb/workbench/views.py index f9df6acf372..aa7088f9902 100644 --- a/specifyweb/workbench/views.py +++ b/specifyweb/workbench/views.py @@ -724,25 +724,16 @@ def upload(request, ds, no_commit: bool, allow_partial: bool) -> http.HttpRespon return http.HttpResponse("dataset has already been uploaded.", status=400) taskid = str(uuid4()) - # async_result = tasks.upload.apply_async( - # [ - # request.specify_collection.id, - # request.specify_user_agent.id, - # ds.id, - # no_commit, - # allow_partial, - # ], - # task_id=taskid, - # ) - do_upload_dataset( - request.specify_collection, - request.specify_user_agent.id, - ds, - no_commit, - allow_partial, - None, + async_result = tasks.upload.apply_async( + [ + request.specify_collection.id, + request.specify_user_agent.id, + ds.id, + no_commit, + allow_partial, + ], + task_id=taskid, ) - async_result = "ss" ds.uploaderstatus = { "operation": "validating" if no_commit else "uploading", "taskid": taskid, From 0c4dac0dd5faa52f478bf3a44c06b6fd4acf4cb6 Mon Sep 17 00:00:00 2001 From: realVinayak Date: Wed, 28 Aug 2024 18:52:06 -0500 Subject: [PATCH 45/63] (batch-edit): Restrict system tables --- .../js_src/lib/components/BatchEdit/index.tsx | 28 ++++++++++--------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx b/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx index 26529893bdc..a8f514bb0e4 100644 --- a/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx @@ -36,6 +36,11 @@ import { import { generateMappingPathPreview } from '../WbPlanView/mappingPreview'; import { LiteralField, Relationship } from '../DataModel/specifyField'; +const queryFieldSpecHeader = (queryFieldSpec: QueryFieldSpec)=> generateMappingPathPreview( + queryFieldSpec.baseTable.name, + queryFieldSpec.toMappingPath() +); + export function BatchEditFromQuery({ query, fields, @@ -67,6 +72,7 @@ export function BatchEditFromQuery({ }); const [errors, setErrors] = React.useState(undefined); const loading = React.useContext(LoadingContext); + return ( <> filters.some((filter)=>filter(fieldSpec))); const hasErrors = Object.values(missingRanks).some((ranks) => ranks.length > 0) || invalidFields.length > 0; if (hasErrors) { - setErrors({ missingRanks, invalidFields }); + setErrors({ missingRanks, invalidFields: invalidFields.map(queryFieldSpecHeader) }); return; } @@ -127,21 +130,20 @@ type QueryError = { function containsFaultyNestedToMany( queryFieldSpec: QueryFieldSpec -): string | undefined { +): boolean { const joinPath = queryFieldSpec.joinPath; - if (joinPath.length <= 1) return undefined; + if (joinPath.length <= 1) return false; const nestedToManyCount = joinPath.filter( (relationship) => relationship.isRelationship && relationshipIsToMany(relationship) ); - return nestedToManyCount.length > 1 - ? generateMappingPathPreview( - queryFieldSpec.baseTable.name, - queryFieldSpec.toMappingPath() - ) - : undefined; + return nestedToManyCount.length > 1; } +const containsSystemTables = (queryFieldSpec: QueryFieldSpec)=>queryFieldSpec.joinPath.some((field)=>field.table.isSystem); + +const filters = [containsFaultyNestedToMany, containsSystemTables]; + const getTreeDefFromName = ( rankName: string, treeDefItems: RA> From 119114806c76dd665ce70273718083a9b278d9ed Mon Sep 17 00:00:00 2001 From: realVinayak Date: Thu, 29 Aug 2024 00:47:41 -0500 Subject: [PATCH 46/63] (batch-edit): UI comment resolves --- specifyweb/frontend/js_src/css/main.css | 6 +- .../AttachmentsBulkImport/Upload.tsx | 2 +- .../lib/components/QueryBuilder/Wrapped.tsx | 2 +- .../lib/components/Toolbar/WbsDialog.tsx | 116 +--------------- .../lib/components/WbActions/WbRollback.tsx | 20 +-- .../lib/components/WbActions/WbUpload.tsx | 14 +- .../js_src/lib/components/WbActions/index.tsx | 30 +++-- .../js_src/lib/components/WbImport/helpers.ts | 2 +- .../lib/components/WbPlanView/index.tsx | 2 +- .../js_src/lib/components/WbToolkit/index.tsx | 2 +- .../components/WbUtils/datasetVariants.tsx | 124 ++++++++++++++++++ .../lib/components/WorkBench/DataSetMeta.tsx | 2 +- .../lib/components/WorkBench/RecordSet.tsx | 8 +- .../lib/components/WorkBench/Results.tsx | 3 + .../lib/components/WorkBench/Status.tsx | 10 +- .../lib/components/WorkBench/WbView.tsx | 3 +- .../js_src/lib/localization/batchEdit.ts | 24 ++++ .../js_src/lib/localization/workbench.ts | 14 +- specifyweb/specify/tree_extras.py | 2 +- specifyweb/stored_queries/batch_edit.py | 2 + 20 files changed, 223 insertions(+), 165 deletions(-) create mode 100644 specifyweb/frontend/js_src/lib/components/WbUtils/datasetVariants.tsx diff --git a/specifyweb/frontend/js_src/css/main.css b/specifyweb/frontend/js_src/css/main.css index 0015bfaa2df..538239fd7e6 100644 --- a/specifyweb/frontend/js_src/css/main.css +++ b/specifyweb/frontend/js_src/css/main.css @@ -255,12 +255,14 @@ --modified-cell: theme('colors.yellow.250'); --search-result: theme('colors.green.300'); --updated-cell: theme('colors.cyan.200'); - --deleted-cell: theme('colors.brand.100'); + --deleted-cell: theme('colors.amber.500'); --matched-and-changed-cell: theme('colors.blue.200'); @apply dark:[--invalid-cell:theme('colors.red.900')] dark:[--modified-cell:theme('colors.yellow.900')] dark:[--new-cell:theme('colors.indigo.900')] - dark:[--updated-cell:theme('colors.indigo.900')] + dark:[--deleted-cell:theme('colors.amber.600')] + dark:[--updated-cell:theme('colors.cyan.800')] + dark:[--matched-and-changed-cell:theme('colors.fuchsia.700')] dark:[--search-result:theme('colors.green.900')]; } diff --git a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/Upload.tsx b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/Upload.tsx index 9363075f5da..d7b809b5481 100644 --- a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/Upload.tsx +++ b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/Upload.tsx @@ -106,7 +106,7 @@ async function prepareForUpload( const dialogText = { onAction: wbText.uploading(), - onCancelled: wbText.uploadCanceled({type: wbText.upload()}), + onCancelled: wbText.uploadCanceled(), onCancelledDescription: wbText.uploadCanceledDescription({type: wbText.upload()}), } as const; diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx index 6411cb932ab..4185b5ca92f 100644 --- a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx +++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx @@ -53,7 +53,7 @@ import type { QueryResultRow } from './Results'; import { QueryResultsWrapper } from './ResultsWrapper'; import { QueryToolbar } from './Toolbar'; import { BatchEditFromQuery } from '../BatchEdit'; -import { datasetVariants } from '../Toolbar/WbsDialog'; +import { datasetVariants } from '../WbUtils/datasetVariants'; const fetchTreeRanks = async (): Promise => treeRanksPromise.then(f.true); diff --git a/specifyweb/frontend/js_src/lib/components/Toolbar/WbsDialog.tsx b/specifyweb/frontend/js_src/lib/components/Toolbar/WbsDialog.tsx index e31c54d07ad..ce81434a74f 100644 --- a/specifyweb/frontend/js_src/lib/components/Toolbar/WbsDialog.tsx +++ b/specifyweb/frontend/js_src/lib/components/Toolbar/WbsDialog.tsx @@ -10,7 +10,6 @@ import type { LocalizedString } from 'typesafe-i18n'; import { useAsyncState } from '../../hooks/useAsyncState'; import { commonText } from '../../localization/common'; -import { wbPlanText } from '../../localization/wbPlan'; import { wbText } from '../../localization/workbench'; import { ajax } from '../../utils/ajax'; import type { RA } from '../../utils/types'; @@ -27,15 +26,12 @@ import { Dialog, dialogClassNames } from '../Molecules/Dialog'; import type { SortConfig } from '../Molecules/Sorting'; import { SortIndicator, useSortConfig } from '../Molecules/Sorting'; import { TableIcon } from '../Molecules/TableIcon'; -import { hasPermission } from '../Permissions/helpers'; import { OverlayContext } from '../Router/Router'; import { uniquifyDataSetName } from '../WbImport/helpers'; import type { Dataset, DatasetBriefPlan } from '../WbPlanView/Wrapped'; import { WbDataSetMeta } from '../WorkBench/DataSetMeta'; import { formatUrl } from '../Router/queryString'; -import { f } from '../../utils/functools'; -import { batchEditText } from '../../localization/batchEdit'; -import { userPreferences } from '../Preferences/userPreferences'; +import { datasetVariants } from '../WbUtils/datasetVariants'; const createWorkbenchDataSet = async () => createEmptyDataSet( @@ -136,7 +132,7 @@ function TableHeader({ type WB_VARIANT = keyof Omit; -export type WbVariantUiSpec = typeof datasetVariants.workbench.uiSpec.viewer; +export type WbVariantLocalization = typeof datasetVariants.workbench.localization.viewer; export function GenericDataSetsDialog({ onClose: handleClose, @@ -147,7 +143,7 @@ export function GenericDataSetsDialog({ readonly onClose: () => void; readonly onDataSetSelect?: (id: number) => void; }): JSX.Element | null { - const {fetchUrl, sortConfig: sortConfigSpec, canEdit, uiSpec, route, metaRoute, canImport} = datasetVariants[wbVariant]; + const {fetchUrl, sortConfig: sortConfigSpec, canEdit, localization, route, metaRoute, canImport} = datasetVariants[wbVariant]; const [unsortedDatasets] = useAsyncState( React.useCallback( async () => ajax>(formatUrl(fetchUrl, {}), { headers: { Accept: 'application/json' } }).then(({data})=>data), @@ -196,13 +192,13 @@ export function GenericDataSetsDialog({ container: dialogClassNames.wideContainer, }} dimensionsKey="DataSetsDialog" - header={uiSpec.datasetsDialog.header(datasets.length)} + header={localization.datasetsDialog.header(datasets.length)} icon={icons.table} onClose={handleClose} > {datasets.length === 0 ? (

    - {uiSpec.datasetsDialog.empty()} + {localization.datasetsDialog.empty()}

    ) : (