Skip to content
Draft
23 changes: 0 additions & 23 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,29 +4,6 @@ All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

## [7.12.0.6](https://github.com/specify/specify7/compare/v7.12.0.5...v7.12.0.6) (21 May 2026)

* Fix aggregated relation parsing for stored queries ([#8059](https://github.com/specify/specify7/pull/8059))
* Added context-scoped caching across bulk creation, uploads, permission lookups, uniqueness rules, and remote/global preferences to reduce repeated queries to improve database performance ([#8057](https://github.com/specify/specify7/pull/8057))
* Refined catalog-number uniqueness checks to be more accurate and collection-scoped.
* Made uniqueness-rule evaluation and permission checks more efficient and cache-aware.
* Added and expanded tests covering uniqueness, caching behavior, and permissions.
* Enabled searching shared trees in the tree viewer ([#8104](https://github.com/specify/specify7/pull/8104))

## [7.12.0.5](https://github.com/specify/specify7/compare/v7.12.0...v7.12.0.5) (29 April 2026)

* Fixes an issue where WorkBench Schema records would be preserved instead of standard Schema Config records when deduplicating ([#7989](https://github.com/specify/specify7/pull/7989))
* Prevents schema container duplication when initializing the database and removes existing duplicates
* Added a 'schema repair' utility for adding missing schema config records
* Fixes Redis key collision so that keys are now automatically qualified by database name ([#7761](https://github.com/specify/specify7/pull/7761))
* Fixes a bug that prevented partial dates from appearing in query results in some cases ([#7970](https://github.com/specify/specify7/pull/7970))
* Fixes a bug preventing the use of the 'negate' operator on tree query fields ([#7986](https://github.com/specify/specify7/pull/7986))
* Fixes an issue that resulted in overly complex queries on the back-end ([#7981](https://github.com/specify/specify7/pull/7981))
* Fixes an issue where app resources are hidden for users that do not have permission to edit Collection Preferences ([#7990](https://github.com/specify/specify7/pull/7990))
* Fixes cascaded delete blocker issues preventing proper deletion of Disciplines associated with Divisions. Improves delete blocker handling for complex relationships, reducing cases where valid deletions were incorrectly prevented ([#7999](https://github.com/specify/specify7/pull/7999))
* Fixes form column definition precedence to ensure the correct configuration is selected when multiple definitions are present. Ensures proper fallback behavior when OS-specific column definitions are unavailable, improving cross-platform UI consistency ([#8028](https://github.com/specify/specify7/pull/8028))
* Fixes an issue in key migration functions that could create duplicate `SpLocaleContainer`, `SpLocaleContainerItem`, and `SpLocaleItemStr` records. Prevents unintended in-place modification of schema config records during migrations, protecting user-customized configurations. Fixes issues in the `fix_schema_config` migration step that could lead to data inconsistency or duplication ([#8039](https://github.com/specify/specify7/pull/8039))

## [7.12.0](https://github.com/specify/specify7/compare/v7.11.2...v7.12.0) (10 April 2026)

### Added
Expand Down
6 changes: 3 additions & 3 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ questions you may have about configuration or deployment.

| Version | Supported |
| ------- | ------------------ |
| 7.12.x | :white_check_mark: |
| 7.11.x | :white_check_mark: |
| < 7.10.x | :x: |
| 7.11.x | :white_check_mark: |
| 7.10.x | :white_check_mark: |
| < 7.9.x | :x: |

We support the latest version of Specify 6 only. You can report vulnerabilities
or other issues for that application on the
Expand Down
520 changes: 260 additions & 260 deletions config/common/schema_localization_en.json

Large diffs are not rendered by default.

59 changes: 22 additions & 37 deletions specifyweb/backend/bulk_copy/bulk_copy.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,11 @@
import json

from specifyweb.backend.permissions.permissions import table_permissions_checker, cache_permission_queries
from specifyweb.backend.permissions.permissions import table_permissions_checker
from django.http import (HttpResponse, HttpResponseNotAllowed)

from specifyweb.specify.api.crud import post_resource
from specifyweb.specify.api.dispatch import HttpResponseCreated
from specifyweb.specify.api.serializers import _obj_to_data, toJson
from specifyweb.backend.businessrules.utils import cache_unique_catnum_preferences
from specifyweb.backend.businessrules.uniqueness_rules import cache_uniqueness_rules
from specifyweb.backend.context.remote_prefs import cache_remote_preferences


def collection_dispatch_bulk_copy(request, model, copies) -> HttpResponse:
Expand All @@ -20,21 +17,15 @@ def collection_dispatch_bulk_copy(request, model, copies) -> HttpResponse:
data = json.loads(request.body)
data = dict(filter(lambda item: item[0] != 'id', data.items())) # Remove ID field before making copies
resp_objs = []
with (
cache_unique_catnum_preferences(),
cache_uniqueness_rules(),
cache_remote_preferences(),
cache_permission_queries()
):
for _ in range(int(copies)):
obj = post_resource(
request.specify_collection,
request.specify_user_agent,
model,
data,
request.GET.get("recordsetid", None),
)
resp_objs.append(_obj_to_data(obj, checker))
for _ in range(int(copies)):
obj = post_resource(
request.specify_collection,
request.specify_user_agent,
model,
data,
request.GET.get("recordsetid", None),
)
resp_objs.append(_obj_to_data(obj, checker))

return HttpResponseCreated(toJson(resp_objs), content_type='application/json')

Expand All @@ -48,23 +39,17 @@ def collection_dispatch_bulk(request, model) -> HttpResponse:

if request.method != 'POST':
return HttpResponseNotAllowed(['POST'])

data = json.loads(request.body)
resp_objs = []
with (
cache_unique_catnum_preferences(),
cache_uniqueness_rules(),
cache_remote_preferences(),
cache_permission_queries()
):
for obj_data in data:
obj = post_resource(
request.specify_collection,
request.specify_user_agent,
model,
obj_data,
request.GET.get("recordsetid", None),
)
resp_objs.append(_obj_to_data(obj, checker))

return HttpResponseCreated(toJson(resp_objs), content_type='application/json')
for obj_data in data:
obj = post_resource(
request.specify_collection,
request.specify_user_agent,
model,
obj_data,
request.GET.get("recordsetid", None),
)
resp_objs.append(_obj_to_data(obj, checker))

return HttpResponseCreated(toJson(resp_objs), content_type='application/json')
2 changes: 1 addition & 1 deletion specifyweb/backend/businessrules/migration_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,11 @@
has_catalognumber_rule = True

if not has_catalognumber_rule:
create_uniqueness_rule(
model_name="Collectionobject",
"Collectionobject",
discipline=discipline,
is_database_constraint=True,
fields=["catalogNumber"],
scopes=["collection"],
registry=apps,
)

Check failure

Code scanning / CodeQL

Wrong name for an argument in a call Error

Keyword argument 'discipline' is not a supported parameter name of
function create_uniqueness_rule
.
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,7 @@

candidate_rules = UniquenessRule.objects.filter(id__in=candidate_rules_with_scope)
if len(candidate_rules) == 0:
create_uniqueness_rule(
model_name='Collectionobject',
discipline=discipline,
is_database_constraint=True,
fields=['catalogNumber'],
scopes=['collection'],
registry=apps
)
create_uniqueness_rule('Collectionobject', discipline=discipline, is_database_constraint=True, fields=['catalogNumber'], scopes=['collection'], registry=apps)

Check failure

Code scanning / CodeQL

Wrong name for an argument in a call Error

Keyword argument 'discipline' is not a supported parameter name of
function create_uniqueness_rule
.
else:
candidate_rules.update(isDatabaseConstraint=True)

Expand Down
7 changes: 1 addition & 6 deletions specifyweb/backend/businessrules/migrations/0005_cojo.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,7 @@ def apply_migration(apps, schema_editor):

for discipline in Discipline.objects.all():
create_uniqueness_rule(
model_name='Collectionobjectgroupjoin',
discipline=discipline,
is_database_constraint=isDatabaseConstraint,
fields=fields,
scopes=scopes,
registry=apps
'Collectionobjectgroupjoin', discipline, isDatabaseConstraint, fields, scopes, apps
)

"""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,10 @@
from specifyweb.backend.businessrules.uniqueness_rules import create_uniqueness_rule, remove_uniqueness_rule

def apply_migration(apps, schema_editor):
create_uniqueness_rule(
model_name="Storage",
discipline=None,
is_database_constraint=True,
fields=["uniqueIdentifier"],
scopes=[],
registry=apps
)
create_uniqueness_rule("Storage", None, True, ["uniqueIdentifier"], [], apps)

def revert_migration(apps, schema_editor):
remove_uniqueness_rule(
model_name="Storage",
discipline=None,
is_database_constraint=True,
fields=["uniqueIdentifier"],
scopes=[],
registry=apps
)
remove_uniqueness_rule("Storage", None, True, ["uniqueIdentifier"], [], apps)

class Migration(migrations.Migration):

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,7 @@ def apply_migration(apps, schema_editor):
continue
# create the uniqueness rule if there are no violating duplicates
create_uniqueness_rule(
model_name=table,
discipline=discipline,
is_database_constraint=False,
fields=fields, scopes=scopes,
registry=apps
)
table, discipline, False, fields, scopes, apps)


def revert_migration(apps, schema_editor):
Expand All @@ -62,13 +57,7 @@ def revert_migration(apps, schema_editor):
for rule in rules:
fields, scopes = rule
remove_uniqueness_rule(
model_name=table,
discipline=discipline,
is_database_constraint=False,
fields=fields,
scopes=scopes,
registry=apps
)
table, discipline, False, fields, scopes, apps)


class Migration(migrations.Migration):
Expand Down
49 changes: 19 additions & 30 deletions specifyweb/backend/businessrules/rules/collectionobject_rules.py
Original file line number Diff line number Diff line change
@@ -1,39 +1,28 @@
from specifyweb.backend.businessrules.orm_signal_handler import orm_signal_handler

from specifyweb.backend.businessrules.exceptions import BusinessRuleException
from specifyweb.backend.businessrules.utils import (
component_catalog_number_exists,
get_default_collectionobjecttype_id,
get_unique_catnum_across_comp_co_coll_pref_by_ids,
)
from specifyweb.backend.businessrules.utils import get_unique_catnum_across_comp_co_coll_pref
from specifyweb.specify.models import Component


@orm_signal_handler('pre_save', 'Collectionobject')
def collectionobject_pre_save(co):
if co.collectionmemberid is None:
co.collectionmemberid = co.collection_id

if co.collectionobjecttype_id is None:
co.collectionobjecttype_id = get_default_collectionobjecttype_id(
co.collection_id
)

if (
co.createdbyagent_id is not None
and co.catalognumber is not None
):

unique_catnum_across_comp_co_coll_pref = (
get_unique_catnum_across_comp_co_coll_pref_by_ids(
co.collection_id,
co.createdbyagent_id,
)
)

if unique_catnum_across_comp_co_coll_pref:
contains_component_duplicates = component_catalog_number_exists(
catalog_number=co.catalognumber,
collection_id=co.collection_id,
)

if contains_component_duplicates:
raise BusinessRuleException("Catalog Number is already in use by a Component in this Collection")
if co.collectionobjecttype is None:
co.collectionobjecttype = co.collection.collectionobjecttype

agent = co.createdbyagent
if agent is not None and agent.specifyuser is not None:

unique_catnum_across_comp_co_coll_pref = get_unique_catnum_across_comp_co_coll_pref(co.collection, co.createdbyagent.specifyuser)

if unique_catnum_across_comp_co_coll_pref:
if co.catalognumber is not None:
contains_component_duplicates = Component.objects.filter(
catalognumber=co.catalognumber).exclude(pk=co.pk).exists()

if contains_component_duplicates:
raise BusinessRuleException(
'Catalog Number is already in use for another Component in this collection.')
58 changes: 14 additions & 44 deletions specifyweb/backend/businessrules/rules/component_rules.py
Original file line number Diff line number Diff line change
@@ -1,52 +1,22 @@
from specifyweb.backend.businessrules.orm_signal_handler import orm_signal_handler
from specifyweb.backend.businessrules.exceptions import BusinessRuleException
from specifyweb.backend.businessrules.utils import (
_component_catnum_cache,
get_unique_catnum_across_comp_co_coll_pref_by_ids,
component_catalog_number_exists
)
from specifyweb.specify.models import Collectionobject
from specifyweb.backend.businessrules.utils import get_unique_catnum_across_comp_co_coll_pref
from specifyweb.specify.models import Collectionobject, Component

@orm_signal_handler('pre_save', 'Component')
def component_pre_save(comp):
created_by_agent_id = comp.createdbyagent_id
collection_object = comp.collectionobject
collection_id = getattr(collection_object, "collection_id", None)
agent = comp.createdbyagent
if agent is not None and agent.specifyuser is not None:
unique_catnum_across_comp_co_coll_pref = get_unique_catnum_across_comp_co_coll_pref(comp.collectionobject.collection, comp.createdbyagent.specifyuser)

# We don't have an easy way at the moment to record that the existing
# Component's catalogNumber was changed without hitting the DB for the old
# value or have some other cache to map ID -> catalogNumber
# While both of those approaches are feasible, let's just clear any
# existing cache for now under the assumption the catalogNumber was changed
if comp.pk is not None:
_component_catnum_cache.clear_keys()
if unique_catnum_across_comp_co_coll_pref:
if comp.catalognumber is not None:
contains_co_duplicates = Collectionobject.objects.filter(
catalognumber=comp.catalognumber).exclude(pk=comp.pk).exists()

if (created_by_agent_id is not None
and comp.catalognumber is not None
and collection_id is not None):
contains_component_duplicates = Component.objects.filter(
catalognumber=comp.catalognumber).exclude(pk=comp.pk).exists()

unique_catnum_across_comp_co_coll_pref = get_unique_catnum_across_comp_co_coll_pref_by_ids(collection_id, created_by_agent_id)

if unique_catnum_across_comp_co_coll_pref:
# FEAT: Cache CO catalognumber?
contains_co_duplicates = Collectionobject.objects.filter(
catalognumber=comp.catalognumber, collection_id=collection_id).exists()

if contains_co_duplicates:
# REFACTOR: localize these table and field names
raise BusinessRuleException("Catalog Number is already in use by another Collection Object in this Collection")

contains_component_duplicates = component_catalog_number_exists(
catalog_number=comp.catalognumber,
excluded_component_id=comp.pk,
collection_id=collection_id
)

if contains_component_duplicates:
raise BusinessRuleException(
# REFACTOR: localize these table and field names
'Catalog Number is already in use by another Component in this Collection')

@orm_signal_handler('pre_delete', 'Component')
def component_pre_delete(comp):
_component_catnum_cache.clear_keys()
if contains_co_duplicates or contains_component_duplicates:
raise BusinessRuleException(
'Catalog Number is already in use for another Collection Object or Component in this collection.')
Loading
Loading