diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a7c74a536f6..8565522320d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -73,6 +73,11 @@ jobs: options: --health-cmd="mariadb-admin ping" --health-interval=5s --health-timeout=2s --health-retries=3 + redis: + image: redis:latest + ports: + - 6379 + steps: - uses: actions/checkout@v4 @@ -128,6 +133,8 @@ jobs: echo "MIGRATOR_PASSWORD = 'MasterPassword'" >> specifyweb/settings/local_specify_settings.py echo "APP_USER_NAME = 'MasterUser'" >> specifyweb/settings/local_specify_settings.py echo "APP_USER_PASSWORD = 'MasterPassword'" >> specifyweb/settings/local_specify_settings.py + echo "REDIS_HOST = '127.0.0.1'" >> specifyweb/settings/local_specify_settings.py + echo "REDIS_PORT = ${{ job.services.redis.ports[6379] }}" >> specifyweb/settings/local_specify_settings.py - name: Need these files to be present run: diff --git a/specifyweb/backend/redis_cache/store.py b/specifyweb/backend/redis_cache/store.py index 61ad5634d80..4760614277b 100644 --- a/specifyweb/backend/redis_cache/store.py +++ b/specifyweb/backend/redis_cache/store.py @@ -1,4 +1,4 @@ -from .utils import _set_string, _get_string +from .utils import _set_string, _get_string, _delete_key, _add_to_set, _set_elements, _redis_type def set_string(key: str, value: str, time_to_live=None, override_existing=True): @@ -15,3 +15,18 @@ def get_string(key: str, delete_key=False) -> str: def get_bytes(key: str, delete_key=False) -> bytes: return _get_string(key, delete_key=delete_key, decode_responses=False) + + +def add_to_set(key: str, *elements: str): + return _add_to_set(key, *elements) + +def set_members(key: str): + return _set_elements(key) + + +def delete_key(key: str): + return _delete_key(key) + + +def redis_type(key: str): + return _redis_type(key) diff --git a/specifyweb/backend/redis_cache/utils.py b/specifyweb/backend/redis_cache/utils.py index d8a709380aa..7ad9f56a930 100644 --- a/specifyweb/backend/redis_cache/utils.py +++ b/specifyweb/backend/redis_cache/utils.py @@ -1,4 +1,4 @@ -from typing import overload +from typing import overload, Literal from redis import Redis from django.conf import settings @@ -12,6 +12,9 @@ def redis_connection(decode_responses=True): raise ValueError("Redis is not correctly configured", redis_host, redis_port) return Redis(host=redis_host, port=redis_port, db=redis_db_index, decode_responses=decode_responses) +def _delete_key(key: str): + host = redis_connection() + host.delete(key) def _set_string(key: str, value: str, time_to_live=None, override_existing=True, decode_responses=True): host = redis_connection(decode_responses=decode_responses) @@ -37,3 +40,20 @@ def _get_string(key: str, delete_key: bool=False, decode_responses=True) -> str return host.getdel(key) return host.get(key) + +def _add_to_set(key: str, *elements: str): + if len(elements) <= 0: + return 0 + host = redis_connection(decode_responses=True) + return host.sadd(key, *elements) + +def _set_elements(key: str) -> set: + host = redis_connection(decode_responses=True) + return host.smembers(key) + +# https://redis.io/docs/latest/commands/type/ +Redis_Type = Literal["none", "string", "list", "set", "hash", "stream", "vectorset"] + +def _redis_type(key: str) -> Redis_Type: + host = redis_connection(decode_responses=True) + return host.type(key) \ No newline at end of file diff --git a/specifyweb/frontend/js_src/lib/components/Interactions/InteractionDialog.tsx b/specifyweb/frontend/js_src/lib/components/Interactions/InteractionDialog.tsx index 0199a185191..c08a1e144b9 100644 --- a/specifyweb/frontend/js_src/lib/components/Interactions/InteractionDialog.tsx +++ b/specifyweb/frontend/js_src/lib/components/Interactions/InteractionDialog.tsx @@ -80,7 +80,8 @@ export function InteractionDialog({ ); const isLoanReturnLike = - isLoanReturn || (actionTable.name !== 'Loan' && actionTable.name.includes('Loan')); + isLoanReturn || + (actionTable.name !== 'Loan' && actionTable.name.includes('Loan')); const itemTable = isLoanReturnLike ? tables.Loan : tables.CollectionObject; @@ -206,8 +207,7 @@ export function InteractionDialog({ ) ).then((data) => availablePrepsReady(catalogNumbers, data, { - skipEntryMatch: - searchField.name.toLowerCase() !== 'catalognumber', + skipEntryMatch: searchField.name.toLowerCase() !== 'catalognumber', }) ) ); @@ -377,7 +377,9 @@ export function InteractionDialog({ values, isLoan ) - ).then((data) => availablePrepsReady(values, data, { skipEntryMatch: true })) + ).then((data) => + availablePrepsReady(values, data, { skipEntryMatch: true }) + ) ); } diff --git a/specifyweb/specify/datamodel.py b/specifyweb/specify/datamodel.py index 9fcbcdcb2b6..7ea0b9d3dba 100644 --- a/specifyweb/specify/datamodel.py +++ b/specifyweb/specify/datamodel.py @@ -1,5 +1,5 @@ from .models_utils.load_datamodel import Table, Field, Relationship, IdField, Datamodel, Index, add_collectingevents_to_locality, \ - flag_dependent_fields, flag_system_tables, DoesNotExistError, TableDoesNotExistError, FieldDoesNotExistError + flag_dependent_fields, flag_system_tables, DoesNotExistError, TableDoesNotExistError, FieldDoesNotExistError, ManyToMany # 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. @@ -815,10 +815,10 @@ def is_tree_table(table: Table): Index(name='SchemeNameIDX', column_names=['SchemeName']) ], relationships=[ - Relationship(name='collections', type='many-to-many',required=False, relatedModelName='Collection', otherSideName='numberingSchemes'), + ManyToMany(name='collections', required=False, relatedModelName="Collection", otherSideName="numberingSchemes", through_model="Autonumschcoll", through_field="autonumberingscheme"), Relationship(name='createdByAgent', type='many-to-one',required=False, relatedModelName='Agent', column='CreatedByAgentID'), - Relationship(name='disciplines', type='many-to-many',required=False, relatedModelName='Discipline', otherSideName='numberingSchemes'), - Relationship(name='divisions', type='many-to-many',required=False, relatedModelName='Division', otherSideName='numberingSchemes'), + ManyToMany(name='disciplines', required=False, relatedModelName="Discipline", otherSideName="numberingSchemes", through_model="Autonumschdsp", through_field="autonumberingscheme"), + ManyToMany(name='divisions', required=False, relatedModelName="Division", otherSideName="numberingSchemes", through_model="Autonumschdiv", through_field="autonumberingscheme"), Relationship(name='modifiedByAgent', type='many-to-one',required=False, relatedModelName='Agent', column='ModifiedByAgentID') ], fieldAliases=[ @@ -1482,7 +1482,7 @@ def is_tree_table(table: Table): Relationship(name='institutionNetwork', type='many-to-one',required=False, relatedModelName='Institution', column='InstitutionNetworkID'), Relationship(name='leftSideRelTypes', type='one-to-many',required=False, relatedModelName='CollectionRelType', otherSideName='leftSideCollection'), Relationship(name='modifiedByAgent', type='many-to-one',required=False, relatedModelName='Agent', column='ModifiedByAgentID'), - Relationship(name='numberingSchemes', type='many-to-many',required=False, relatedModelName='AutoNumberingScheme', otherSideName='collections'), + ManyToMany(name='numberingSchemes', required=False, relatedModelName="AutoNumberingScheme", otherSideName="collections", through_model="Autonumschcoll", through_field="collection"), Relationship(name='pickLists', type='one-to-many',required=False, relatedModelName='PickList', otherSideName='collection'), Relationship(name='prepTypes', type='one-to-many',required=False, relatedModelName='PrepType', otherSideName='collection'), Relationship(name='rightSideRelTypes', type='one-to-many',required=False, relatedModelName='CollectionRelType', otherSideName='rightSideCollection'), @@ -1601,7 +1601,7 @@ def is_tree_table(table: Table): Relationship(name='otherIdentifiers', type='one-to-many',required=False, relatedModelName='OtherIdentifier', otherSideName='collectionObject', dependent=True), Relationship(name='paleoContext', type='many-to-one',required=False, relatedModelName='PaleoContext', column='PaleoContextID', otherSideName='collectionObjects'), Relationship(name='preparations', type='one-to-many',required=False, relatedModelName='Preparation', otherSideName='collectionObject', dependent=True), - Relationship(name='projects', type='many-to-many',required=False, relatedModelName='Project', otherSideName='collectionObjects'), + ManyToMany(name='projects', required=False, relatedModelName="Project", otherSideName="collectionObjects", through_model="Project_colobj", through_field="collectionobject"), Relationship(name='rightSideRels', type='one-to-many',required=False, relatedModelName='CollectionRelationship', otherSideName='rightSide', dependent=True), Relationship(name='treatmentEvents', type='one-to-many',required=False, relatedModelName='TreatmentEvent', otherSideName='collectionObject', dependent=True), Relationship(name='visibilitySetBy', type='many-to-one',required=False, relatedModelName='SpecifyUser', column='VisibilitySetByID'), @@ -3064,7 +3064,7 @@ def is_tree_table(table: Table): Relationship(name='lithoStratTreeDef', type='many-to-one',required=False, relatedModelName='LithoStratTreeDef', column='LithoStratTreeDefID', otherSideName='disciplines'), Relationship(name='tectonicUnitTreeDef', type='many-to-one',required=False, relatedModelName='TectonicUnitTreeDef', column='TectonicUnitTreeDefID', otherSideName='disciplines'), Relationship(name='modifiedByAgent', type='many-to-one',required=False, relatedModelName='Agent', column='ModifiedByAgentID'), - Relationship(name='numberingSchemes', type='many-to-many',required=False, relatedModelName='AutoNumberingScheme', otherSideName='disciplines'), + ManyToMany(name='numberingSchemes', required=False, relatedModelName="AutoNumberingScheme", otherSideName="disciplines", through_model="Autonumschdsp", through_field="discipline"), Relationship(name='spExportSchemas', type='one-to-many',required=False, relatedModelName='SpExportSchema', otherSideName='discipline'), Relationship(name='spLocaleContainers', type='one-to-many',required=False, relatedModelName='SpLocaleContainer', otherSideName='discipline'), Relationship(name='userGroups', type='one-to-many',required=False, relatedModelName='SpPrincipal', otherSideName='scope') @@ -3236,7 +3236,7 @@ def is_tree_table(table: Table): Relationship(name='institution', type='many-to-one',required=True, relatedModelName='Institution', column='InstitutionID', otherSideName='divisions'), Relationship(name='members', type='one-to-many',required=False, relatedModelName='Agent', otherSideName='division'), Relationship(name='modifiedByAgent', type='many-to-one',required=False, relatedModelName='Agent', column='ModifiedByAgentID'), - Relationship(name='numberingSchemes', type='many-to-many',required=False, relatedModelName='AutoNumberingScheme', otherSideName='divisions'), + ManyToMany(name='numberingSchemes', required=False, relatedModelName="AutoNumberingScheme", otherSideName="divisions", through_model="Autonumschdiv", through_field="division"), Relationship(name='userGroups', type='one-to-many',required=False, relatedModelName='SpPrincipal', otherSideName='scope') ], fieldAliases=[ @@ -5900,7 +5900,7 @@ def is_tree_table(table: Table): ], relationships=[ Relationship(name='agent', type='many-to-one',required=False, relatedModelName='Agent', column='ProjectAgentID'), - Relationship(name='collectionObjects', type='many-to-many',required=False, relatedModelName='CollectionObject', otherSideName='projects'), + ManyToMany(name='collectionObjects', required=False, relatedModelName="CollectionObject", otherSideName="projects", through_model="Project_colobj", through_field="project"), Relationship(name='createdByAgent', type='many-to-one',required=False, relatedModelName='Agent', column='CreatedByAgentID'), Relationship(name='modifiedByAgent', type='many-to-one',required=False, relatedModelName='Agent', column='ModifiedByAgentID') ], @@ -6360,7 +6360,7 @@ def is_tree_table(table: Table): Relationship(name='discipline', type='many-to-one',required=True, relatedModelName='Discipline', column='DisciplineID', otherSideName='spExportSchemas'), Relationship(name='modifiedByAgent', type='many-to-one',required=False, relatedModelName='Agent', column='ModifiedByAgentID'), Relationship(name='spExportSchemaItems', type='one-to-many',required=False, relatedModelName='SpExportSchemaItem', otherSideName='spExportSchema'), - Relationship(name='spExportSchemaMappings', type='many-to-many',required=False, relatedModelName='SpExportSchemaMapping', otherSideName='spExportSchemas') + ManyToMany(name='spExportSchemaMappings', required=False, relatedModelName="SpExportSchemaMapping", otherSideName="spExportSchemas", through_model="Spexportschema_exportmapping", through_field="spexportschema"), ], fieldAliases=[ @@ -6451,7 +6451,7 @@ def is_tree_table(table: Table): Relationship(name='createdByAgent', type='many-to-one',required=False, relatedModelName='Agent', column='CreatedByAgentID'), Relationship(name='mappings', type='one-to-many',required=False, relatedModelName='SpExportSchemaItemMapping', otherSideName='exportSchemaMapping'), Relationship(name='modifiedByAgent', type='many-to-one',required=False, relatedModelName='Agent', column='ModifiedByAgentID'), - Relationship(name='spExportSchemas', type='many-to-many',required=False, relatedModelName='SpExportSchema', otherSideName='spExportSchemaMappings'), + ManyToMany(name='spExportSchemas', required=False, relatedModelName="SpExportSchema", otherSideName="spExportSchemaMappings", through_model="Spexportschema_exportmapping", through_field="spexportschemamapping"), Relationship(name='symbiotaInstances', type='one-to-many',required=False, relatedModelName='SpSymbiotaInstance', otherSideName='schemaMapping') ], fieldAliases=[ @@ -6613,7 +6613,7 @@ def is_tree_table(table: Table): ], relationships=[ - Relationship(name='principals', type='many-to-many',required=False, relatedModelName='SpPrincipal', otherSideName='permissions') + ManyToMany(name='principals', required=False, relatedModelName="SpPrincipal", otherSideName="permissions", through_model="Spprincipal_sppermission", through_field="sppermission"), ], fieldAliases=[ @@ -6643,9 +6643,9 @@ def is_tree_table(table: Table): relationships=[ Relationship(name='createdByAgent', type='many-to-one',required=False, relatedModelName='Agent', column='CreatedByAgentID'), Relationship(name='modifiedByAgent', type='many-to-one',required=False, relatedModelName='Agent', column='ModifiedByAgentID'), - Relationship(name='permissions', type='many-to-many',required=False, relatedModelName='SpPermission', otherSideName='principals'), + ManyToMany(name='permissions', required=False, relatedModelName="SpPermission", otherSideName="principals", through_model="Spprincipal_sppermission", through_field="spprincipal"), Relationship(name='scope', type='many-to-one',required=False, relatedModelName='UserGroupScope', column='userGroupScopeID', otherSideName='userGroups'), - Relationship(name='specifyUsers', type='many-to-many',required=False, relatedModelName='SpecifyUser', otherSideName='spPrincipals') + ManyToMany(name='specifyUsers', required=False, relatedModelName="SpecifyUser", otherSideName="spPrincipals", through_model="Specifyuser_spprincipal", through_field="spprincipal"), ], fieldAliases=[ @@ -6954,7 +6954,7 @@ def is_tree_table(table: Table): Relationship(name='modifiedByAgent', type='many-to-one',required=False, relatedModelName='Agent', column='ModifiedByAgentID'), Relationship(name='spAppResourceDirs', type='one-to-many',required=False, relatedModelName='SpAppResourceDir', otherSideName='specifyUser'), Relationship(name='spAppResources', type='one-to-many',required=False, relatedModelName='SpAppResource', otherSideName='specifyUser'), - Relationship(name='spPrincipals', type='many-to-many',required=False, relatedModelName='SpPrincipal', otherSideName='specifyUsers'), + ManyToMany(name='spPrincipals', required=False, relatedModelName="SpPrincipal", otherSideName="specifyUsers", through_model="Specifyuser_spprincipal", through_field="specifyuser"), Relationship(name='spQuerys', type='one-to-many',required=False, relatedModelName='SpQuery', otherSideName='specifyUser'), Relationship(name='taskSemaphores', type='one-to-many',required=False, relatedModelName='SpTaskSemaphore', otherSideName='owner'), Relationship(name='workbenchTemplates', type='one-to-many',required=False, relatedModelName='WorkbenchTemplate', otherSideName='specifyUser'), diff --git a/specifyweb/specify/migrations/0043_normalize_many_to_many.py b/specifyweb/specify/migrations/0043_normalize_many_to_many.py new file mode 100644 index 00000000000..73a65553868 --- /dev/null +++ b/specifyweb/specify/migrations/0043_normalize_many_to_many.py @@ -0,0 +1,528 @@ +import json +import django.db.models.deletion + +from django.conf import settings +from django.db import migrations, models + +import specifyweb.specify.models +from specifyweb.backend.redis_cache.store import delete_key, redis_type, add_to_set, set_members + +""" +WARNING: Data loss may occur if the Redis container is stopped once this +migration has been stared but has not finished. +For example, if an error occurs during this migration and the Redis container +is stopped. +Please ensure data within Redis is persisted via a mount, or there are +additional backups of the following tables before this migration: +- autonumsch_coll +- autonumsch_dsp +- autonumsch_div +- specifyuser_spprincipal +- spprincipal_sppermission +- sp_schema_mapping +- project_colobj + +This migration creates or normalizes the Many to Many Join Tables for all +instances (those created in Specify 6, and those created in Specify 7). +The migration is necessitated due to the fact that the Django version we're +using when the tables are being created does not support more than one Primary +Key per table. +This is problematic becuase Specify 6 Many to Many Join tables all had more +than one Primary Key. + +The forwards migration steps are the following: +- Store existing Many To Many records in an intermediary source (Redis) +- Drop the old Many to Many tables +- Recreate the Many to Many tables via Django +- Migrate the stored records to the new Many to Many tables +""" + +""" +A static representation of the Many To Many Join tables from Specify 6. +The keys of this dict should be the name of the table in the database. +""" +LEGACY_MANY_TO_MANY_JOIN_TABLES = { + "autonumsch_coll": { + "to": ('specify', 'Autonumschcoll'), + "fields": ( + { + "new_field_name": "autonumberingscheme_id", + "legacy_column_name": "AutoNumberingSchemeID" + }, + { + "new_field_name": "collection_id", + "legacy_column_name": "CollectionID" + } + ), + "sql": """ +CREATE TABLE `autonumsch_coll` ( + `CollectionID` int(11) NOT NULL, + `AutoNumberingSchemeID` int(11) NOT NULL, + PRIMARY KEY (`CollectionID`,`AutoNumberingSchemeID`), + KEY `FK46F04F2AFE55DD76` (`AutoNumberingSchemeID`), + KEY `FK46F04F2A8C2288BA` (`CollectionID`), + CONSTRAINT `FK46F04F2A8C2288BA` FOREIGN KEY (`CollectionID`) REFERENCES `collection` (`UserGroupScopeId`), + CONSTRAINT `FK46F04F2AFE55DD76` FOREIGN KEY (`AutoNumberingSchemeID`) REFERENCES `autonumberingscheme` (`AutoNumberingSchemeID`) +); +""" + }, + "autonumsch_dsp": { + "to": ('specify', 'Autonumschdsp'), + "fields": ( + { + "new_field_name": "autonumberingscheme_id", + "legacy_column_name": "AutoNumberingSchemeID" + }, + { + "new_field_name": "discipline_id", + "legacy_column_name": "DisciplineID" + } + ), + "sql": """ +CREATE TABLE `autonumsch_dsp` ( + `DisciplineID` int(11) NOT NULL, + `AutoNumberingSchemeID` int(11) NOT NULL, + PRIMARY KEY (`DisciplineID`,`AutoNumberingSchemeID`), + KEY `FKA8BE5C3FE55DD76` (`AutoNumberingSchemeID`), + KEY `FKA8BE5C34CE675DE` (`DisciplineID`), + CONSTRAINT `FKA8BE5C34CE675DE` FOREIGN KEY (`DisciplineID`) REFERENCES `discipline` (`UserGroupScopeId`), + CONSTRAINT `FKA8BE5C3FE55DD76` FOREIGN KEY (`AutoNumberingSchemeID`) REFERENCES `autonumberingscheme` (`AutoNumberingSchemeID`) +) +""" + }, + "autonumsch_div": { + "to": ('specify', 'Autonumschdiv'), + "fields": ( + { + "new_field_name": "autonumberingscheme_id", + "legacy_column_name": "AutoNumberingSchemeID" + }, + { + "new_field_name": "division_id", + "legacy_column_name": "DivisionID" + } + ), + "sql": """ +CREATE TABLE `autonumsch_div` ( + `DivisionID` int(11) NOT NULL, + `AutoNumberingSchemeID` int(11) NOT NULL, + PRIMARY KEY (`DivisionID`,`AutoNumberingSchemeID`), + KEY `FKA8BE493FE55DD76` (`AutoNumberingSchemeID`), + KEY `FKA8BE49397C961D8` (`DivisionID`), + CONSTRAINT `FKA8BE49397C961D8` FOREIGN KEY (`DivisionID`) REFERENCES `division` (`UserGroupScopeId`), + CONSTRAINT `FKA8BE493FE55DD76` FOREIGN KEY (`AutoNumberingSchemeID`) REFERENCES `autonumberingscheme` (`AutoNumberingSchemeID`) +) +""" + }, + "specifyuser_spprincipal": { + "to": ('specify', 'Specifyuser_spprincipal'), + "fields": ( + { + "new_field_name": "specifyuser_id", + "legacy_column_name": "SpecifyUserID" + }, + { + "new_field_name": "spprincipal_id", + "legacy_column_name": "SpPrincipalID" + } + ), + "sql": """ +CREATE TABLE `specifyuser_spprincipal` ( + `SpecifyUserID` int(11) NOT NULL, + `SpPrincipalID` int(11) NOT NULL, + PRIMARY KEY (`SpecifyUserID`,`SpPrincipalID`), + KEY `FK81E18B5E4BDD9E10` (`SpecifyUserID`), + KEY `FK81E18B5E99A7381A` (`SpPrincipalID`), + CONSTRAINT `FK81E18B5E4BDD9E10` FOREIGN KEY (`SpecifyUserID`) REFERENCES `specifyuser` (`SpecifyUserID`), + CONSTRAINT `FK81E18B5E99A7381A` FOREIGN KEY (`SpPrincipalID`) REFERENCES `spprincipal` (`SpPrincipalID`) +) +""" + }, + "spprincipal_sppermission": { + "to": ('specify', 'Spprincipal_sppermission'), + "fields": ( + { + "new_field_name": "sppermission_id", + "legacy_column_name": "SpPermissionID" + }, + { + "new_field_name": "spprincipal_id", + "legacy_column_name": "SpPrincipalID" + } + ), + "sql": """ +CREATE TABLE `spprincipal_sppermission` ( + `SpPermissionID` int(11) NOT NULL, + `SpPrincipalID` int(11) NOT NULL, + PRIMARY KEY (`SpPermissionID`,`SpPrincipalID`), + KEY `FK9DD8B2FA99A7381A` (`SpPrincipalID`), + KEY `FK9DD8B2FA891F8736` (`SpPermissionID`), + CONSTRAINT `FK9DD8B2FA891F8736` FOREIGN KEY (`SpPermissionID`) REFERENCES `sppermission` (`SpPermissionID`), + CONSTRAINT `FK9DD8B2FA99A7381A` FOREIGN KEY (`SpPrincipalID`) REFERENCES `spprincipal` (`SpPrincipalID`) +) +""" + }, + "sp_schema_mapping": { + "to": ('specify', 'Spexportschema_exportmapping'), + "fields": ( + { + "new_field_name": "spexportschema_id", + "legacy_column_name": "SpExportSchemaID" + }, + { + "new_field_name": "spexportschemamapping_id", + "legacy_column_name": "SpExportSchemaMappingID" + } + ), + "sql": """ +CREATE TABLE `sp_schema_mapping` ( + `SpExportSchemaMappingID` int(11) NOT NULL, + `SpExportSchemaID` int(11) NOT NULL, + PRIMARY KEY (`SpExportSchemaMappingID`,`SpExportSchemaID`), + KEY `FKC5EDFE525722A7A2` (`SpExportSchemaID`), + KEY `FKC5EDFE52F7C8AAB0` (`SpExportSchemaMappingID`), + CONSTRAINT `FKC5EDFE525722A7A2` FOREIGN KEY (`SpExportSchemaID`) REFERENCES `spexportschema` (`SpExportSchemaID`), + CONSTRAINT `FKC5EDFE52F7C8AAB0` FOREIGN KEY (`SpExportSchemaMappingID`) REFERENCES `spexportschemamapping` (`SpExportSchemaMappingID`) +) +""" + }, + "project_colobj": { + "to": ('specify', 'Project_colobj'), + "fields": ( + { + "new_field_name": "project_id", + "legacy_column_name": "ProjectID" + }, + { + "new_field_name": "collectionobject_id", + "legacy_column_name": "CollectionObjectID" + } + ), + "sql": """ +CREATE TABLE `project_colobj` ( + `ProjectID` int(11) NOT NULL, + `CollectionObjectID` int(11) NOT NULL, + PRIMARY KEY (`ProjectID`,`CollectionObjectID`), + KEY `FK1E416F5DAF28760A` (`ProjectID`), + KEY `FK1E416F5D75E37458` (`CollectionObjectID`), + CONSTRAINT `FK1E416F5D75E37458` FOREIGN KEY (`CollectionObjectID`) REFERENCES `collectionobject` (`CollectionObjectID`), + CONSTRAINT `FK1E416F5DAF28760A` FOREIGN KEY (`ProjectID`) REFERENCES `project` (`ProjectID`) +) +""" + } +} + +def redis_table_key(table: str): + return f"migration:0043:{table}" + +results = dict() + + +def tables_exist(connection, *table_names: str) -> tuple[str, ...]: + db_name = connection.settings_dict['NAME'] + rows = None + with connection.cursor() as cursor: + sql = """ + SELECT TABLE_NAME + FROM INFORMATION_SCHEMA.TABLES + WHERE TABLE_SCHEMA = %s + AND TABLE_NAME IN %s; + """ + cursor.execute(sql, [db_name, table_names]) + rows = cursor.fetchall() + if not rows: + return tuple() + return tuple(tables[0] for tables in rows) + + +def get_existing_records(connection, table: str, table_schema) -> tuple[str, ...]: + columns = { + field_map["legacy_column_name"]: field_map["new_field_name"] + for field_map in table_schema["fields"] + } + column_str = ", ".join(columns.keys()) + rows = None + final_column_names = tuple(columns.keys()) + with connection.cursor() as cursor: + # We aren't binding values, we're binding column and table names here. + # Couldn't find a way to prepare these values, so using string + # interpolation. Yuck + sql = """ + SELECT {col_str} FROM `{table_name}`; + """.format(col_str=column_str, table_name=table) + cursor.execute(sql) + rows = cursor.fetchall() + final_column_names = tuple( + description[0] for description in cursor.description) + if not rows: + return tuple() + return tuple(json.dumps({columns[col_name]: value + for col_name, value in zip(final_column_names, row)}) + for row in rows) + + +def store_existing_records(connection): + existing_tables = tables_exist( + connection, *LEGACY_MANY_TO_MANY_JOIN_TABLES.keys()) + for existing_table in existing_tables: + schema = LEGACY_MANY_TO_MANY_JOIN_TABLES[existing_table] + if redis_type(redis_table_key(existing_table)) != "set": + delete_key(redis_table_key(existing_table)) + existing_records = get_existing_records( + connection, existing_table, schema) + add_to_set(redis_table_key(existing_table), *existing_records) + + +def migrate_old_records(apps): + for table in LEGACY_MANY_TO_MANY_JOIN_TABLES.keys(): + raw_existing_records = set_members(redis_table_key(table)) + many_to_many_migration_schema = LEGACY_MANY_TO_MANY_JOIN_TABLES[table] + app_label, model_label = many_to_many_migration_schema["to"] + Model = apps.get_model(app_label, model_label) + Model.objects.bulk_create((Model(**json.loads(record)) for record in raw_existing_records)) + +def split_iterable(iterable, chunk_size=999): + for i in range(0, len(iterable), chunk_size): + yield iterable[i:i+chunk_size] + +def migrate_to_legacy(connection): + for table, table_schema in LEGACY_MANY_TO_MANY_JOIN_TABLES.items(): + raw_existing_records = tuple(set_members(redis_table_key(table))) + if len(raw_existing_records) <= 0: + continue + columns = { + field_map["new_field_name"]: field_map["legacy_column_name"] + for field_map in table_schema["fields"] + } + column_str = ", ".join(columns.values()) + with connection.cursor() as cursor: + # Executemany has a maximum evaluation size of 999 statements + # Just in case there's more than that many records, we split up the + # iterable into 999 sized chunks and evaluate each chunk + # independently + for chunked_records in split_iterable(raw_existing_records): + records = tuple(json.loads(raw_record) for raw_record in chunked_records) + data = tuple( + tuple(record[col] for col in columns.keys()) + for record in records + ) + sql = f"INSERT INTO {table} ({column_str}) VALUES (%s, %s)" + # It might be more performant to use string interpolation + # here instead of execute many + # Don't want to have to deal with potential SQL injection + # coming from the Redis DB though... + cursor.executemany(sql, data) + +def wrapped_migrate_to_legacy(apps, schema_editor): + connection = schema_editor.connection + migrate_to_legacy(connection) + for table_name in LEGACY_MANY_TO_MANY_JOIN_TABLES.keys(): + delete_key(redis_table_key(table_name)) + +def wrapped_migrate_old_records(apps, schema_editor): + migrate_old_records(apps) + for table_name in LEGACY_MANY_TO_MANY_JOIN_TABLES.keys(): + delete_key(redis_table_key(table_name)) + + +def wrapped_store_records(apps, schema_editor): + connection = schema_editor.connection + store_existing_records(connection) + + +class Migration(migrations.Migration): + dependencies = [ + ('specify', '0042_discipline_type_picklist'), + ] + + operations = [ + migrations.RunPython( + wrapped_store_records, + wrapped_migrate_to_legacy, + atomic=True, + ), + *[migrations.RunSQL( + sql=f"DROP TABLE IF EXISTS {table}", + reverse_sql=LEGACY_MANY_TO_MANY_JOIN_TABLES[table]["sql"] + ) for table in LEGACY_MANY_TO_MANY_JOIN_TABLES.keys()], + migrations.CreateModel( + name='Spprincipal_sppermission', + fields=[ + ('id', models.AutoField(db_column='SpPrincipalSpPermissionID', + primary_key=True, serialize=False)), + ('sppermission', models.ForeignKey(db_column='SpPermissionID', + on_delete=django.db.models.deletion.CASCADE, related_name='+', to='specify.sppermission')), + ('spprincipal', models.ForeignKey(db_column='SpPrincipalID', + on_delete=django.db.models.deletion.CASCADE, related_name='+', to='specify.spprincipal')), + ], + options={ + 'db_table': 'spprincipal_sppermission', + }, + ), + migrations.CreateModel( + name='Spexportschema_exportmapping', + fields=[ + ('id', models.AutoField( + db_column='SpExportSchemaExportMappingID', primary_key=True, serialize=False)), + ('spexportschema', models.ForeignKey(db_column='SpExportSchemaID', + on_delete=specifyweb.specify.models.protect_with_blockers, related_name='+', to='specify.spexportschema')), + ('spexportschemamapping', models.ForeignKey(db_column='SpExportSchemaMappingID', + on_delete=specifyweb.specify.models.protect_with_blockers, related_name='+', to='specify.spexportschemamapping')), + ], + options={ + 'db_table': 'sp_schema_mapping', + }, + ), + migrations.CreateModel( + name='Specifyuser_spprincipal', + fields=[ + ('id', models.AutoField(db_column='SpeicfyuserSpPrincipalID', + primary_key=True, serialize=False)), + ('specifyuser', models.ForeignKey(db_column='SpecifyUserID', + on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL)), + ('spprincipal', models.ForeignKey(db_column='SpPrincipalID', + on_delete=django.db.models.deletion.CASCADE, related_name='+', to='specify.spprincipal')), + ], + options={ + 'db_table': 'specifyuser_spprincipal', + }, + ), + migrations.CreateModel( + name='Project_colobj', + fields=[ + ('id', models.AutoField(db_column='ProjectColObjID', + primary_key=True, serialize=False)), + ('collectionobject', models.ForeignKey(db_column='CollectionObjectID', + on_delete=specifyweb.specify.models.protect_with_blockers, related_name='+', to='specify.collectionobject')), + ('project', models.ForeignKey(db_column='ProjectID', + on_delete=specifyweb.specify.models.protect_with_blockers, related_name='+', to='specify.project')), + ], + options={ + 'db_table': 'project_colobj', + }, + ), + migrations.CreateModel( + name='Autonumschdsp', + fields=[ + ('id', models.AutoField(db_column='AutonumSchDspID', + primary_key=True, serialize=False)), + ('autonumberingscheme', models.ForeignKey(db_column='AutoNumberingSchemeID', + on_delete=django.db.models.deletion.CASCADE, related_name='+', to='specify.autonumberingscheme')), + ('discipline', models.ForeignKey(db_column='DisciplineID', + on_delete=django.db.models.deletion.CASCADE, related_name='+', to='specify.discipline')), + ], + options={ + 'db_table': 'autonumsch_dsp', + }, + ), + migrations.CreateModel( + name='Autonumschdiv', + fields=[ + ('id', models.AutoField(db_column='AutonumSchDivID', + primary_key=True, serialize=False)), + ('autonumberingscheme', models.ForeignKey(db_column='AutoNumberingSchemeID', + on_delete=django.db.models.deletion.CASCADE, related_name='+', to='specify.autonumberingscheme')), + ('division', models.ForeignKey(db_column='DivisionID', + on_delete=django.db.models.deletion.CASCADE, related_name='+', to='specify.division')), + ], + options={ + 'db_table': 'autonumsch_div', + }, + ), + migrations.CreateModel( + name='Autonumschcoll', + fields=[ + ('id', models.AutoField(db_column='AutonumSchCollID', + primary_key=True, serialize=False)), + ('autonumberingscheme', models.ForeignKey(db_column='AutoNumberingSchemeID', + on_delete=django.db.models.deletion.CASCADE, related_name='+', to='specify.autonumberingscheme')), + ('collection', models.ForeignKey(db_column='CollectionID', + on_delete=django.db.models.deletion.CASCADE, related_name='+', to='specify.collection')), + ], + options={ + 'db_table': 'autonumsch_coll', + }, + ), + migrations.AddField( + model_name='autonumberingscheme', + name='collections', + field=models.ManyToManyField( + related_name='numberingschemes', through='specify.Autonumschcoll', to='specify.collection'), + ), + migrations.AddField( + model_name='autonumberingscheme', + name='disciplines', + field=models.ManyToManyField( + related_name='numberingschemes', through='specify.Autonumschdsp', to='specify.discipline'), + ), + migrations.AddField( + model_name='autonumberingscheme', + name='divisions', + field=models.ManyToManyField( + related_name='numberingschemes', through='specify.Autonumschdiv', to='specify.division'), + ), + migrations.AddField( + model_name='project', + name='collectionobjects', + field=models.ManyToManyField( + related_name='projects', through='specify.Project_colobj', to='specify.collectionobject'), + ), + migrations.AddField( + model_name='specifyuser', + name='spprincipals', + field=models.ManyToManyField( + related_name='spprincipals', through='specify.Specifyuser_spprincipal', to='specify.spprincipal'), + ), + migrations.AddField( + model_name='spexportschema', + name='mappings', + field=models.ManyToManyField( + related_name='spexportschemas', through='specify.Spexportschema_exportmapping', to='specify.spexportschemamapping'), + ), + migrations.AddField( + model_name='spprincipal', + name='sppermissions', + field=models.ManyToManyField( + related_name='spprincipals', through='specify.Spprincipal_sppermission', to='specify.sppermission'), + ), + migrations.AddConstraint( + model_name='spprincipal_sppermission', + constraint=models.UniqueConstraint( + fields=('spprincipal', 'sppermission'), name='spprincipal_sppermission'), + ), + migrations.AddConstraint( + model_name='spexportschema_exportmapping', + constraint=models.UniqueConstraint(fields=( + 'spexportschema', 'spexportschemamapping'), name='exportschema_exportmapping'), + ), + migrations.AddConstraint( + model_name='specifyuser_spprincipal', + constraint=models.UniqueConstraint( + fields=('specifyuser', 'spprincipal'), name='specifyuser_spprincipal'), + ), + migrations.AddConstraint( + model_name='project_colobj', + constraint=models.UniqueConstraint( + fields=('project', 'collectionobject'), name='project_collectionobject'), + ), + migrations.AddConstraint( + model_name='autonumschdsp', + constraint=models.UniqueConstraint(fields=( + 'autonumberingscheme', 'discipline'), name='autonumberingscheme_discipline'), + ), + migrations.AddConstraint( + model_name='autonumschdiv', + constraint=models.UniqueConstraint(fields=( + 'autonumberingscheme', 'division'), name='autonumberingscheme_division'), + ), + migrations.AddConstraint( + model_name='autonumschcoll', + constraint=models.UniqueConstraint(fields=( + 'autonumberingscheme', 'collection'), name='autonumberingscheme_collection'), + ), + migrations.RunPython( + wrapped_migrate_old_records, + wrapped_store_records, + atomic=True + ) + ] diff --git a/specifyweb/specify/models.py b/specifyweb/specify/models.py index d78f01d3e0c..3397105bb25 100644 --- a/specifyweb/specify/models.py +++ b/specifyweb/specify/models.py @@ -741,6 +741,26 @@ class Autonumberingscheme(models.Model): createdbyagent = models.ForeignKey('Agent', db_column='CreatedByAgentID', related_name='+', null=True, on_delete=protect_with_blockers) modifiedbyagent = models.ForeignKey('Agent', db_column='ModifiedByAgentID', related_name='+', null=True, on_delete=protect_with_blockers) + # Relationships: Many-to-Many + collections = models.ManyToManyField( + "Collection", + through="Autonumschcoll", + through_fields=("autonumberingscheme", "collection"), + related_name="numberingschemes" + ) + disciplines = models.ManyToManyField( + "Discipline", + through="Autonumschdsp", + through_fields=("autonumberingscheme", "discipline"), + related_name="numberingschemes" + ) + divisions = models.ManyToManyField( + "Division", + through="Autonumschdiv", + through_fields=("autonumberingscheme", "division"), + related_name="numberingschemes" + ) + class Meta: db_table = 'autonumberingscheme' ordering = () @@ -5583,6 +5603,14 @@ class Project(models.Model): createdbyagent = models.ForeignKey('Agent', db_column='CreatedByAgentID', related_name='+', null=True, on_delete=protect_with_blockers) modifiedbyagent = models.ForeignKey('Agent', db_column='ModifiedByAgentID', related_name='+', null=True, on_delete=protect_with_blockers) + # Relationships: Many-to-Many + collectionobjects = models.ManyToManyField( + 'CollectionObject', + through="Project_colobj", + through_fields=("project", "collectionobject"), + related_name="projects" + ) + class Meta: db_table = 'project' ordering = () @@ -6042,6 +6070,14 @@ class Spexportschema(models.Model): discipline = models.ForeignKey('Discipline', db_column='DisciplineID', related_name='spexportschemas', null=False, on_delete=protect_with_blockers) modifiedbyagent = models.ForeignKey('Agent', db_column='ModifiedByAgentID', related_name='+', null=True, on_delete=protect_with_blockers) + # Relationships: Many-to-Many + mappings = models.ManyToManyField( + "Spexportschemamapping", + through="Spexportschema_exportmapping", + through_fields=("spexportschema", "spexportschemamapping"), + related_name="spexportschemas" + ) + class Meta: db_table = 'spexportschema' ordering = () @@ -6309,6 +6345,14 @@ class Spprincipal(models.Model): modifiedbyagent = models.ForeignKey('Agent', db_column='ModifiedByAgentID', related_name='+', null=True, on_delete=protect_with_blockers) userGroupScopeID = models.IntegerField(blank=True, null=True, db_column='userGroupScopeID') + # Relationships: Many-to-Many + sppermissions = models.ManyToManyField( + "SpPermission", + through="Spprincipal_sppermission", + through_fields=("spprincipal", "sppermission"), + related_name="spprincipals" + ) + class Meta: db_table = 'spprincipal' ordering = () @@ -6609,6 +6653,14 @@ class Specifyuser(model_extras.Specifyuser): createdbyagent = models.ForeignKey('Agent', db_column='CreatedByAgentID', related_name='+', null=True, on_delete=protect_with_blockers) modifiedbyagent = models.ForeignKey('Agent', db_column='ModifiedByAgentID', related_name='+', null=True, on_delete=protect_with_blockers) + # Relationships: Many-to-Many + spprincipals = models.ManyToManyField( + "SpPrincipal", + through="Specifyuser_spprincipal", + through_fields=("specifyuser", "spprincipal"), + related_name="specifyusers" + ) + class Meta: db_table = 'specifyuser' ordering = () @@ -7988,4 +8040,143 @@ class Meta: db_table = 'tectonicunit' ordering = () - save = partialmethod(custom_save) \ No newline at end of file + save = partialmethod(custom_save) + +class Autonumschcoll(models.Model): + """ + Many to Many Join table for Autonumberingscheme and Collection. + Instead of using this class directly, prefer to use + Autonumberingscheme.collections or Collection.numberingschemes + """ + # specify_model = datamodel.get_table_strict("Autonumschcoll") + + id = models.AutoField(primary_key=True, db_column='AutonumSchCollID') + + autonumberingscheme = models.ForeignKey('Autonumberingscheme', db_column='AutoNumberingSchemeID', related_name="+", on_delete=models.CASCADE) + collection = models.ForeignKey('Collection', db_column='CollectionID', related_name="+", on_delete=models.CASCADE) + + save = partialmethod(custom_save) + + class Meta: + db_table='autonumsch_coll' + constraints = [ + models.UniqueConstraint(fields=["autonumberingscheme", "collection"], name="autonumberingscheme_collection") + ] + +class Autonumschdsp(models.Model): + """ + Many to Many Join table for Autonumberingscheme and Discipline. + Instead of using this class directly, prefer to use + Autonumberingscheme.disciplines or Discipline.numberingschemes + """ + # specify_model = datamodel.get_table_strict("Autonumschdsp") + + id = models.AutoField(primary_key=True, db_column='AutonumSchDspID') + + autonumberingscheme = models.ForeignKey('Autonumberingscheme', db_column='AutoNumberingSchemeID', related_name="+", on_delete=models.CASCADE) + discipline = models.ForeignKey('Discipline', db_column='DisciplineID', related_name="+", on_delete=models.CASCADE) + + save = partialmethod(custom_save) + + class Meta: + db_table='autonumsch_dsp' + constraints = [ + models.UniqueConstraint(fields=["autonumberingscheme", "discipline"], name="autonumberingscheme_discipline") + ] + +class Autonumschdiv(models.Model): + """ + Many to Many Join table for Autonumberingscheme and Division. + Instead of using this class directly, prefer to use + Autonumberingscheme.divisions or Division.numberingschemes + """ + # specify_model = datamodel.get_table_strict("Autonumschdiv") + + id = models.AutoField(primary_key=True, db_column='AutonumSchDivID') + + autonumberingscheme = models.ForeignKey('Autonumberingscheme', db_column='AutoNumberingSchemeID', related_name="+", on_delete=models.CASCADE) + division = models.ForeignKey('Division', db_column='DivisionID', related_name="+", on_delete=models.CASCADE) + + save = partialmethod(custom_save) + + class Meta: + db_table='autonumsch_div' + constraints = [ + models.UniqueConstraint(fields=["autonumberingscheme", "division"], name="autonumberingscheme_division") + ] + +class Specifyuser_spprincipal(models.Model): + """ + Many to Many Join table for SpecifyUser and SpPrincipal. + Instead of using this class directly, prefer to use + Specifyuser.spprincipals or Spprincipal.specifyusers + """ + id = models.AutoField(primary_key=True, db_column='SpeicfyuserSpPrincipalID') + + specifyuser = models.ForeignKey('SpecifyUser', db_column='SpecifyUserID', on_delete=models.CASCADE, related_name="+") + spprincipal = models.ForeignKey('SpPrincipal', db_column='SpPrincipalID', on_delete=models.CASCADE, related_name="+") + + save = partialmethod(custom_save) + + class Meta: + db_table = 'specifyuser_spprincipal' + constraints = [ + models.UniqueConstraint(fields=["specifyuser", "spprincipal"], name="specifyuser_spprincipal") + ] + +class Spprincipal_sppermission(models.Model): + """ + Many to Many Join table for SpPrincipal and SpPermission. + Instead of using this class directly, prefer to use + SpPrincipal.sppermissions or Sppermission.spprincipals + """ + id = models.AutoField(primary_key=True, db_column='SpPrincipalSpPermissionID') + + sppermission = models.ForeignKey('SpPermission', db_column='SpPermissionID', related_name="+", on_delete=models.CASCADE) + spprincipal = models.ForeignKey('SpPrincipal', db_column='SpPrincipalID', related_name="+", on_delete=models.CASCADE) + + save = partialmethod(custom_save) + + class Meta: + db_table = 'spprincipal_sppermission' + constraints = [ + models.UniqueConstraint(fields=["spprincipal", "sppermission"], name="spprincipal_sppermission") + ] + +class Project_colobj(models.Model): + """ + Many to Many Join table for Project and CollectionObject. + Instead of using this class directly, prefer to use + Project.collectionobjects or Collectionobject.projects + """ + id = models.AutoField(primary_key=True, db_column='ProjectColObjID') + + project = models.ForeignKey('Project', db_column='ProjectID', related_name="+", on_delete=protect_with_blockers) + collectionobject = models.ForeignKey('CollectionObject', db_column='CollectionObjectID', related_name="+", on_delete=protect_with_blockers) + + save = partialmethod(custom_save) + + class Meta: + db_table = 'project_colobj' + constraints = [ + models.UniqueConstraint(fields=["project", "collectionobject"], name="project_collectionobject") + ] + +class Spexportschema_exportmapping(models.Model): + """ + Many to Many Join table for SpExportSchema and SpExportSchemaMapping. + Instead of using this class directly, prefer to use + Spexportschema.mappings or Spexportschemamapping.spexportschemas + """ + id = models.AutoField(primary_key=True, db_column='SpExportSchemaExportMappingID') + + spexportschema = models.ForeignKey('Spexportschema', db_column='SpExportSchemaID', related_name="+", on_delete=protect_with_blockers) + spexportschemamapping = models.ForeignKey('Spexportschemamapping',db_column='SpExportSchemaMappingID', related_name="+", on_delete=protect_with_blockers) + + save = partialmethod(custom_save) + + class Meta: + db_table = 'sp_schema_mapping' + constraints = [ + models.UniqueConstraint(fields=["spexportschema", "spexportschemamapping"], name="exportschema_exportmapping") + ] \ No newline at end of file diff --git a/specifyweb/specify/models_utils/load_datamodel.py b/specifyweb/specify/models_utils/load_datamodel.py index 0432496fab0..dbe2c8f8f72 100644 --- a/specifyweb/specify/models_utils/load_datamodel.py +++ b/specifyweb/specify/models_utils/load_datamodel.py @@ -368,6 +368,35 @@ def __init__( def is_remote_to_one(self): return self.type == "one-to-one" and self.column == None +# REFACTOR: extract other relationship types from base Relationship? +class ManyToMany(Relationship): + through_model: str + through_field: str + + def __init__(self, + name = None, + required = None, + relatedModelName = None, + otherSideName = None, + through_model = None, + through_field = None): + super().__init__(name=name, + type='many-to-many', + is_relationship=True, + #FEATURE: add support for dependent many-to-many + dependent=False, + required=required, + relatedModelName=relatedModelName, + otherSideName=otherSideName) + if through_model is None: + raise ValueError("A through model must be specified for a \ + ManyToMany Relationship!") + if through_field is None: + raise ValueError("A column on the through table for the source \ + side must be specified!") + + self.through_model = through_model + self.through_field = through_field def make_table(tabledef: ElementTree.Element) -> Table: iddef = tabledef.find("id") diff --git a/specifyweb/specify/models_utils/model_timestamp.py b/specifyweb/specify/models_utils/model_timestamp.py index 0080174df18..800f07d10b6 100644 --- a/specifyweb/specify/models_utils/model_timestamp.py +++ b/specifyweb/specify/models_utils/model_timestamp.py @@ -12,8 +12,11 @@ def save_auto_timestamp_field_with_override(save_func, args, kwargs, obj): 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 + field.name for field in model._meta.get_fields(include_hidden=True) + if field.concrete and not field.primary_key + # FEATURE: add support for patching in many_to_many changes + and not getattr(field, "many_to_many", False) ] if obj.id is not None: diff --git a/specifyweb/specify/models_utils/models_by_table_id.py b/specifyweb/specify/models_utils/models_by_table_id.py index 6132fb5088f..56ed4c6030c 100644 --- a/specifyweb/specify/models_utils/models_by_table_id.py +++ b/specifyweb/specify/models_utils/models_by_table_id.py @@ -217,7 +217,14 @@ 1026:'Tectonicunittreedefitem', 1027:'Tectonicunit', 1028:'Spdatasetattachment', - 1029: 'Component' + 1029: 'Component', + 1030:'Autonumschcoll', + 1031:'Autonumschdiv', + 1032:'Autonumschdsp', + 1033:'Project_colobj', + 1034:'Spexportschema_exportmapping', + 1035:'Specifyuser_spprincipal', + 1036:'Spprincipal_sppermission', } model_names_by_app = { @@ -448,7 +455,14 @@ 'Tectonicunittreedef', 'Tectonicunittreedefitem', 'Tectonicunit', - 'Component' + 'Component', + 'Autonumschcoll', + 'Autonumschdiv', + 'Autonumschdsp', + 'Project_colobj', + 'Spexportschema_exportmapping', + 'Specifyuser_spprincipal', + 'Spprincipal_sppermission', } }