diff --git a/specifyweb/businessrules/tree_rules.py b/specifyweb/businessrules/tree_rules.py index 4f5f51aec4a..417e9c8a3f2 100644 --- a/specifyweb/businessrules/tree_rules.py +++ b/specifyweb/businessrules/tree_rules.py @@ -17,3 +17,7 @@ def cannot_delete_root_treedefitem(sender, obj): "id" : obj.id }}) +@orm_signal_handler('pre_save') +def set_is_accepted_if_prefereed(sender, obj): + if hasattr(obj, 'isaccepted'): + obj.isaccepted = obj.accepted_id == None diff --git a/specifyweb/businessrules/uniqueness_rules.py b/specifyweb/businessrules/uniqueness_rules.py index 4479f5d846d..da23e0cda3e 100644 --- a/specifyweb/businessrules/uniqueness_rules.py +++ b/specifyweb/businessrules/uniqueness_rules.py @@ -1,4 +1,6 @@ +import json from django.core.exceptions import ObjectDoesNotExist +from typing import Dict, List, Union from specifyweb.specify import models from .orm_signal_handler import orm_signal_handler from .exceptions import BusinessRuleException @@ -28,6 +30,7 @@ def check_unique(instance): else: @orm_signal_handler('pre_save', model_name) def check_unique(instance): + if isinstance(parent_field, dict): return try: parent = getattr(instance, parent_field + '_id', None) except ObjectDoesNotExist: @@ -53,71 +56,23 @@ def check_unique(instance): "conflicting" : list(conflicts.values_list('id', flat=True)[:100])}) return check_unique -UNIQUENESS_RULES = { - 'Accession': { - 'accessionnumber': ['division'], - }, - 'Appraisal': { - 'appraisalnumber': ['accession'], - }, - 'Author': { - 'agent': ['referencework'], - 'ordernumber': ['referencework'], - }, - 'Collection': { - 'collectionname': ['discipline'], - 'code': ['discipline'], - }, - 'Collectionobject': { - 'catalognumber': ['collection'], - }, - 'Collector': { - 'agent': ['collectingevent'], - }, - 'Discipline': { - 'name': ['division'], - }, - 'Division': { - 'name': ['institution'], - }, - 'Gift': { - 'giftnumber': ['discipline'], - }, - 'Groupperson': { - 'member': ['group'], - }, - 'Institution': { - 'name': [None], - }, - 'Loan': { - 'loannumber': ['discipline'], - }, - 'Permit': { - 'permitnumber': [None], - }, - 'Picklist': { - 'name': ['collection'], - }, - 'Preptype': { - 'name': ['collection'], - }, - 'Repositoryagreement': { - 'repositoryagreementnumber': ['division'], - }, - 'Spappresourcedata': { - 'spappresource': [None], - }, - } +RAW_UNIQUENESS_RULES: Dict[str, Dict[str, List[Union[Dict[str, Union[str, list]], str, None]]]] = \ + json.load(open('specifyweb/frontend/js_src/lib/components/DataModel/uniquness_rules.json')) +def parse_uniqueness_rules(): + PARSED_UNIQUENESS_RULES = {} + for table, rules in RAW_UNIQUENESS_RULES.items(): + table = table.lower().capitalize() + if hasattr(models, table): + PARSED_UNIQUENESS_RULES[table] = {} + for field_name, rule in rules.items(): + # The Specify Model field names are always in lowercase + field_name = field_name.lower() + PARSED_UNIQUENESS_RULES[table][field_name] = rule -# This check is provided to support the Specify 6.8.01 -# datamodel (schema version 2.9). When support for that -# version is dropped it can be removed and this definition -# can be included in the block above. -if hasattr(models, 'Determiner'): - UNIQUENESS_RULES['Determiner'] = { - 'agent': ['determination'], - } + return PARSED_UNIQUENESS_RULES + +UNIQUENESS_RULES = parse_uniqueness_rules() uniqueness_rules = [make_uniqueness_rule(model, parent_field, unique_field) diff --git a/specifyweb/frontend/js_src/lib/components/AppResources/Aside.tsx b/specifyweb/frontend/js_src/lib/components/AppResources/Aside.tsx index 2f37ecf836e..c8c31880b74 100644 --- a/specifyweb/frontend/js_src/lib/components/AppResources/Aside.tsx +++ b/specifyweb/frontend/js_src/lib/components/AppResources/Aside.tsx @@ -214,7 +214,7 @@ function TreeItem({ > handleFold( @@ -226,7 +226,9 @@ function TreeItem({ > {count}, + wrap: (count) => ( + {count} + ), }} string={commonText.jsxCountLine({ resource: label, diff --git a/specifyweb/frontend/js_src/lib/components/Core/Main.tsx b/specifyweb/frontend/js_src/lib/components/Core/Main.tsx index bed7b9ca318..e9492b7f51a 100644 --- a/specifyweb/frontend/js_src/lib/components/Core/Main.tsx +++ b/specifyweb/frontend/js_src/lib/components/Core/Main.tsx @@ -10,7 +10,6 @@ import { userText } from '../../localization/user'; import { f } from '../../utils/functools'; import type { GetOrSet, RA } from '../../utils/types'; import { Button } from '../Atoms/Button'; -import { enableBusinessRules } from '../DataModel/businessRules'; import { ErrorBoundary } from '../Errors/ErrorBoundary'; import { Header } from '../Header'; import type { MenuItemName } from '../Header/menuItemDefinitions'; @@ -42,7 +41,6 @@ export function Main({ const mainRef = React.useRef(null); React.useEffect(() => { - enableBusinessRules(true); console.groupEnd(); }, []); diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/businessRules.test.ts b/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/businessRules.test.ts new file mode 100644 index 00000000000..96063662487 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/businessRules.test.ts @@ -0,0 +1,194 @@ +import { overrideAjax } from '../../../tests/ajax'; +import { mockTime, requireContext } from '../../../tests/helpers'; +import { businessRuleDefs } from '../businessRuleDefs'; +import { SerializedModel } from '../helperTypes'; +import { getResourceApiUrl } from '../resource'; +import { schema } from '../schema'; +import { Determination } from '../types'; + +mockTime(); +requireContext(); + +test('uniqueness rules assigned correctly', async () => { + const accessionAgentUniquenessRules = { + role: [ + { + field: 'accession', + otherFields: ['agent'], + }, + { + field: 'repositoryagreement', + otherFields: ['agent'], + }, + ], + agent: [ + { + field: 'accession', + otherFields: ['role'], + }, + { + field: 'repositoryagreement', + otherFields: ['role'], + }, + ], + }; + expect(businessRuleDefs.AccessionAgent?.uniqueIn).toBe( + accessionAgentUniquenessRules + ); +}); + +const determinationId = 321; +const determinationUrl = getResourceApiUrl('Determination', determinationId); +const determinationResponse: Partial> = { + id: determinationId, + resource_uri: determinationUrl, +}; + +const collectionObjectId = 220; +const collectionObjectUrl = getResourceApiUrl( + 'CollectionObject', + collectionObjectId +); +const collectionObjectResponse = { + id: collectionObjectId, + resource_uri: collectionObjectUrl, + catalognumber: '000022002', + collection: getResourceApiUrl('Collection', 4), + determinations: determinationUrl, +}; + +overrideAjax(collectionObjectUrl, collectionObjectResponse); +overrideAjax(determinationUrl, determinationResponse); + +describe('business rules', () => { + test('collectionObject customInit', async () => { + const resource = new schema.models.CollectionObject.Resource({ + id: collectionObjectId, + }); + await resource.fetch(); + expect(resource.get('collectingEvent')).toBeDefined(); + resource.save(); + }); + + describe('determination business rules', () => { + test('determination customInit', async () => { + const determination = new schema.models.Determination.Resource({ + id: determinationId, + }); + await determination.fetch(); + expect(determination.get('isCurrent')).toBe(true); + }); + test('only one determination isCurrent', async () => { + const determination = new schema.models.Determination.Resource({ + id: determinationId, + }); + const resource = new schema.models.CollectionObject.Resource({ + id: collectionObjectId, + }); + await resource.rgetCollection('determinations').then((collection) => { + collection.add(new schema.models.Determination.Resource()); + }); + expect(determination.get('isCurrent')).toBe(false); + }); + test('determination taxon field check', async () => { + const determination = new schema.models.Determination.Resource({ + id: determinationId, + }); + const taxonId = 19345; + const taxonUrl = getResourceApiUrl('Taxon', taxonId); + const taxonResponse = { + resource_uri: getResourceApiUrl('Taxon', taxonUrl), + id: taxonId, + name: 'melas', + fullName: 'Ameiurus melas', + }; + overrideAjax(taxonUrl, taxonResponse); + determination.set( + 'taxon', + new schema.models.Taxon.Resource({ + id: taxonId, + }) + ); + expect(determination.get('preferredTaxon')).toBe(taxonUrl); + }); + }); + + test('dnaSequence genesequence fieldCheck', async () => { + const dnaSequence = new schema.models.DNASequence.Resource({ + id: 1, + }); + dnaSequence.set('geneSequence', 'cat123gaaz'); + + expect(dnaSequence.get('totalResidues')).toBe(10); + expect(dnaSequence.get('compA')).toBe(3); + expect(dnaSequence.get('ambiguousResidues')).toBe(4); + }); +}); + +describe('uniquenessRules', () => { + const permitOneId = 1; + const permitOneUrl = '/api/specify/permit/1/'; + const permitOneResponse = { + id: permitOneId, + resource_uri: permitOneUrl, + permitNumber: '20', + }; + overrideAjax(permitOneUrl, permitOneResponse); + + const permitTwoId = 2; + const permitTwoUrl = getResourceApiUrl('Permit', permitTwoId); + const permitTwoResponse = { + id: permitTwoId, + resource_uri: permitTwoUrl, + permitNumber: '20', + }; + overrideAjax(permitTwoUrl, permitTwoResponse); + + overrideAjax(getResourceApiUrl('CollectionObject', 221), { + id: 221, + resource_uri: getResourceApiUrl('CollectionObject', 221), + catalogNumber: '000022002', + }); + + test('global uniquenessRule', async () => { + const testPermit = new schema.models.Permit.Resource({ + id: permitOneId, + permitNumber: '20', + }); + await testPermit.save(); + + const duplicatePermit = new schema.models.Permit.Resource({ + id: permitTwoId, + permitNumber: '20', + }); + expect( + duplicatePermit + .fetch() + .then((permit) => + permit.businessRuleManager?.checkField('permitNumber') + ) + ).resolves.toBe({ + key: 'br-uniqueness-permitnumber', + valid: false, + reason: 'Value must be unique to Database', + }); + }); + + test('scoped uniqueness rule', async () => { + const resource = new schema.models.CollectionObject.Resource({ + id: 221, + catalogNumber: '000022002', + }); + expect( + resource + .fetch() + .then((collectionObject) => + collectionObject.businessRuleManager?.checkField('catalogNumber') + ) + ).resolves.toBe({ + key: 'br-uniqueness-catalognumber', + valid: false, + reason: 'Value must be unique to Collection', + }); + }); +}); 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 3e89a65e086..0b9679396a5 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 @@ -251,6 +251,7 @@ describe('getUniqueFields', () => { test('CollectionObject', () => expect(getUniqueFields(schema.models.CollectionObject)).toEqual([ 'catalogNumber', + 'uniqueIdentifier', 'guid', 'collectionObjectAttachments', 'timestampCreated', @@ -259,6 +260,7 @@ describe('getUniqueFields', () => { ])); test('Locality', () => expect(getUniqueFields(schema.models.Locality)).toEqual([ + 'uniqueIdentifier', 'localityAttachments', 'guid', 'timestampCreated', @@ -293,6 +295,7 @@ test('getFieldsToNotClone', () => { 'guid', 'timestampCreated', 'totalCountAmt', + 'uniqueIdentifier', 'version', 'collectionObjectAttachments', 'currentDetermination', @@ -306,6 +309,7 @@ test('getFieldsToNotClone', () => { 'text1', 'timestampCreated', 'totalCountAmt', + 'uniqueIdentifier', 'version', 'collectionObjectAttachments', 'currentDetermination', diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.js b/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.js deleted file mode 100644 index 9da3ef8b505..00000000000 --- a/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.js +++ /dev/null @@ -1,303 +0,0 @@ -import {interactionBusinessRules} from './interactionBusinessRules'; -import {schema} from './schema'; - -export const businessRuleDefs = Object.freeze({ - Accession: { - uniqueIn: { - accessionnumber: 'division' - } - }, - AccessionAgent: { - uniqueIn: { - role: [{field: 'accession', otherfields: ['agent']}, {field: 'repositoryagreement', otherfields: ['agent']}], - agent: [{field: 'accession', otherfields: ['role']}, {field: 'repositoryagreement', otherfields: ['role']}] - } - }, - Appraisal: { - uniqueIn: { - appraisalnumber: 'accession' - } - }, - Author: { - uniqueIn: { - agent: 'referencework' - } - }, - BorrowAgent: { - uniqueIn: { - role: {field: 'borrow', otherfields: ['agent']}, - agent: {field: 'borrow', otherfields: ['role']} - } - }, - BorrowMaterial: { - customChecks: { - quantityreturned(borrowmaterial) { - const returned = borrowmaterial.get('quantityreturned'); - let newval; - if (returned > borrowmaterial.get('quantity')) { - /* - *Return { - *valid: false, - *reason: 'value must be < ' + borrowmaterial.get('quantity') - *}; - */ - newval = borrowmaterial.get('quantity'); - } - if (returned > borrowmaterial.get('quantityresolved')) { - /* - *Return { - *valid: false, - *reason: 'quantity returned must be less than or equal to quantity resolved' - *}; - */ - newval = borrowmaterial.get('quantityresolved'); - } - newval && borrowmaterial.set('quantityreturned', newval); - }, - quantityresolved(borrowmaterial) { - const resolved = borrowmaterial.get('quantityresolved'); - let newval; - if (resolved > borrowmaterial.get('quantity')) { - /* - *Return { - *valid: false, - *reason: 'value must be < ' + borrowmaterial.get('quantity') - *}; - */ - newval = borrowmaterial.get('quantity'); - } - if (resolved < borrowmaterial.get('quantityreturned')) { - /* - *Return { - *valid: false, - *reason: 'quantity resolved must be greater than or equal to quantity returned' - *}; - */ - newval = borrowmaterial.get('quantityreturned'); - } - newval && borrowmaterial.set('quantityresolved', newval); - } - } - }, - Collection: { - uniqueIn: { - name: 'discipline' - } - }, - CollectingEvent: { - }, - CollectionObject: { - uniqueIn: { - catalognumber: 'collection', - guid: 'institution', - }, - customInit(collectionObject) { - const ceField = collectionObject.specifyModel.getField('collectingevent'); - if (ceField.isDependent() && collectionObject.get('collectingevent') == null) { - collectionObject.set('collectingevent', new schema.models.CollectingEvent.Resource()); - } - } - }, - Collector: { - uniqueIn: { - agent: 'collectingevent' - } - }, - Determination: { - onRemoved(det, detCollection) { - /* - * Example usage: - * if (detCollection.related.specifyModel.name == 'CollectionObject') { - * var collectionobject = detCollection.related; - * console.log("removed determination", det, "from collection object", collectionobject); - * } - */ - }, - onAdded(det, detCollection) { - /* - * Example usage: - * if (detCollection.related.specifyModel.name == 'CollectionObject') { - * var collectionobject = detCollection.related; - * console.log("added determination", det, "to collection object", collectionobject); - * } - */ - }, - customInit(determination) { - if (determination.isNew()) { - const setCurrent = function() { - determination.set('iscurrent', true); - if (determination.collection != null) { - determination.collection.each((other) => { - if (other.cid !== determination.cid) { - other.set('iscurrent', false); - } - }); - } - }; - if (determination.collection != null) setCurrent(); - determination.on('add', setCurrent); - } - }, - customChecks: { - taxon: determination => determination.rget('taxon', true).then( - taxon => taxon == null ? - { valid: true, action() { determination.set('preferredtaxon', null); }} - : (function recur(taxon) { - return taxon.get('acceptedtaxon') == null ? - { valid: true, action() { determination.set('preferredtaxon', taxon); }} - : taxon.rget('acceptedtaxon', true).then(recur); - })(taxon)), - - iscurrent(determination) { - if (determination.get('iscurrent') && (determination.collection != null)) { - determination.collection.each((other) => { - if (other.cid !== determination.cid) { - other.set('iscurrent', false); - } - }); - } - if (determination.collection != null && !determination.collection.any(c => c.get('iscurrent'))) { - determination.set('iscurrent', true); - } - return {valid: true}; - } - } - }, - Discipline: { - uniqueIn: { - name: 'division' - } - }, - Division: { - uniqueIn: { - name: 'institution' - } - }, - Gift: { - uniqueIn: { - giftnumber: 'discipline' - } - }, - GiftAgent: { - uniqueIn: { - role: {field: 'gift', otherfields: ['agent']}, - agent: {field: 'gift', otherfields: ['role']} - } - }, - GiftPreparation: { - customChecks: { - quantity(iprep) { - interactionBusinessRules.checkPrepAvailability(iprep); - } - } - }, - Institution: { - unique: ['name'] - }, - Loan: { - uniqueIn: { - loannumber: 'discipline' - } - }, - LoanAgent: { - uniqueIn: { - role: {field: 'loan', otherfields: ['agent']}, - agent: {field: 'loan', otherfields: ['role']} - } - }, - /* - * Might be able to use something like this to check when form is loaded after add-items or create-new for invalid amounts due to - * changes in other sessions - */ - LoanPreparation: { - customChecks: { - quantity(iprep) { - interactionBusinessRules.checkPrepAvailability(iprep); - } - } - - }, - LoanReturnPreparation: { - onRemoved(loanreturnprep, collection) { - interactionBusinessRules.updateLoanPrep(loanreturnprep, collection); - }, - customInit(loanreturnprep) { - interactionBusinessRules.totalLoaned = undefined; - interactionBusinessRules.totalResolved = undefined; - interactionBusinessRules.returned = undefined; - interactionBusinessRules.resolved = undefined; - loanreturnprep.get('quantityreturned') == null && loanreturnprep.set('quantityreturned', 0); - loanreturnprep.get('quantityresolved') == null && loanreturnprep.set('quantityresolved', 0); - }, - customChecks: { - quantityreturned(loanreturnprep) { - const returned = loanreturnprep.get('quantityreturned'); - const previousReturned = interactionBusinessRules.previousReturned[loanreturnprep.cid] - ? interactionBusinessRules.previousReturned[loanreturnprep.cid] - : 0; - if (returned != previousReturned) { - const delta = returned - previousReturned; - let resolved = loanreturnprep.get('quantityresolved'); - const totalLoaned = interactionBusinessRules.getTotalLoaned(loanreturnprep); - const totalResolved = interactionBusinessRules.getTotalResolved(loanreturnprep); - const max = totalLoaned - totalResolved; - if (delta + resolved > max) { - loanreturnprep.set('quantityreturned', previousReturned); - } else { - resolved = loanreturnprep.get('quantityresolved') + delta; - interactionBusinessRules.previousResolved[loanreturnprep.cid] = resolved; - loanreturnprep.set('quantityresolved', resolved); - } - interactionBusinessRules.previousReturned[loanreturnprep.cid] = loanreturnprep.get('quantityreturned'); - interactionBusinessRules.updateLoanPrep(loanreturnprep, loanreturnprep.collection); - } - }, - quantityresolved(loanreturnprep) { - const resolved = loanreturnprep.get('quantityresolved'); - const previousResolved = interactionBusinessRules.previousResolved[loanreturnprep.cid] - ? interactionBusinessRules.previousResolved[loanreturnprep.cid] - : 0; - if (resolved != previousResolved) { - const returned = loanreturnprep.get('quantityreturned'); - const totalLoaned = interactionBusinessRules.getTotalLoaned(loanreturnprep); - const totalResolved = interactionBusinessRules.getTotalResolved(loanreturnprep); - const max = totalLoaned - totalResolved; - if (resolved > max) { - loanreturnprep.set('quantityresolved', previousResolved); - } - if (resolved < returned) { - interactionBusinessRules.previousReturned[loanreturnprep.cid] = resolved; - loanreturnprep.set('quantityreturned', resolved); - } - interactionBusinessRules.previousResolved[loanreturnprep.cid] = loanreturnprep.get('quantityresolved'); - interactionBusinessRules.updateLoanPrep(loanreturnprep, loanreturnprep.collection); - } - } - } - }, - Permit: { - unique: ['permitnumber'] - }, - PickList: { - uniqueIn: { - name: 'collection' - } - }, - PrepType: { - uniqueIn: { - name: 'collection' - } - }, - SpecifyUser: { - uniqueIn: { - name: 'institution', - }, - }, - RepositoryAgreement: { - uniqueIn: { - repositoryagreementnumber: 'division', - role: {field: 'borrow', otherfields: ['agent']}, - agent: {field: 'borrow', otherfields: ['role']} - } - } - }); diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts b/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts new file mode 100644 index 00000000000..993fee899e0 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleDefs.ts @@ -0,0 +1,395 @@ +import { RA } from '../../utils/types'; +import { AnySchema, TableFields } from './helperTypes'; +import { + checkPrepAvailability, + getTotalLoaned, + getTotalResolved, + getTotalReturned, + previousLoanPreparations, + updateLoanPrep, +} from './interactionBusinessRules'; +import { SpecifyResource } from './legacyTypes'; +import { BusinessRuleResult } from './businessRules'; +import { schema } from './schema'; +import { Collection } from './specifyModel'; +import { + BorrowMaterial, + CollectionObject, + Determination, + DisposalPreparation, + DNASequence, + GiftPreparation, + LoanPreparation, + LoanReturnPreparation, + Tables, + Taxon, +} from './types'; +import uniquenessRules from './uniquness_rules.json'; + +export type BusinessRuleDefs = { + readonly onRemoved?: ( + resource: SpecifyResource, + collection: Collection + ) => void; + readonly uniqueIn?: UniquenessRule; + readonly customInit?: (resource: SpecifyResource) => void; + readonly fieldChecks?: { + [FIELD_NAME in TableFields]?: ( + resource: SpecifyResource + ) => Promise | void; + }; +}; + +const uniqueRules: JSONUniquenessRules = uniquenessRules; + +type JSONUniquenessRules = { + [TABLE in keyof Tables]?: JSONUniquenessRule; +}; + +type JSONUniquenessRule = { + [FIELD_NAME in TableFields]?: + | RA + | RA<{ field: string; otherFields: string[] }> + | RA; +}; + +export type UniquenessRule = { + [FIELD_NAME in TableFields]?: + | RA + | RA<{ field: string; otherFields: string[] }> + | RA; +}; + +type MappedBusinessRuleDefs = { + [TABLE in keyof Tables]?: BusinessRuleDefs; +}; + +export const nonUniqueBusinessRuleDefs: MappedBusinessRuleDefs = { + BorrowMaterial: { + fieldChecks: { + quantityReturned: ( + borrowMaterial: SpecifyResource + ): void => { + const returned = borrowMaterial.get('quantityReturned'); + const resolved = borrowMaterial.get('quantityResolved'); + const quantity = borrowMaterial.get('quantity'); + var newVal: number | undefined = undefined; + if ( + typeof quantity === 'number' && + typeof returned === 'number' && + returned > quantity + ) { + newVal = quantity; + } + if (returned && resolved && returned > resolved) { + newVal = resolved; + } + + newVal && borrowMaterial.set('quantityReturned', newVal); + }, + quantityResolved: ( + borrowMaterial: SpecifyResource + ): void => { + const resolved = borrowMaterial.get('quantityResolved'); + const quantity = borrowMaterial.get('quantity'); + const returned = borrowMaterial.get('quantityReturned'); + let newVal: number | undefined = undefined; + if (resolved && quantity && resolved > quantity) { + newVal = quantity; + } + if (resolved && returned && resolved < returned) { + newVal = returned; + } + + if (typeof newVal === 'number') + borrowMaterial.set('quantityResolved', newVal); + }, + }, + }, + + CollectionObject: { + customInit: function ( + collectionObject: SpecifyResource + ): void { + const ceField = collectionObject.specifyModel.getField('collectingEvent'); + if ( + ceField?.isDependent() && + collectionObject.get('collectingEvent') === undefined + ) { + collectionObject.set( + 'collectingEvent', + new schema.models.CollectingEvent.Resource() + ); + } + }, + }, + + Determination: { + customInit: (determinaton: SpecifyResource): void => { + if (determinaton.isNew()) { + const setCurrent = () => { + determinaton.set('isCurrent', true); + if (determinaton.collection != null) { + determinaton.collection.models.map( + (other: SpecifyResource) => { + if (other.cid !== determinaton.cid) + other.set('isCurrent', false); + } + ); + } + }; + if (determinaton.collection !== null) setCurrent(); + determinaton.on('add', setCurrent); + } + }, + fieldChecks: { + taxon: ( + determination: SpecifyResource + ): Promise => { + return determination + .rgetPromise('taxon', true) + .then((taxon: SpecifyResource | null) => { + const getLastAccepted = ( + taxon: SpecifyResource + ): Promise> => { + return taxon + .rgetPromise('acceptedTaxon', true) + .then((accepted) => + accepted === null ? taxon : getLastAccepted(accepted) + ); + }; + return taxon === null + ? { + valid: true, + action: () => determination.set('preferredTaxon', null), + } + : { + valid: true, + action: async () => + determination.set( + 'preferredTaxon', + await getLastAccepted(taxon) + ), + }; + }); + }, + isCurrent: ( + determination: SpecifyResource + ): Promise => { + if ( + determination.get('isCurrent') && + determination.collection != null + ) { + determination.collection.models.map( + (other: SpecifyResource) => { + if (other.cid !== determination.cid) { + other.set('isCurrent', false); + } + } + ); + } + if ( + determination.collection != null && + !determination.collection.models.some( + (c: SpecifyResource) => c.get('isCurrent') + ) + ) { + determination.set('isCurrent', true); + } + return Promise.resolve({ valid: true }); + }, + }, + }, + DisposalPreparation: { + fieldChecks: { + quantity: (disposalPrep: SpecifyResource): void => { + checkPrepAvailability(disposalPrep); + }, + }, + }, + DNASequence: { + fieldChecks: { + geneSequence: (dnaSequence: SpecifyResource): void => { + const current = dnaSequence.get('geneSequence'); + if (current === null) return; + const countObj = { a: 0, t: 0, g: 0, c: 0, ambiguous: 0 }; + for (let i = 0; i < current.length; i++) { + const char = current.at(i)?.toLowerCase().trim(); + if (char !== '') { + switch (char) { + case 'a': + countObj['a'] += 1; + break; + case 't': + countObj['t'] += 1; + break; + case 'g': + countObj['g'] += 1; + break; + case 'c': + countObj['c'] += 1; + break; + default: + countObj['ambiguous'] += 1; + } + } + } + dnaSequence.set('compA', countObj['a']); + dnaSequence.set('compT', countObj['t']); + dnaSequence.set('compG', countObj['g']); + dnaSequence.set('compC', countObj['c']); + dnaSequence.set('ambiguousResidues', countObj['ambiguous']); + dnaSequence.set( + 'totalResidues', + countObj['a'] + + countObj['t'] + + countObj['g'] + + countObj['c'] + + countObj['ambiguous'] + ); + }, + }, + }, + GiftPreparation: { + fieldChecks: { + quantity: (iprep: SpecifyResource): void => { + checkPrepAvailability(iprep); + }, + }, + }, + LoanPreparation: { + customInit: (resource: SpecifyResource): void => { + if (!resource.isNew()) + resource.rgetCollection('loanReturnPreparations').then(updateLoanPrep); + }, + fieldChecks: { + quantity: (iprep: SpecifyResource): void => { + checkPrepAvailability(iprep); + }, + }, + }, + LoanReturnPreparation: { + onRemoved: ( + _loanReturnPrep: SpecifyResource, + collection: Collection + ): void => updateLoanPrep(collection), + + customInit: (resource: SpecifyResource): void => { + const returned = resource.get('quantityReturned'); + const resolved = resource.get('quantityResolved'); + if (returned === undefined) resource.set('quantityReturned', 0); + if (resolved === undefined) resource.set('quantityResolved', 0); + if (!resource.isNew()) { + previousLoanPreparations.previousReturned[resource.cid] = + Number(returned); + previousLoanPreparations.previousResolved[resource.cid] = + Number(resolved); + } + updateLoanPrep(resource.collection); + }, + fieldChecks: { + quantityReturned: ( + loanReturnPrep: SpecifyResource + ) => { + const returned = Number(loanReturnPrep.get('quantityReturned'))!; + const previousReturned = + previousLoanPreparations.previousReturned[loanReturnPrep.cid] ?? 0; + const previousResolved = + previousLoanPreparations.previousResolved[loanReturnPrep.cid] ?? 0; + + const totalLoaned = getTotalLoaned(loanReturnPrep)!; + + const totalResolved = getTotalResolved(loanReturnPrep)!; + const available = totalLoaned - totalResolved; + + if (returned != previousReturned) { + if (returned === available && previousReturned - returned === 1) { + } else if (returned < 0 || previousReturned < 0) { + loanReturnPrep.set('quantityReturned', 0); + } else { + const changeInReturn = returned - previousReturned; + previousLoanPreparations.previousResolved[loanReturnPrep.cid] = + changeInReturn + previousResolved; + loanReturnPrep.set( + 'quantityResolved', + changeInReturn + previousResolved + ); + } + } + + if (returned > totalLoaned) + loanReturnPrep.set('quantityReturned', totalLoaned); + + const returnedLeft = totalLoaned - getTotalReturned(loanReturnPrep)!; + if (returned > returnedLeft) + loanReturnPrep.set('quantityReturned', returnedLeft); + + if (previousResolved < returned) { + loanReturnPrep.set('quantityResolved', returned); + previousLoanPreparations.previousResolved[loanReturnPrep.cid] = + returned; + } + + previousLoanPreparations.previousReturned[loanReturnPrep.cid] = + returned; + updateLoanPrep(loanReturnPrep.collection); + }, + quantityResolved: ( + loanReturnPrep: SpecifyResource + ): void => { + const resolved = Number(loanReturnPrep.get('quantityResolved')); + + const totalLoaned = getTotalLoaned(loanReturnPrep)!; + const totalResolved = getTotalResolved(loanReturnPrep)!; + const available = totalLoaned - totalResolved; + if (resolved > available) { + loanReturnPrep.set('quantityResolved', available); + } + + if (resolved < 0) loanReturnPrep.set('quantityResolved', 0); + + previousLoanPreparations.previousResolved[loanReturnPrep.cid] = + resolved; + updateLoanPrep(loanReturnPrep.collection); + }, + }, + }, +}; + +/* From this code, Typescript believes that a businessRuleDefs uniqueIn can be from any table + * For example, it believes the following is possible: + * BusinessRuleDefs & {uniqueIn: UniquenessRule | UniquenessRule | ...} + */ +// @ts-expect-error +export const businessRuleDefs: MappedBusinessRuleDefs = Object.fromEntries( + ( + Object.keys({ ...uniqueRules, ...nonUniqueBusinessRuleDefs }) as RA< + keyof Tables + > + ).map((table) => { + /* + * To ensure compatibility and consistency with other areas of the frontend, + * the undefined type is preferable over the null type. + * In the JSON uniqueness rules, if a field should be unique at a global (institution) + * level, then it is unique in 'null'. + * Thus we need to replace null with undefined + */ + const uniquenessRules: UniquenessRule | undefined = + uniqueRules[table] !== undefined + ? Object.fromEntries( + Object.entries(uniqueRules[table]!).map(([fieldName, rule]) => { + return [fieldName, rule[0] === null ? [undefined] : rule]; + }) + ) + : undefined; + const ruleDefs = + nonUniqueBusinessRuleDefs[table] === undefined + ? uniquenessRules === undefined + ? undefined + : { uniqueIn: uniquenessRules } + : Object.assign({}, nonUniqueBusinessRuleDefs[table], { + uniqueIn: uniquenessRules, + }); + return [table, ruleDefs]; + }) +); diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.js b/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.js deleted file mode 100644 index c3935440e72..00000000000 --- a/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.js +++ /dev/null @@ -1,281 +0,0 @@ -import _ from 'underscore'; - -import {formsText} from '../../localization/forms'; -import {flippedPromise} from '../../utils/promise'; -import {formatConjunction} from '../Atoms/Internationalization'; -import {isTreeResource} from '../InitialContext/treeRanks'; -import {businessRuleDefs} from './businessRuleDefs'; -import {idFromUrl} from './resource'; -import {SaveBlockers} from './saveBlockers'; -import {initializeTreeRecord, treeBusinessRules} from './treeBusinessRules'; - -let enabled = true; - - export function attachBusinessRules(resource) { - if(!enabled) return; - let mgr; - mgr = resource.businessRuleMgr = new BusinessRuleMgr(resource); - mgr.setupEvents(); - resource.saveBlockers = new SaveBlockers(resource); - if(isTreeResource(resource)) - initializeTreeRecord(resource); - mgr.doCustomInit(); - } - - function BusinessRuleMgr(resource) { - this.resource = resource; - this.rules = businessRuleDefs[this.resource.specifyModel.name]; - this.pending = Promise.resolve(null); - this.fieldChangePromises = {}; - this.watchers = {}; - } - - _(BusinessRuleMgr.prototype).extend({ - addPromise(promise) { - this.pending = Promise.allSettled([this.pending, promise]).then(()=>null); - }, - - setupEvents() { - this.resource.on('change', this.changed, this); - this.resource.on('add', this.added, this); - this.resource.on('remove', this.removed, this); - }, - - invokeRule(ruleName, fieldName, args) { - /* - * Var resource = this.resource; - * var msg = 'BR ' + ruleName + (fieldName ? '[' + fieldName + '] ': ' ') + 'finished on'; - * promise.then(function(result) { console.debug(msg, resource, {args: args, result: result}); }); - */ - return this._invokeRule(ruleName, fieldName, args); - }, - async _invokeRule(ruleName, fieldName, args) { - let rule = this.rules && this.rules[ruleName]; - if (!rule) return `no rule: ${ ruleName}`; - if (fieldName) { - rule = rule[fieldName]; - if (!rule) return `no rule: ${ ruleName } for: ${ fieldName}`; - } - return rule.apply(this, args); - }, - - doCustomInit() { - this.addPromise( - this.invokeRule('customInit', null, [this.resource])); - }, - - changed(resource) { - if (!resource._fetch && !resource._save) { - _.each(resource.changed, function(__, fieldName) { - this.checkField(fieldName); - }, this); - } - }, - - added(resource, collection) { - if (resource.specifyModel && resource.specifyModel.getField('ordinal')) { - resource.set('ordinal', collection.indexOf(resource)); - } - this.addPromise( - this.invokeRule('onAdded', null, [resource, collection])); - }, - - removed(resource, collection) { - this.addPromise( - this.invokeRule('onRemoved', null, [resource, collection])); - }, - - checkField(fieldName) { - fieldName = fieldName.toLowerCase(); - - const thisCheck = flippedPromise(); - /* - * ThisCheck.promise.then(function(result) { console.debug('BR finished checkField', - * {field: fieldName, result: result}); }); - */ - this.addPromise(thisCheck); - - /* - * If another change happens while the previous check is pending, - * that check is superseded by checking the new value. - */ - this.fieldChangePromises[fieldName] && this.fieldChangePromises[fieldName].resolve('superseded'); - this.fieldChangePromises[fieldName] = thisCheck; - - const checks = [ - this.invokeRule('customChecks', fieldName, [this.resource]), - this.checkUnique(fieldName) - ]; - - if(isTreeResource(this.resource)) - checks.push(treeBusinessRules(this.resource, fieldName)); - - Promise.all(checks).then((results) => - // Only process these results if the change has not been superseded - (thisCheck === this.fieldChangePromises[fieldName]) && - this.processCheckFieldResults(fieldName, results) - ).then(() => { thisCheck.resolve('finished'); }); - }, - async processCheckFieldResults(fieldName, results) { - const resource = this.resource; - return Promise.all(results.map((result) => { - if (!result) return null; - if (result.valid === false) { - resource.saveBlockers?.add(result.key, fieldName, result.reason); - } else if (result.valid === true) { - resource.saveBlockers?.remove(result.key); - } - return result.action && result.action(); - })); - }, - async checkUnique(fieldName) { - const _this = this; - let results; - if (this.rules && this.rules.unique && _(this.rules.unique).contains(fieldName)) { - // Field value is required to be globally unique. - results = [uniqueIn(null, this.resource, fieldName)]; - } else { - let toOneFields = (this.rules && this.rules.uniqueIn && this.rules.uniqueIn[fieldName]) || []; - if (!_.isArray(toOneFields)) toOneFields = [toOneFields]; - results = _.map(toOneFields, (def) => { - let field = def; - let fieldNames = [fieldName]; - if (typeof def !== 'string') { - fieldNames = fieldNames.concat(def.otherfields); - field = def.field; - } - return uniqueIn(field, _this.resource, fieldNames); - }); - } - Promise.all(results).then((results) => { - _.chain(results).pluck('localDupes').compact().flatten().each((dup) => { - const event = `${dup.cid }:${ fieldName}`; - if (_this.watchers[event]) return; - _this.watchers[event] = dup.on('change remove', () => { - _this.checkField(fieldName); - }); - }); - }); - return combineUniquenessResults(results).then((result) => { - // Console.debug('BR finished checkUnique for', fieldName, result); - result.key = `br-uniqueness-${ fieldName}`; - return result; - }); - } - }); - - - var combineUniquenessResults = async function(deferredResults) { - return Promise.all(deferredResults).then((results) => { - const invalids = _.filter(results, (result) => !result.valid); - return invalids.length === 0 - ? {valid: true} - : { - valid: false, - reason: formatConjunction(_(invalids).pluck('reason')) - }; - }); - }; - - const getUniqueInInvalidReason = function(parentFldInfo, fldInfo) { - if (fldInfo.length > 1) - return parentFldInfo - ? formsText.valuesOfMustBeUniqueToField({ - values: formatConjunction(fldInfo.map((fld) => fld.label)), - fieldName: parentFldInfo.label, - }) - : formsText.valuesOfMustBeUniqueToDatabase({ - values: formatConjunction(fldInfo.map((fld) => fld.label)) - }); - else - return parentFldInfo - ? formsText.valueMustBeUniqueToField({fieldName: parentFldInfo.label}) - : formsText.valueMustBeUniqueToDatabase(); - }; - - var uniqueIn = function(toOneField, resource, valueFieldArgument) { - const valueField = Array.isArray(valueFieldArgument) ? valueFieldArgument : [valueFieldArgument]; - const value = _.map(valueField, (v) => resource.get(v)); - const valueFieldInfo = _.map(valueField, (v) => resource.specifyModel.getField(v)); - const valueIsToOne = _.map(valueFieldInfo, (fi) => fi.type === 'many-to-one'); - const valueId = _.map(value, (v, index) => { - if (valueIsToOne[index]) { - if (_.isNull(v) || v === undefined) { - return null; - } else { - return _.isString(v) ? idFromUrl(v) : v.id; - } - } else { - return undefined; - } - }); - - const toOneFieldInfo = toOneField ? resource.specifyModel.getField(toOneField) : undefined; - const valid = { - valid: true - }; - const invalid = { - valid: false, - reason: getUniqueInInvalidReason(toOneFieldInfo, valueFieldInfo) - }; - - const allNullOrUndefinedToOnes = _.reduce(valueId, (result, v, index) => result && - valueIsToOne[index] ? _.isNull(valueId[index]) : false, true); - if (allNullOrUndefinedToOnes) { - return Promise.resolve(valid); - } - - const hasSameValue = function(other, value, valueField, valueIsToOne, valueId) { - if ((other.id != null) && other.id === resource.id) return false; - if (other.cid === resource.cid) return false; - const otherValue = other.get(valueField); - return valueIsToOne && otherValue !== undefined && !(_.isString(otherValue)) ? Number.parseInt(otherValue.id) === Number.parseInt(valueId) : value === otherValue; - }; - - const hasSameValues = function(other, values, valueFields, valuesAreToOne, valueIds) { - return _.reduce(values, (result, value_, index) => result && hasSameValue(other, value_, valueFields[index], valuesAreToOne[index], valueIds[index]), true); - }; - - if (toOneField == null) { - const filters = {}; - for (const [f, element] of valueField.entries()) { - filters[element] = valueId[f] || value[f]; - } - const others = new resource.specifyModel.LazyCollection({ - filters - }); - return others.fetch().then(() => _.any(others.models, (other) => hasSameValues(other, value, valueField, valueIsToOne, valueId)) ? invalid : valid); - } else { - const haveLocalColl = (resource.collection && resource.collection.related && - toOneFieldInfo.relatedModel === resource.collection.related.specifyModel); - - const localCollection = haveLocalColl ? _.compact(resource.collection.models) : []; - const dupes = _.filter(localCollection, (other) => hasSameValues(other, value, valueField, valueIsToOne, valueId)); - if (dupes.length > 0) { - invalid.localDupes = dupes; - return Promise.resolve(invalid); - } - return resource.rget(toOneField).then((related) => { - if (!related) return valid; - const filters = {}; - for (const [f, element] of valueField.entries()) { - filters[element] = valueId[f] || value[f]; - } - const others = new resource.specifyModel.ToOneCollection({ - related, - field: toOneFieldInfo, - filters - }); - return others.fetch().then(() => { - let inDatabase = others.chain().compact(); - inDatabase = haveLocalColl ? inDatabase.filter((other) => !(resource.collection.get(other.id))).value() : inDatabase.value(); - return _.any(inDatabase, (other) => hasSameValues(other, value, valueField, valueIsToOne, valueId)) ? invalid : valid; - }); - }); - } - }; - - -export function enableBusinessRules(e) { - return enabled = e; -} diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts b/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts new file mode 100644 index 00000000000..c272a4802ea --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/DataModel/businessRules.ts @@ -0,0 +1,397 @@ +import { IR, overwriteReadOnly, RA } from '../../utils/types'; +import { AnySchema, AnyTree, CommonFields, TableFields } from './helperTypes'; +import { SpecifyResource } from './legacyTypes'; +import { BusinessRuleDefs, businessRuleDefs } from './businessRuleDefs'; +import { flippedPromise, ResolvablePromise } from '../../utils/promise'; +import { isTreeResource } from '../InitialContext/treeRanks'; +import { initializeTreeRecord, treeBusinessRules } from './treeBusinessRules'; +import { Collection } from './specifyModel'; +import { SaveBlockers } from './saveBlockers'; +import { formatConjunction } from '../Atoms/Internationalization'; +import { formsText } from '../../localization/forms'; +import { LiteralField, Relationship } from './specifyField'; +import { idFromUrl } from './resource'; + +export class BusinessRuleManager { + private readonly resource: SpecifyResource; + private readonly rules: BusinessRuleDefs | undefined; + public pendingPromises: Promise = + Promise.resolve(undefined); + private fieldChangePromises: { + [key: string]: ResolvablePromise; + } = {}; + private watchers: { [key: string]: () => void } = {}; + + public constructor(resource: SpecifyResource) { + this.resource = resource; + this.rules = businessRuleDefs[this.resource.specifyModel.name]; + } + + private addPromise( + promise: Promise + ): void { + this.pendingPromises = Promise.allSettled([ + this.pendingPromises, + promise, + ]).then(() => undefined); + } + + private changed(resource: SpecifyResource): void { + if (resource.changed !== undefined) { + Object.keys(resource.changed).forEach((field) => { + this.checkField(field); + }); + } + } + + private removed( + resource: SpecifyResource, + collection: Collection + ): void { + this.addPromise( + this.invokeRule('onRemoved', undefined, [resource, collection]) + ); + } + + public setUpManager(): void { + this.addPromise(this.invokeRule('customInit', undefined, [this.resource])); + if (isTreeResource(this.resource as SpecifyResource)) + initializeTreeRecord(this.resource as SpecifyResource); + + this.resource.on('change', this.changed, this); + this.resource.on('remove', this.removed, this); + } + + public checkField(fieldName: keyof SCHEMA['fields']) { + fieldName = + typeof fieldName === 'string' ? fieldName.toLowerCase() : fieldName; + const thisCheck: ResolvablePromise = flippedPromise(); + this.addPromise(thisCheck); + + this.fieldChangePromises[fieldName as string] !== undefined && + this.fieldChangePromises[fieldName as string].resolve('superseded'); + this.fieldChangePromises[fieldName as string] = thisCheck; + + const checks = [ + this.invokeRule('fieldChecks', fieldName, [this.resource]), + this.checkUnique(fieldName), + ]; + + if (isTreeResource(this.resource as SpecifyResource)) + checks.push( + treeBusinessRules( + this.resource as SpecifyResource, + fieldName as string + ) + ); + + Promise.all(checks) + .then((results) => { + return thisCheck === this.fieldChangePromises[fieldName as string] + ? this.processCheckFieldResults(fieldName, results) + : undefined; + }) + .then(() => thisCheck.resolve('finished')); + } + + private processCheckFieldResults( + fieldName: keyof SCHEMA['fields'], + results: RA | undefined> + ) { + results.map((result) => { + if (result !== undefined) { + if (result.key === undefined) { + if (result.valid && typeof result.action === 'function') { + result.action(); + } + } else if (result.valid === false) { + this.resource.saveBlockers!.add( + result.key, + fieldName as string, + result.reason + ); + } else { + this.resource.saveBlockers!.remove(result.key); + if (typeof result.action === 'function') { + result.action(); + } + } + } + }); + } + + private async checkUnique( + fieldName: keyof SCHEMA['fields'] + ): Promise { + const scopeFields = + this.rules?.uniqueIn !== undefined + ? this.rules?.uniqueIn[ + this.resource.specifyModel.getField(fieldName as string) + ?.name as TableFields + ] ?? [] + : []; + const results: RA>> = scopeFields.map( + (uniqueRule) => { + let scope = uniqueRule; + let fieldNames: string[] | undefined = [fieldName as string]; + if (uniqueRule !== undefined && typeof uniqueRule !== 'string') { + fieldNames = fieldNames.concat(uniqueRule.otherFields); + scope = uniqueRule.field; + } + return this.uniqueIn(scope as string, fieldNames); + } + ); + + Promise.all(results).then((results) => { + results + .map((result: BusinessRuleResult) => result['localDuplicates']) + .flat() + .filter((result) => result !== undefined) + .forEach((duplicate: SpecifyResource | undefined) => { + if (duplicate === undefined) return; + const event = duplicate.cid + ':' + (fieldName as string); + if (!this.watchers[event]) { + this.watchers[event] = () => + duplicate.on('change remove', () => this.checkField(fieldName)); + } + }); + }); + return Promise.all(results).then((results) => { + const invalids = results.filter((result) => !result.valid); + return invalids.length < 1 + ? { key: 'br-uniqueness-' + (fieldName as string), valid: true } + : { + key: 'br-uniqueness-' + (fieldName as string), + valid: false, + reason: formatConjunction( + invalids.map( + (invalid) => + invalid['reason' as keyof BusinessRuleResult] as string + ) + ), + }; + }); + } + + private getUniqueInvalidReason( + scopeField: Relationship | LiteralField | undefined, + field: RA + ): string { + if (field.length > 1) + return scopeField + ? formsText.valuesOfMustBeUniqueToField({ + values: formatConjunction(field.map((fld) => fld.label)), + fieldName: scopeField.label, + }) + : formsText.valuesOfMustBeUniqueToDatabase({ + values: formatConjunction(field.map((fld) => fld.label)), + }); + else + return scopeField + ? formsText.valueMustBeUniqueToField({ + fieldName: scopeField.label, + }) + : formsText.valueMustBeUniqueToDatabase(); + } + + private async uniqueIn( + scope: string | undefined, + fieldNames: RA | string | undefined + ): Promise> { + if (fieldNames === undefined) { + return { + valid: false, + reason: formsText.valueMustBeUniqueToDatabase(), + }; + } + fieldNames = Array.isArray(fieldNames) ? fieldNames : [fieldNames]; + + const fieldValues = fieldNames.map((value) => this.resource.get(value)); + + const fieldInfo = fieldNames.map( + (field) => this.resource.specifyModel.getField(field)! + ); + + const fieldIsToOne = fieldInfo.map( + (field) => field?.type === 'many-to-one' + ); + + const fieldIds = fieldValues.map((value, index) => { + if (fieldIsToOne[index] !== undefined) { + if (value !== undefined && value !== null) { + return idFromUrl(value); + } + } + return undefined; + }); + + const scopeFieldInfo = + scope !== null && scope !== undefined + ? (this.resource.specifyModel.getField(scope) as Relationship) + : undefined; + + const allNullOrUndefinedToOnes = fieldIds.reduce( + (previous, _current, index) => + previous && fieldIsToOne[index] ? fieldIds[index] === null : false, + true + ); + + const invalidResponse: BusinessRuleResult = { + valid: false, + reason: fieldInfo.some((field) => field === undefined) + ? '' + : this.getUniqueInvalidReason(scopeFieldInfo, fieldInfo), + }; + + if (allNullOrUndefinedToOnes) return { valid: true }; + + const hasSameValues = (other: SpecifyResource): boolean => { + const hasSameValue = ( + fieldValue: string | number | null, + fieldName: string + ): boolean => { + if (other.id !== null && other.id === this.resource.id) return false; + if (other.cid === this.resource.cid) return false; + const otherValue = other.get(fieldName); + + return fieldValue === otherValue; + }; + + return fieldValues.reduce( + (previous, current, index) => + previous && hasSameValue(current, fieldNames![index]), + true + ); + }; + + if (scope !== undefined) { + const localCollection = + this.resource.collection?.models !== undefined + ? this.resource.collection.models.filter( + (resource) => resource !== undefined + ) + : []; + + const duplicates = localCollection.filter((resource) => + hasSameValues(resource) + ); + + if (duplicates.length > 0) { + overwriteReadOnly(invalidResponse, 'localDuplicates', duplicates); + return invalidResponse; + } + + const relatedPromise: Promise> = + this.resource.rgetPromise(scope); + + return relatedPromise.then((related) => { + if (!related) return { valid: true }; + const filters: Partial> = {}; + for (let f = 0; f < fieldNames!.length; f++) { + filters[fieldNames![f]] = fieldIds[f] || fieldValues[f]; + } + const others = new this.resource.specifyModel.ToOneCollection({ + related: related, + field: scopeFieldInfo, + filters: filters as Partial< + { + readonly orderby: string; + readonly domainfilter: boolean; + } & SCHEMA['fields'] & + CommonFields & + IR + >, + }); + + return others.fetch().then((fetchedCollection) => { + const inDatabase = fetchedCollection.models.filter( + (otherResource) => otherResource !== undefined + ); + + if (inDatabase.some((other) => hasSameValues(other))) { + return invalidResponse; + } else { + return { valid: true }; + } + }); + }); + } else { + const filters: Partial> = {}; + + for (let f = 0; f < fieldNames.length; f++) { + filters[fieldNames[f]] = fieldIds[f] || fieldValues[f]; + } + const others = new this.resource.specifyModel.LazyCollection({ + filters: filters as Partial< + { + readonly orderby: string; + readonly domainfilter: boolean; + } & SCHEMA['fields'] & + CommonFields & + IR + >, + }); + return others.fetch().then((fetchedCollection) => { + if ( + fetchedCollection.models.some((other: SpecifyResource) => + hasSameValues(other) + ) + ) { + return invalidResponse; + } else { + return { valid: true }; + } + }); + } + } + + private async invokeRule( + ruleName: keyof BusinessRuleDefs, + fieldName: keyof SCHEMA['fields'] | undefined, + args: RA + ): Promise { + if (this.rules === undefined || ruleName === 'uniqueIn') { + return undefined; + } + let rule = this.rules[ruleName]; + + if ( + rule !== undefined && + ruleName === 'fieldChecks' && + fieldName !== undefined + ) + rule = + rule[ + this.resource.specifyModel.getField(fieldName as string) + ?.name as keyof typeof rule + ]; + + if (rule === undefined) return undefined; + + /* + * For some reason, Typescript still thinks that this.rules["fieldChecks"] is a valid rule + * thus rule.apply() would be invalid + * However, rule will never be this.rules["fieldChecks"] + */ + // @ts-expect-error + return rule.apply(undefined, args); + } +} + +export function attachBusinessRules(resource: SpecifyResource) { + const businessRuleManager = new BusinessRuleManager(resource); + overwriteReadOnly(resource, 'saveBlockers', new SaveBlockers(resource)); + overwriteReadOnly(resource, 'businessRuleManager', businessRuleManager); + businessRuleManager.setUpManager(); +} + +export type BusinessRuleResult = { + readonly key?: string; + readonly localDuplicates?: RA>; +} & ( + | { + readonly valid: true; + readonly action?: () => void; + } + | { readonly valid: false; readonly reason: string } +); diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/collectionApi.js b/specifyweb/frontend/js_src/lib/components/DataModel/collectionApi.js index e951d81820b..e52c11f1cdc 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/collectionApi.js +++ b/specifyweb/frontend/js_src/lib/components/DataModel/collectionApi.js @@ -1,141 +1,167 @@ import _ from 'underscore'; -import {assert} from '../Errors/assert'; -import {Backbone} from './backbone'; -import {hasHierarchyField} from './schema'; - +import { assert } from '../Errors/assert'; +import { Backbone } from './backbone'; +import { hasHierarchyField } from './schema'; + +const Base = Backbone.Collection.extend({ + __name__: 'CollectionBase', + async getTotalCount() { + return this.length; + }, +}); + +function notSupported() { + throw new Error('method is not supported'); +} + +async function fakeFetch() { + return this; +} + +function setupToOne(collection, options) { + collection.field = options.field; + collection.related = options.related; + + assert( + collection.field.model === collection.model.specifyModel, + "field doesn't belong to model" + ); + assert( + collection.field.relatedModel === collection.related.specifyModel, + 'field is not to related resource' + ); +} + +export const DependentCollection = Base.extend({ + __name__: 'DependentCollectionBase', + constructor(options, models = []) { + assert(_.isArray(models)); + Base.call(this, models, options); + }, + initialize(_models, options) { + this.on( + 'add remove', + function () { + /* + * Warning: changing a collection record does not trigger a + * change event in the parent (though it probably should) + */ + this.trigger('saverequired'); + }, + this + ); + + setupToOne(this, options); + + /* + * If the id of the related resource changes, we go through and update + * all the objects that point to it with the new pointer. + * This is to support having collections of objects attached to + * newly created resources that don't have ids yet. When the + * resource is saved, the related objects can have their foreign keys + * set correctly. + */ + this.related.on( + 'change:id', + function () { + const relatedUrl = this.related.url(); + _.chain(this.models) + .compact() + .invoke('set', this.field.name, relatedUrl); + }, + this + ); + }, + isComplete() { + return true; + }, + fetch: fakeFetch, + sync: notSupported, + create: notSupported, +}); + +export const LazyCollection = Base.extend({ + __name__: 'LazyCollectionBase', + _neverFetched: true, + constructor(options) { + options ||= {}; + Base.call(this, null, options); + this.filters = options.filters || {}; + this.domainfilter = + Boolean(options.domainfilter) && + (typeof this.model?.specifyModel !== 'object' || + hasHierarchyField(this.model.specifyModel)); + }, + url() { + return `/api/specify/${this.model.specifyModel.name.toLowerCase()}/`; + }, + isComplete() { + return this.length === this._totalCount; + }, + parse(resp) { + let objects; + if (resp.meta) { + this._totalCount = resp.meta.total_count; + objects = resp.objects; + } else { + console.warn("expected 'meta' in response"); + this._totalCount = resp.length; + objects = resp; + } -const Base = Backbone.Collection.extend({ - __name__: "CollectionBase", - async getTotalCount() { return this.length; } - }); + return objects; + }, + async fetch(options) { + this._neverFetched = false; - function notSupported() { throw new Error("method is not supported"); } + if (this._fetch) return this._fetch; + else if (this.isComplete() || this.related?.isNew()) return this; - async function fakeFetch() { - return this; - } + if (this.isComplete()) + console.error('fetching for already filled collection'); - function setupToOne(collection, options) { - collection.field = options.field; - collection.related = options.related; + options ||= {}; - assert(collection.field.model === collection.model.specifyModel, "field doesn't belong to model"); - assert(collection.field.relatedModel === collection.related.specifyModel, "field is not to related resource"); - } + options.update = true; + options.remove = false; + options.silent = true; + assert(options.at == null); - export const DependentCollection = Base.extend({ - __name__: "DependentCollectionBase", - constructor(options, models=[]) { - assert(_.isArray(models)); - Base.call(this, models, options); - }, - initialize(_models, options) { - this.on('add remove', function() { - /* - * Warning: changing a collection record does not trigger a - * change event in the parent (though it probably should) - */ - this.trigger('saverequired'); - }, this); - - setupToOne(this, options); - - /* - * If the id of the related resource changes, we go through and update - * all the objects that point to it with the new pointer. - * This is to support having collections of objects attached to - * newly created resources that don't have ids yet. When the - * resource is saved, the related objects can have their foreign keys - * set correctly. - */ - this.related.on('change:id', function() { - const relatedUrl = this.related.url(); - _.chain(this.models).compact().invoke('set', this.field.name, relatedUrl); - }, this); - }, - isComplete() { return true; }, - fetch: fakeFetch, - sync: notSupported, - create: notSupported - }); + options.data = + options.data || + _.extend({ domainfilter: this.domainfilter }, this.filters); + options.data.offset = this.length; - export const LazyCollection = Base.extend({ - __name__: "LazyCollectionBase", - _neverFetched: true, - constructor(options) { - options ||= {}; - Base.call(this, null, options); - this.filters = options.filters || {}; - this.domainfilter = Boolean(options.domainfilter) && ( - typeof this.model?.specifyModel !== 'object' - || hasHierarchyField(this.model.specifyModel) - ); - }, - url() { - return `/api/specify/${ this.model.specifyModel.name.toLowerCase() }/`; - }, - isComplete() { - return this.length === this._totalCount; - }, - parse(resp) { - let objects; - if (resp.meta) { - this._totalCount = resp.meta.total_count; - objects = resp.objects; - } else { - console.warn("expected 'meta' in response"); - this._totalCount = resp.length; - objects = resp; - } - - return objects; - }, - async fetch(options) { - this._neverFetched = false; - - if(this._fetch) - return this._fetch; - else if(this.isComplete() || this.related?.isNew()) - return this; - - if (this.isComplete()) - console.error("fetching for already filled collection"); - - options ||= {}; - - options.update = true; - options.remove = false; - options.silent = true; - assert(options.at == null); - - options.data = options.data || _.extend({domainfilter: this.domainfilter}, this.filters); - options.data.offset = this.length; - - _(options).has('limit') && ( options.data.limit = options.limit ); - this._fetch = Backbone.Collection.prototype.fetch.call(this, options); - return this._fetch.then(() => { this._fetch = null; return this; }); - }, - async fetchIfNotPopulated() { - return this._neverFetched && this.related?.isNew() !== true ? this.fetch() : this; - }, - getTotalCount() { - if (_.isNumber(this._totalCount)) return Promise.resolve(this._totalCount); - return this.fetchIfNotPopulated().then((_this) => _this._totalCount); - } + _(options).has('limit') && (options.data.limit = options.limit); + this._fetch = Backbone.Collection.prototype.fetch.call(this, options); + return this._fetch.then(() => { + this._fetch = null; + return this; }); - - export const ToOneCollection = LazyCollection.extend({ - __name__: "LazyToOneCollectionBase", - initialize(_models, options) { - setupToOne(this, options); - }, - async fetch() { - if (this.related.isNew()){ - console.error("can't fetch collection related to unpersisted resource"); - return this; - } - this.filters[this.field.name.toLowerCase()] = this.related.id; - return Reflect.apply(LazyCollection.prototype.fetch, this, arguments); - } - }); \ No newline at end of file + }, + async fetchIfNotPopulated() { + return this._neverFetched && this.related?.isNew() !== true + ? this.fetch() + : this; + }, + getTotalCount() { + if (_.isNumber(this._totalCount)) return Promise.resolve(this._totalCount); + return this.fetchIfNotPopulated().then((_this) => _this._totalCount); + }, +}); + +export const ToOneCollection = LazyCollection.extend({ + __name__: 'LazyToOneCollectionBase', + initialize(_models, options) { + setupToOne(this, options); + }, + async fetch() { + if (this.related.isNew()) { + console.error("can't fetch collection related to unpersisted resource"); + return this; + } + this.filters[this.field.name.toLowerCase()] = this.related.id; + return Reflect.apply(LazyCollection.prototype.fetch, this, arguments); + }, +}); diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/helperTypes.ts b/specifyweb/frontend/js_src/lib/components/DataModel/helperTypes.ts index c76d6047986..80db4835d0a 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/helperTypes.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/helperTypes.ts @@ -1,5 +1,5 @@ import type { IR, RA } from '../../utils/types'; -import type { Tables } from './types'; +import type { Preparation, Tables } from './types'; /** * Represents a schema for any table @@ -55,6 +55,19 @@ export type AnyTree = Extract< } >; +export type AnyInteractionPreparation = Extract< + Tables[keyof Tables], + { + readonly toOneIndependent: { + readonly preparation: Preparation | null; + }; + } & { + readonly fields: { + readonly quantity: number | null; + }; + } +>; + /** * Filter table schemas down to schemas for tables whose names end with a * particular substring diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/helpers.ts b/specifyweb/frontend/js_src/lib/components/DataModel/helpers.ts index e137e61f3ae..5c3f5773f36 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/helpers.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/helpers.ts @@ -116,7 +116,9 @@ export const toTable = ( resource: SpecifyResource, tableName: TABLE_NAME ): SpecifyResource | undefined => - resource.specifyModel.name === tableName ? resource : undefined; + resource.specifyModel.name === tableName + ? (resource as SpecifyResource) + : undefined; export const toResource = ( resource: SerializedResource, @@ -150,7 +152,9 @@ export const toTables = ( resource: SpecifyResource, tableNames: RA ): SpecifyResource | undefined => - f.includes(tableNames, resource.specifyModel.name) ? resource : undefined; + f.includes(tableNames, resource.specifyModel.name) + ? (resource as SpecifyResource) + : undefined; export const deserializeResource = ( serializedResource: SerializedModel | SerializedResource diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/interactionBusinessRules.js b/specifyweb/frontend/js_src/lib/components/DataModel/interactionBusinessRules.js deleted file mode 100644 index db43c01fce2..00000000000 --- a/specifyweb/frontend/js_src/lib/components/DataModel/interactionBusinessRules.js +++ /dev/null @@ -1,54 +0,0 @@ -import _ from 'underscore'; - -import {getPrepAvailability} from '../../utils/ajax/specifyApi'; -import {idFromUrl} from './resource'; -import {schema} from './schema'; - -export const interactionBusinessRules = { - previousReturned: [], - previousResolved: [], - getTotalLoaned(loanreturnprep) { - if (this.totalLoaned === undefined && loanreturnprep.collection) { - this.totalLoaned = loanreturnprep.collection.related.get('quantity'); - } - return this.totalLoaned; - }, - getTotalResolved(loanreturnprep) { - /* - * Probably could just check preparation since its quantities are updated while loanreturnprep subview is open - * But for now, iterate other returns - */ - return loanreturnprep.collection ? _.reduce(loanreturnprep.collection.models, (sum, m) => m.cid == loanreturnprep.cid ? sum : sum + m.get('quantityresolved'), 0) : loanreturnprep.get('quantityresolved'); - }, - - checkPrepAvailability(interactionprep) { - if (interactionprep && interactionprep.get('preparation')) { - // Return interactionprep.get('preparation').get('CountAmt'); - const prepuri = interactionprep.get('preparation'); - const pmod = schema.models.Preparation; - const prepId = idFromUrl(prepuri); - const iprepId = interactionprep.isNew() ? undefined : interactionprep.get('id'); - const iprepName = interactionprep.isNew() ? undefined : interactionprep.specifyModel.name; - getPrepAvailability(prepId, iprepId, iprepName).then((available) => { - if (available !== undefined && Number(available[0]) < interactionprep.get('quantity')) { - interactionprep.set('quantity', Number(available[0])); - } - }); - } - }, - - updateLoanPrep(loanreturnprep, collection) { - if (collection && collection.related.specifyModel.name == 'LoanPreparation') { - const sums = _.reduce(collection.models, (memo, lrp) => { - memo.returned += lrp.get('quantityreturned'); - memo.resolved += lrp.get('quantityresolved'); - return memo; - }, {returned: 0, resolved: 0}); - const loanprep = collection.related; - loanprep.set('quantityreturned', sums.returned); - loanprep.set('quantityresolved', sums.resolved); - loanprep.set('isresolved', sums.resolved == loanprep.get('quantity')); - } - } - }; - diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/interactionBusinessRules.ts b/specifyweb/frontend/js_src/lib/components/DataModel/interactionBusinessRules.ts new file mode 100644 index 00000000000..edbbd08bf8c --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/DataModel/interactionBusinessRules.ts @@ -0,0 +1,164 @@ +import { getPrepAvailability } from '../../utils/ajax/specifyApi'; +import { AnyInteractionPreparation } from './helperTypes'; +import { SpecifyResource } from './legacyTypes'; +import { fetchResource, idFromUrl } from './resource'; +import { Collection } from './specifyModel'; +import { LoanPreparation, LoanReturnPreparation } from './types'; + +type PreviousLoanReturnPreparations = { + previousReturned: { + [cid: string]: number; + }; + previousResolved: { + [cid: string]: number; + }; +}; + +/** + * This object is used to maintain an 'asychronous' count of previousReturned + * and previousResolved for LoanReturnPreparation. + * The internal _previous_attributes can not be used here as that would cause an infinite + * recursion chain + * + * For uses of this object, see the LoanReturnPreparation business rules in + * businessRuleDefs.ts + */ +export const previousLoanPreparations: PreviousLoanReturnPreparations = { + previousReturned: {}, + previousResolved: {}, +}; + +/** + * Given a LoanReturnPreparation, return its LoanPreparation's quantity + */ + +export const getTotalLoaned = ( + loanReturnPrep: SpecifyResource +): number | undefined => { + return loanReturnPrep.collection !== undefined + ? loanReturnPrep.collection.related?.get('quantity') + : undefined; +}; + +/** + * Given a LoanReturnPreparation, return its LoanPreparation's quantityReturned + */ +export const getTotalReturned = ( + loanReturnPrep: SpecifyResource +) => { + return loanReturnPrep.collection !== null + ? loanReturnPrep.collection.models.reduce((sum, loanPrep) => { + const returned = loanPrep.get('quantityReturned'); + return loanPrep.cid != loanReturnPrep.cid + ? sum + (typeof returned === 'number' ? returned : 0) + : sum; + }, 0) + : loanReturnPrep.get('quantityReturned'); +}; + +/** + * Given a LoanReturnPreparation, return its LoanPreparation's quantityResolved + */ +export const getTotalResolved = ( + loanReturnPrep: SpecifyResource +) => { + return loanReturnPrep.collection !== null + ? loanReturnPrep.collection.models.reduce((sum, loanPrep) => { + const resolved = loanPrep.get('quantityResolved'); + return loanPrep.cid != loanReturnPrep.cid + ? sum + (typeof resolved === 'number' ? resolved : 0) + : sum; + }, 0) + : loanReturnPrep.get('quantityResolved'); +}; + +/** + * Given a collection of LoanReturnPreparations, iterate through the + * collection and store the sum all of the quantityReturned and quantityResolved + * into an object + * + * Then, update the related Loan Preparation and set its quantityReturned and + * quantityResolved to the summed object values + */ +export const updateLoanPrep = ( + collection: Collection +) => { + if ( + collection != undefined && + collection.related?.specifyModel.name == 'LoanPreparation' + ) { + const sums = collection.models.reduce( + (memo: { returned: number; resolved: number }, loanReturnPrep) => { + const returned = loanReturnPrep.get('quantityReturned'); + const resolved = loanReturnPrep.get('quantityResolved'); + memo.returned += + typeof returned === 'number' || typeof returned === 'string' + ? Number(returned) + : 0; + memo.resolved += + typeof resolved === 'number' || typeof resolved === 'string' + ? Number(resolved) + : 0; + return memo; + }, + { returned: 0, resolved: 0 } + ); + const loanPrep = collection.related as SpecifyResource; + loanPrep.set('quantityReturned', sums.returned); + loanPrep.set('quantityResolved', sums.resolved); + } +}; + +/** + * Check to enure an Interaction Preparation's quantity is greater + * than or equal to zero and less than preparation's count. + * If the interactionPrep's quantity exceeds this range, set it + * to the closest maxiumum of the range + */ +const validateInteractionPrepQuantity = ( + interactionPrep: SpecifyResource +) => { + const prepUri = interactionPrep.get('preparation') ?? ''; + const prepId = idFromUrl(prepUri); + prepId === undefined + ? Promise.resolve() + : fetchResource('Preparation', prepId).then((preparation) => { + const prepQuanity = interactionPrep.get('quantity'); + + if (typeof preparation.countAmt === 'number') { + if (Number(prepQuanity) >= preparation.countAmt) + interactionPrep.set('quantity', preparation.countAmt); + if (Number(prepQuanity) < 0) interactionPrep.set('quantity', 0); + } + }); +}; + +export const checkPrepAvailability = ( + interactionPrep: SpecifyResource +) => { + if ( + interactionPrep != undefined && + interactionPrep.get('preparation') != undefined + ) { + const prepUri = interactionPrep.get('preparation'); + const prepId = typeof prepUri === 'string' ? idFromUrl(prepUri) : undefined; + validateInteractionPrepQuantity(interactionPrep); + const interactionId = interactionPrep.isNew() + ? undefined + : interactionPrep.get('id'); + const interactionModelName = interactionPrep.specifyModel.name; + if (prepId !== undefined) + getPrepAvailability(prepId, interactionId, interactionModelName).then( + (available) => { + const quantity = interactionPrep.get('quantity'); + if ( + available != 'null' && + typeof quantity === 'number' && + Number(available[0]) < quantity + ) { + interactionPrep.set('quantity', Number(available[0])); + } + } + ); + } +}; diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/legacyTypes.ts b/specifyweb/frontend/js_src/lib/components/DataModel/legacyTypes.ts index 402eac3c55d..8041b06f954 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/legacyTypes.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/legacyTypes.ts @@ -8,7 +8,9 @@ import type { CommonFields, SerializedModel, SerializedResource, + TableFields, } from './helperTypes'; +import { BusinessRuleManager } from './businessRules'; import type { SaveBlockers } from './saveBlockers'; import type { Collection, SpecifyModel } from './specifyModel'; @@ -29,13 +31,11 @@ export type SpecifyResource = { readonly saveBlockers?: Readonly>; readonly parent?: SpecifyResource; readonly noBusinessRules: boolean; - readonly collection: { - readonly related: SpecifyResource; - }; - readonly businessRuleMgr?: { - readonly pending: Promise; - readonly checkField: (fieldName: string) => Promise; + readonly changed?: { + [FIELD_NAME in TableFields]?: string | number; }; + readonly collection: Collection; + readonly businessRuleManager?: BusinessRuleManager; /* * Shorthand method signature is used to prevent * https://github.com/microsoft/TypeScript/issues/48339 @@ -184,7 +184,11 @@ export type SpecifyResource = { placeInSameHierarchy( resource: SpecifyResource ): SpecifyResource | undefined; - on(eventName: string, callback: (...args: RA) => void): void; + on( + eventName: string, + callback: (...args: RA) => void, + thisArg?: any + ): void; once(eventName: string, callback: (...args: RA) => void): void; off(eventName?: string, callback?: (...args: RA) => void): void; trigger(eventName: string, ...args: RA): void; diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/resource.ts b/specifyweb/frontend/js_src/lib/components/DataModel/resource.ts index 245ab64f28c..ed94d12d2b5 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/resource.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/resource.ts @@ -16,7 +16,6 @@ import type { AnySchema, SerializedModel, SerializedResource, - TableFields, } from './helperTypes'; import type { SpecifyResource } from './legacyTypes'; import { getModel, schema } from './schema'; @@ -272,17 +271,6 @@ export const getFieldsToClone = (model: SpecifyModel): RA => ) .map(({ name }) => name); -// REFACTOR: move this into businessRuleDefs.ts -const businessRules = businessRuleDefs as { - readonly [TABLE_NAME in keyof Tables]?: { - readonly uniqueIn?: { - readonly [FIELD_NAME in Lowercase>]?: - | Lowercase - | unknown; - }; - }; -}; - const uniqueFields = [ 'guid', 'timestampCreated', @@ -293,10 +281,9 @@ const uniqueFields = [ export const getUniqueFields = (model: SpecifyModel): RA => f.unique([ - ...Object.entries(businessRules[model.name]?.uniqueIn ?? {}) + ...Object.entries(businessRuleDefs[model.name]?.uniqueIn ?? {}) .filter( ([_fieldName, uniquenessRules]) => - typeof uniquenessRules === 'string' && uniquenessRules in schema.domainLevelIds ) .map(([fieldName]) => model.strictGetField(fieldName).name), diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/schemaOverrides.ts b/specifyweb/frontend/js_src/lib/components/DataModel/schemaOverrides.ts index 9872f16e56b..fe6801e2470 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/schemaOverrides.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/schemaOverrides.ts @@ -110,6 +110,14 @@ const globalFieldOverrides: { Attachment: { tableID: 'optional', }, + DNASequence: { + totalResidues: 'readOnly', + compA: 'readOnly', + compG: 'readOnly', + compC: 'readOnly', + compT: 'readOnly', + ambiguousResidues: 'readOnly', + }, Taxon: { parent: 'required', isAccepted: 'readOnly', diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/treeBusinessRules.ts b/specifyweb/frontend/js_src/lib/components/DataModel/treeBusinessRules.ts index 1d48b82fcf6..56d241f378b 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/treeBusinessRules.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/treeBusinessRules.ts @@ -2,6 +2,7 @@ import { treeText } from '../../localization/tree'; import { ajax } from '../../utils/ajax'; import { f } from '../../utils/functools'; import { formatUrl } from '../Router/queryString'; +import { BusinessRuleResult } from './businessRules'; import type { AnyTree } from './helperTypes'; import type { SpecifyResource } from './legacyTypes'; import type { Taxon, TaxonTreeDefItem } from './types'; @@ -23,18 +24,6 @@ export const treeBusinessRules = async ( ? predictFullName(resource, false) : undefined; -export type BusinessRuleResult = { - readonly key: string; -} & ( - | { - readonly valid: true; - readonly action: () => void; - } - | { readonly valid: false; readonly reason: string } -); - -const badTreeStructureError = 'bad-tree-structure'; - const predictFullName = async ( resource: SpecifyResource, reportBadStructure: boolean @@ -61,7 +50,7 @@ const predictFullName = async ( parent.id === resource.id || parent.get('rankId') >= definitionItem.get('rankId') ) - throw new Error(badTreeStructureError); + throw new Error('badTreeStructureError'); if ((resource.get('name')?.length ?? 0) === 0) return undefined; const treeName = resource.specifyModel.name.toLowerCase(); @@ -88,7 +77,7 @@ const predictFullName = async ( } as const) ) .catch((error) => { - if (error.message === badTreeStructureError && reportBadStructure) + if (error.message === 'badTreeStructureError' && reportBadStructure) return { key: 'tree-structure', valid: false, diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/uniquness_rules.json b/specifyweb/frontend/js_src/lib/components/DataModel/uniquness_rules.json new file mode 100644 index 00000000000..3f15eb2d4b8 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/DataModel/uniquness_rules.json @@ -0,0 +1,252 @@ +{ + "Accession":{ + "accessionNumber":[ + "division" + ] + }, + "AccessionAgent":{ + "role":[ + { + "field":"accession", + "otherFields":[ + "agent" + ] + }, + { + "field":"repositoryagreement", + "otherFields":[ + "agent" + ] + } + ], + "agent":[ + { + "field":"accession", + "otherFields":[ + "role" + ] + }, + { + "field":"repositoryagreement", + "otherFields":[ + "role" + ] + } + ] + }, + "Appraisal":{ + "appraisalNumber":[ + "accession" + ] + }, + "Author":{ + "agent":[ + "referencework" + ], + "ordernumber":[ + "referencework" + ] + }, + "BorrowAgent":{ + "role":[ + { + "field":"borrow", + "otherFields":[ + "agent" + ] + } + ], + "agent":[ + { + "field":"borrow", + "otherFields":[ + "role" + ] + } + ] + }, + "Collection":{ + "collectionName":[ + "discipline" + ], + "code":[ + "discipline" + ] + }, + "CollectingEvent": { + "uniqueIdentifier" : [ + "discipline" + ] + }, + "CollectionObject":{ + "catalogNumber":[ + "collection" + ], + "uniqueIdentifier" : [ + "collection" + ], + "guid":[ + "institution" + ] + }, + "Collector":{ + "agent":[ + "collectingevent" + ] + }, + "Determiner":{ + "agent":[ + "determination" + ] + }, + "Discipline":{ + "name":[ + "division" + ] + }, + "DisposalAgent" : { + "role":[ + { + "field":"disposal", + "otherFields":[ + "agent" + ] + } + ], + "agent":[ + { + "field":"disposal", + "otherFields":[ + "role" + ] + } + ] + }, + "Division":{ + "name":[ + "institution" + ] + }, + "Extractor" : { + "agent" : [ + "dnasequence" + ] + }, + "FundingAgent" : { + "agent" : [ + "collectingtrip" + ] + }, + "Gift":{ + "giftNumber":[ + "discipline" + ] + }, + "GiftAgent":{ + "role": [ + { + "field":"gift", + "otherFields":[ + "agent" + ] + } + ], + "agent":[ + { + "field":"gift", + "otherFields":[ + "role" + ] + } + ] + }, + "GroupPerson":{ + "member":[ + "group" + ] + }, + "Institution":{ + "name":[ + null + ] + }, + "Loan":{ + "loanNumber":[ + "discipline" + ] + }, + "LoanAgent":{ + "role":[ + { + "field":"loan", + "otherFields":[ + "agent" + ] + } + ], + "agent":[ + { + "field":"loan", + "otherFields":[ + "role" + ] + } + ] + }, + "Locality" : { + "uniqueIdentifier" : [ + "discipline" + ] + }, + "LocalityCitation" : { + "referenceWork" : [ + "locality" + ] + }, + "PcrPerson" : { + "agent" : [ + "dnasequence" + ] + }, + "Permit":{ + "permitNumber":[ + null + ] + }, + "PickList":{ + "name":[ + "collection" + ] + }, + "PrepType":{ + "name":[ + "collection" + ] + }, + "RepositoryAgreement":{ + "repositoryAgreementNumber":[ + "division" + ] + }, + "SpAppResourceData":{ + "spAppResource":[ + null + ] + }, + "SpecifyUser" : { + "name" : [ + null + ] + }, + "TaxonTreeDef" : { + "name": ["discipline"] + }, + "TaxonTreeDefItem" : { + "name" : [ + "treeDef" + ], + "title" : [ + "treeDef" + ] + } + } \ No newline at end of file diff --git a/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx b/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx index b5a0bd075c6..4b6d5c36873 100644 --- a/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx +++ b/specifyweb/frontend/js_src/lib/components/FormCells/FormTable.tsx @@ -418,7 +418,8 @@ export function FormTable({ ? undefined : isDependent ? (): void => { - const resource = new relationship.relatedModel.Resource(); + const resource = + new relationship.relatedModel.Resource() as SpecifyResource; handleAddResources([resource]); } : (): void => diff --git a/specifyweb/frontend/js_src/lib/components/Forms/Save.tsx b/specifyweb/frontend/js_src/lib/components/Forms/Save.tsx index aef8c78d942..b982bbd5105 100644 --- a/specifyweb/frontend/js_src/lib/components/Forms/Save.tsx +++ b/specifyweb/frontend/js_src/lib/components/Forms/Save.tsx @@ -145,40 +145,42 @@ export function SaveButton({ } loading( - (resource.businessRuleMgr?.pending ?? Promise.resolve()).then(() => { - const blockingResources = Array.from( - resource.saveBlockers?.blockingResources ?? [] - ); - blockingResources.forEach((resource) => - resource.saveBlockers?.fireDeferredBlockers() - ); - if (blockingResources.length > 0) { - setShowBlockedDialog(true); - return; - } + (resource.businessRuleManager?.pendingPromises ?? Promise.resolve()).then( + () => { + const blockingResources = Array.from( + resource.saveBlockers?.blockingResources ?? [] + ); + blockingResources.forEach((resource) => + resource.saveBlockers?.fireDeferredBlockers() + ); + if (blockingResources.length > 0) { + setShowBlockedDialog(true); + return; + } - /* - * Save process is canceled if false was returned. This also allows to - * implement custom save behavior - */ - if (handleSaving?.(unsetUnloadProtect) === false) return; + /* + * Save process is canceled if false was returned. This also allows to + * implement custom save behavior + */ + if (handleSaving?.(unsetUnloadProtect) === false) return; - setIsSaving(true); - return resource - .save({ onSaveConflict: hasSaveConflict }) - .catch((error_) => - // FEATURE: if form save fails, should make the error message dismissible (if safe) - Object.getOwnPropertyDescriptor(error_ ?? {}, 'handledBy') - ?.value === hasSaveConflict - ? undefined - : error(error_) - ) - .finally(() => { - unsetUnloadProtect(); - handleSaved?.(); - setIsSaving(false); - }); - }) + setIsSaving(true); + return resource + .save({ onSaveConflict: hasSaveConflict }) + .catch((error_) => + // FEATURE: if form save fails, should make the error message dismissible (if safe) + Object.getOwnPropertyDescriptor(error_ ?? {}, 'handledBy') + ?.value === hasSaveConflict + ? undefined + : error(error_) + ) + .finally(() => { + unsetUnloadProtect(); + handleSaved?.(); + setIsSaving(false); + }); + } + ) ); } diff --git a/specifyweb/frontend/js_src/lib/components/PickLists/index.tsx b/specifyweb/frontend/js_src/lib/components/PickLists/index.tsx index b6831b6705f..80a5dcd3f6f 100644 --- a/specifyweb/frontend/js_src/lib/components/PickLists/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/PickLists/index.tsx @@ -77,7 +77,7 @@ export function PickListComboBox( // Listen for field value change React.useEffect(() => { if (props.resource === undefined) return undefined; - void props.resource.businessRuleMgr?.checkField(props.field.name); + void props.resource.businessRuleManager?.checkField(props.field.name); return resourceOn( props.resource, `change:${props.field.name}`, diff --git a/specifyweb/frontend/js_src/lib/components/Statistics/StatsResult.tsx b/specifyweb/frontend/js_src/lib/components/Statistics/StatsResult.tsx index 514b4b1add4..415661b091a 100644 --- a/specifyweb/frontend/js_src/lib/components/Statistics/StatsResult.tsx +++ b/specifyweb/frontend/js_src/lib/components/Statistics/StatsResult.tsx @@ -54,10 +54,7 @@ export function StatsResult({ ) : (
  • - + {label} {value ?? commonText.loading()} diff --git a/specifyweb/frontend/js_src/lib/components/Toolbar/Query.tsx b/specifyweb/frontend/js_src/lib/components/Toolbar/Query.tsx index b80380b35ac..6cd8eb21414 100644 --- a/specifyweb/frontend/js_src/lib/components/Toolbar/Query.tsx +++ b/specifyweb/frontend/js_src/lib/components/Toolbar/Query.tsx @@ -192,6 +192,10 @@ export function QueryList({ {typeof callBack === 'string' ? ( + /* + * BUG: consider applying these styles everywhere + * className="max-w-full overflow-auto" + */ {text} diff --git a/specifyweb/frontend/js_src/lib/tests/ajax/static/api/specify/discipline/domainfilter=false&name=Ichthyology&division=2&offset=0.json b/specifyweb/frontend/js_src/lib/tests/ajax/static/api/specify/discipline/domainfilter=false&name=Ichthyology&division=2&offset=0.json new file mode 100644 index 00000000000..3161b8e2b98 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/tests/ajax/static/api/specify/discipline/domainfilter=false&name=Ichthyology&division=2&offset=0.json @@ -0,0 +1,33 @@ +{ + "objects": [ + { + "id": 3, + "ispaleocontextembedded": true, + "name": "Ichthyology", + "paleocontextchildtable": "collectionobject", + "regnumber": "1344636812.54", + "timestampcreated": "2012-08-09T12:23:29", + "timestampmodified": "2012-08-09T12:23:29", + "type": "fish", + "version": 11, + "createdbyagent": "/api/specify/agent/1986/", + "datatype": "/api/specify/datatype/1/", + "division": "/api/specify/division/2/", + "geographytreedef": "/api/specify/geographytreedef/1/", + "geologictimeperiodtreedef": "/api/specify/geologictimeperiodtreedef/1/", + "lithostrattreedef": "/api/specify/lithostrattreedef/1/", + "modifiedbyagent": "/api/specify/agent/1987/", + "taxontreedef": "/api/specify/taxontreedef/1/", + "attributedefs": "/api/specify/attributedef/?discipline=3", + "collections": "/api/specify/collection/?discipline=3", + "spexportschemas": "/api/specify/spexportschema/?discipline=3", + "splocalecontainers": "/api/specify/splocalecontainer/?discipline=3", + "resource_uri": "/api/specify/discipline/3/" + } + ], + "meta": { + "limit": 20, + "offset": 0, + "total_count": 1 + } + } \ No newline at end of file diff --git a/specifyweb/frontend/js_src/lib/tests/ajax/static/api/specify/division/2.json b/specifyweb/frontend/js_src/lib/tests/ajax/static/api/specify/division/2.json new file mode 100644 index 00000000000..1ef56faa1ef --- /dev/null +++ b/specifyweb/frontend/js_src/lib/tests/ajax/static/api/specify/division/2.json @@ -0,0 +1,23 @@ +{ + "id": 2, + "abbrev": "FISH", + "altname": "KU Biodiversity Institute Ichthyology Division", + "description": null, + "discipline": "fish", + "iconuri": null, + "name": "Ichthyology", + "regnumber": "1344636812.33", + "remarks": null, + "timestampcreated": "2012-08-09T12:23:29", + "timestampmodified": "2019-12-18T09:17:04", + "uri": null, + "version": 2, + "address": null, + "createdbyagent": "/api/specify/agent/1986/", + "institution": "/api/specify/institution/1/", + "modifiedbyagent": "/api/specify/agent/1514/", + "members": "/api/specify/agent/?division=2", + "disciplines": "/api/specify/discipline/?division=2", + "resource_uri": "/api/specify/division/2/" + } + \ No newline at end of file diff --git a/specifyweb/frontend/js_src/lib/tests/ajax/static/api/specify/division/domainfilter=false&name=Ichthyology&institution=1&offset=0.json b/specifyweb/frontend/js_src/lib/tests/ajax/static/api/specify/division/domainfilter=false&name=Ichthyology&institution=1&offset=0.json new file mode 100644 index 00000000000..617a6948725 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/tests/ajax/static/api/specify/division/domainfilter=false&name=Ichthyology&institution=1&offset=0.json @@ -0,0 +1,31 @@ +{ + "objects": [ + { + "id": 2, + "abbrev": "FISH", + "altname": "KU Biodiversity Institute Ichthyology Division", + "description": null, + "discipline": "fish", + "iconuri": null, + "name": "Ichthyology", + "regnumber": "1344636812.33", + "remarks": null, + "timestampcreated": "2012-08-09T12:23:29", + "timestampmodified": "2019-12-18T09:17:04", + "uri": null, + "version": 2, + "address": null, + "createdbyagent": "/api/specify/agent/1986/", + "institution": "/api/specify/institution/1/", + "modifiedbyagent": "/api/specify/agent/1514/", + "members": "/api/specify/agent/?division=2", + "disciplines": "/api/specify/discipline/?division=2", + "resource_uri": "/api/specify/division/2/" + } + ], + "meta": { + "limit": 20, + "offset": 0, + "total_count": 1 + } + } \ No newline at end of file diff --git a/specifyweb/frontend/js_src/lib/tests/ajax/static/api/specify/institution/domainfilter=false&name=University+of+Kansas+Biodiversity+Institute&offset=0 b/specifyweb/frontend/js_src/lib/tests/ajax/static/api/specify/institution/domainfilter=false&name=University+of+Kansas+Biodiversity+Institute&offset=0 new file mode 100644 index 00000000000..b02c1fb713c --- /dev/null +++ b/specifyweb/frontend/js_src/lib/tests/ajax/static/api/specify/institution/domainfilter=false&name=University+of+Kansas+Biodiversity+Institute&offset=0 @@ -0,0 +1,49 @@ +{ + "objects": [ + { + "id": 1, + "altname": "http://grbio.org/cool/iakn-125z", + "code": "KU", + "copyright": "https://creativecommons.org/licenses/by/4.0/legalcode", + "currentmanagedrelversion": "6.4.11", + "currentmanagedschemaversion": null, + "description": null, + "disclaimer": "https://creativecommons.org/licenses/by-nc/4.0/legalcode", + "guid": "77ff1bff-af23-4647-b5d1-9d3c414fd003", + "hasbeenasked": true, + "iconuri": null, + "ipr": null, + "isaccessionsglobal": false, + "isanonymous": false, + "isreleasemanagedglobally": false, + "issecurityon": true, + "isserverbased": false, + "issharinglocalities": false, + "issinglegeographytree": false, + "license": null, + "lsidauthority": null, + "minimumpwdlength": 0, + "name": "University of Kansas Biodiversity Institute", + "regnumber": "1344636808.22", + "remarks": null, + "termsofuse": "http://biodiversity.ku.edu/research/university-kansas-biodiversity-institute-data-publication-and-use-norms", + "timestampcreated": "2012-08-09T12:23:29", + "timestampmodified": "2020-06-30T14:01:34", + "uri": null, + "version": 19, + "address": null, + "createdbyagent": "/api/specify/agent/1986/", + "modifiedbyagent": "/api/specify/agent/1514/", + "storagetreedef": "/api/specify/storagetreedef/1/", + "contentcontacts": "/api/specify/agent/?instcontentcontact=1", + "technicalcontacts": "/api/specify/agent/?insttechcontact=1", + "divisions": "/api/specify/division/?institution=1", + "resource_uri": "/api/specify/institution/1/" + } + ], + "meta": { + "limit": 20, + "offset": 0, + "total_count": 1 + } +} diff --git a/specifyweb/frontend/js_src/lib/utils/promise.ts b/specifyweb/frontend/js_src/lib/utils/promise.ts index 29ad6cb5cc3..aa4560677e1 100644 --- a/specifyweb/frontend/js_src/lib/utils/promise.ts +++ b/specifyweb/frontend/js_src/lib/utils/promise.ts @@ -1,4 +1,4 @@ -type ResolvablePromise = Promise & { +export type ResolvablePromise = Promise & { // eslint-disable-next-line functional/prefer-readonly-type resolve: T extends undefined ? (value?: T) => void : (value: T) => void; // eslint-disable-next-line functional/prefer-readonly-type