Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
90b3b66
Refactor picklist handling to improve null handling and type coercion…
acwhite211 Sep 30, 2025
ee9d106
Apply format.py changes from issue-7456
acwhite211 Oct 15, 2025
29e01a2
Apply changes from adding stats_counts context api
acwhite211 Oct 20, 2025
2d9dd2c
Apply group_concat and blank_nulls changes
acwhite211 Oct 20, 2025
cb1b9dd
apply changes from issue-7510
acwhite211 Oct 24, 2025
fb5d5e9
Apply new changes for new renumber_tree function
acwhite211 Nov 3, 2025
9151d0a
Update docker-compose.yml for mariadb 11.8
acwhite211 Nov 3, 2025
b77fb62
Fix blank_nulls wrapping numeric CatalogNumber cast instead of raw co…
acwhite211 Nov 5, 2025
dcecec6
fix customized formatters
acwhite211 Nov 5, 2025
4b9f0c3
fix: Use named locks when autonumbering
melton-jason Jan 22, 2026
12eb463
chore: simplify autonumber interface
melton-jason Jan 27, 2026
dfa7d6a
feat: backport Redis to v7.11.3 base
melton-jason Jan 28, 2026
f294d5b
feat: add utilities for interacting with Redis
melton-jason Jan 28, 2026
4303545
feat: allow using a Manager to handle named locking
melton-jason Jan 28, 2026
94fd657
fix: better handle subsequent requests to get same lock
melton-jason Jan 28, 2026
0dd769a
fix: resolve erronous fillin_year in apply_autonumbering
melton-jason Jan 28, 2026
6fad8f9
refactor: specify type in apply_autonumbering function
melton-jason Jan 28, 2026
4de5c06
feat: integrate autonumbering locks with Workbench
melton-jason Jan 29, 2026
8fbec8b
feat: allow getting scope from model classes
melton-jason Jan 30, 2026
3870a0f
feat: add more scoping tables
melton-jason Jan 30, 2026
e2edb11
fix: properly respect scope when setting autonumbering keys
melton-jason Jan 30, 2026
d48a7a0
fix: resolve typo in static method decorator
melton-jason Jan 30, 2026
957ba26
fix: implement comparison operators for scope type
melton-jason Jan 30, 2026
0039829
fix: internally always use strings for scope types when autonumbering
melton-jason Jan 30, 2026
75964c1
feat: spin up a redis instance during backend tests
melton-jason Jan 30, 2026
f6e5550
fix: resolve more failing tests
melton-jason Jan 30, 2026
43fc35d
fix: make scopetype helper method static
melton-jason Jan 31, 2026
654c0cd
fix: use ScopeType ints when creating attachments
melton-jason Jan 31, 2026
04a6922
fix: handle edge case where inferred scope is not related object
melton-jason Feb 2, 2026
7942817
fix: handle case when lock dispatcher is not present when autonumbering
melton-jason Feb 2, 2026
d5f8120
chore: add doc-strings to scoping functions
melton-jason Feb 3, 2026
ac87330
fix: use proper field object in is_related
melton-jason Feb 3, 2026
9676173
chore: update loan autonumber test
melton-jason Feb 3, 2026
0c3e863
fix: correct collection being passed to autonumbering function in tests
melton-jason Feb 3, 2026
07d003e
fix: respect accession scope when filtering query by collection
melton-jason Feb 3, 2026
efa9396
fix: respect regexp when reading highest stored value
melton-jason Feb 3, 2026
f36908d
Merge pull request #7671 from specify/issue-6490-patch
melton-jason Feb 4, 2026
ba2c8cc
Merge remote-tracking branch 'origin/main' into v7.11.4-prerelease
melton-jason Feb 6, 2026
cdf0bd1
fix: use app and label name instead of object reference to determine …
melton-jason Feb 6, 2026
997724e
chore: remove unused imports
melton-jason Feb 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ ASSET_SERVER_URL=http://host.docker.internal/web_asset_store.xml
# Make sure to set the `ASSET_SERVER_KEY` to a unique value
ASSET_SERVER_KEY=your_asset_server_access_key

# Information to connect to a Redis database
# Specify will use this database as a process broker and storage for temporary
# values
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_DB_INDEX=0
Expand Down
7 changes: 7 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,11 @@ jobs:
options:
--health-cmd="mariadb-admin ping" --health-interval=5s
--health-timeout=2s --health-retries=3
redis:
image: redis:latest
ports:
- 6379


steps:
- uses: actions/checkout@v4
Expand Down Expand Up @@ -128,6 +133,8 @@ jobs:
echo "MIGRATOR_PASSWORD = 'MasterPassword'" >> specifyweb/settings/local_specify_settings.py
echo "APP_USER_NAME = 'MasterUser'" >> specifyweb/settings/local_specify_settings.py
echo "APP_USER_PASSWORD = 'MasterPassword'" >> specifyweb/settings/local_specify_settings.py
echo "REDIS_HOST = '127.0.0.1'" >> specifyweb/settings/local_specify_settings.py
echo "REDIS_PORT = ${{ job.services.redis.ports[6379] }}" >> specifyweb/settings/local_specify_settings.py

- name: Need these files to be present
run:
Expand Down
4 changes: 2 additions & 2 deletions specifyweb/backend/businessrules/rules/attachment_rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ def attachment_jointable_save(sender, obj):

attachee = get_attachee(obj)
obj.attachment.tableid = attachee.specify_model.tableId
scopetype, scope = Scoping(attachee)()
obj.attachment.scopetype, obj.attachment.scopeid = scopetype, scope.id
scopetype, scope = Scoping.from_instance(attachee)
obj.attachment.scopetype, obj.attachment.scopeid = scopetype.value, scope.id
obj.attachment.save()


Expand Down
116 changes: 116 additions & 0 deletions specifyweb/backend/redis_cache/connect.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
from redis import Redis
from django.conf import settings

class RedisConnection:
def __init__(self,
host=getattr(settings, "REDIS_HOST", None),
port=getattr(settings, "REDIS_PORT", None),
db_index=getattr(settings, "REDIS_DB_INDEX", 0),
decode_responses=True):
if None in (host, port, db_index):
raise ValueError(
"Redis is not correctly configured", host, port, db_index)
self.host = host
self.port = port
self.db_index = db_index
self.decode_responses = decode_responses
self.connection = Redis(
host=self.host,
port=self.port,
db=self.db_index,
decode_responses=self.decode_responses
)

def delete(self, key: str):
return self.connection.delete(key)


class RedisDataType:
def __init__(self, established: RedisConnection) -> None:
self._established = established

@property
def connection(self):
return self._established.connection

def delete(self, key: str):
return self._established.delete(key)

class RedisList(RedisDataType):
"""
See https://redis.io/docs/latest/develop/data-types/lists/
"""

def left_push(self, key: str, value) -> int:
return self.connection.lpush(key, value)

def right_push(self, key: str, value) -> int:
return self.connection.rpush(key, value)

def right_pop(self, key: str) -> str | bytes | None:
return self.connection.rpop(key)

def left_pop(self, key: str) -> str | bytes | None:
return self.connection.lpop(key)

def length(self, key: str) -> int:
return self.connection.llen(key)

def range(self, key: str, start_index: int, end_index: int) -> list[str] | list[bytes]:
return self.connection.lrange(key, start_index, end_index)

def trim(self, key: str, start_index: int, end_index: int) -> list[str] | list[bytes]:
return self.connection.ltrim(key, start_index, end_index)

def blocking_left_pop(self, key: str, timeout: int) -> str | bytes | None:
response = self.connection.blpop(key, timeout=timeout)
if response is None:
return None
_filled_list_key, item = response
return item

class RedisSet(RedisDataType):
"""
See https://redis.io/docs/latest/develop/data-types/sets/
"""
def add(self, key: str, *values: str) -> int:
return self.connection.sadd(key, *values)

def is_member(self, key: str, value: str) -> bool:
is_member = int(self.connection.sismember(key, value))
return is_member == 1

def remove(self, key: str, value: str):
return self.connection.srem(key, value)

def size(self, key: str) -> int:
return self.connection.scard(key)

def members(self, key: str) -> set[str]:
return self.connection.smembers(key)

def union(self, *keys: str) -> set[str]:
return self.connection.sunion(*keys)

def intersection(self, *keys: str) -> set[str]:
return self.connection.sinter(*keys)

def difference(self, *keys: str) -> set[str]:
return self.connection.sdiff(*keys)

class RedisString(RedisDataType):
"""
See https://redis.io/docs/latest/develop/data-types/strings/
"""

def set(self, key, value, time_to_live=None, override_existing=True):
flags = {
"ex": time_to_live,
"nx": not override_existing
}
self.connection.set(key, value, **flags)

def get(self, key, delete_key=False) -> str | bytes | None:
if delete_key:
return self.connection.getdel(key)
return self.connection.get(key)
61 changes: 61 additions & 0 deletions specifyweb/backend/redis_cache/rqueue.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import json

from typing import Callable, Generator, Iterable, cast

from specifyweb.backend.redis_cache.connect import RedisConnection, RedisList, RedisSet

type Serialized = str | bytes | bytearray

type Serializer[T] = Callable[[T], str]
type Deserializer[T] = Callable[[Serialized], T]

def default_serializer(obj) -> str:
return str(obj)

def default_deserializer(serialized: Serialized):
return serialized

class RedisQueue[T]:
def __init__(self, connection: RedisConnection, key: str,
serializer: Serializer[T] | None = None,
deserializer: Deserializer[T] | None = None):
self.connection = RedisList(connection)
self.key = key
self.serializer = serializer or cast(Serializer[T], default_serializer)
self.deserializer = deserializer or cast(Deserializer[T], default_deserializer)

def key_name(self, *name_parts: str | None):
key_name = "_".join([self.key, *(part for part in name_parts if part is not None)])
return key_name

def push(self, *objs: T, sub_key: str | None = None) -> int:
key_name = self.key_name(sub_key)
return self.connection.right_push(key_name, *self._serialize_objs(*objs))

def pop(self, sub_key: str | None = None) -> T | None:
key_name = self.key_name(sub_key)
popped = self.connection.left_pop(key_name)
if popped is None:
return None
return self.deserializer(popped)

def wait_and_pop(self, timeout: int = 0, sub_key: str | None = None) -> T:
key_name = self.key_name(sub_key)
popped = self.connection.blocking_left_pop(key_name, timeout)
if popped is None:
raise TimeoutError("No items in queue after timeout")
return self.deserializer(popped)

def peek(self, sub_key: str | None = None) -> T | None:
key_name = self.key_name(sub_key)
top_value = self._deserialize_objs(*self.connection.range(key_name, 0, 0))
if len(top_value) == 0:
return None
return top_value[0]

def _serialize_objs(self, *objs: T) -> Generator[str, None, None]:
return (self.serializer(obj) for obj in objs)

def _deserialize_objs(self, *serialized: Serialized):
return tuple(self.deserializer(obj) for obj in serialized)

1 change: 0 additions & 1 deletion specifyweb/backend/stored_queries/execution.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,6 @@ class QuerySort:
def by_id(sort_id: QUREYFIELD_SORT_T):
return QuerySort.SORT_TYPES[sort_id]


def DefaultQueryFormatterProps():
return ObjectFormatterProps(
format_agent_type=False,
Expand Down
13 changes: 11 additions & 2 deletions specifyweb/backend/workbench/upload/parse.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,12 @@ class ParseSucess(NamedTuple):
ParseResult = ParseSucess | ParseFailure


def parse_field(table_name: str, field_name: str, raw_value: str, formatter: ScopedFormatter | None = None) -> ParseResult:
def parse_field(
table_name: str,
field_name: str,
raw_value: str,
formatter: ScopedFormatter | None = None
) -> ParseResult:
table = datamodel.get_table_strict(table_name)
field = table.get_field_strict(field_name)

Expand Down Expand Up @@ -170,7 +175,11 @@ def parse_date(table: Table, field_name: str, dateformat: str, value: str) -> Pa
return ParseFailure('badDateFormat', {'value': value, 'format': dateformat})


def parse_formatted(uiformatter: ScopedFormatter, table: Table, field: Field | Relationship, value: str) -> ParseResult:
def parse_formatted(
uiformatter: ScopedFormatter,
table: Table,
field: Field | Relationship,
value: str) -> ParseResult:
try:
canonicalized = uiformatter(table, value)
except FormatMismatch as e:
Expand Down
36 changes: 28 additions & 8 deletions specifyweb/backend/workbench/upload/scoping.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@


from functools import reduce
from typing import Any, cast
from typing import Any, Callable, cast
from collections.abc import Callable

from specifyweb.specify.datamodel import datamodel, Table, is_tree_table
from specifyweb.specify.utils.func import CustomRepr
from specifyweb.specify.utils.autonumbering import AutonumberingLockDispatcher
from specifyweb.specify.models_utils.load_datamodel import DoesNotExistError
from specifyweb.specify import models
from specifyweb.backend.trees.utils import get_default_treedef
Expand Down Expand Up @@ -113,7 +114,8 @@ def extend_columnoptions(
fieldname: str,
row: Row | None = None,
toOne: dict[str, Uploadable] | None = None,
context: ScopeContext | None = None
context: ScopeContext | None = None,
lock_dispatcher: Callable[[], AutonumberingLockDispatcher] | None = None
) -> ExtendedColumnOptions:

context = context or ScopeContext()
Expand All @@ -125,7 +127,7 @@ def extend_columnoptions(

ui_formatter = get_or_defer_formatter(collection, tablename, fieldname, row, toOne, context)
scoped_formatter = (
None if ui_formatter is None else ui_formatter.apply_scope(collection)
None if ui_formatter is None else ui_formatter.apply_scope(collection, lock_dispatcher)
)

if tablename.lower() == "collectionobjecttype" and fieldname.lower() == "name":
Expand Down Expand Up @@ -256,7 +258,11 @@ def get_or_defer_formatter(


def apply_scoping_to_uploadtable(
ut: UploadTable, collection, context: ScopeContext | None = None, row=None
ut: UploadTable,
collection,
context: ScopeContext | None = None,
row=None,
lock_dispatcher: Callable[[], AutonumberingLockDispatcher] | None = None
) -> ScopedUploadTable:
# IMPORTANT:
# before this comment, collection is untrusted and unreliable
Expand All @@ -276,7 +282,7 @@ def apply_scoping_to_uploadtable(

apply_scoping = lambda key, value: get_deferred_scoping(
key, table.django_name, value, row, ut, context
).apply_scoping(collection, context, row)
).apply_scoping(collection, context, row, lock_dispatcher=lock_dispatcher)

to_ones = {
key: adjuster(apply_scoping(key, value), key)
Expand All @@ -299,7 +305,14 @@ def _backref(key):
scoped_table = ScopedUploadTable(
name=ut.name,
wbcols={
f: extend_columnoptions(colopts, collection, table.name, f, row, ut.toOne, context)
f: extend_columnoptions(colopts,
collection,
table.name,
f,
row,
ut.toOne,
context,
lock_dispatcher=lock_dispatcher)
for f, colopts in ut.wbcols.items()
},
static=ut.static,
Expand Down Expand Up @@ -347,7 +360,10 @@ def set_order_number(
return tmr._replace(strong_ignore=[*tmr.strong_ignore, *to_ignore])


def apply_scoping_to_treerecord(tr: TreeRecord, collection, context: ScopeContext | None = None) -> ScopedTreeRecord:
def apply_scoping_to_treerecord(tr: TreeRecord,
collection,
context: ScopeContext | None = None,
lock_dispatcher: Callable[[], AutonumberingLockDispatcher] | None = None) -> ScopedTreeRecord:
table = datamodel.get_table_strict(tr.name)

treedef = get_default_treedef(table, collection)
Expand Down Expand Up @@ -376,7 +392,11 @@ def apply_scoping_to_treerecord(tr: TreeRecord, collection, context: ScopeContex
else r._replace(treedef_id=treedef.id) if r.treedef_id is None # Adjust treeid for parsed JSON plans
else r
): {
f: extend_columnoptions(colopts, collection, table.name, f)
f: extend_columnoptions(colopts,
collection,
table.name,
f,
lock_dispatcher=lock_dispatcher)
for f, colopts in cols.items()
}
for r, cols in tr.ranks.items()
Expand Down
Loading