From 4019815f25a7c7f16791081db589b9b71b2329ff Mon Sep 17 00:00:00 2001 From: melton-jason Date: Sun, 28 Jun 2026 22:19:42 -0500 Subject: [PATCH 1/7] 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 2/7] 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 3/7] 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 4/7] 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 5/7] 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 6/7] 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 7/7] 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 ] )