From 4019815f25a7c7f16791081db589b9b71b2329ff Mon Sep 17 00:00:00 2001 From: melton-jason Date: Sun, 28 Jun 2026 22:19:42 -0500 Subject: [PATCH 01/10] feat: general backend pagination for delete blockers --- specifyweb/backend/delete_blockers/urls.py | 1 + specifyweb/backend/delete_blockers/views.py | 66 ++++++++++++++++++++- 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/specifyweb/backend/delete_blockers/urls.py b/specifyweb/backend/delete_blockers/urls.py index 9945728ca5d..d141bb9f968 100644 --- a/specifyweb/backend/delete_blockers/urls.py +++ b/specifyweb/backend/delete_blockers/urls.py @@ -3,5 +3,6 @@ from . import views urlpatterns = [ re_path(r'^delete_blockers/(?P\w+)/(?P\d+)/$', views.delete_blockers), + re_path(r'^old_delete_blockers/(?P\w+)/(?P\d+)/$', views.old_delete_blockers), ] diff --git a/specifyweb/backend/delete_blockers/views.py b/specifyweb/backend/delete_blockers/views.py index e562390aa76..ac17ff71b95 100644 --- a/specifyweb/backend/delete_blockers/views.py +++ b/specifyweb/backend/delete_blockers/views.py @@ -1,24 +1,32 @@ +from collections import defaultdict + from django import http from django.db import router, transaction -from django.db.models.deletion import Collector +from django.db.models.deletion import Collector, CASCADE, PROTECT from django.db.models import ForeignKey +from django.views.decorators.http import require_POST from specifyweb.middleware.general import require_http_methods from specifyweb.specify.api.crud import ( get_discipline_delete_guard_blockers, get_object_or_404, + get_model, prepare_discipline_for_delete, ) +from specifyweb.specify.models import protect_with_blockers from specifyweb.specify.api.serializers import toJson from specifyweb.specify.views import login_maybe_required @login_maybe_required @require_http_methods(['GET', 'HEAD']) -def delete_blockers(request, model, id): +def old_delete_blockers(request, model, id): """Returns a JSON list of fields on that point to related resources which prevent the resource of that model from being deleted. """ + # limit = request.GET["limit"] + # depth_limit = request.GET["depthLimit"] + obj = get_object_or_404(model, id=int(id)) using = router.db_for_write(obj.__class__, instance=obj) @@ -38,6 +46,60 @@ def delete_blockers(request, model, id): return http.HttpResponse(toJson(result), content_type='application/json') +@login_maybe_required +@require_http_methods(['GET']) +def delete_blockers(request, model, id): + limit = int(request.GET["limit"]) if "limit" in request.GET else 20 + offset = int(request.GET["offset"]) if "offset" in request.GET else 0 + obj = get_object_or_404(model, id=int(id)) + immediate, deferred = fetch_immediate_blockers(obj, limit=limit, offset=offset) + result = { + "results": immediate, + "next": deferred + } + return http.HttpResponse(toJson(result), content_type='application/json') + +def fetch_immediate_blockers(obj, limit=20, offset=0): + all_fields = obj._meta.get_fields(include_hidden=True) + all_relationships = filter( + # Check whether there are any concrete fields that SHOULD be included + # here, like some ToOne fields that acts as blockers + lambda field: field.is_relation and not field.concrete, + all_fields + ) + results = [] + next = [] + for relationship in all_relationships: + related_ids = _prepare_blockers(obj, relationship, limit=limit, offset=offset) + if len(related_ids) == 0: + continue + complete = limit == 0 or len(related_ids) < limit + payload = { + "table": relationship.related_model._meta.model_name, + "field": relationship.field.name, + "ids": list(related_ids), + "offset": offset, + "limit": limit, + "complete": complete + } + if relationship.on_delete is protect_with_blockers or relationship.on_delete is PROTECT: + results.append(payload) + elif relationship.on_delete is CASCADE: + next.append(payload) + return results, next + + +def _prepare_blockers(obj, relationship, limit=20, offset=0): + query_set = ( + relationship.related_model.objects + .filter( + **{relationship.field.name: obj.pk} + ).order_by("pk") + .values_list("pk", flat=True)) + if limit != 0: + query_set = query_set[offset: offset + limit] + return query_set + def _collect_delete_blockers(obj, using) -> list[dict]: collector = Collector(using=using) collector.delete_blockers = [] From f821721267e35c7482175eb43816ddd7ab9d3d75 Mon Sep 17 00:00:00 2001 From: melton-jason Date: Tue, 28 Jul 2026 12:23:09 -0500 Subject: [PATCH 02/10] chore: remove whitespace in .env for consisitency --- .env | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.env b/.env index f9cfe7040ab..1daacb33da3 100644 --- a/.env +++ b/.env @@ -31,7 +31,7 @@ APP_USER_PASSWORD=specify_user # https://discourse.specifysoftware.org/t/allow-support-login-documentation/2838 ALLOW_SUPPORT_LOGIN=false # The amount of time in seconds each token is valid for -SUPPORT_LOGIN_TTL = 180 +SUPPORT_LOGIN_TTL=180 # Make sure to set the `SECRET_KEY` to a unique value SECRET_KEY=change_this_to_some_unique_random_string @@ -60,7 +60,7 @@ CELERY_RESULT_BACKEND=redis://redis/1 # timezone as the operating system. # If running in a Windows environment this must be set to the same as your # system time zone. -TIME_ZONE = America/Chicago +TIME_ZONE=America/Chicago # This variable controls the Specify 7 logging level. Possible values # are: From fac4edce1ec2acc0698381ae963764c72acc94ff Mon Sep 17 00:00:00 2001 From: melton-jason Date: Thu, 30 Jul 2026 21:58:50 -0500 Subject: [PATCH 03/10] fix: inherit host for authenticated connection and allow host configuration --- sp7_db_setup_check.sh | 45 ++++++++++++++++++++++--------------------- 1 file changed, 23 insertions(+), 22 deletions(-) diff --git a/sp7_db_setup_check.sh b/sp7_db_setup_check.sh index 0cb250b7145..a70f2cff864 100644 --- a/sp7_db_setup_check.sh +++ b/sp7_db_setup_check.sh @@ -169,13 +169,14 @@ fi # Detect client host as seen by MariaDB CLIENT_HOST="$(mariadb -N -B -h "$DB_HOST" -P "$DB_PORT" \ -u "$MASTER_USER_NAME" --password="$MASTER_USER_PASSWORD" \ - -sse "SELECT SUBSTRING_INDEX(USER(),'@',-1);")" || CLIENT_HOST="" + -sse "SELECT SUBSTRING_INDEX(CURRENT_USER(),'@',-1);")" || CLIENT_HOST="" CLIENT_HOST="${CLIENT_HOST%% *}" +MIGRATOR_USER_HOST="${MIGRATOR_USER_HOST:-${CLIENT_HOST:-}}" +APP_USER_HOST="${APP_USER_HOST:-${CLIENT_HOST:-}}" + if [[ -n "$CLIENT_HOST" ]]; then echo "Client host as seen by MariaDB: '$CLIENT_HOST'" -else - echo "Warning: Could not detect client host via USER(); will only create users for explicit *_HOST values." fi # Create database if it doesn't exist @@ -203,13 +204,13 @@ if [[ "$SAME_MASTER_AND_MIGRATOR" == true ]]; then echo "Relying on master privileges for runtime connections." else USER_EXISTS=$(mysql -h "$DB_HOST" -P "$DB_PORT" -u "$MASTER_USER_NAME" --password="$MASTER_USER_PASSWORD" -sse \ -"SELECT COUNT(*) FROM mysql.user WHERE user = '$SQL_MIGRATOR_NAME' AND host = '$CLIENT_HOST';") +"SELECT COUNT(*) FROM mysql.user WHERE user = '$SQL_MIGRATOR_NAME' AND host = '$MIGRATOR_USER_HOST';") if [[ "$USER_EXISTS" -eq 0 && "$MIGRATOR_NAME" != "root" ]]; then echo "Creating migrator user '$MIGRATOR_NAME'..." - echo "Executing: mysql -h \"$DB_HOST\" -P \"$DB_PORT\" -u \"$MASTER_USER_NAME\" --password=\"\" -e \"CREATE USER '${SQL_MIGRATOR_NAME}'@'${CLIENT_HOST}' IDENTIFIED BY '';\"" + echo "Executing: mysql -h \"$DB_HOST\" -P \"$DB_PORT\" -u \"$MASTER_USER_NAME\" --password=\"\" -e \"CREATE USER '${SQL_MIGRATOR_NAME}'@'${MIGRATOR_USER_HOST}' IDENTIFIED BY '';\"" if mysql -h "$DB_HOST" -P "$DB_PORT" -u "$MASTER_USER_NAME" --password="$MASTER_USER_PASSWORD" \ - -e "CREATE USER '$SQL_MIGRATOR_NAME'@'$CLIENT_HOST' IDENTIFIED BY '$SQL_MIGRATOR_PASSWORD';"; then + -e "CREATE USER '$SQL_MIGRATOR_NAME'@'$MIGRATOR_USER_HOST' IDENTIFIED BY '$SQL_MIGRATOR_PASSWORD';"; then NEW_MIGRATOR_USER_CREATED=1 else echo "Error: Failed to create user." @@ -225,20 +226,20 @@ else echo "Migrator user already exists. Refreshing privileges on '${DB_NAME}'..." fi -echo "Executing: mysql -h \"$DB_HOST\" -P \"$DB_PORT\" -u \"$MASTER_USER_NAME\" --password=\"\" -e \"GRANT ALL PRIVILEGES ON \`${SQL_DB_IDENTIFIER}\`.* TO '${SQL_MIGRATOR_NAME}'@'${CLIENT_HOST}'; FLUSH PRIVILEGES;\"" +echo "Executing: mysql -h \"$DB_HOST\" -P \"$DB_PORT\" -u \"$MASTER_USER_NAME\" --password=\"\" -e \"GRANT ALL PRIVILEGES ON \`${SQL_DB_IDENTIFIER}\`.* TO '${SQL_MIGRATOR_NAME}'@'${MIGRATOR_USER_HOST}'; FLUSH PRIVILEGES;\"" if ! mysql -h "$DB_HOST" -P "$DB_PORT" -u "$MASTER_USER_NAME" --password="$MASTER_USER_PASSWORD" \ - -e "GRANT ALL PRIVILEGES ON \`${SQL_DB_IDENTIFIER}\`.* TO '${SQL_MIGRATOR_NAME}'@'${CLIENT_HOST}'; FLUSH PRIVILEGES;"; then + -e "GRANT ALL PRIVILEGES ON \`${SQL_DB_IDENTIFIER}\`.* TO '${SQL_MIGRATOR_NAME}'@'${MIGRATOR_USER_HOST}'; FLUSH PRIVILEGES;"; then echo "Error: Failed to grant privileges to migrator user." exit 1 fi GRANTS_OUTPUT="$(mysql -N -B --raw -h "$DB_HOST" -P "$DB_PORT" \ -u "$MASTER_USER_NAME" --password="$MASTER_USER_PASSWORD" \ - -e "SHOW GRANTS FOR '${SQL_MIGRATOR_NAME}'@'${CLIENT_HOST}';" 2>/dev/null || true)" + -e "SHOW GRANTS FOR '${SQL_MIGRATOR_NAME}'@'${MIGRATOR_USER_HOST}';" 2>/dev/null || true)" if [[ -z "$GRANTS_OUTPUT" ]]; then - echo "Error: Could not retrieve grants for '${SQL_MIGRATOR_NAME}'@'${CLIENT_HOST}'." + echo "Error: Could not retrieve grants for '${SQL_MIGRATOR_NAME}'@'${MIGRATOR_USER_HOST}'." exit 1 fi @@ -253,9 +254,9 @@ for g in "${MIGRATOR_GRANTS_LINES[@]}"; do done if [[ "$migrator_has_required_permissions" == true ]]; then - echo "Verified: '${SQL_MIGRATOR_NAME}'@'${CLIENT_HOST}' has migration privileges on '${DB_NAME}'." + echo "Verified: '${SQL_MIGRATOR_NAME}'@'${MIGRATOR_USER_HOST}' has migration privileges on '${DB_NAME}'." else - echo "Error: '${SQL_MIGRATOR_NAME}'@'${CLIENT_HOST}' lacks migration privileges on '${DB_NAME}'." + echo "Error: '${SQL_MIGRATOR_NAME}'@'${MIGRATOR_USER_HOST}' lacks migration privileges on '${DB_NAME}'." echo "Required for migrations (any one GRANT must include all of): ${MIGRATION_REQUIRED_PRIVS[*]}" echo "Grants found:" echo "$GRANTS_OUTPUT" @@ -265,16 +266,16 @@ fi # Create app user if it doesn't exist USER_EXISTS=$(mysql -h "$DB_HOST" -P "$DB_PORT" -u "$MASTER_USER_NAME" --password="$MASTER_USER_PASSWORD" -sse \ -"SELECT COUNT(*) FROM mysql.user WHERE user = '$SQL_APP_USER_NAME' AND host = '$CLIENT_HOST';") +"SELECT COUNT(*) FROM mysql.user WHERE user = '$SQL_APP_USER_NAME' AND host = '$APP_USER_HOST';") if [[ "$USER_EXISTS" -eq 0 && "$APP_USER_NAME" != "root" ]]; then echo "Creating app user '$SQL_APP_USER_NAME'..." - echo "Executing: mysql -h \"$DB_HOST\" -P \"$DB_PORT\" -u \"$MASTER_USER_NAME\" --password=\"\" -e \"CREATE USER '${SQL_APP_USER_NAME}'@'${CLIENT_HOST}' IDENTIFIED BY '';\"" + echo "Executing: mysql -h \"$DB_HOST\" -P \"$DB_PORT\" -u \"$MASTER_USER_NAME\" --password=\"\" -e \"CREATE USER '${SQL_APP_USER_NAME}'@'${APP_USER_HOST}' IDENTIFIED BY '';\"" if mysql -h "$DB_HOST" -P "$DB_PORT" -u "$MASTER_USER_NAME" --password="$MASTER_USER_PASSWORD" \ - -e "CREATE USER '$SQL_APP_USER_NAME'@'$CLIENT_HOST' IDENTIFIED BY '$SQL_APP_USER_PASSWORD';"; then + -e "CREATE USER '$SQL_APP_USER_NAME'@'$APP_USER_HOST' IDENTIFIED BY '$SQL_APP_USER_PASSWORD';"; then NEW_APP_USER_CREATED=1 else - echo "Error: Failed to create app user '${APP_USER_NAME}'@'${CLIENT_HOST}'." + echo "Error: Failed to create app user '${APP_USER_NAME}'@'${APP_USER_HOST}'." echo "Falling back to migrator credentials for app user." APP_USER_NAME="$MIGRATOR_NAME" APP_USER_PASSWORD="$MIGRATOR_PASSWORD" @@ -304,17 +305,17 @@ else echo "App user already exists. Refreshing privileges on '${DB_NAME}'..." fi -echo "Executing: mysql -h \"$DB_HOST\" -P \"$DB_PORT\" -u \"$MASTER_USER_NAME\" --password=\"\" -e \"GRANT SELECT, INSERT, UPDATE, DELETE, CREATE TEMPORARY TABLES, LOCK TABLES, EXECUTE ON \`${SQL_DB_IDENTIFIER}\`.* TO ${SQL_APP_USER_NAME}@'${CLIENT_HOST}'; FLUSH PRIVILEGES;\"" -if ! mysql -h "$DB_HOST" -P "$DB_PORT" -u "$MASTER_USER_NAME" --password="$MASTER_USER_PASSWORD" -e "GRANT SELECT, INSERT, UPDATE, DELETE, CREATE TEMPORARY TABLES, LOCK TABLES, EXECUTE ON \`${SQL_DB_IDENTIFIER}\`.* TO '${SQL_APP_USER_NAME}'@'${CLIENT_HOST}'; FLUSH PRIVILEGES;"; then +echo "Executing: mysql -h \"$DB_HOST\" -P \"$DB_PORT\" -u \"$MASTER_USER_NAME\" --password=\"\" -e \"GRANT SELECT, INSERT, UPDATE, DELETE, CREATE TEMPORARY TABLES, LOCK TABLES, EXECUTE ON \`${SQL_DB_IDENTIFIER}\`.* TO ${SQL_APP_USER_NAME}@'${APP_USER_HOST}'; FLUSH PRIVILEGES;\"" +if ! mysql -h "$DB_HOST" -P "$DB_PORT" -u "$MASTER_USER_NAME" --password="$MASTER_USER_PASSWORD" -e "GRANT SELECT, INSERT, UPDATE, DELETE, CREATE TEMPORARY TABLES, LOCK TABLES, EXECUTE ON \`${SQL_DB_IDENTIFIER}\`.* TO '${SQL_APP_USER_NAME}'@'${APP_USER_HOST}'; FLUSH PRIVILEGES;"; then echo "Error: Failed to grant privileges to app user." exit 1 fi APP_GRANTS_RAW="$(mysql -N -B -h "$DB_HOST" -P "$DB_PORT" -u "$MASTER_USER_NAME" --password="$MASTER_USER_PASSWORD" \ - -e "SHOW GRANTS FOR '${SQL_APP_USER_NAME}'@'${CLIENT_HOST}';" 2>/dev/null || true)" + -e "SHOW GRANTS FOR '${SQL_APP_USER_NAME}'@'${APP_USER_HOST}';" 2>/dev/null || true)" if [[ -z "$APP_GRANTS_RAW" ]]; then - echo "Error: Could not retrieve grants for '${SQL_APP_USER_NAME}'@'${CLIENT_HOST}'." + echo "Error: Could not retrieve grants for '${SQL_APP_USER_NAME}'@'${APP_USER_HOST}'." exit 1 fi @@ -330,9 +331,9 @@ for g in "${APP_GRANTS_LINES[@]}"; do done if [[ "$app_has_required_permissions" == true ]]; then - echo "Verified: '${APP_USER_NAME}'@'${CLIENT_HOST}' has required privileges on '${DB_NAME}'." + echo "Verified: '${APP_USER_NAME}'@'${APP_USER_HOST}' has required privileges on '${DB_NAME}'." else - echo "Error: '${APP_USER_NAME}'@'${CLIENT_HOST}' lacks required privileges on '${DB_NAME}'." + echo "Error: '${APP_USER_NAME}'@'${APP_USER_HOST}' lacks required privileges on '${DB_NAME}'." echo "Required (any one GRANT must include all of): ${APP_REQUIRED_PRIVS[*]}" echo "Grants found:" echo "$APP_GRANTS_RAW" From 9ebe0c9c996c12683ea77f3a5fdb10008ef071b6 Mon Sep 17 00:00:00 2001 From: melton-jason Date: Thu, 30 Jul 2026 22:29:12 -0500 Subject: [PATCH 04/10] feat: ignore explicit host variables when user name is the same --- sp7_db_setup_check.sh | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/sp7_db_setup_check.sh b/sp7_db_setup_check.sh index a70f2cff864..8d73a7fddd1 100644 --- a/sp7_db_setup_check.sh +++ b/sp7_db_setup_check.sh @@ -172,11 +172,18 @@ CLIENT_HOST="$(mariadb -N -B -h "$DB_HOST" -P "$DB_PORT" \ -sse "SELECT SUBSTRING_INDEX(CURRENT_USER(),'@',-1);")" || CLIENT_HOST="" CLIENT_HOST="${CLIENT_HOST%% *}" -MIGRATOR_USER_HOST="${MIGRATOR_USER_HOST:-${CLIENT_HOST:-}}" -APP_USER_HOST="${APP_USER_HOST:-${CLIENT_HOST:-}}" +if [[ "$SAME_MASTER_AND_MIGRATOR" == true ]]; then + MIGRATOR_USER_HOST="${CLIENT_HOST:-}" +else + MIGRATOR_USER_HOST="${MIGRATOR_USER_HOST:-${CLIENT_HOST:-}}" +fi -if [[ -n "$CLIENT_HOST" ]]; then - echo "Client host as seen by MariaDB: '$CLIENT_HOST'" +if [[ "$SAME_MASTER_AND_APP" == true ]]; then + APP_USER_HOST="${CLIENT_HOST:-}" +elif [[ "$SAME_MIGRATOR_AND_APP" == true ]]; then + APP_USER_HOST="${MIGRATOR_USER_HOST:-}" +else + APP_USER_HOST="${APP_USER_HOST:-${CLIENT_HOST:-}}" fi # Create database if it doesn't exist @@ -264,6 +271,8 @@ else fi fi +# BUG: this should probably be skipped if the app user is the same as the +# master or migrator # Create app user if it doesn't exist USER_EXISTS=$(mysql -h "$DB_HOST" -P "$DB_PORT" -u "$MASTER_USER_NAME" --password="$MASTER_USER_PASSWORD" -sse \ "SELECT COUNT(*) FROM mysql.user WHERE user = '$SQL_APP_USER_NAME' AND host = '$APP_USER_HOST';") From abc1400f0451002d7110457165663700d95dfff7 Mon Sep 17 00:00:00 2001 From: melton-jason Date: Sun, 2 Aug 2026 19:55:10 -0500 Subject: [PATCH 05/10] fix: stop duplicating roles if descriptions don't match --- specifyweb/backend/permissions/initialize.py | 608 ++++++++----------- 1 file changed, 250 insertions(+), 358 deletions(-) diff --git a/specifyweb/backend/permissions/initialize.py b/specifyweb/backend/permissions/initialize.py index f2ab54ac44b..dfcdf0e8425 100644 --- a/specifyweb/backend/permissions/initialize.py +++ b/specifyweb/backend/permissions/initialize.py @@ -4,6 +4,7 @@ from collections import defaultdict from django.db import transaction, connection +from django.db.models.functions import Lower from django.apps import apps from specifyweb.specify.datamodel import datamodel @@ -79,6 +80,13 @@ def create_admins(apps=apps) -> None: action="%", ) +_USERTYPES_TO_ROLE_NAMES = { + "Manager": "Collection Admin", + "FullAccess": "Full Access - Legacy", + "LimitedAccess": "Read Only - Legacy", + "Guest": "Read Only - Legacy", +} + def assign_users_to_roles(apps=apps) -> None: Role = apps.get_model('permissions', 'Role') UserPolicy = apps.get_model('permissions', 'UserPolicy') @@ -93,13 +101,6 @@ def assign_users_to_roles(apps=apps) -> None: "Guest": "This is a legacy role that provides read only access and is assigned to user in the Limited Access and Guest groups from Specify 6. This is to maintain consistency with the permissions granted these users in previous versions of Specify 7.", } - ROLE_NAMES = { - "Manager": "Collection Admin", - "FullAccess": "Full Access - Legacy", - "LimitedAccess": "Read Only - Legacy", - "Guest": "Read Only - Legacy", - } - results = [] with connection.cursor() as cursor: @@ -129,12 +130,11 @@ def assign_users_to_roles(apps=apps) -> None: for user_id, user_name, user_type, collection_id, collection_name in results: # REFACTOR: If we want to exlcude all other roles, why don't we write # the exlcusion in the query rather than evaluate in Python? - if user_type not in ROLE_NAMES.keys(): + if user_type not in _USERTYPES_TO_ROLE_NAMES.keys(): continue - role_name = ROLE_NAMES.get(user_type, f"{user_type} - {collection_name}") + role_name = _USERTYPES_TO_ROLE_NAMES.get(user_type, f"{user_type} - {collection_name}") role_description = ROLE_DESCRIPTIONS.get(user_type, "No description available.") - logger.info(f"Assigned user {user_name} to role {role_name} for collection {collection_name}.") role, _ = Role.objects.get_or_create( collection_id=collection_id, @@ -143,6 +143,8 @@ def assign_users_to_roles(apps=apps) -> None: "description": role_description } ) + # BUG: What if the user was intentionally removed from this role? + # This would incorrectly re-add them :( UserRole.objects.get_or_create( specifyuser_id=user_id, role=role @@ -155,6 +157,7 @@ def assign_users_to_roles(apps=apps) -> None: resource=CollectionAccessPT.access.resource(), action=CollectionAccessPT.access.action() ) + logger.info(f"Assigned user {user_name} to role {role_name} for collection {collection_name}.") def assign_users_to_roles_during_testing(apps=apps) -> None: from specifyweb.backend.context.views import users_collections_for_sp6 @@ -207,366 +210,255 @@ def assign_users_to_roles_during_testing(apps=apps) -> None: action=CollectionAccessPT.access.action(), ) -def create_roles(apps = apps) -> None: - LibraryRole = apps.get_model('permissions', 'LibraryRole') - Role = apps.get_model('permissions', 'Role') - Collection = apps.get_model('specify', 'Collection') - Specifyuser = apps.get_model('specify', 'Specifyuser') - - role, is_new = LibraryRole.objects.get_or_create(name="Assign Roles", description="Gives ability to assign existing roles to existing users.") - if is_new: - role.policies.get_or_create(resource="/permissions/user/roles", action="read") - role.policies.get_or_create(resource="/permissions/user/roles", action="update") - role.policies.get_or_create(resource="/permissions/roles", action="read") - - role, is_new = LibraryRole.objects.get_or_create(name="Create Data Sets", description="Allows creating new Data Sets in the WorkBench, without ability to upload them.\n\nSuch user would create a Data Sets, map the columns, fix validation issues, and then transfer the Data Set to another user for review and upload.") - if is_new: - role.policies.get_or_create(resource="/workbench/dataset", action="create") - role.policies.get_or_create(resource="/workbench/dataset", action="update") - role.policies.get_or_create(resource="/workbench/dataset", action="delete") - role.policies.get_or_create(resource="/workbench/dataset", action="validate") - role.policies.get_or_create(resource="/workbench/dataset", action="transfer") - - role, is_new = LibraryRole.objects.get_or_create(name="Edit Forms and Global Preferences", description="Grants full access to resource editor. This allows editing form definitions and global Specify preferences.") - if is_new: - role.policies.get_or_create(resource="/table/spappresource", action="read") - role.policies.get_or_create(resource="/table/spappresource", action="create") - role.policies.get_or_create(resource="/table/spappresource", action="update") - role.policies.get_or_create(resource="/table/spappresource", action="delete") - role.policies.get_or_create(resource="/table/spappresourcedata", action="read") - role.policies.get_or_create(resource="/table/spappresourcedata", action="create") - role.policies.get_or_create(resource="/table/spappresourcedata", action="update") - role.policies.get_or_create(resource="/table/spappresourcedata", action="delete") - role.policies.get_or_create(resource="/table/spappresourcedir", action="read") - role.policies.get_or_create(resource="/table/spappresourcedir", action="create") - role.policies.get_or_create(resource="/table/spappresourcedir", action="update") - role.policies.get_or_create(resource="/table/spappresourcedir", action="delete") - role.policies.get_or_create(resource="/table/spviewsetobj", action="read") - role.policies.get_or_create(resource="/table/spviewsetobj", action="create") - role.policies.get_or_create(resource="/table/spviewsetobj", action="update") - role.policies.get_or_create(resource="/table/spviewsetobj", action="delete") - - role, is_new = LibraryRole.objects.get_or_create(name="Edit Pick lists", description="Gives full access to modifying pick lists.") - if is_new: - role.policies.get_or_create(resource="/table/picklist", action="read") - role.policies.get_or_create(resource="/table/picklist", action="create") - role.policies.get_or_create(resource="/table/picklist", action="update") - role.policies.get_or_create(resource="/table/picklist", action="delete") - role.policies.get_or_create(resource="/table/picklistitem", action="read") - role.policies.get_or_create(resource="/table/picklistitem", action="create") - role.policies.get_or_create(resource="/table/picklistitem", action="update") - role.policies.get_or_create(resource="/table/picklistitem", action="delete") - - role, is_new = LibraryRole.objects.get_or_create(name="Edit Taxon Tree", description="Gives full access to the Taxon Tree.\n\nWarning: Taxon Tree may be shared between collections. Edits in one collection may affect another.") - if is_new: - role.policies.get_or_create(resource="/tree/edit/taxon", action="merge") - role.policies.get_or_create(resource="/tree/edit/taxon", action="move") - role.policies.get_or_create(resource="/tree/edit/taxon", action="synonymize") - role.policies.get_or_create(resource="/tree/edit/taxon", action="desynonymize") - role.policies.get_or_create(resource="/tree/edit/taxon", action="repair") - role.policies.get_or_create(resource="/table/taxon", action="read") - role.policies.get_or_create(resource="/table/taxon", action="update") - role.policies.get_or_create(resource="/table/taxon", action="delete") - role.policies.get_or_create(resource="/table/taxon", action="create") - role.policies.get_or_create(resource="/table/taxonattribute", action="read") - role.policies.get_or_create(resource="/table/taxonattribute", action="delete") - role.policies.get_or_create(resource="/table/taxonattribute", action="update") - role.policies.get_or_create(resource="/table/taxonattribute", action="create") - role.policies.get_or_create(resource="/table/taxoncitation", action="read") - role.policies.get_or_create(resource="/table/taxoncitation", action="create") - role.policies.get_or_create(resource="/table/taxoncitation", action="update") - role.policies.get_or_create(resource="/table/taxoncitation", action="delete") - role.policies.get_or_create(resource="/table/taxontreedef", action="read") - role.policies.get_or_create(resource="/table/taxontreedef", action="update") - role.policies.get_or_create(resource="/table/taxontreedefitem", action="read") - role.policies.get_or_create(resource="/table/taxontreedefitem", action="update") - role.policies.get_or_create(resource="/table/taxonattachment", action="read") - role.policies.get_or_create(resource="/table/taxonattachment", action="create") - role.policies.get_or_create(resource="/table/taxonattachment", action="update") - role.policies.get_or_create(resource="/table/taxonattachment", action="delete") - - role, is_new = LibraryRole.objects.get_or_create(name="Export Data", description="Gives ability to export DwC Archive from any table.") - if is_new: - role.policies.get_or_create(resource="/export/dwca", action="execute") - role.policies.get_or_create(resource="/table/%", action="read") - - role, is_new = LibraryRole.objects.get_or_create(name="Full Data Access", description="Grants read and edit access to all tables") - if is_new: - role.policies.get_or_create(resource="/table/%", action="read") - role.policies.get_or_create(resource="/table/%", action="create") - role.policies.get_or_create(resource="/table/%", action="update") - role.policies.get_or_create(resource="/table/%", action="delete") - - role, is_new = LibraryRole.objects.get_or_create(name="Full WorkBench access", description="Gives full access to the WorkBench. Allows creating new records in any table.") - if is_new: - role.policies.get_or_create(resource="/workbench/dataset", action="create") - role.policies.get_or_create(resource="/workbench/dataset", action="update") - role.policies.get_or_create(resource="/workbench/dataset", action="delete") - role.policies.get_or_create(resource="/workbench/dataset", action="validate") - role.policies.get_or_create(resource="/workbench/dataset", action="upload") - role.policies.get_or_create(resource="/workbench/dataset", action="unupload") - role.policies.get_or_create(resource="/workbench/dataset", action="transfer") - role.policies.get_or_create(resource="/table/%", action="read") - role.policies.get_or_create(resource="/table/%", action="create") - - role, is_new = LibraryRole.objects.get_or_create(name="Inspect Audit Log", description="Allows to run a query builder query on the Audit Log table.") - if is_new: - role.policies.get_or_create(resource="/table/spauditlog", action="read") - role.policies.get_or_create(resource="/table/spauditlogfield", action="read") - role.policies.get_or_create(resource="/querybuilder/query", action="execute") - - role, is_new = LibraryRole.objects.get_or_create(name="Manage Interactions", description="Grants full access to interactions tables.") - if is_new: - role.policies.get_or_create(resource="/table/appraisal", action="read") - role.policies.get_or_create(resource="/table/appraisal", action="create") - role.policies.get_or_create(resource="/table/appraisal", action="update") - role.policies.get_or_create(resource="/table/appraisal", action="delete") - role.policies.get_or_create(resource="/table/borrow", action="read") - role.policies.get_or_create(resource="/table/borrow", action="create") - role.policies.get_or_create(resource="/table/borrow", action="delete") - role.policies.get_or_create(resource="/table/borrow", action="update") - role.policies.get_or_create(resource="/table/borrowagent", action="read") - role.policies.get_or_create(resource="/table/borrowagent", action="create") - role.policies.get_or_create(resource="/table/borrowagent", action="update") - role.policies.get_or_create(resource="/table/borrowagent", action="delete") - role.policies.get_or_create(resource="/table/borrowmaterial", action="read") - role.policies.get_or_create(resource="/table/borrowmaterial", action="create") - role.policies.get_or_create(resource="/table/borrowmaterial", action="update") - role.policies.get_or_create(resource="/table/borrowmaterial", action="delete") - role.policies.get_or_create(resource="/table/borrowreturnmaterial", action="read") - role.policies.get_or_create(resource="/table/borrowreturnmaterial", action="create") - role.policies.get_or_create(resource="/table/borrowreturnmaterial", action="update") - role.policies.get_or_create(resource="/table/borrowreturnmaterial", action="delete") - role.policies.get_or_create(resource="/table/deaccession", action="read") - role.policies.get_or_create(resource="/table/deaccession", action="create") - role.policies.get_or_create(resource="/table/deaccession", action="update") - role.policies.get_or_create(resource="/table/deaccession", action="delete") - role.policies.get_or_create(resource="/table/deaccessionagent", action="read") - role.policies.get_or_create(resource="/table/deaccessionagent", action="create") - role.policies.get_or_create(resource="/table/deaccessionagent", action="update") - role.policies.get_or_create(resource="/table/deaccessionagent", action="delete") - role.policies.get_or_create(resource="/table/disposal", action="read") - role.policies.get_or_create(resource="/table/disposal", action="create") - role.policies.get_or_create(resource="/table/disposal", action="update") - role.policies.get_or_create(resource="/table/disposal", action="delete") - role.policies.get_or_create(resource="/table/disposalagent", action="read") - role.policies.get_or_create(resource="/table/disposalagent", action="create") - role.policies.get_or_create(resource="/table/disposalagent", action="update") - role.policies.get_or_create(resource="/table/disposalagent", action="delete") - role.policies.get_or_create(resource="/table/disposalpreparation", action="read") - role.policies.get_or_create(resource="/table/disposalpreparation", action="create") - role.policies.get_or_create(resource="/table/disposalpreparation", action="update") - role.policies.get_or_create(resource="/table/disposalpreparation", action="delete") - role.policies.get_or_create(resource="/table/exchangein", action="read") - role.policies.get_or_create(resource="/table/exchangein", action="create") - role.policies.get_or_create(resource="/table/exchangein", action="update") - role.policies.get_or_create(resource="/table/exchangein", action="delete") - role.policies.get_or_create(resource="/table/exchangeinprep", action="read") - role.policies.get_or_create(resource="/table/exchangeinprep", action="create") - role.policies.get_or_create(resource="/table/exchangeinprep", action="delete") - role.policies.get_or_create(resource="/table/exchangeinprep", action="update") - role.policies.get_or_create(resource="/table/exchangeout", action="read") - role.policies.get_or_create(resource="/table/exchangeout", action="update") - role.policies.get_or_create(resource="/table/exchangeout", action="delete") - role.policies.get_or_create(resource="/table/exchangeout", action="create") - role.policies.get_or_create(resource="/table/exchangeoutprep", action="read") - role.policies.get_or_create(resource="/table/exchangeoutprep", action="create") - role.policies.get_or_create(resource="/table/exchangeoutprep", action="update") - role.policies.get_or_create(resource="/table/exchangeoutprep", action="delete") - role.policies.get_or_create(resource="/table/gift", action="read") - role.policies.get_or_create(resource="/table/gift", action="create") - role.policies.get_or_create(resource="/table/gift", action="update") - role.policies.get_or_create(resource="/table/gift", action="delete") - role.policies.get_or_create(resource="/table/giftagent", action="read") - role.policies.get_or_create(resource="/table/giftagent", action="create") - role.policies.get_or_create(resource="/table/giftagent", action="update") - role.policies.get_or_create(resource="/table/giftagent", action="delete") - role.policies.get_or_create(resource="/table/giftpreparation", action="read") - role.policies.get_or_create(resource="/table/giftpreparation", action="update") - role.policies.get_or_create(resource="/table/giftpreparation", action="delete") - role.policies.get_or_create(resource="/table/giftpreparation", action="create") - role.policies.get_or_create(resource="/table/inforequest", action="read") - role.policies.get_or_create(resource="/table/inforequest", action="create") - role.policies.get_or_create(resource="/table/inforequest", action="update") - role.policies.get_or_create(resource="/table/inforequest", action="delete") - role.policies.get_or_create(resource="/table/loan", action="read") - role.policies.get_or_create(resource="/table/loan", action="create") - role.policies.get_or_create(resource="/table/loan", action="update") - role.policies.get_or_create(resource="/table/loan", action="delete") - role.policies.get_or_create(resource="/table/loanagent", action="read") - role.policies.get_or_create(resource="/table/loanagent", action="create") - role.policies.get_or_create(resource="/table/loanagent", action="update") - role.policies.get_or_create(resource="/table/loanagent", action="delete") - role.policies.get_or_create(resource="/table/loanpreparation", action="read") - role.policies.get_or_create(resource="/table/loanpreparation", action="create") - role.policies.get_or_create(resource="/table/loanpreparation", action="update") - role.policies.get_or_create(resource="/table/loanpreparation", action="delete") - role.policies.get_or_create(resource="/table/loanreturnpreparation", action="read") - role.policies.get_or_create(resource="/table/loanreturnpreparation", action="create") - role.policies.get_or_create(resource="/table/loanreturnpreparation", action="update") - role.policies.get_or_create(resource="/table/loanreturnpreparation", action="delete") - role.policies.get_or_create(resource="/table/permit", action="read") - role.policies.get_or_create(resource="/table/permit", action="create") - role.policies.get_or_create(resource="/table/permit", action="update") - role.policies.get_or_create(resource="/table/permit", action="delete") - role.policies.get_or_create(resource="/table/shipment", action="read") - role.policies.get_or_create(resource="/table/shipment", action="create") - role.policies.get_or_create(resource="/table/shipment", action="update") - role.policies.get_or_create(resource="/table/shipment", action="delete") - role.policies.get_or_create(resource="/table/borrowattachment", action="read") - role.policies.get_or_create(resource="/table/borrowattachment", action="create") - role.policies.get_or_create(resource="/table/borrowattachment", action="update") - role.policies.get_or_create(resource="/table/borrowattachment", action="delete") - role.policies.get_or_create(resource="/table/deaccessionattachment", action="read") - role.policies.get_or_create(resource="/table/deaccessionattachment", action="create") - role.policies.get_or_create(resource="/table/deaccessionattachment", action="update") - role.policies.get_or_create(resource="/table/deaccessionattachment", action="delete") - role.policies.get_or_create(resource="/table/disposalattachment", action="read") - role.policies.get_or_create(resource="/table/disposalattachment", action="create") - role.policies.get_or_create(resource="/table/disposalattachment", action="update") - role.policies.get_or_create(resource="/table/disposalattachment", action="delete") - role.policies.get_or_create(resource="/table/giftattachment", action="read") - role.policies.get_or_create(resource="/table/giftattachment", action="create") - role.policies.get_or_create(resource="/table/giftattachment", action="update") - role.policies.get_or_create(resource="/table/giftattachment", action="delete") - role.policies.get_or_create(resource="/table/loanattachment", action="create") - role.policies.get_or_create(resource="/table/loanattachment", action="update") - role.policies.get_or_create(resource="/table/loanattachment", action="delete") - role.policies.get_or_create(resource="/table/loanattachment", action="read") - role.policies.get_or_create(resource="/table/permitattachment", action="read") - role.policies.get_or_create(resource="/table/permitattachment", action="create") - role.policies.get_or_create(resource="/table/permitattachment", action="update") - role.policies.get_or_create(resource="/table/permitattachment", action="delete") - - role, is_new = LibraryRole.objects.get_or_create(name="Print Reports", description="Gives ability to execute reports from any table.") - if is_new: - role.policies.get_or_create(resource="/report", action="execute") - role.policies.get_or_create(resource="/table/%", action="read") - - role, is_new = LibraryRole.objects.get_or_create(name="Read-Only Access", description="Grants read access to all tables") - if is_new: - role.policies.get_or_create(resource="/table/%", action="read") - - role, is_new = LibraryRole.objects.get_or_create(name="Run Queries", description="Gives access to execute queries on any table, export query results and create record sets.") - if is_new: - role.policies.get_or_create(resource="/querybuilder/query", action="execute") - role.policies.get_or_create(resource="/querybuilder/query", action="export_csv") - role.policies.get_or_create(resource="/querybuilder/query", action="export_kml") - role.policies.get_or_create(resource="/querybuilder/query", action="create_recordset") - role.policies.get_or_create(resource="/table/spquery", action="read") - role.policies.get_or_create(resource="/table/spquery", action="create") - role.policies.get_or_create(resource="/table/spquery", action="update") - role.policies.get_or_create(resource="/table/spquery", action="delete") - role.policies.get_or_create(resource="/table/spqueryfield", action="read") - role.policies.get_or_create(resource="/table/spqueryfield", action="create") - role.policies.get_or_create(resource="/table/spqueryfield", action="update") - role.policies.get_or_create(resource="/table/spqueryfield", action="delete") - role.policies.get_or_create(resource="/table/recordset", action="read") - role.policies.get_or_create(resource="/table/recordset", action="create") - role.policies.get_or_create(resource="/table/recordset", action="update") - role.policies.get_or_create(resource="/table/recordset", action="delete") - role.policies.get_or_create(resource="/table/recordsetitem", action="read") - role.policies.get_or_create(resource="/table/recordsetitem", action="create") - role.policies.get_or_create(resource="/table/recordsetitem", action="update") - role.policies.get_or_create(resource="/table/recordsetitem", action="delete") - role.policies.get_or_create(resource="/table/%", action="read") - - role, is_new = LibraryRole.objects.get_or_create(name="Security Admin", description="Grants full access to security settings within a collection.") - if is_new: - role.policies.get_or_create(resource="/permissions/%", action="read") - role.policies.get_or_create(resource="/permissions/%", action="update") - role.policies.get_or_create(resource="/permissions/%", action="create") - role.policies.get_or_create(resource="/permissions/%", action="delete") - role.policies.get_or_create(resource="/permissions/%", action="copy_from_library") - role.policies.get_or_create(resource="/table/specifyuser", action="read") - role.policies.get_or_create(resource="/table/specifyuser", action="create") - role.policies.get_or_create(resource="/table/specifyuser", action="update") - role.policies.get_or_create(resource="/table/specifyuser", action="delete") - - - collection_admin, is_new = LibraryRole.objects.get_or_create( - name="Collection Admin", - description="Grants full access to all abilities within a collection.") - if is_new: - collection_admin.policies.get_or_create(resource="%", action="%") - - read_only, is_new = LibraryRole.objects.get_or_create( - name="Read Only - Legacy", - description="This is a legacy role that provides " +_INTERACTION_TABLES = ( + "appraisal", "inforequest", "permit", "shipment", + "borrow", "borrowagent", "borrowmaterial", "borrowreturnmaterial", + "deaccession", "deaccessionagent", + "disposal", "disposalagent", "disposalpreparation", + "exchangein", "exchangeinprep", + "exchangeout", "exchangeoutprep", + "gift", "giftagent", "giftpreparation", + "loan", "loanagent", "loanpreparation", "loanreturnpreparation", + "borrowattachment", "deaccessionattachment", "disposalattachment", + "giftattachment", "loanattachment", "permitattachment" +) + +LIBRARY_ROLES = { + "Assign Roles": { + "description": "Gives ability to assign existing roles to existing users.", + "policies": { + "/permissions/user/roles": ("read", "update"), + "/permissions/roles": ("read",) + } + }, + "Create Data Sets": { + "description": "Allows creating new Data Sets in the WorkBench, without ability to upload them.\n\nSuch user would create a Data Sets, map the columns, fix validation issues, and then transfer the Data Set to another user for review and upload.", + "policies": { + "/workbench/dataset": ("create", "update", "delete", "validate", "transfer") + } + }, + "Edit Forms and Global Preferences": { + "description": "Grants full access to resource editor. This allows editing form definitions and global Specify preferences.", + "policies": { + "/table/spappresource": ("read", "create", "update", "delete"), + "/table/spappresourcedata": ("read", "create", "update", "delete"), + "/table/spappresourcedir": ("read", "create", "update", "delete"), + "/table/spviewsetobj": ("read", "create", "update", "delete") + } + }, + "Edit Pick lists": { + "description": "Gives full access to modifying pick lists.", + "policies": { + "/table/picklist": ("read", "create", "update", "delete"), + "/table/picklistitem": ("read", "create", "update", "delete") + } + }, + "Edit Taxon Tree": { + "description": "Gives full access to the Taxon Tree.\n\nWarning: Taxon Tree may be shared between collections. Edits in one collection may affect another.", + "policies": { + "/tree/edit/taxon": ("merge", "move", "synonymize", "desynonymize", "repair"), + "/table/taxon": ("read", "create", "update", "delete"), + "/table/taxonattribute": ("read", "create", "update", "delete"), + "/table/taxoncitation": ("read", "create", "update", "delete"), + "/table/taxontreedef": ("read", "update"), + "/table/taxontreedefitem": ("read", "update"), + "/table/taxonattachment": ("read", "create", "update", "delete") + } + }, + "Export Data": { + "description": "Gives ability to export DwC Archive from any table.", + "policies": { + "/export/dwca": ("execute",), + "/table/%": ("read",) + } + }, + "Full Data Access": { + "description": "Grants read and edit access to all tables", + "policies": { + "/table/%": ("read", "create", "update", "delete") + } + }, + "Full WorkBench access": { + "description": "Gives full access to the WorkBench. Allows creating new records in any table.", + "policies": { + "/workbench/dataset": ("create", "update", "delete", "validate", "upload", "unupload", "transfer"), + "/table/%": ("read", "create") + } + }, + "Inspect Audit Log": { + "description": "Allows to run a query builder query on the Audit Log table.", + "policies": { + "/table/spauditlog": ("read",), + "/table/spauditlogfield": ("read",), + "/querybuilder/query": ("execute",) + } + }, + "Manage Interactions": { + "description": "Grants full access to interactions tables.", + "policies": { + f"/table/{interaction_table}": ("read", "create", "update", "delete") + for interaction_table in _INTERACTION_TABLES + } + }, + "Print Reports": { + "description": "Gives ability to execute reports from any table.", + "policies": { + "/report": ("execute",), + "/table/%": ("read",) + } + }, + "Read-Only Access": { + "description": "Grants read access to all tables", + "policies": { + "/table/%": ("read",) + } + }, + "Run Queries": { + "description": "Gives access to execute queries on any table, export query results and create record sets.", + "policies": { + "/querybuilder/query": ("execute", "export_csv", "export_kml", "create_recordset"), + "/table/spquery": ("read", "create", "update", "delete"), + "/table/spqueryfield": ("read", "create", "update", "delete"), + "/table/recordset": ("read", "create", "update", "delete"), + "/table/recordsetitem": ("read", "create", "update", "delete"), + "/table/%": ("read", ) + } + }, + "Security Admin": { + "description": "Grants full access to security settings within a collection.", + "policies": { + "/permissions/%": ("read", "create", "update", "delete", "copy_from_library"), + "/table/specifyuser": ("read", "create", "update", "delete") + } + }, + "Collection Admin": { + "description": "Grants full access to all abilities within a collection.", + "policies": { + "%": ("%",) + } + }, + "Read Only - Legacy": { + "description": "This is a legacy role that provides " "read only access and is assigned to user in the " "Limited Access and Guest groups from Specify 6. " "This is to maintain consistency with the permissions " - "granted these users in previous versions of Specify 7." - ) - if is_new: - read_only.policies.get_or_create(resource="/field/%", action="%") - read_only.policies.get_or_create(resource="/table/%", action="read") - - read_only.policies.get_or_create(resource="/querybuilder/%", action="%") - - full_access, is_new = LibraryRole.objects.get_or_create( - name='Full Access - Legacy', - description="This is a legacy role that provides " + "granted these users in previous versions of Specify 7.", + "policies": { + "/field/%": ("%",), + "/table/%": ("read",), + "/querybuilder/%": ("%",) + } + }, + "Full Access - Legacy": { + "description": "This is a legacy role that provides " "read write access to most Specify resources and " "is assigned to users in the Full Access group from Specify 6. " "This is to maintain consistency with the permissions " - "granted these users in previous versions of Specify 7." + "granted these users in previous versions of Specify 7.", + "policies": { + "/field/%": ("%",), + "/table/%": ("read",), + **{ + f"/table/{table.name.lower()}": ("%",) + for table in datamodel.tables + if not table.system or table.name.endswith("Attachment") + }, + "/table/picklist": ("%",), + "/table/picklistitem": ("%",), + "/table/recordset": ("%",), + "/table/recordsetitem": ("%",), + "/table/spquery": ("%",), + "/table/spqueryfield": ("%",), + "/tree/%": ("%",), + "/report": ("%",), + "/querybuilder/%": ("%",) + } + } +} + +def _create_role_and_policies(role_model, role_policy_model, role_name: str, role_filters: dict = dict()): + resolved_role = LIBRARY_ROLES[role_name] + role, is_new = role_model.objects.get_or_create( + name=role_name, + **role_filters, + defaults={ + "description": resolved_role["description"] + } + ) + if not is_new: + return role + + role_policy_model.objects.bulk_create( + [ + role_policy_model( + role=role, + resource=resource, + action=action + ) + for policy in resolved_role["policies"] + for resource, actions in policy.items() + for action in actions + ] ) - if is_new: - full_access.policies.get_or_create(resource="/field/%", action="%") - full_access.policies.get_or_create(resource="/table/%", action="read") - - for table in datamodel.tables: - if not table.system or table.name.endswith('Attachment'): - full_access.policies.get_or_create(resource=f"/table/{table.name.lower()}", action="%") - - full_access.policies.get_or_create(resource="/table/picklist", action="%") - full_access.policies.get_or_create(resource="/table/picklistitem", action="%") - full_access.policies.get_or_create(resource="/table/recordset", action="%") - full_access.policies.get_or_create(resource="/table/recordsetitem", action="%") +def create_missing_library_roles(apps = apps): + LibraryRole = apps.get_model('permissions', 'LibraryRole') + LibraryRolePolicy = apps.get_model('permissions', 'LibraryRolePolicy') + all_roles = set(LIBRARY_ROLES.keys()) + + existing_role_names = LibraryRole.objects.annotate( + name_lower=Lower("name") + ).filter( + name_lower__in=(role.lower() for role in all_roles) + ).values_list("name", flat=True) + + missing_roles = all_roles - set(existing_role_names) + for missing_role in missing_roles: + _create_role_and_policies( + LibraryRole, + LibraryRolePolicy, + missing_role + ) - full_access.policies.get_or_create(resource="/table/spquery", action="%") - full_access.policies.get_or_create(resource="/table/spqueryfield", action="%") - full_access.policies.get_or_create(resource="/tree/%", action="%") - full_access.policies.get_or_create(resource="/report", action="%") - full_access.policies.get_or_create(resource="/querybuilder/%", action="%") +def create_roles(apps = apps) -> None: + Role = apps.get_model('permissions', 'Role') + RolePolicy = apps.get_model('permissions', 'RolePolicy') + Collection = apps.get_model('specify', 'Collection') + Specifyuser = apps.get_model('specify', 'Specifyuser') + + create_missing_library_roles(apps) # copy the appropriate roles into the individual collections. - users = Specifyuser.objects.all() - user_types = {user.usertype for user in users} - - if 'Guest' in user_types or 'LimitedAccess' in user_types: - for collection in Collection.objects.all(): - r, is_new = Role.objects.get_or_create( - collection_id=collection.id, - name=read_only.name, - description=read_only.description, + user_types = Specifyuser.objects.all().values_list("usertype", flat=True).distinct() + + has_guest = 'Guest' in user_types or 'LimitedAccess' in user_types + has_full_access = 'FullAccess' in user_types + + for collection_id in Collection.objects.all().values_list("pk", flat=True): + if has_guest: + _create_role_and_policies( + Role, + RolePolicy, + _USERTYPES_TO_ROLE_NAMES.get('Guest', 'Read Only - Legacy'), + { + "collection_id": collection_id + } ) - if is_new: - for lp in read_only.policies.all(): - r.policies.get_or_create(resource=lp.resource, action=lp.action) - - if 'FullAccess' in user_types: - for collection in Collection.objects.all(): - r, is_new = Role.objects.get_or_create( - collection_id=collection.id, - name=full_access.name, - description=full_access.description, + if has_full_access: + _create_role_and_policies( + Role, + RolePolicy, + _USERTYPES_TO_ROLE_NAMES.get('FullAccess', 'Full Access - Legacy'), + { + "collection_id": collection_id + } ) - if is_new: - for lp in full_access.policies.all(): - r.policies.get_or_create(resource=lp.resource, action=lp.action) - - - for collection_id in Collection.objects.values_list('id', flat=True): - # Copy the collection admin role into the collection roles. - ca, is_new = Role.objects.get_or_create( - collection_id=collection_id, - name=collection_admin.name, - description=collection_admin.description, + _create_role_and_policies( + Role, + RolePolicy, + _USERTYPES_TO_ROLE_NAMES.get('Manager', 'Collection Admin'), + { + "collection_id": collection_id + } ) - if is_new: - for lp in collection_admin.policies.all(): - ca.policies.get_or_create(resource=lp.resource, action=lp.action) \ No newline at end of file From 18f87ced1d1c6d791b20c386f23fe7f5670ef2fb Mon Sep 17 00:00:00 2001 From: melton-jason Date: Sun, 2 Aug 2026 20:08:16 -0500 Subject: [PATCH 06/10] fix: handle case whne duplicate roles exist :( --- specifyweb/backend/permissions/initialize.py | 24 +++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/specifyweb/backend/permissions/initialize.py b/specifyweb/backend/permissions/initialize.py index dfcdf0e8425..0f410b82232 100644 --- a/specifyweb/backend/permissions/initialize.py +++ b/specifyweb/backend/permissions/initialize.py @@ -136,13 +136,25 @@ def assign_users_to_roles(apps=apps) -> None: role_name = _USERTYPES_TO_ROLE_NAMES.get(user_type, f"{user_type} - {collection_name}") role_description = ROLE_DESCRIPTIONS.get(user_type, "No description available.") - role, _ = Role.objects.get_or_create( + # BUG: Starting in v7.11.2 (e876cbe), duplicate roles could be created + # when calling run_key_migration_functions if the description for a + # default role had changed + # Once run_key_migration_functions was moved to container startup in + # v7.12.0 (8646b82), this had an even greater impact. + # This means after that if a user had modified the description of any + # default Role, there will already be a duplicate in their database... + role = Role.objects.filter( collection_id=collection_id, - name=role_name, - defaults={ - "description": role_description - } - ) + name=role_name + ).order_by("pk").first() + + if role is None: + role = Role.objects.create( + collection_id=collection_id, + name=role_name, + description=role_description + ) + # BUG: What if the user was intentionally removed from this role? # This would incorrectly re-add them :( UserRole.objects.get_or_create( From 383e28cf914124c0a12b8b05c364b331a1311385 Mon Sep 17 00:00:00 2001 From: melton-jason Date: Mon, 3 Aug 2026 09:59:45 -0500 Subject: [PATCH 07/10] fix: incorrect lookup for role policy bulk create (cherry picked from commit b738b91ad650ccb00b561cbce27694c6921c022c) --- specifyweb/backend/permissions/initialize.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/specifyweb/backend/permissions/initialize.py b/specifyweb/backend/permissions/initialize.py index 0f410b82232..d4238c37799 100644 --- a/specifyweb/backend/permissions/initialize.py +++ b/specifyweb/backend/permissions/initialize.py @@ -2,6 +2,7 @@ import logging from collections import defaultdict +from typing import TypedDict from django.db import transaction, connection from django.db.models.functions import Lower @@ -388,8 +389,12 @@ def assign_users_to_roles_during_testing(apps=apps) -> None: } } +class DefaultRole(TypedDict): + description: str + policies: dict[str, tuple[str, ...]] + def _create_role_and_policies(role_model, role_policy_model, role_name: str, role_filters: dict = dict()): - resolved_role = LIBRARY_ROLES[role_name] + resolved_role: DefaultRole = LIBRARY_ROLES[role_name] role, is_new = role_model.objects.get_or_create( name=role_name, **role_filters, @@ -407,8 +412,7 @@ def _create_role_and_policies(role_model, role_policy_model, role_name: str, rol resource=resource, action=action ) - for policy in resolved_role["policies"] - for resource, actions in policy.items() + for resource, actions in resolved_role["policies"].items() for action in actions ] ) From 905537ba7b19a760c246d84a56a315c1b62913ad Mon Sep 17 00:00:00 2001 From: melton-jason Date: Wed, 9 Sep 2026 09:16:18 -0500 Subject: [PATCH 08/10] chore: add propper Jest links --- .../__tests__/__snapshots__/AppResourceDownload.test.tsx.snap | 2 +- .../__tests__/__snapshots__/AppResourceEditButton.test.tsx.snap | 2 +- .../__tests__/__snapshots__/AppResourceLoad.test.tsx.snap | 2 +- .../__tests__/__snapshots__/AppResourcesAside.test.tsx.snap | 2 +- .../__tests__/__snapshots__/AppResourcesFilters.test.tsx.snap | 2 +- .../__tests__/__snapshots__/AppResourcesTab.test.tsx.snap | 2 +- .../AppResources/__tests__/__snapshots__/Tabs.test.tsx.snap | 2 +- .../__snapshots__/buildAppResourceConformation.test.ts.snap | 2 +- .../__tests__/__snapshots__/useResourcesTree.test.ts.snap | 2 +- .../Atoms/__tests__/__snapshots__/Button.test.tsx.snap | 2 +- .../Atoms/__tests__/__snapshots__/DataEntry.test.ts.snap | 2 +- .../components/Atoms/__tests__/__snapshots__/Form.test.tsx.snap | 2 +- .../components/Atoms/__tests__/__snapshots__/Link.test.ts.snap | 2 +- .../Atoms/__tests__/__snapshots__/Submit.test.ts.snap | 2 +- .../Atoms/__tests__/__snapshots__/index.test.tsx.snap | 2 +- .../__tests__/__snapshots__/AttachmentCell.test.tsx.snap | 2 +- .../__tests__/__snapshots__/ChronoChart.test.tsx.snap | 2 +- .../__tests__/__snapshots__/UploadAttachment.test.tsx.snap | 2 +- .../Attachments/__tests__/__snapshots__/utils.test.ts.snap | 2 +- .../__tests__/__snapshots__/utils.test.ts.snap | 2 +- .../DataModel/__tests__/__snapshots__/specifyTable.test.ts.snap | 2 +- .../FieldFormatters/__tests__/__snapshots__/index.test.ts.snap | 2 +- .../FormEditor/__tests__/__snapshots__/createView.test.ts.snap | 2 +- .../__tests__/__snapshots__/fetchAllViews.test.tsx.snap | 2 +- .../FormEditor/__tests__/__snapshots__/index.test.ts.snap | 2 +- .../FormEditor/__tests__/__snapshots__/viewSpec.test.ts.snap | 2 +- .../FormMeta/__tests__/__snapshots__/CarryForward.test.ts.snap | 2 +- .../__tests__/__snapshots__/PartialDateUi.test.tsx.snap | 2 +- .../Formatters/__tests__/__snapshots__/formatters.test.ts.snap | 2 +- .../__tests__/__snapshots__/remotePrefs.test.ts.snap | 2 +- .../__tests__/__snapshots__/treeRanks.test.ts.snap | 2 +- .../Merging/__tests__/__snapshots__/autoMerge.test.ts.snap | 2 +- .../QueryBuilder/__tests__/__snapshots__/fromTree.test.ts.snap | 2 +- .../__tests__/__snapshots__/useTypeSearch.test.ts.snap | 2 +- .../WebLinks/__tests__/__snapshots__/index.test.ts.snap | 2 +- .../js_src/lib/utils/__tests__/__snapshots__/fonts.test.ts.snap | 2 +- 36 files changed, 36 insertions(+), 36 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/__snapshots__/AppResourceDownload.test.tsx.snap b/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/__snapshots__/AppResourceDownload.test.tsx.snap index 292c15a107e..46751898bc3 100644 --- a/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/__snapshots__/AppResourceDownload.test.tsx.snap +++ b/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/__snapshots__/AppResourceDownload.test.tsx.snap @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[`AppResourceDownload simple render 1`] = ` diff --git a/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/__snapshots__/AppResourceEditButton.test.tsx.snap b/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/__snapshots__/AppResourceEditButton.test.tsx.snap index 4ca187a9c14..501bcf85b47 100644 --- a/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/__snapshots__/AppResourceEditButton.test.tsx.snap +++ b/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/__snapshots__/AppResourceEditButton.test.tsx.snap @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[`AppResourceEditButton simple render 1`] = ` diff --git a/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/__snapshots__/AppResourceLoad.test.tsx.snap b/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/__snapshots__/AppResourceLoad.test.tsx.snap index 19648bbfc44..c1ef6e24b60 100644 --- a/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/__snapshots__/AppResourceLoad.test.tsx.snap +++ b/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/__snapshots__/AppResourceLoad.test.tsx.snap @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[`AppResourceLoad simple render 1`] = ` diff --git a/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/__snapshots__/AppResourcesAside.test.tsx.snap b/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/__snapshots__/AppResourcesAside.test.tsx.snap index 04acc08c687..ae2678bdc88 100644 --- a/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/__snapshots__/AppResourcesAside.test.tsx.snap +++ b/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/__snapshots__/AppResourcesAside.test.tsx.snap @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[`AppResourcesAside (expanded case) expanded case 1`] = ` diff --git a/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/__snapshots__/AppResourcesFilters.test.tsx.snap b/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/__snapshots__/AppResourcesFilters.test.tsx.snap index cb54620dd9f..c1e27f76c83 100644 --- a/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/__snapshots__/AppResourcesFilters.test.tsx.snap +++ b/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/__snapshots__/AppResourcesFilters.test.tsx.snap @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[`AppResourcesFilters simple render 1`] = ` diff --git a/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/__snapshots__/AppResourcesTab.test.tsx.snap b/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/__snapshots__/AppResourcesTab.test.tsx.snap index 2b59062a1df..ec02fe71b3a 100644 --- a/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/__snapshots__/AppResourcesTab.test.tsx.snap +++ b/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/__snapshots__/AppResourcesTab.test.tsx.snap @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[`AppResourcesTab dialog render 1`] = `
diff --git a/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/__snapshots__/buildAppResourceConformation.test.ts.snap b/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/__snapshots__/buildAppResourceConformation.test.ts.snap index e5f1b432813..cd1ef64c3e8 100644 --- a/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/__snapshots__/buildAppResourceConformation.test.ts.snap +++ b/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/__snapshots__/buildAppResourceConformation.test.ts.snap @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[`buildAppResourceConformation multi-level completely empty tree 1`] = ` [ diff --git a/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/__snapshots__/useResourcesTree.test.ts.snap b/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/__snapshots__/useResourcesTree.test.ts.snap index 22f837bf573..d0991deb267 100644 --- a/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/__snapshots__/useResourcesTree.test.ts.snap +++ b/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/__snapshots__/useResourcesTree.test.ts.snap @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[`useResourcesTree all appresource dir 1`] = ` [ diff --git a/specifyweb/frontend/js_src/lib/components/Atoms/__tests__/__snapshots__/Button.test.tsx.snap b/specifyweb/frontend/js_src/lib/components/Atoms/__tests__/__snapshots__/Button.test.tsx.snap index c0a3a8aed1d..e89d2dfdfec 100644 --- a/specifyweb/frontend/js_src/lib/components/Atoms/__tests__/__snapshots__/Button.test.tsx.snap +++ b/specifyweb/frontend/js_src/lib/components/Atoms/__tests__/__snapshots__/Button.test.tsx.snap @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[`Button.BorderedGray renders without errors 1`] = ` diff --git a/specifyweb/frontend/js_src/lib/components/Atoms/__tests__/__snapshots__/DataEntry.test.ts.snap b/specifyweb/frontend/js_src/lib/components/Atoms/__tests__/__snapshots__/DataEntry.test.ts.snap index 1d35add6612..2724a1b1932 100644 --- a/specifyweb/frontend/js_src/lib/components/Atoms/__tests__/__snapshots__/DataEntry.test.ts.snap +++ b/specifyweb/frontend/js_src/lib/components/Atoms/__tests__/__snapshots__/DataEntry.test.ts.snap @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[` renders without errors 1`] = ` diff --git a/specifyweb/frontend/js_src/lib/components/Atoms/__tests__/__snapshots__/Form.test.tsx.snap b/specifyweb/frontend/js_src/lib/components/Atoms/__tests__/__snapshots__/Form.test.tsx.snap index f3058a02cbf..0f7e95583a1 100644 --- a/specifyweb/frontend/js_src/lib/components/Atoms/__tests__/__snapshots__/Form.test.tsx.snap +++ b/specifyweb/frontend/js_src/lib/components/Atoms/__tests__/__snapshots__/Form.test.tsx.snap @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[`
removes notSubmitted class name on submit 1`] = ` diff --git a/specifyweb/frontend/js_src/lib/components/Atoms/__tests__/__snapshots__/Link.test.ts.snap b/specifyweb/frontend/js_src/lib/components/Atoms/__tests__/__snapshots__/Link.test.ts.snap index b39a77c83d8..01cd1d18170 100644 --- a/specifyweb/frontend/js_src/lib/components/Atoms/__tests__/__snapshots__/Link.test.ts.snap +++ b/specifyweb/frontend/js_src/lib/components/Atoms/__tests__/__snapshots__/Link.test.ts.snap @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[`Link.BorderedGray renders without errors 1`] = ` diff --git a/specifyweb/frontend/js_src/lib/components/Atoms/__tests__/__snapshots__/Submit.test.ts.snap b/specifyweb/frontend/js_src/lib/components/Atoms/__tests__/__snapshots__/Submit.test.ts.snap index 4a36eb44633..38ac70e7b28 100644 --- a/specifyweb/frontend/js_src/lib/components/Atoms/__tests__/__snapshots__/Submit.test.ts.snap +++ b/specifyweb/frontend/js_src/lib/components/Atoms/__tests__/__snapshots__/Submit.test.ts.snap @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[`Submit.Danger renders without errors 1`] = ` diff --git a/specifyweb/frontend/js_src/lib/components/Atoms/__tests__/__snapshots__/index.test.tsx.snap b/specifyweb/frontend/js_src/lib/components/Atoms/__tests__/__snapshots__/index.test.tsx.snap index b8b75efd116..9aab22b6984 100644 --- a/specifyweb/frontend/js_src/lib/components/Atoms/__tests__/__snapshots__/index.test.tsx.snap +++ b/specifyweb/frontend/js_src/lib/components/Atoms/__tests__/__snapshots__/index.test.tsx.snap @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[`Can use as a controlled component 1`] = ` diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/__tests__/__snapshots__/AttachmentCell.test.tsx.snap b/specifyweb/frontend/js_src/lib/components/Attachments/__tests__/__snapshots__/AttachmentCell.test.tsx.snap index 01111a2da05..39a988c47eb 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/__tests__/__snapshots__/AttachmentCell.test.tsx.snap +++ b/specifyweb/frontend/js_src/lib/components/Attachments/__tests__/__snapshots__/AttachmentCell.test.tsx.snap @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[`AttachmentCell simple render 1`] = ` diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/__tests__/__snapshots__/ChronoChart.test.tsx.snap b/specifyweb/frontend/js_src/lib/components/Attachments/__tests__/__snapshots__/ChronoChart.test.tsx.snap index a3c20bb31a1..c3e8ec312b6 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/__tests__/__snapshots__/ChronoChart.test.tsx.snap +++ b/specifyweb/frontend/js_src/lib/components/Attachments/__tests__/__snapshots__/ChronoChart.test.tsx.snap @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[`ChronoChart dialog open and close 1`] = `
diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/__tests__/__snapshots__/utils.test.ts.snap b/specifyweb/frontend/js_src/lib/components/Attachments/__tests__/__snapshots__/utils.test.ts.snap index a60a2eb0d03..206c6fdda2c 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/__tests__/__snapshots__/utils.test.ts.snap +++ b/specifyweb/frontend/js_src/lib/components/Attachments/__tests__/__snapshots__/utils.test.ts.snap @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[`allTablesWithAttachments 1`] = ` [ diff --git a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/__tests__/__snapshots__/utils.test.ts.snap b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/__tests__/__snapshots__/utils.test.ts.snap index 6d12772ce10..2a4a271fe67 100644 --- a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/__tests__/__snapshots__/utils.test.ts.snap +++ b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/__tests__/__snapshots__/utils.test.ts.snap @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[`reconstruct deleting attachment spec 1`] = ` [ diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/__snapshots__/specifyTable.test.ts.snap b/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/__snapshots__/specifyTable.test.ts.snap index fd73e1fe5c9..4425fd98366 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/__snapshots__/specifyTable.test.ts.snap +++ b/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/__snapshots__/specifyTable.test.ts.snap @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[`fields are loaded 1`] = ` [ diff --git a/specifyweb/frontend/js_src/lib/components/FieldFormatters/__tests__/__snapshots__/index.test.ts.snap b/specifyweb/frontend/js_src/lib/components/FieldFormatters/__tests__/__snapshots__/index.test.ts.snap index c823d7dba47..13662ae86ec 100644 --- a/specifyweb/frontend/js_src/lib/components/FieldFormatters/__tests__/__snapshots__/index.test.ts.snap +++ b/specifyweb/frontend/js_src/lib/components/FieldFormatters/__tests__/__snapshots__/index.test.ts.snap @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[`field formatters are fetched and parsed correctly 1`] = ` { diff --git a/specifyweb/frontend/js_src/lib/components/FormEditor/__tests__/__snapshots__/createView.test.ts.snap b/specifyweb/frontend/js_src/lib/components/FormEditor/__tests__/__snapshots__/createView.test.ts.snap index 4ced007cfc9..d91863bbc5f 100644 --- a/specifyweb/frontend/js_src/lib/components/FormEditor/__tests__/__snapshots__/createView.test.ts.snap +++ b/specifyweb/frontend/js_src/lib/components/FormEditor/__tests__/__snapshots__/createView.test.ts.snap @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[`Tables with form tables computed correctly 1`] = ` [ diff --git a/specifyweb/frontend/js_src/lib/components/FormEditor/__tests__/__snapshots__/fetchAllViews.test.tsx.snap b/specifyweb/frontend/js_src/lib/components/FormEditor/__tests__/__snapshots__/fetchAllViews.test.tsx.snap index ab1ce534d9b..98208a49a69 100644 --- a/specifyweb/frontend/js_src/lib/components/FormEditor/__tests__/__snapshots__/fetchAllViews.test.tsx.snap +++ b/specifyweb/frontend/js_src/lib/components/FormEditor/__tests__/__snapshots__/fetchAllViews.test.tsx.snap @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[`fetchAllViews 1`] = ` { diff --git a/specifyweb/frontend/js_src/lib/components/FormEditor/__tests__/__snapshots__/index.test.ts.snap b/specifyweb/frontend/js_src/lib/components/FormEditor/__tests__/__snapshots__/index.test.ts.snap index 1e7ca28896a..2ebe6233144 100644 --- a/specifyweb/frontend/js_src/lib/components/FormEditor/__tests__/__snapshots__/index.test.ts.snap +++ b/specifyweb/frontend/js_src/lib/components/FormEditor/__tests__/__snapshots__/index.test.ts.snap @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[`Can edit a form definition 1`] = ` { diff --git a/specifyweb/frontend/js_src/lib/components/FormEditor/__tests__/__snapshots__/viewSpec.test.ts.snap b/specifyweb/frontend/js_src/lib/components/FormEditor/__tests__/__snapshots__/viewSpec.test.ts.snap index 02fdefd49aa..56159e20d5c 100644 --- a/specifyweb/frontend/js_src/lib/components/FormEditor/__tests__/__snapshots__/viewSpec.test.ts.snap +++ b/specifyweb/frontend/js_src/lib/components/FormEditor/__tests__/__snapshots__/viewSpec.test.ts.snap @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[`Can edit form definition 1`] = ` " diff --git a/specifyweb/frontend/js_src/lib/components/FormMeta/__tests__/__snapshots__/CarryForward.test.ts.snap b/specifyweb/frontend/js_src/lib/components/FormMeta/__tests__/__snapshots__/CarryForward.test.ts.snap index 5e4e6f21c14..ee37d431ac5 100644 --- a/specifyweb/frontend/js_src/lib/components/FormMeta/__tests__/__snapshots__/CarryForward.test.ts.snap +++ b/specifyweb/frontend/js_src/lib/components/FormMeta/__tests__/__snapshots__/CarryForward.test.ts.snap @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[`dependentFields 1`] = ` { diff --git a/specifyweb/frontend/js_src/lib/components/FormPlugins/__tests__/__snapshots__/PartialDateUi.test.tsx.snap b/specifyweb/frontend/js_src/lib/components/FormPlugins/__tests__/__snapshots__/PartialDateUi.test.tsx.snap index d2930538113..fb4094c85f1 100644 --- a/specifyweb/frontend/js_src/lib/components/FormPlugins/__tests__/__snapshots__/PartialDateUi.test.tsx.snap +++ b/specifyweb/frontend/js_src/lib/components/FormPlugins/__tests__/__snapshots__/PartialDateUi.test.tsx.snap @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[`PartialDateUi renders without errors 1`] = ` diff --git a/specifyweb/frontend/js_src/lib/components/Formatters/__tests__/__snapshots__/formatters.test.ts.snap b/specifyweb/frontend/js_src/lib/components/Formatters/__tests__/__snapshots__/formatters.test.ts.snap index 33999118207..dea1513e2c1 100644 --- a/specifyweb/frontend/js_src/lib/components/Formatters/__tests__/__snapshots__/formatters.test.ts.snap +++ b/specifyweb/frontend/js_src/lib/components/Formatters/__tests__/__snapshots__/formatters.test.ts.snap @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[`Formatters are fetched and parsed correctly 1`] = ` { diff --git a/specifyweb/frontend/js_src/lib/components/InitialContext/__tests__/__snapshots__/remotePrefs.test.ts.snap b/specifyweb/frontend/js_src/lib/components/InitialContext/__tests__/__snapshots__/remotePrefs.test.ts.snap index 8e56db1c7c4..a145bc62b30 100644 --- a/specifyweb/frontend/js_src/lib/components/InitialContext/__tests__/__snapshots__/remotePrefs.test.ts.snap +++ b/specifyweb/frontend/js_src/lib/components/InitialContext/__tests__/__snapshots__/remotePrefs.test.ts.snap @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[`fetches and parses remotePrefs correctly 1`] = ` { diff --git a/specifyweb/frontend/js_src/lib/components/InitialContext/__tests__/__snapshots__/treeRanks.test.ts.snap b/specifyweb/frontend/js_src/lib/components/InitialContext/__tests__/__snapshots__/treeRanks.test.ts.snap index def76761ae1..0088f4403af 100644 --- a/specifyweb/frontend/js_src/lib/components/InitialContext/__tests__/__snapshots__/treeRanks.test.ts.snap +++ b/specifyweb/frontend/js_src/lib/components/InitialContext/__tests__/__snapshots__/treeRanks.test.ts.snap @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[`Get tree definition for the Geography tree 1`] = ` [ diff --git a/specifyweb/frontend/js_src/lib/components/Merging/__tests__/__snapshots__/autoMerge.test.ts.snap b/specifyweb/frontend/js_src/lib/components/Merging/__tests__/__snapshots__/autoMerge.test.ts.snap index 6c6d6979017..f14891e48d1 100644 --- a/specifyweb/frontend/js_src/lib/components/Merging/__tests__/__snapshots__/autoMerge.test.ts.snap +++ b/specifyweb/frontend/js_src/lib/components/Merging/__tests__/__snapshots__/autoMerge.test.ts.snap @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[`autoMerge cautious 1`] = ` { diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/__tests__/__snapshots__/fromTree.test.ts.snap b/specifyweb/frontend/js_src/lib/components/QueryBuilder/__tests__/__snapshots__/fromTree.test.ts.snap index ce5558cd8c2..04dc1e16698 100644 --- a/specifyweb/frontend/js_src/lib/components/QueryBuilder/__tests__/__snapshots__/fromTree.test.ts.snap +++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/__tests__/__snapshots__/fromTree.test.ts.snap @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[`queryFromTree 1`] = ` [ diff --git a/specifyweb/frontend/js_src/lib/components/QueryComboBox/__tests__/__snapshots__/useTypeSearch.test.ts.snap b/specifyweb/frontend/js_src/lib/components/QueryComboBox/__tests__/__snapshots__/useTypeSearch.test.ts.snap index 1aaa7712679..075e54a269e 100644 --- a/specifyweb/frontend/js_src/lib/components/QueryComboBox/__tests__/__snapshots__/useTypeSearch.test.ts.snap +++ b/specifyweb/frontend/js_src/lib/components/QueryComboBox/__tests__/__snapshots__/useTypeSearch.test.ts.snap @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[`type searches are fetched and parsed correctly 1`] = ` [ diff --git a/specifyweb/frontend/js_src/lib/components/WebLinks/__tests__/__snapshots__/index.test.ts.snap b/specifyweb/frontend/js_src/lib/components/WebLinks/__tests__/__snapshots__/index.test.ts.snap index 75ae2c01278..4a044a71656 100644 --- a/specifyweb/frontend/js_src/lib/components/WebLinks/__tests__/__snapshots__/index.test.ts.snap +++ b/specifyweb/frontend/js_src/lib/components/WebLinks/__tests__/__snapshots__/index.test.ts.snap @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[`weblinks are fetched and parsed correctly 1`] = ` { diff --git a/specifyweb/frontend/js_src/lib/utils/__tests__/__snapshots__/fonts.test.ts.snap b/specifyweb/frontend/js_src/lib/utils/__tests__/__snapshots__/fonts.test.ts.snap index a0f85639adf..eb8cb86fba0 100644 --- a/specifyweb/frontend/js_src/lib/utils/__tests__/__snapshots__/fonts.test.ts.snap +++ b/specifyweb/frontend/js_src/lib/utils/__tests__/__snapshots__/fonts.test.ts.snap @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[`Unavailable fonts are filterd out 1`] = ` [ From 3c6eea450a0d64acde19574d66f0bdd5ed4731c1 Mon Sep 17 00:00:00 2001 From: melton-jason Date: Wed, 9 Sep 2026 11:47:17 -0500 Subject: [PATCH 09/10] feat: generalize QueryBuilder result hook --- .../{QueryBuilder => FormSliders}/hooks.tsx | 105 ++++++++++++------ .../lib/components/QueryBuilder/Results.tsx | 4 +- .../lib/components/SpecifyNetwork/Map.tsx | 4 +- specifyweb/frontend/js_src/lib/utils/utils.ts | 2 +- specifyweb/specify/api/crud.py | 3 - specifyweb/specify/models.py | 1 - 6 files changed, 76 insertions(+), 43 deletions(-) rename specifyweb/frontend/js_src/lib/components/{QueryBuilder => FormSliders}/hooks.tsx (58%) diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/hooks.tsx b/specifyweb/frontend/js_src/lib/components/FormSliders/hooks.tsx similarity index 58% rename from specifyweb/frontend/js_src/lib/components/QueryBuilder/hooks.tsx rename to specifyweb/frontend/js_src/lib/components/FormSliders/hooks.tsx index 5877a45cc6b..b5be6940a3b 100644 --- a/specifyweb/frontend/js_src/lib/components/QueryBuilder/hooks.tsx +++ b/specifyweb/frontend/js_src/lib/components/FormSliders/hooks.tsx @@ -1,25 +1,70 @@ import React from 'react'; - import { useTriggerState } from '../../hooks/useTriggerState'; -import type { GetOrSet, IR, R, RA } from '../../utils/types'; +import { GetOrSet, R, RA } from '../../utils/types'; import { removeKey } from '../../utils/utils'; +import { DEFAULT_FETCH_LIMIT } from '../DataModel/collection'; import { raise, softFail } from '../Errors/Crash'; -import type { QueryResultRow, QueryResultsProps } from './Results'; -export function useFetchQueryResults({ +/** + * Provides a way to somewhat lazily paginate through an arbitary set of + * records. + * Pseudocode Example: + * ```ts + * // Say we have some record IDs that can be in some set of arbitary size, and + * // we want to handle pagination within that set. + * const initialIds = [1, 2, 3]; + * const totalCount = 200; + * // The "fetch" function fetches a page of records at some offset up to some + * // limit + * const fetchMoreIds = (offset: number) => fetch("url", {offset, limit: 20}); + * const { + * results: [results, setResults], + * onFetchMore: handleFetchMore, + * totalCount: [totalCount, setTotalCount], + * canFetchMore + * } = usePaginatedRecords({ + * initialData: initialIds, + * totalCount, + * fetchSize: 3, + * fetchResults: fetchMoreIds + * }); + * + * // We can call handleFetchMore to automatically grab the next fetchSize + * // records + * const fetchedNext = await handleFetchMore(); + * console.log(fetchedNext); // [4, 5, 6] + * // Now results will have the next fetchSize results + * console.log(results); // [1, 2, 3, 4, 5, 6] + * // We can pass in a specific index to fetch only from that index + * const fetchFromFar = await handleFetchMore(100); + * console.log(fetchFromFar); // [99, 100, 101] + * // Note that the results array can be sparse, with holes at the indexes + * // where results have not been fetched + * console.log(results); + * // [1, 2, 3, 4, 5, 6, , 99, 100, 101] + * ``` + */ +export function usePaginatedRecords< + PAGINATED_TYPE, + FETCH_ARGS extends RA, +>({ initialData, - fetchResults, totalCount: initialTotalCount, - fetchSize, -}: Pick< - QueryResultsProps, - 'fetchResults' | 'fetchSize' | 'initialData' | 'totalCount' ->): { - readonly results: GetOrSet | undefined>; - readonly fetchersRef: { - readonly current: IR | void>>; - }; - readonly onFetchMore: (index?: number) => Promise | void>; + fetchSize = DEFAULT_FETCH_LIMIT, + fetchResults, +}: { + readonly initialData: RA | undefined; + readonly totalCount: number | undefined; + readonly fetchSize?: number; + readonly fetchResults: + | ((offset: number, ...args: FETCH_ARGS) => Promise>) + | undefined; +}): { + readonly results: GetOrSet | undefined>; + readonly onFetchMore: ( + index?: number, + ...args: FETCH_ARGS + ) => Promise | void>; readonly totalCount: GetOrSet; readonly canFetchMore: boolean; } { @@ -30,34 +75,27 @@ export function useFetchQueryResults({ * hundreds of thousands of results. */ const getSetResults = useTriggerState< - RA | undefined + RA | undefined >(initialData); const [results, setResults] = getSetResults; const resultsRef = React.useRef(results); - const handleSetResults: GetOrSet< - RA | undefined - >[1] = React.useCallback( - (results) => { - const resolved = - typeof results === 'function' ? results(resultsRef.current) : results; - setResults(resolved); - resultsRef.current = resolved; - }, - [setResults] - ); // Queue for fetching - const fetchersRef = React.useRef | void>>>({}); + const fetchersRef = React.useRef | void>>>({}); const getSetTotalCount = useTriggerState(initialTotalCount); const [totalCount] = getSetTotalCount; + const canFetchMore = !Array.isArray(results) || totalCount === undefined || results.length < totalCount; const handleFetchMore = React.useCallback( - async (index?: number): Promise | void> => { + async ( + index?: number, + ...args: FETCH_ARGS + ): Promise | void> => { const currentResults = resultsRef.current; const canFetch = Array.isArray(currentResults); @@ -87,7 +125,7 @@ export function useFetchQueryResults({ : naiveFetchIndex; // Prevent concurrent fetching in different places - fetchersRef.current[fetchIndex] ??= fetchResults(fetchIndex) + fetchersRef.current[fetchIndex] ??= fetchResults(fetchIndex, ...args) .then(async (newResults) => { if ( process.env.NODE_ENV === 'development' && @@ -111,7 +149,7 @@ export function useFetchQueryResults({ combinedResults[fetchIndex] ??= undefined; combinedResults.splice(fetchIndex, newResults.length, ...newResults); - handleSetResults(combinedResults); + setResults(combinedResults); fetchersRef.current = removeKey( fetchersRef.current, @@ -119,7 +157,7 @@ export function useFetchQueryResults({ ); if (typeof index === 'number' && index >= combinedResults.length) - return handleFetchMore(index); + return handleFetchMore(index, ...args); return newResults; }) .catch(raise); @@ -130,8 +168,7 @@ export function useFetchQueryResults({ ); return { - fetchersRef, - results: [results, handleSetResults], + results: [results, setResults], onFetchMore: handleFetchMore, totalCount: getSetTotalCount, canFetchMore, diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Results.tsx b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Results.tsx index b9153b0697d..cf6eeb3c5e9 100644 --- a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Results.tsx +++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Results.tsx @@ -13,6 +13,7 @@ import type { SpecifyResource } from '../DataModel/legacyTypes'; import { schema } from '../DataModel/schema'; import type { SpecifyTable } from '../DataModel/specifyTable'; import type { SpQuery } from '../DataModel/types'; +import { usePaginatedRecords } from '../FormSliders/hooks'; import { treeRanksPromise } from '../InitialContext/treeRanks'; import { RecordMergingLink } from '../Merging'; import { loadingGif } from '../Molecules'; @@ -31,7 +32,6 @@ import { CreateRecordSet } from './CreateRecordSet'; import type { QueryFieldSpec } from './fieldSpec'; import type { QueryField } from './helpers'; import { sortTypes } from './helpers'; -import { useFetchQueryResults } from './hooks'; import { QueryResultsTable } from './ResultsTable'; import { QueryToForms } from './ToForms'; import { QueryToMap } from './ToMap'; @@ -99,7 +99,7 @@ export function QueryResults(props: QueryResultsProps): JSX.Element { onFetchMore: handleFetchMore, totalCount: [totalCount, setTotalCount], canFetchMore, - } = useFetchQueryResults(props); + } = usePaginatedRecords(props); const canMergeTable = canMerge(table); diff --git a/specifyweb/frontend/js_src/lib/components/SpecifyNetwork/Map.tsx b/specifyweb/frontend/js_src/lib/components/SpecifyNetwork/Map.tsx index 5dc4454f599..4217eb75e6f 100644 --- a/specifyweb/frontend/js_src/lib/components/SpecifyNetwork/Map.tsx +++ b/specifyweb/frontend/js_src/lib/components/SpecifyNetwork/Map.tsx @@ -13,12 +13,12 @@ import type { SerializedResource } from '../DataModel/helperTypes'; import type { SpecifyResource } from '../DataModel/legacyTypes'; import { genericTables, getTableById, tables } from '../DataModel/tables'; import type { SpQuery, Tables } from '../DataModel/types'; +import { usePaginatedRecords } from '../FormSliders/hooks'; import type { LeafletInstance } from '../Leaflet/addOns'; import { LoadingScreen } from '../Molecules/Dialog'; import { queryFromTree } from '../QueryBuilder/fromTree'; import type { QueryField } from '../QueryBuilder/helpers'; import { parseQueryFields } from '../QueryBuilder/helpers'; -import { useFetchQueryResults } from '../QueryBuilder/hooks'; import type { QueryResultRow } from '../QueryBuilder/Results'; import { useQueryResultsWrapper } from '../QueryBuilder/ResultsWrapper'; import { @@ -144,7 +144,7 @@ function Map({ results: [results], canFetchMore, onFetchMore: handleFetchMore, - } = useFetchQueryResults(props); + } = usePaginatedRecords(props); const undefinedResult = results?.indexOf(undefined); const loadedResults = ( diff --git a/specifyweb/frontend/js_src/lib/utils/utils.ts b/specifyweb/frontend/js_src/lib/utils/utils.ts index 39f1f86830a..437d3a6d924 100644 --- a/specifyweb/frontend/js_src/lib/utils/utils.ts +++ b/specifyweb/frontend/js_src/lib/utils/utils.ts @@ -217,7 +217,7 @@ export const split = ( * Convert an array of [key,value] tuples to a RA<[key, RA]> * * @remarks - * KEY doesn't have to be a string. It can be of any time + * KEY doesn't have to be a string. It can be of any type */ export const group = ( entries: RA diff --git a/specifyweb/specify/api/crud.py b/specifyweb/specify/api/crud.py index a20cdc0abe2..086a44b5e15 100644 --- a/specifyweb/specify/api/crud.py +++ b/specifyweb/specify/api/crud.py @@ -1,6 +1,3 @@ - - - import logging from typing import Any, Dict from collections.abc import Callable diff --git a/specifyweb/specify/models.py b/specifyweb/specify/models.py index 075ff8c174f..c8da16cd41f 100644 --- a/specifyweb/specify/models.py +++ b/specifyweb/specify/models.py @@ -1,6 +1,5 @@ from functools import partialmethod from django.db import models -from django.db.models import Q, CheckConstraint from django.utils import timezone from specifyweb.backend.businessrules.exceptions import AbortSave from specifyweb.specify.models_utils.model_timestamp import save_auto_timestamp_field_with_override From 6357f669c362e1a5f6dbcf2e4e6fb1f3135f44b3 Mon Sep 17 00:00:00 2001 From: melton-jason Date: Wed, 9 Sep 2026 12:01:55 -0500 Subject: [PATCH 10/10] chore: fix offset in hook comment --- .../frontend/js_src/lib/components/FormSliders/hooks.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/FormSliders/hooks.tsx b/specifyweb/frontend/js_src/lib/components/FormSliders/hooks.tsx index b5be6940a3b..e6516f0cd4c 100644 --- a/specifyweb/frontend/js_src/lib/components/FormSliders/hooks.tsx +++ b/specifyweb/frontend/js_src/lib/components/FormSliders/hooks.tsx @@ -36,12 +36,12 @@ import { raise, softFail } from '../Errors/Crash'; * // Now results will have the next fetchSize results * console.log(results); // [1, 2, 3, 4, 5, 6] * // We can pass in a specific index to fetch only from that index - * const fetchFromFar = await handleFetchMore(100); - * console.log(fetchFromFar); // [99, 100, 101] + * const fetchFromFar = await handleFetchMore(99); + * console.log(fetchFromFar); // [100, 101, 102] * // Note that the results array can be sparse, with holes at the indexes * // where results have not been fetched * console.log(results); - * // [1, 2, 3, 4, 5, 6, , 99, 100, 101] + * // [1, 2, 3, 4, 5, 6, , 100, 101, 102] * ``` */ export function usePaginatedRecords<