Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
4856c9c
Fix(stats): serialize calculations and cache permission-checked results
CarolineDenis Oct 20, 2026
2cd2c05
Fix: disable refresh while loading and retry busy requests
CarolineDenis Oct 20, 2026
1748719
Tests: Add backend tests for stats coordination
CarolineDenis Oct 20, 2026
8b8145d
Tests: Add frontend tests to verify refresh button is disabled when s…
CarolineDenis Oct 20, 2026
b876e2d
Tests: Add frontend for stats queuing
CarolineDenis Oct 20, 2026
e608c7b
Fix: Make the Statistics cache optional.
CarolineDenis Oct 20, 2026
0cf07d5
Fix: Restore tolerant error handling for dynamic-group queries.
CarolineDenis Oct 20, 2026
4153333
fix: use a private retry-delay header
CarolineDenis Oct 27, 2026
f8c9cdf
Fix: release calculation locks on every exit
CarolineDenis Oct 27, 2026
3499e61
Refactor(statistics): let views build cached responses
CarolineDenis Oct 27, 2026
9852b92
Refactor(query): separate cache permission checks from query execution
CarolineDenis Oct 27, 2026
a80af04
refactor(stats): validate permissions before cache lookup
CarolineDenis Oct 27, 2026
9362ac7
Fix: avoid query validation on cache hits
CarolineDenis Oct 27, 2026
41a099c
Fix(stats): exclude query metadata from cache key
CarolineDenis Oct 27, 2026
37a3b97
Fix: bound advisory lock names in dispatcher
CarolineDenis Oct 27, 2026
0b735e0
Dependencies: Upgrade Hypothesis in requirement testing
CarolineDenis Oct 27, 2026
da3e177
Potential fix for pull request finding 'CodeQL / Cyclic import'
CarolineDenis Oct 27, 2026
5859902
test(stats): pass retry header via Response constructor
CarolineDenis Oct 27, 2026
f91eca1
Potential fix for pull request finding 'CodeQL / Cyclic import'
CarolineDenis Oct 27, 2026
52eb613
Merge conflicts
CarolineDenis Oct 27, 2026
6b76e1e
Refactor: Fix cycling import
CarolineDenis Oct 27, 2026
96b7f75
Potential fix for pull request finding 'CodeQL / Cyclic import'
CarolineDenis Oct 27, 2026
a235708
Potential fix for pull request finding 'CodeQL / Cyclic import'
CarolineDenis Oct 27, 2026
56af4f8
Potential fix for pull request finding 'CodeQL / Cyclic import'
CarolineDenis Oct 27, 2026
454d472
Potential fix for pull request finding 'CodeQL / Cyclic import'
CarolineDenis Oct 27, 2026
627c90f
Potential fix for pull request finding 'CodeQL / Cyclic import'
CarolineDenis Oct 27, 2026
f1106c0
Potential fix for pull request finding 'CodeQL / Cyclic import'
CarolineDenis Oct 27, 2026
b29173e
Potential fix for pull request finding 'CodeQL / Cyclic import'
CarolineDenis Oct 27, 2026
ddecda6
Potential fix for pull request finding 'CodeQL / Cyclic import'
CarolineDenis Oct 27, 2026
a25151c
Fix cycling imports
CarolineDenis Oct 27, 2026
6638234
Potential fix for pull request finding 'CodeQL / Cyclic import'
CarolineDenis Oct 27, 2026
14ec429
Potential fix for pull request finding 'CodeQL / Cyclic import'
CarolineDenis Oct 27, 2026
b6baa4b
Potential fix for pull request finding 'CodeQL / Cyclic import'
CarolineDenis Oct 27, 2026
7410664
Potential fix for pull request finding 'CodeQL / Cyclic import'
CarolineDenis Oct 27, 2026
4cc0670
Potential fix for pull request finding 'CodeQL / Cyclic import'
CarolineDenis Oct 27, 2026
198cb2c
Potential fix for pull request finding 'CodeQL / Cyclic import'
CarolineDenis Oct 27, 2026
48c1728
Potential fix for pull request finding 'CodeQL / Cyclic import'
CarolineDenis Oct 27, 2026
d576a09
Fix: Add missing import
CarolineDenis Oct 27, 2026
4d316e2
Add missing import
CarolineDenis Oct 27, 2026
f0c5032
Potential fix for pull request finding 'CodeQL / Cyclic import'
CarolineDenis Oct 27, 2026
5437b09
Add missing import
CarolineDenis Oct 27, 2026
8a64225
Fix: Add query construct import
CarolineDenis Oct 28, 2026
1f46aa5
Fix: Use the existing workbench delimiter.
CarolineDenis Oct 28, 2026
7c34751
Fix: Clear the retry delay when cancelStatisticsRequests runs.
CarolineDenis Oct 28, 2026
7f46ebe
Fix cycles
CarolineDenis Oct 28, 2026
6478516
Fix: delimiter mismatch
CarolineDenis Oct 28, 2026
4ccbb04
Refactor: Extract users_collections_for_sp6
CarolineDenis Oct 28, 2026
b3fca76
Merge remote-tracking branch 'origin/main' into issue-8639
melton-jason Oct 30, 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
2 changes: 0 additions & 2 deletions specifyweb/backend/businessrules/exceptions.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
from specifyweb.middleware.general import SpecifyExceptionWrapper

class BusinessRuleException(Exception):
pass

Expand Down
10 changes: 10 additions & 0 deletions specifyweb/backend/context/collection_access.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
def users_collections_for_sp6(cursor, user_id):
cursor.execute("""
select distinct c.usergroupscopeid, c.collectionname from collection c
inner join spprincipal p on p.usergroupscopeid = c.usergroupscopeid
inner join specifyuser_spprincipal up on up.spprincipalid = p.spprincipalid
inner join specifyuser u on u.specifyuserid = up.specifyuserid and p.grouptype is null
where up.specifyuserid = %s
""", [user_id])

return list(cursor.fetchall())
12 changes: 1 addition & 11 deletions specifyweb/backend/context/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
from .app_resource import get_app_resource, FORM_RESOURCE_EXCLUDED_LST
from .schema_localization import get_schema_languages, get_schema_localization
from .viewsets import get_views
from .collection_access import users_collections_for_sp6
from specifyweb.backend.setup_tool.api import (
get_config_progress,
filter_ready_disciplines_for_config_tasks,
Expand All @@ -53,17 +54,6 @@
def set_collection_cookie(response, collection_id): # pragma: no cover
response.set_cookie('collection', str(collection_id), max_age=365*24*60*60)

def users_collections_for_sp6(cursor, user_id): # pragma: no cover
cursor.execute("""
select distinct c.usergroupscopeid, c.collectionname from collection c
inner join spprincipal p on p.usergroupscopeid = c.usergroupscopeid
inner join specifyuser_spprincipal up on up.spprincipalid = p.spprincipalid
inner join specifyuser u on u.specifyuserid = up.specifyuserid and p.grouptype is null
where up.specifyuserid = %s
""", [user_id])

return list(cursor.fetchall())

def users_collections_for_sp7(userid: int) -> list: # pragma: no cover
return [
c
Expand Down
3 changes: 2 additions & 1 deletion specifyweb/backend/express_search/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@
from specifyweb.backend.stored_queries.execution import DefaultQueryFormatterProps, filter_by_collection
from specifyweb.backend.stored_queries.format import ObjectFormatter
from specifyweb.backend.stored_queries.queryfield import QueryField
from specifyweb.backend.stored_queries.query_construct import QueryConstruct
from specifyweb.backend.stored_queries.queryfieldspec import QueryFieldSpec

logger = logging.getLogger(__name__)
Expand All @@ -32,6 +31,8 @@ def get_express_search_config(collection, user):


def build_primary_query(session, searchtable, terms, collection, user, as_scalar=False):
from specifyweb.backend.stored_queries.query_construct_model import QueryConstruct

table = datamodel.get_table(searchtable.find('tableName').text)
check_table_permissions(collection, user, table, "read")

Expand Down
2 changes: 1 addition & 1 deletion specifyweb/backend/permissions/initialize.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ def assign_users_to_roles(apps=apps) -> None:
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
from specifyweb.backend.context.collection_access import users_collections_for_sp6

Role = apps.get_model('permissions', 'Role')
UserPolicy = apps.get_model('permissions', 'UserPolicy')
Expand Down
120 changes: 120 additions & 0 deletions specifyweb/backend/stats/coordination.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import hashlib
import json
import logging
from collections.abc import Callable
from typing import cast
from contextlib import contextmanager

from django.http import JsonResponse
from redis.exceptions import RedisError

from specifyweb.backend.cache.redis.connect import RedisString, RedisConnection
from specifyweb.backend.permissions.permissions import check_table_permissions
from specifyweb.specify.models_utils.lock_tables import LockDispatcher

logger = logging.getLogger(__name__)

STATISTICS_CACHE_SECONDS = 300
STATISTICS_RETRY_SECONDS = 2


class StatisticsBusy(Exception):
pass


def busy_response() -> JsonResponse:
response = JsonResponse(
{"message": "Statistics are already being calculated. Please retry."},
status=409,
)
response["X-Specify-Retry-After"] = str(STATISTICS_RETRY_SECONDS)
return response


def statistics_result(
collection,
user,
query_key: str,
calculate: Callable[[], str],
) -> str:
"""Reuse cached statistics without handling query validity.

Callers must enforce current permissions before returning the result to the
user because a cache hit skips calculate. On a miss, calculate is responsible
for normal query construction and execution; exceptions propagate and failed
calculations are not cached.
"""
try:
redis_cache = RedisString(RedisConnection())
except (ValueError, RedisError):
logger.warning("Statistics cache unavailable; calculating without caching.", exc_info=True)
with run_statistics_query(calculate) as result:
return result

digest = hashlib.sha256(query_key.encode()).hexdigest()
redis_cache_key = f"statistics:{collection.id}:{user.id}:{digest}"

skip_caching = False
try:
cached_value = redis_cache.get(redis_cache_key)
except RedisError:
logger.warning("Statistics cache read failed; calculating without caching.", exc_info=True)
cached_value = None
skip_caching = True

if cached_value is not None:
# We know this should be a string because the RedisConnection has
# decode_responses=True and we should only be setting strings to this
# key
return cast(str, cached_value)

with run_statistics_query(calculate) as result:
if skip_caching:
return result
try:
redis_cache.set(redis_cache_key, result, time_to_live=STATISTICS_CACHE_SECONDS)
except RedisError:
logger.warning("Statistics cache write failed; returning uncached result.", exc_info=True)
return result

@contextmanager
def run_statistics_query(calculate: Callable[[], str]):
"""
This context manager will hold statistics locks until your code completely
exits.
If statistics locks are already being held, this will instead raise a
StatisticsBusy exception.

Example:
```py
with run_statistics_query(calculate) as result:
# if the locks are already being held, StatisticsBusy is raised
# otherwise, statistics locks are acquired and calculate is ran
# do something with the final result here
print("out of block, statistics locks are released")
# once the with block is exited, the statistics locks will be released
```
"""
# A database advisory lock has no lease that could expire during a slow query.
# Keep the lock database-scoped while letting the dispatcher bound its name.
with LockDispatcher(lock_name_separator=":") as locks:
try:
locks.acquire("statistics", timeout=0)
except TimeoutError as error:
logger.debug("Deferring statistics calculation: %s", error)
raise StatisticsBusy from error

result = calculate()
yield result

def statistics_content(request, tables, calculate: Callable[[], str]) -> str:
for table in tables:
check_table_permissions(
request.specify_collection, request.specify_user, table, "read"
)
return statistics_result(
request.specify_collection,
request.specify_user,
json.dumps([request.path, sorted(request.GET.items())]),
calculate,
)
Loading
Loading