Skip to content

Commit e5a78d4

Browse files
Merge branch 'main' into issue-4950
2 parents 3419afc + 81966d3 commit e5a78d4

31 files changed

Lines changed: 1833 additions & 186 deletions

File tree

.env

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,9 @@ ASSET_SERVER_URL=http://host.docker.internal/web_asset_store.xml
4343
# Make sure to set the `ASSET_SERVER_KEY` to a unique value
4444
ASSET_SERVER_KEY=your_asset_server_access_key
4545

46+
# Information to connect to a Redis database
47+
# Specify will use this database as a process broker and storage for temporary
48+
# values
4649
REDIS_HOST=redis
4750
REDIS_PORT=6379
4851
REDIS_DB_INDEX=0

.github/workflows/test.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,11 @@ jobs:
7373
options:
7474
--health-cmd="mariadb-admin ping" --health-interval=5s
7575
--health-timeout=2s --health-retries=3
76+
redis:
77+
image: redis:latest
78+
ports:
79+
- 6379
80+
7681

7782
steps:
7883
- uses: actions/checkout@v4
@@ -128,6 +133,8 @@ jobs:
128133
echo "MIGRATOR_PASSWORD = 'MasterPassword'" >> specifyweb/settings/local_specify_settings.py
129134
echo "APP_USER_NAME = 'MasterUser'" >> specifyweb/settings/local_specify_settings.py
130135
echo "APP_USER_PASSWORD = 'MasterPassword'" >> specifyweb/settings/local_specify_settings.py
136+
echo "REDIS_HOST = '127.0.0.1'" >> specifyweb/settings/local_specify_settings.py
137+
echo "REDIS_PORT = ${{ job.services.redis.ports[6379] }}" >> specifyweb/settings/local_specify_settings.py
131138
132139
- name: Need these files to be present
133140
run:

specifyweb/backend/businessrules/rules/attachment_rules.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,8 @@ def attachment_jointable_save(sender, obj):
3030

3131
attachee = get_attachee(obj)
3232
obj.attachment.tableid = attachee.specify_model.tableId
33-
scopetype, scope = Scoping(attachee)()
34-
obj.attachment.scopetype, obj.attachment.scopeid = scopetype, scope.id
33+
scopetype, scope = Scoping.from_instance(attachee)
34+
obj.attachment.scopetype, obj.attachment.scopeid = scopetype.value, scope.id
3535
obj.attachment.save()
3636

3737

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
from redis import Redis
2+
from django.conf import settings
3+
4+
class RedisConnection:
5+
def __init__(self,
6+
host=getattr(settings, "REDIS_HOST", None),
7+
port=getattr(settings, "REDIS_PORT", None),
8+
db_index=getattr(settings, "REDIS_DB_INDEX", 0),
9+
decode_responses=True):
10+
if None in (host, port, db_index):
11+
raise ValueError(
12+
"Redis is not correctly configured", host, port, db_index)
13+
self.host = host
14+
self.port = port
15+
self.db_index = db_index
16+
self.decode_responses = decode_responses
17+
self.connection = Redis(
18+
host=self.host,
19+
port=self.port,
20+
db=self.db_index,
21+
decode_responses=self.decode_responses
22+
)
23+
24+
def delete(self, key: str):
25+
return self.connection.delete(key)
26+
27+
28+
class RedisDataType:
29+
def __init__(self, established: RedisConnection) -> None:
30+
self._established = established
31+
32+
@property
33+
def connection(self):
34+
return self._established.connection
35+
36+
def delete(self, key: str):
37+
return self._established.delete(key)
38+
39+
class RedisList(RedisDataType):
40+
"""
41+
See https://redis.io/docs/latest/develop/data-types/lists/
42+
"""
43+
44+
def left_push(self, key: str, value) -> int:
45+
return self.connection.lpush(key, value)
46+
47+
def right_push(self, key: str, value) -> int:
48+
return self.connection.rpush(key, value)
49+
50+
def right_pop(self, key: str) -> str | bytes | None:
51+
return self.connection.rpop(key)
52+
53+
def left_pop(self, key: str) -> str | bytes | None:
54+
return self.connection.lpop(key)
55+
56+
def length(self, key: str) -> int:
57+
return self.connection.llen(key)
58+
59+
def range(self, key: str, start_index: int, end_index: int) -> list[str] | list[bytes]:
60+
return self.connection.lrange(key, start_index, end_index)
61+
62+
def trim(self, key: str, start_index: int, end_index: int) -> list[str] | list[bytes]:
63+
return self.connection.ltrim(key, start_index, end_index)
64+
65+
def blocking_left_pop(self, key: str, timeout: int) -> str | bytes | None:
66+
response = self.connection.blpop(key, timeout=timeout)
67+
if response is None:
68+
return None
69+
_filled_list_key, item = response
70+
return item
71+
72+
class RedisSet(RedisDataType):
73+
"""
74+
See https://redis.io/docs/latest/develop/data-types/sets/
75+
"""
76+
def add(self, key: str, *values: str) -> int:
77+
return self.connection.sadd(key, *values)
78+
79+
def is_member(self, key: str, value: str) -> bool:
80+
is_member = int(self.connection.sismember(key, value))
81+
return is_member == 1
82+
83+
def remove(self, key: str, value: str):
84+
return self.connection.srem(key, value)
85+
86+
def size(self, key: str) -> int:
87+
return self.connection.scard(key)
88+
89+
def members(self, key: str) -> set[str]:
90+
return self.connection.smembers(key)
91+
92+
def union(self, *keys: str) -> set[str]:
93+
return self.connection.sunion(*keys)
94+
95+
def intersection(self, *keys: str) -> set[str]:
96+
return self.connection.sinter(*keys)
97+
98+
def difference(self, *keys: str) -> set[str]:
99+
return self.connection.sdiff(*keys)
100+
101+
class RedisString(RedisDataType):
102+
"""
103+
See https://redis.io/docs/latest/develop/data-types/strings/
104+
"""
105+
106+
def set(self, key, value, time_to_live=None, override_existing=True):
107+
flags = {
108+
"ex": time_to_live,
109+
"nx": not override_existing
110+
}
111+
self.connection.set(key, value, **flags)
112+
113+
def get(self, key, delete_key=False) -> str | bytes | None:
114+
if delete_key:
115+
return self.connection.getdel(key)
116+
return self.connection.get(key)
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import json
2+
3+
from typing import Callable, Generator, Iterable, cast
4+
5+
from specifyweb.backend.redis_cache.connect import RedisConnection, RedisList, RedisSet
6+
7+
type Serialized = str | bytes | bytearray
8+
9+
type Serializer[T] = Callable[[T], str]
10+
type Deserializer[T] = Callable[[Serialized], T]
11+
12+
def default_serializer(obj) -> str:
13+
return str(obj)
14+
15+
def default_deserializer(serialized: Serialized):
16+
return serialized
17+
18+
class RedisQueue[T]:
19+
def __init__(self, connection: RedisConnection, key: str,
20+
serializer: Serializer[T] | None = None,
21+
deserializer: Deserializer[T] | None = None):
22+
self.connection = RedisList(connection)
23+
self.key = key
24+
self.serializer = serializer or cast(Serializer[T], default_serializer)
25+
self.deserializer = deserializer or cast(Deserializer[T], default_deserializer)
26+
27+
def key_name(self, *name_parts: str | None):
28+
key_name = "_".join([self.key, *(part for part in name_parts if part is not None)])
29+
return key_name
30+
31+
def push(self, *objs: T, sub_key: str | None = None) -> int:
32+
key_name = self.key_name(sub_key)
33+
return self.connection.right_push(key_name, *self._serialize_objs(*objs))
34+
35+
def pop(self, sub_key: str | None = None) -> T | None:
36+
key_name = self.key_name(sub_key)
37+
popped = self.connection.left_pop(key_name)
38+
if popped is None:
39+
return None
40+
return self.deserializer(popped)
41+
42+
def wait_and_pop(self, timeout: int = 0, sub_key: str | None = None) -> T:
43+
key_name = self.key_name(sub_key)
44+
popped = self.connection.blocking_left_pop(key_name, timeout)
45+
if popped is None:
46+
raise TimeoutError("No items in queue after timeout")
47+
return self.deserializer(popped)
48+
49+
def peek(self, sub_key: str | None = None) -> T | None:
50+
key_name = self.key_name(sub_key)
51+
top_value = self._deserialize_objs(*self.connection.range(key_name, 0, 0))
52+
if len(top_value) == 0:
53+
return None
54+
return top_value[0]
55+
56+
def _serialize_objs(self, *objs: T) -> Generator[str, None, None]:
57+
return (self.serializer(obj) for obj in objs)
58+
59+
def _deserialize_objs(self, *serialized: Serialized):
60+
return tuple(self.deserializer(obj) for obj in serialized)
61+

specifyweb/backend/redis_cache/store.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from .utils import _set_string, _get_string
1+
from .utils import _set_string, _get_string, _delete_key, _add_to_set, _set_elements, _redis_type
22

33

44
def set_string(key: str, value: str, time_to_live=None, override_existing=True):
@@ -15,3 +15,18 @@ def get_string(key: str, delete_key=False) -> str:
1515

1616
def get_bytes(key: str, delete_key=False) -> bytes:
1717
return _get_string(key, delete_key=delete_key, decode_responses=False)
18+
19+
20+
def add_to_set(key: str, *elements: str):
21+
return _add_to_set(key, *elements)
22+
23+
def set_members(key: str):
24+
return _set_elements(key)
25+
26+
27+
def delete_key(key: str):
28+
return _delete_key(key)
29+
30+
31+
def redis_type(key: str):
32+
return _redis_type(key)

specifyweb/backend/redis_cache/utils.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import overload
1+
from typing import overload, Literal
22

33
from redis import Redis
44
from django.conf import settings
@@ -12,6 +12,9 @@ def redis_connection(decode_responses=True):
1212
raise ValueError("Redis is not correctly configured", redis_host, redis_port)
1313
return Redis(host=redis_host, port=redis_port, db=redis_db_index, decode_responses=decode_responses)
1414

15+
def _delete_key(key: str):
16+
host = redis_connection()
17+
host.delete(key)
1518

1619
def _set_string(key: str, value: str, time_to_live=None, override_existing=True, decode_responses=True):
1720
host = redis_connection(decode_responses=decode_responses)
@@ -37,3 +40,20 @@ def _get_string(key: str, delete_key: bool=False, decode_responses=True) -> str
3740
return host.getdel(key)
3841

3942
return host.get(key)
43+
44+
def _add_to_set(key: str, *elements: str):
45+
if len(elements) <= 0:
46+
return 0
47+
host = redis_connection(decode_responses=True)
48+
return host.sadd(key, *elements)
49+
50+
def _set_elements(key: str) -> set:
51+
host = redis_connection(decode_responses=True)
52+
return host.smembers(key)
53+
54+
# https://redis.io/docs/latest/commands/type/
55+
Redis_Type = Literal["none", "string", "list", "set", "hash", "stream", "vectorset"]
56+
57+
def _redis_type(key: str) -> Redis_Type:
58+
host = redis_connection(decode_responses=True)
59+
return host.type(key)

specifyweb/backend/stored_queries/execution.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,6 @@ class QuerySort:
5151
def by_id(sort_id: QUREYFIELD_SORT_T):
5252
return QuerySort.SORT_TYPES[sort_id]
5353

54-
5554
def DefaultQueryFormatterProps():
5655
return ObjectFormatterProps(
5756
format_agent_type=False,

specifyweb/backend/workbench/upload/parse.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,12 @@ class ParseSucess(NamedTuple):
4444
ParseResult = ParseSucess | ParseFailure
4545

4646

47-
def parse_field(table_name: str, field_name: str, raw_value: str, formatter: ScopedFormatter | None = None) -> ParseResult:
47+
def parse_field(
48+
table_name: str,
49+
field_name: str,
50+
raw_value: str,
51+
formatter: ScopedFormatter | None = None
52+
) -> ParseResult:
4853
table = datamodel.get_table_strict(table_name)
4954
field = table.get_field_strict(field_name)
5055

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

172177

173-
def parse_formatted(uiformatter: ScopedFormatter, table: Table, field: Field | Relationship, value: str) -> ParseResult:
178+
def parse_formatted(
179+
uiformatter: ScopedFormatter,
180+
table: Table,
181+
field: Field | Relationship,
182+
value: str) -> ParseResult:
174183
try:
175184
canonicalized = uiformatter(table, value)
176185
except FormatMismatch as e:

0 commit comments

Comments
 (0)