Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
3b0b36e
Initial Django 5.2 upgrade
acwhite211 May 1, 2026
eef1949
Remove old USE_L10N, not needed in new version
acwhite211 May 1, 2026
ed2cf1d
Fix ManyToManyField through_fields for Django upgrade
acwhite211 May 1, 2026
0033c8d
Replace the removed models.NullBooleanField inheritance with a local …
acwhite211 May 1, 2026
530e6a9
auto-update from django-update
g1rly-c0d3r Jul 9, 2026
02206b4
expose django settings to pytest
g1rly-c0d3r Jul 9, 2026
0f3c25a
Merge branch 'main' into issue-8046
g1rly-c0d3r Jul 14, 2026
ffecf67
Merge branch 'main' into issue-8046
g1rly-c0d3r Jul 15, 2026
aed5838
Lint code with ESLint and Prettier
g1rly-c0d3r Jul 15, 2026
686e165
fix: make logging out a `POST` request rather than a `GET` request
g1rly-c0d3r Jul 16, 2026
31c2acb
chore: error handling for when the logout request fails
g1rly-c0d3r Jul 17, 2026
6ef25c4
fix: revert unintended change to specify_settings.py
g1rly-c0d3r Jul 17, 2026
e2b0eee
fix: revert unintended change to specify_settings.py
g1rly-c0d3r Jul 17, 2026
a65c822
fix: remove unused import from `inheritence/urls.py`
g1rly-c0d3r Jul 17, 2026
b602879
Merge branch 'main' into issue-8046
g1rly-c0d3r Jul 17, 2026
82ee2fe
fix: update Missing agent to point to new logout endpoint
g1rly-c0d3r Jul 17, 2026
a963629
fix: update `ChooseCollection` to use the new api for logging out
g1rly-c0d3r Jul 17, 2026
15c82a2
fix: fix 421 error code caused by race condition from two `.then()` c…
g1rly-c0d3r Jul 20, 2026
3246bbd
Sync localization strings with Weblate
Jul 18, 2026
614994b
Sync localization strings with Weblate
Jul 18, 2026
ac18076
Sync localization strings with Weblate
Maxime-Griveau Jul 20, 2026
1dc34ca
SQUASH LATER: broken test
g1rly-c0d3r Jul 21, 2026
435c163
test: adding unit tests
g1rly-c0d3r Jul 22, 2026
219e60c
test: remove front-end logout tests
g1rly-c0d3r Jul 22, 2026
6a6d62d
Merge branch 'main' into issue-8046
g1rly-c0d3r Jul 22, 2026
6729ea7
fix: correctly call `userText.logOut()` for label
g1rly-c0d3r Jul 22, 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: 2 additions & 0 deletions pytest.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[pytest]
DJANGO_SETTINGS_MODULE = specifyweb.settings
10 changes: 8 additions & 2 deletions requirements-testing.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
django-stubs==1.12.0
# django-stubs==1.12.0
django-stubs==5.2.9
mypy==1.5.1
Comment on lines +2 to 3

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== requirements-testing.txt =="
cat -n requirements-testing.txt

echo
echo "== nearby django-stubs/mypy pins =="
rg -n 'django-stubs|mypy' requirements*.txt pyproject.toml setup.cfg tox.ini 2>/dev/null || true

echo
echo "== lock/config references =="
fd -a '.*requirements.*|.*constraints.*|pyproject.toml|setup.cfg|tox.ini' . | sed 's#^\./##' | head -100

Repository: specify/specify7

Length of output: 792


🌐 Web query:

django-stubs 5.2.9 mypy compatibility supported versions

💡 Result:

For django-stubs version 5.2.9, the supported environment configurations are as follows [1][2]: Mypy versions: 1.13 through 1.19 [1][2] Django versions: 5.2 (full support), with partial support for Django 5.1 and 5.0 [1][2] Python versions: 3.10 through 3.13 [1][2] Version 5.2.9 was the final release of the 5.2.x series for django-stubs, designed to target Django 5.2 [3][4]. Subsequent releases transitioned to supporting Django 6.0 [3][4]. When using "partial support" for older Django versions, the stubs may contain newer type definitions (e.g., if a return type changed between versions) or include symbols not present in your specific, older Django installation [5]. It is generally recommended to use the version of django-stubs that corresponds to your installed Django version to ensure full compatibility [5].

Citations:


Align mypy with django-stubs 5.2.9.

django-stubs 5.2.9 documents supported mypy versions as 1.13–1.19, but this file pins mypy==1.5.1. Use a supported mypy pin here, such as mypy==1.13.0, or keep the older django-stubs version if mypy 1.5.1 is required.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@requirements-testing.txt` around lines 2 - 3, Update the mypy dependency pin
in requirements-testing.txt to a version supported by django-stubs==5.2.9, such
as mypy==1.13.0; retain the django-stubs pin and do not keep the incompatible
mypy==1.5.1 combination.

hypothesis>=6.131.9,<7
hypothesis-jsonschema==0.19.0
types-requests==2.28.5
lxml
lxml
debugpy==1.6.5
pytest==7.2.1
pytest-django==4.12.0
django-extensions==4.1
# django-debug-toolbar==4.4.6
4 changes: 2 additions & 2 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,14 @@ wheel
kombu==5.5.2
redis==6.4.0
celery==5.5.1
Django==4.2.30
Django==5.2.13
mysqlclient==2.1.1
SQLAlchemy==1.4.54
sqlalchemy2-stubs
requests==2.32.4
pycryptodome==3.21.0
PyJWT==2.12.1

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== requirements.txt =="
sed -n '1,60p' requirements.txt 2>/dev/null || true

echo
echo "== PyJWT references in repo =="
rg -n --fixed-strings "PyJWKClient|PyJWT|requests_jwt|jwt\." -S . --glob '!*.lock' --glob '!node_modules/**' --glob '!dist/**' --glob '!build/**' | head -200 || true

echo
echo "== lockfiles mentioning PyJWT =="
for f in $(git ls-files | rg '(^|/)(requirements.*\.txt|poetry\.lock|Pipfile\.lock|uv\.lock|package-lock\.json|yarn\.lock|pnpm-lock\.yaml)$'); do
  echo "--- $f"
  rg -n --fixed-strings "PyJWT|pyjwt|jwk|jwk-set" "$f" || true
done

Repository: specify/specify7

Length of output: 673


🌐 Web query:

OSV GHSA-993g-76c3-p5m4 PyJWT PyJWKClient scheme-validation fixed version

💡 Result:

The vulnerability GHSA-993g-76c3-p5m4 (also tracked as CVE-2026-48522) involves a missing scheme allowlist in PyJWT's PyJWKClient, which allowed the library to fetch URIs using schemes beyond the expected http and https (such as file://, ftp://, and data:) [1][2][3]. This flaw enabled potential Server-Side Request Forgery (SSRF) and, in specific configurations, token forgery [2][4]. The vulnerability is fixed in PyJWT version 2.13.0 [3][5]. In this release, PyJWKClient was updated to strictly reject any URI scheme that is not explicitly http or https [5][6]. Additionally, a new allowed_schemes initialization parameter was added to allow for future configuration of allowed schemes [1][2].

Citations:


🌐 Web query:

GitHub PyJWKClient GHSA-993g-76c3-p5m4 fixed commit PyJWT 2.12.1 PyJWT 2.13.0 allow_schemes

💡 Result:

The vulnerability GHSA-993g-76c3-p5m4, also identified as CVE-2026-48522, addressed a security flaw in PyJWT's PyJWKClient where it lacked a scheme allowlist [1][2]. This allowed the client to fetch JWKS data using unintended schemes such as file://, ftp://, and data:, potentially leading to SSRF or token forgery if an application accepted attacker-influenced URIs [1][3]. This issue was fixed in PyJWT version 2.13.0 [4][5]. The fix introduced an allowed_schemes parameter to the PyJWKClient constructor [6][7]. Key details of the fix: - PyJWKClient now defaults to allowed_schemes=("http", "https") [7]. - The library performs pre-validation of the URI scheme before invoking urllib.request.urlopen [1][7]. - If a scheme is not in the allowed list, PyJWKClient raises a PyJWKClientError [1][7]. - This change allows developers to maintain backward compatibility for standard HTTP/HTTPS use cases while providing the flexibility to enable other schemes explicitly if required [6][7]. It is important to note that this update specifically addresses the URI scheme restriction [1][5]. It is distinct from another security fix, GHSA-jq35-7prp-9v3f, which was also released in PyJWT 2.13.0 and addressed an algorithm allow-list bypass when using PyJWK keys [8][9].

Citations:


Upgrade PyJWT to avoid known PyJWKClient vulnerabilities.

PyJWT==2.12.1 is affected by PyJWKClient scheme-validation issues; update to 2.13.0 or later so the default JWKS fetches are limited to allowed HTTP(S) schemes.

🧰 Tools
🪛 OSV Scanner (2.4.0)

[HIGH] 14-14: pyjwt 2.12.1: undefined

(PYSEC-2026-175)


[HIGH] 14-14: pyjwt 2.12.1: undefined

(PYSEC-2026-177)


[HIGH] 14-14: pyjwt 2.12.1: undefined

(PYSEC-2026-178)


[HIGH] 14-14: pyjwt 2.12.1: undefined

(PYSEC-2026-179)


[HIGH] 14-14: pyjwt 2.12.1: PyJWKClient: missing scheme allowlist enables CVE-2024-21643-class SSRF + token forgery via file://, ftp://, data: schemes

(GHSA-993g-76c3-p5m4)


[HIGH] 14-14: pyjwt 2.12.1: PyJWKClient unbounded JWKS endpoint requests via attacker-controlled kid values (DoS)

(GHSA-fhv5-28vv-h8m8)


[HIGH] 14-14: pyjwt 2.12.1: PyJWT: Algorithm allow-list bypass when decoding with PyJWK / PyJWKClient keys

(GHSA-jq35-7prp-9v3f)


[HIGH] 14-14: pyjwt 2.12.1: PyJWT: Unauthenticated DoS via unbounded Base64URL decoding of unused payload segment in b64=false detached JWS

(GHSA-w7vc-732c-9m39)


[HIGH] 14-14: pyjwt 2.12.1: PyJWT: Public-key JWK accepted as HMAC secret enables forged HS256 tokens when mixed families are allowed

(GHSA-xgmm-8j9v-c9wx)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@requirements.txt` at line 14, Update the PyJWT dependency version in
requirements.txt from 2.12.1 to 2.13.0 or later, ensuring the pinned version
includes the PyJWKClient scheme-validation fix.

Source: Linters/SAST tools

django-auth-ldap==1.2.17
django-auth-ldap==5.3.0
jsonschema==4.0.1
typing-extensions==4.12.2
stream-zip==0.0.83
Expand Down
8 changes: 4 additions & 4 deletions specifyweb/backend/batch_identify/urls.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
from django.urls import re_path
from django.urls import path

from . import views

urlpatterns = [
re_path(r'^batch_identify/resolve/$', views.batch_identify_resolve),
re_path(r'^batch_identify/validate_record_set/$', views.batch_identify_validate_record_set),
re_path(r'^batch_identify/$', views.batch_identify),
path('batch_identify/resolve/', views.batch_identify_resolve),
path('batch_identify/validate_record_set/', views.batch_identify_validate_record_set),
path('batch_identify/', views.batch_identify),
]
12 changes: 6 additions & 6 deletions specifyweb/backend/context/testurls.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@
Provides urls to access the mocked views
"""

from django.urls import path, re_path
from django.urls import path

from . import testsviews as views

urlpatterns = [
path('collection/', views.collection),
re_path(r'^domain.json$', views.domain),
re_path(r'^viewsets/(?P<level>\d+).xml$', views.viewsets),
re_path(r'^schema_localization.json$', views.schema_localization),
re_path(r'^app.resource$', views.app_resource),
re_path(r'^available_related_searches.json$', views.available_related_searches),
path('domain.json', views.domain),
path('viewsets/<int:level>.xml', views.viewsets),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf 'Files matching testurls.py:\n'
fd 'testurls\.py$' . || true

printf '\nGit-tracked context testurls.py:\n'
git ls-files | rg '(^|/)testurls\.py$' | sed -n '1,50p'

printf '\nRelevant snippets in context testurls.py:\n'
while IFS= read -r f; do
  echo "--- $f"
  nl -ba "$f" | sed -n '1,40p'
done < <(git ls-files | rg 'context/testurls\.py$' || true)

printf '\nSearch for viewsets and level=':
rg -n "def viewsets|viewsets|'viewset_'\+level|viewset_|level\.xml" --glob '*.py' .

Repository: specify/specify7

Length of output: 392


🏁 Script executed:

#!/bin/bash
set -u

echo "### testurls.py"
nl -ba specifyweb/backend/context/testurls.py | sed -n '1,80p'

echo
echo "### viewsets usages / level handling"
rg -n "def viewsets|viewsets|'viewset_'|\"viewset_|level\.xml|viewset_" --glob '*.py' .

echo
echo "### Django int converter behavior probe"
python3 - <<'PY'
try:
    from django.urls.converters import IntConverter
    ic = IntConverter()
    converted = ic.to_python('05')
    print(type(converted).__name__, repr(converted))
    print(type(converted).__name__, type('').__name__, isinstance('', str))
    print(converted + '/abc.xml')
except Exception as e:
    print(type(e).__name__, str(e))
PY

Repository: specify/specify7

Length of output: 3369


🏁 Script executed:

#!/bin/bash
set -u

echo "### specifyweb/backend/context/testurls.py"
awk '{printf "%6d\t%s\n", NR, $0}' specifyweb/backend/context/testurls.py | sed -n '1,80p'

echo
echo "### specifyweb/backend/context/testsviews.py"
awk '{printf "%6d\t%s\n", NR, $0}' specifyweb/backend/context/testsviews.py | sed -n '1,60p'

echo
echo "### viewsets path/url/import references"
rg -n "testurls|testsviews|viewsets/<int:level>|def viewsets\(request, level\)" --glob '*.py' .

Repository: specify/specify7

Length of output: 2308


🌐 Web query:

Django URL converters int converter documentation

💡 Result:

In Django, the int URL converter is used within path patterns to match a sequence of digits and convert the captured value into a Python integer [1][2]. Key technical details: - Matching behavior: The int converter matches zero or any positive integer [1][2]. - Type conversion: It automatically converts the captured string value into a Python int before passing it as a keyword argument to the view function [1][3]. - Implementation: Internally, it uses the regular expression [0-9]+ to identify matching segments [4]. - Usage: You use it in a URL pattern by placing it inside angle brackets, such as path('articles/int:year/', views.year_archive) [1][2]. If you need different matching logic (for example, to support negative numbers or specific constraints), you can register a custom path converter using the register_converter function [1][2][3].

Citations:


Keep level as a string.

Django’s <int:level> converter passes an int, while testsviews.viewsets() concatenates it with strings, raising a TypeError for this URL when called.

Proposed fix
-    path('viewsets/<int:level>.xml', views.viewsets),
+    path('viewsets/<str:level>.xml', views.viewsets),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
path('viewsets/<int:level>.xml', views.viewsets),
path('viewsets/<str:level>.xml', views.viewsets),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@specifyweb/backend/context/testurls.py` at line 12, Update the URL pattern
for views.viewsets to use a string-compatible path converter instead of
<int:level>, so the level argument remains a string when passed to
testsviews.viewsets().

path('schema_localization.json', views.schema_localization),
path('app.resource', views.app_resource),
path('available_related_searches.json', views.available_related_searches),
]
42 changes: 21 additions & 21 deletions specifyweb/backend/context/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
Defines the urls for the app context subsystem
"""

from django.urls import path, re_path
from django.urls import path
from django.urls import path

from . import views, user_resources, collection_resources
Expand All @@ -16,26 +16,26 @@
path('language/', views.languages),
path('schema/language/', views.schema_language),

re_path(r'^api_endpoints.json$', views.api_endpoints),
re_path(r'^api_endpoints_all.json$', views.api_endpoints_all),
re_path(r'^user.json$', views.user),
re_path(r'^stats_counts.json$', views.stats_counts),
re_path(r'^system_info.json$', views.system_info),
re_path(r'^all_system_data.json$', views.all_system_data),
re_path(r'^all_system_config_data.json$', views.all_system_config_data),
re_path(r'^server_time.json$', views.get_server_time),
re_path(r'^domain.json$', views.domain),
re_path(r'^view.json$', views.view),
re_path(r'^views.json$', views.views),
re_path(r'^viewsets.json$', views.viewsets),
re_path(r'^datamodel.json$', views.datamodel),
re_path(r'^schema_localization.json$', views.schema_localization),
re_path(r'^app.resource$', views.app_resource),
re_path(r'^available_related_searches.json$', views.available_related_searches),
re_path(r'^remoteprefs.properties$', views.remote_prefs),

re_path(r'^attachment_settings.json$', attachment_settings),
re_path(r'^report_runner_status.json$', report_runner_status),
path('api_endpoints.json', views.api_endpoints),
path('api_endpoints_all.json', views.api_endpoints_all),
path('user.json', views.user),
path('stats_counts.json', views.stats_counts),
path('system_info.json', views.system_info),
path('all_system_data.json', views.all_system_data),
path('all_system_config_data.json', views.all_system_config_data),
path('server_time.json', views.get_server_time),
path('domain.json', views.domain),
path('view.json', views.view),
path('views.json', views.views),
path('viewsets.json', views.viewsets),
path('datamodel.json', views.datamodel),
path('schema_localization.json', views.schema_localization),
path('app.resource', views.app_resource),
path('available_related_searches.json', views.available_related_searches),
path('remoteprefs.properties', views.remote_prefs),

path('attachment_settings.json', attachment_settings),
path('report_runner_status.json', report_runner_status),

path('user_resource/', user_resources.user_resources),
path('user_resource/<int:resourceid>/', user_resources.user_resource),
Expand Down
8 changes: 4 additions & 4 deletions specifyweb/backend/inheritance/urls.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@

from django.urls import include, path, re_path
from django.urls import path

from specifyweb.backend.inheritance import views

urlpatterns = [
# cat num for siblings
re_path(r'^catalog_number_for_sibling/$', views.catalog_number_for_sibling),
path('catalog_number_for_sibling/', views.catalog_number_for_sibling),

# cat num for parent
re_path(r'^catalog_number_from_parent/$', views.catalog_number_from_parent),
]
path('catalog_number_from_parent/', views.catalog_number_from_parent),
]
12 changes: 6 additions & 6 deletions specifyweb/backend/setup_tool/urls.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@

from django.urls import re_path
from django.urls import path

from . import views

urlpatterns = [
# check if the db is new at login
re_path(r'^setup_progress/$', views.get_setup_progress),
re_path(r'^config_progress/$', views.get_config_progress),
path('setup_progress/', views.get_setup_progress),
path('config_progress/', views.get_config_progress),

re_path(r'^setup_database/create/$', views.setup_database_view),
path('setup_database/create/', views.setup_database_view),

re_path(r'^discipline_and_trees/create/$', views.create_discipline_and_trees),
re_path(r'^running_worker_tasks/$', views.get_running_worker_tasks),
path('discipline_and_trees/create/', views.create_discipline_and_trees),
path('running_worker_tasks/', views.get_running_worker_tasks),

# These urls are functional but unused by the setup process. The API can be used instead.
# re_path(r'^institution/create/$', views.create_institution_view),
Expand Down
4 changes: 2 additions & 2 deletions specifyweb/backend/trees/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@

# Create new trees
path('create_default_tree/', views.create_default_tree_view),
re_path(r'^create_default_tree/status/(?P<task_id>[^/]+)/$', views.default_tree_upload_status),
re_path(r'^create_default_tree/abort/(?P<task_id>[^/]+)/$', views.abort_default_tree_creation),
path('create_default_tree/status/<str:task_id>/', views.default_tree_upload_status),
path('create_default_tree/abort/<str:task_id>/', views.abort_default_tree_creation),
path('default_tree_mapping/', views.default_tree_mapping),
path('db_encoding/', views.get_db_encoding),
]
6 changes: 1 addition & 5 deletions specifyweb/frontend/js_src/lib/components/Atoms/Icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -151,11 +151,7 @@ export const dialogIcons: RR<
> = {
none: undefined,
error: <span className="text-red-500">{icons.exclamationCircle}</span>,
warning: (
<span className="text-red-500">
{icons.exclamation}
</span>
),
warning: <span className="text-red-500">{icons.exclamation}</span>,
success: <span className="text-green-500">{icons.checkCircle}</span>,
info: icons.informationCircle,
question: icons.questionCircle,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -172,9 +172,9 @@ function Wrapped({
<Button.BorderedGray
onClick={(): void =>
loading(
ping('/accounts/logout/').then(() =>
ping('/accounts/logout/', {method: 'POST'}).then(() =>
globalThis.location.assign(
formatUrl('/accounts/logout/', { next: nextUrl })
formatUrl('/specify/command/logout/', { next: nextUrl })
)
)
)
Expand Down
2 changes: 1 addition & 1 deletion specifyweb/frontend/js_src/lib/components/Core/Main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ function MissingAgent(): JSX.Element {
}}
forceToTop
header={userText.noAgent()}
onClose={(): void => globalThis.location.assign('/accounts/logout/')}
onClose={(): void => globalThis.location.assign('/specify/command/logout/')}
>
{userText.noAgentDescription()}
</Dialog>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ const rawUserTools = ensure<IR<IR<Omit<MenuItem, 'name'>>>>()({
[userText.userAccount()]: {
logOut: {
title: userText.logOut(),
url: '/accounts/logout/',
url: '/specify/command/logout/',
icon: icons.logout,
enabled: () => userInformation.isauthenticated,
onClick: async () =>
Expand Down
23 changes: 23 additions & 0 deletions specifyweb/frontend/js_src/lib/components/Logout/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import React from 'react';
import { ajax } from '../../utils/ajax';
import { LoadingScreen } from '../Molecules/Dialog';
import { formatUrl } from '../Router/queryString';
import { LoadingContext } from '../Core/Contexts';
import { softError } from '../Errors/assert';

export function Logout() : JSX.Element {
const loading = React.useContext(LoadingContext);

loading(
ajax<string>('/accounts/logout/', {
method: 'POST',
headers: { },
})
.then(
() => globalThis.location.assign(formatUrl("/accounts/login/", {next: "/specify/"})),
Comment thread
g1rly-c0d3r marked this conversation as resolved.
(error) => softError(error)
)
)
Comment on lines +11 to +20

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate file =="
fd -a 'index\.tsx$' . | sed 's#^\./##' | grep 'frontend/js_src/lib/components/Logout/index.tsx' || true

echo "== file contents =="
cat -n specifyweb/frontend/js_src/lib/components/Logout/index.tsx

echo "== definitions/usages =="
rg -n "function Loading|const Loading|export .*Loading|function ajax|const ajax|function softError|const softError|loading\\(" specifyweb/frontend/js_src/lib/components specifyweb/frontend/js_src/lib 2>/dev/null | head -200

Repository: specify/specify7

Length of output: 22738


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== candidate files for LoadingContext/loading/softError/Route components =="
rg -n "LoadingContext|LoadingScreen|loading\\(|softError|formatUrl\\(" specifyweb/frontend/js_src/lib -S

echo "== structural scan for Loading component definition =="
ast-grep scan --lang tsx --rule 'kind: Component; matcher: {kind: FunctionDeclaration, has: {kind: BlockStatement}}' specifyweb/frontend/js_src/lib/components 2>/dev/null || true

Repository: specify/specify7

Length of output: 50372


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== LoadingContext implementation =="
sed -n '1,230p' specifyweb/frontend/js_src/lib/components/Core/Contexts.tsx

echo "== ajax implementation relevant section =="
sed -n '1,155p' specifyweb/frontend/js_src/lib/utils/ajax/index.ts

echo "== static checker for render-time loading calls =="
python3 - <<'PY'
from pathlib import Path
p = Path('specifyweb/frontend/js_src/lib/components/Logout/index.tsx')
text = p.read_text()
print('contains ajax call:', 'ajax<string>' in text)
print('inside function body:')
start = text.index('export function Logout')
end = text.index('return <LoadingScreen />') + len('return <LoadingScreen />')
body = text[start:end]
print(body)
print('has useEffect:', 'useEffect' in body)
PY

Repository: specify/specify7

Length of output: 14011


Move the logout request into an effect.

ajax<...>('/accounts/logout/', ...) creates a new fetch promise during every render, so rerenders can submit logout multiple times; start this one-time POST from a stable effect.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@specifyweb/frontend/js_src/lib/components/Logout/index.tsx` around lines 11 -
20, Move the logout request and its loading/redirect error handling from the
component render path into a mount-only effect, using the component’s existing
effect and loading utilities. Ensure the POST to /accounts/logout/ starts once
after mount, while preserving the current success redirect and softError
behavior.


return <LoadingScreen />
Comment on lines +16 to +22

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Handle failures without leaving an endless loading screen.

softError only logs in production, while LoadingScreen resolves its pending promise only on unmount. A failed POST therefore leaves the user stuck on the loading screen. Render an error/retry state or navigate to a terminal fallback.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@specifyweb/frontend/js_src/lib/components/Logout/index.tsx` around lines 16 -
22, Update the logout failure handler in the promise chain around softError so a
failed POST transitions away from the indefinitely displayed LoadingScreen.
Render an error/retry state or navigate to a terminal fallback on rejection,
while preserving the existing successful redirect behavior.

}
8 changes: 8 additions & 0 deletions specifyweb/frontend/js_src/lib/components/Router/Routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,14 @@ export const routes: RA<EnhancedRoute> = [
({ CacheBuster }) => CacheBuster
),
},
{
path: 'logout',
title: userText.logOut(),
element: () =>
import('../Logout').then(
({ Logout }) => Logout
)
},
],
},
{
Expand Down
4 changes: 0 additions & 4 deletions specifyweb/settings/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,10 +150,6 @@ def get_sa_db_url(db_name):
# to load the internationalization machinery.
USE_I18N = True

# If you set this to False, Django will not format dates, numbers and
# calendars according to the current locale
USE_L10N = True

LANGUAGE_COOKIE_NAME='language'

# Absolute filesystem path to the directory that will hold user-uploaded files.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Generated by Django 5.2.13 on 2026-04-30

from django.db import migrations, models

class Migration(migrations.Migration):
dependencies = [
('specify', '0044_alter_deletion_cascade'),
]

operations = [
migrations.AlterField(
model_name='autonumberingscheme',
name='collections',
field=models.ManyToManyField(related_name='numberingschemes', through='specify.Autonumschcoll', through_fields=('autonumberingscheme', 'collection'), to='specify.collection'),
),
migrations.AlterField(
model_name='autonumberingscheme',
name='disciplines',
field=models.ManyToManyField(related_name='numberingschemes', through='specify.Autonumschdsp', through_fields=('autonumberingscheme', 'discipline'), to='specify.discipline'),
),
migrations.AlterField(
model_name='autonumberingscheme',
name='divisions',
field=models.ManyToManyField(related_name='numberingschemes', through='specify.Autonumschdiv', through_fields=('autonumberingscheme', 'division'), to='specify.division'),
),
migrations.AlterField(
model_name='project',
name='collectionobjects',
field=models.ManyToManyField(related_name='projects', through='specify.Project_colobj', through_fields=('project', 'collectionobject'), to='specify.collectionobject'),
),
migrations.AlterField(
model_name='specifyuser',
name='spprincipals',
field=models.ManyToManyField(related_name='specifyusers', through='specify.Specifyuser_spprincipal', through_fields=('specifyuser', 'spprincipal'), to='specify.spprincipal'),
),
migrations.AlterField(
model_name='spexportschema',
name='mappings',
field=models.ManyToManyField(related_name='spexportschemas', through='specify.Spexportschema_exportmapping', through_fields=('spexportschema', 'spexportschemamapping'), to='specify.spexportschemamapping'),
),
migrations.AlterField(
model_name='spprincipal',
name='sppermissions',
field=models.ManyToManyField(related_name='spprincipals', through='specify.Spprincipal_sppermission', through_fields=('spprincipal', 'sppermission'), to='specify.sppermission'),
),
]
23 changes: 12 additions & 11 deletions specifyweb/specify/urls.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
# Entrypoint for the routing of the app

from django.urls import path
from django.urls import include, re_path

from specifyweb.specify import views
from specifyweb.specify.models_utils import schema

urlpatterns = [
# the main business data API
re_path(r'^specify_schema/openapi.json$', schema.openapi),
path('specify_schema/openapi.json', schema.openapi),
re_path(r'^specify_schema/(?P<model>\w+)/$', schema.view),
re_path(r'^specify/', include('specifyweb.backend.batch_identify.urls')), # batch identify
path('specify/', include('specifyweb.backend.batch_identify.urls')), # batch identify
re_path(r'^specify/(?P<model>\w+)/(?P<id>\d+)/$', views.resource), # permissions added
re_path(r'^specify/(?P<model>\w+)/$', views.collection), # permissions added

Expand All @@ -19,29 +20,29 @@
# === Backwards compatibility ===

# Merge endpoints
re_path(r'^specify/merge/', include('specifyweb.backend.merge.urls')),
path('specify/merge/', include('specifyweb.backend.merge.urls')),

# Inheritance (catalog number endpoints)
re_path(r'^specify/', include('specifyweb.backend.inheritance.urls')),
path('specify/', include('specifyweb.backend.inheritance.urls')),

# Series endpoints
re_path(r'^specify', include('specifyweb.backend.series.urls')),
path('specify', include('specifyweb.backend.series.urls')),

# Table rows
re_path(r'^', include('specifyweb.backend.table_rows.urls')),
path('', include('specifyweb.backend.table_rows.urls')),

# Delete blockers
re_path(r'^', include('specifyweb.backend.delete_blockers.urls')),
path('', include('specifyweb.backend.delete_blockers.urls')),

## Bulk copy
re_path(r'^specify', include('specifyweb.backend.bulk_copy.urls')),
path('specify', include('specifyweb.backend.bulk_copy.urls')),

# Trees
re_path(r'^', include('specifyweb.backend.trees.urls')),
path('', include('specifyweb.backend.trees.urls')),

# Locality update tool
re_path(r'^', include('specifyweb.backend.locality_update_tool.urls')),
path('', include('specifyweb.backend.locality_update_tool.urls')),

# Master key + User management
re_path(r'^', include('specifyweb.backend.accounts.urls')),
path('', include('specifyweb.backend.accounts.urls')),
]
7 changes: 6 additions & 1 deletion specifyweb/specify/utils/case_insensitive_bool.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,12 @@ def to_python(self, value):
return False
return super().to_python(value)

class NullBooleanField(models.NullBooleanField):
class NullBooleanField(BooleanField):
def __init__(self, *args, **kwargs):
kwargs.setdefault('null', True)
kwargs.setdefault('blank', True)
super().__init__(*args, **kwargs)

def to_python(self, value):
if value == 'true':
return True
Expand Down
Loading
Loading