diff --git a/requirements-testing.txt b/requirements-testing.txt index aab161ee7f1..7afca2e23d2 100644 --- a/requirements-testing.txt +++ b/requirements-testing.txt @@ -1,8 +1,12 @@ -django-stubs==1.12.0 -mypy==1.5.1 +# django-stubs==1.12.0 +django-stubs==5.2.9 +mypy==1.13.0 hypothesis>=6.131.9,<7 hypothesis-jsonschema==0.19.0 types-requests==2.28.5 lxml -coverage -pytest-django +debugpy==1.6.5 +pytest==7.2.1 +pytest-django==4.12.0 +django-extensions==4.1 +coverage==7.15.2 diff --git a/requirements.txt b/requirements.txt index e424999bd63..cafc7294af9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 -django-auth-ldap==1.2.17 +PyJWT==2.13.0 +django-auth-ldap==5.3.0 jsonschema==4.0.1 typing-extensions==4.12.2 stream-zip==0.0.83 diff --git a/specifyweb/backend/accounts/views.py b/specifyweb/backend/accounts/views.py index b944befe109..b2dc0358d35 100644 --- a/specifyweb/backend/accounts/views.py +++ b/specifyweb/backend/accounts/views.py @@ -193,7 +193,7 @@ def oic_callback(request: http.HttpRequest) -> http.HttpResponse: spuserexternalid.save() login(request, - cast(AbstractBaseUser, spuserexternalid.specifyuser), + cast(Specifyuser, spuserexternalid.specifyuser), backend='django.contrib.auth.backends.ModelBackend') return http.HttpResponseRedirect('/accounts/choose_collection') diff --git a/specifyweb/backend/batch_identify/urls.py b/specifyweb/backend/batch_identify/urls.py index cb3d477f44c..1334f7cd43c 100644 --- a/specifyweb/backend/batch_identify/urls.py +++ b/specifyweb/backend/batch_identify/urls.py @@ -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), ] diff --git a/specifyweb/backend/context/testurls.py b/specifyweb/backend/context/testurls.py index 23511f0c477..ca070a830aa 100644 --- a/specifyweb/backend/context/testurls.py +++ b/specifyweb/backend/context/testurls.py @@ -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\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/.xml', views.viewsets), + path('schema_localization.json', views.schema_localization), + path('app.resource', views.app_resource), + path('available_related_searches.json', views.available_related_searches), ] diff --git a/specifyweb/backend/context/urls.py b/specifyweb/backend/context/urls.py index 4b90082ac0a..22ad76b0008 100644 --- a/specifyweb/backend/context/urls.py +++ b/specifyweb/backend/context/urls.py @@ -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 @@ -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//', user_resources.user_resource), diff --git a/specifyweb/backend/inheritance/urls.py b/specifyweb/backend/inheritance/urls.py index 462139e9117..3b28d5d65f8 100644 --- a/specifyweb/backend/inheritance/urls.py +++ b/specifyweb/backend/inheritance/urls.py @@ -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), -] \ No newline at end of file + path('catalog_number_from_parent/', views.catalog_number_from_parent), +] diff --git a/specifyweb/backend/setup_tool/urls.py b/specifyweb/backend/setup_tool/urls.py index 985784585fd..11bc0df459f 100644 --- a/specifyweb/backend/setup_tool/urls.py +++ b/specifyweb/backend/setup_tool/urls.py @@ -1,17 +1,16 @@ -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_setup_progress), + path('setup_progress/', views.get_setup_progress), + path('config_progress/', views.get_setup_progress), + path('setup_database/create/', views.setup_database_view), - re_path(r'^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), @@ -22,4 +21,4 @@ # re_path(f'^taxontreedef/create/$', views.create_taxon_tree_view), # re_path(f'^collection/create/$', views.create_collection_view), # re_path(f'^specifyuser/create/$', views.create_specifyuser_view), -] \ No newline at end of file +] diff --git a/specifyweb/backend/trees/urls.py b/specifyweb/backend/trees/urls.py index cff4ea19989..527e6b70760 100644 --- a/specifyweb/backend/trees/urls.py +++ b/specifyweb/backend/trees/urls.py @@ -23,8 +23,8 @@ # Create new trees path('create_default_tree/', views.create_default_tree_view), - re_path(r'^create_default_tree/status/(?P[^/]+)/$', views.default_tree_upload_status), - re_path(r'^create_default_tree/abort/(?P[^/]+)/$', views.abort_default_tree_creation), + path('create_default_tree/status//', views.default_tree_upload_status), + path('create_default_tree/abort//', views.abort_default_tree_creation), path('default_tree_mapping/', views.default_tree_mapping), path('db_encoding/', views.get_db_encoding), ] \ No newline at end of file diff --git a/specifyweb/backend/workbench/models.py b/specifyweb/backend/workbench/models.py index f944d8c75fb..e4cf90a3484 100644 --- a/specifyweb/backend/workbench/models.py +++ b/specifyweb/backend/workbench/models.py @@ -172,7 +172,7 @@ def get_dataset_as_dict(self): return ds_dict def was_uploaded(self) -> bool: - return self.uploadresult and self.uploadresult["success"] + return bool(self.uploadresult and self.uploadresult["success"]) class Spdatasetattachment(models.Model): specify_model = datamodel.get_table_strict('spdatasetattachment') @@ -202,4 +202,4 @@ class Meta: ] - # save = partialmethod(custom_save) \ No newline at end of file + # save = partialmethod(custom_save) diff --git a/specifyweb/backend/workbench/tests.py b/specifyweb/backend/workbench/tests.py index 63a754813c8..e967dad5ef8 100644 --- a/specifyweb/backend/workbench/tests.py +++ b/specifyweb/backend/workbench/tests.py @@ -174,8 +174,11 @@ def test_create_record_set(self) -> None: no_commit=False, allow_partial=False, ) - - self.assertTrue(dataset.uploadresult["success"]) + + # ignoring this mympy type error becuase mypy deduces that the type of `uploadresult` is + # Any | None. we know it is a JSONField, but since that is a field of the parent of Spdataset, mypy can't find it. + # TODO (mypy): figure out how to tell mypy that uploadresult is iterable + self.assertTrue(dataset.uploadresult["success"]) #type: ignore response = c.post( f"/api/workbench/create_recordset/{datasetid}/", @@ -337,4 +340,4 @@ def test_transfer_dataset_rejects_invalid_user(self) -> None: self.assertEqual(response.status_code, 400) self.dataset.refresh_from_db() self.assertEqual(self.dataset.specifyuser, self.specifyuser) - self.assertFalse(Message.objects.exists()) \ No newline at end of file + self.assertFalse(Message.objects.exists()) diff --git a/specifyweb/backend/workbench/upload/predicates.py b/specifyweb/backend/workbench/upload/predicates.py index 30b3c4be5bb..78e2eebd96c 100644 --- a/specifyweb/backend/workbench/upload/predicates.py +++ b/specifyweb/backend/workbench/upload/predicates.py @@ -283,7 +283,7 @@ def canonicalize_remove_node(node: ToRemoveNode) -> Q: def _map_matchee(matchee: list[ToRemoveMatchee], model_name: str) -> Exists: - model: Model = get_model(model_name) + model = get_model(model_name) qs = [Q(**match["filter_on"]) for match in matchee] qs_or = Func.make_ors(qs) query = model.objects.filter(qs_or) @@ -309,7 +309,7 @@ class ContetRef(Exception): ] -def safe_fetch(model: Model, filters, version): +def safe_fetch(model, filters, version): if filters is None: return None try: @@ -354,4 +354,4 @@ def resolve_reference_attributes( clone_attrs = {field: getattr(reference_record, field) for field in all_fields} - return clone_attrs \ No newline at end of file + return clone_attrs diff --git a/specifyweb/frontend/js_src/lib/components/ChooseCollection/index.tsx b/specifyweb/frontend/js_src/lib/components/ChooseCollection/index.tsx index 5f0908d3c9b..53266302ad1 100644 --- a/specifyweb/frontend/js_src/lib/components/ChooseCollection/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/ChooseCollection/index.tsx @@ -172,9 +172,9 @@ function Wrapped({ 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 }) ) ) ) diff --git a/specifyweb/frontend/js_src/lib/components/Core/Main.tsx b/specifyweb/frontend/js_src/lib/components/Core/Main.tsx index 881fd55ba00..92a8971cc46 100644 --- a/specifyweb/frontend/js_src/lib/components/Core/Main.tsx +++ b/specifyweb/frontend/js_src/lib/components/Core/Main.tsx @@ -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()} diff --git a/specifyweb/frontend/js_src/lib/components/Header/userToolDefinitions.ts b/specifyweb/frontend/js_src/lib/components/Header/userToolDefinitions.ts index e91854c92f7..97290ded00b 100644 --- a/specifyweb/frontend/js_src/lib/components/Header/userToolDefinitions.ts +++ b/specifyweb/frontend/js_src/lib/components/Header/userToolDefinitions.ts @@ -27,7 +27,7 @@ const rawUserTools = ensure>>>()({ [userText.userAccount()]: { logOut: { title: userText.logOut(), - url: '/accounts/logout/', + url: '/specify/command/logout/', icon: icons.logout, enabled: () => userInformation.isauthenticated, onClick: async () => diff --git a/specifyweb/frontend/js_src/lib/components/Logout/index.tsx b/specifyweb/frontend/js_src/lib/components/Logout/index.tsx new file mode 100644 index 00000000000..ade4987259b --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/Logout/index.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import { useSearchParameter } from '../../hooks/navigation'; +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); + const [nextUrl = '/specify/'] = useSearchParameter('nextUrl'); + + console.log('next => ' + nextUrl); + + React.useEffect(() => { + loading( + ajax('/accounts/logout/', { method: 'POST', headers: {} }) + .catch((error) => softError(error)) + .finally(() => + globalThis.location.assign( + formatUrl('/accounts/login/', { next: nextUrl }) + ) + ) + ); + }, [loading]); + + return ; +} diff --git a/specifyweb/frontend/js_src/lib/components/Router/Routes.tsx b/specifyweb/frontend/js_src/lib/components/Router/Routes.tsx index 2f2042f4b08..e5c97cfa656 100644 --- a/specifyweb/frontend/js_src/lib/components/Router/Routes.tsx +++ b/specifyweb/frontend/js_src/lib/components/Router/Routes.tsx @@ -466,6 +466,14 @@ export const routes: RA = [ ({ CacheBuster }) => CacheBuster ), }, + { + path: 'logout', + title: userText.logOut(), + element: () => + import('../Logout').then( + ({ Logout }) => Logout + ) + }, ], }, { diff --git a/specifyweb/settings/__init__.py b/specifyweb/settings/__init__.py index dd932b0ec53..65d8992cd9e 100644 --- a/specifyweb/settings/__init__.py +++ b/specifyweb/settings/__init__.py @@ -143,14 +143,12 @@ def get_sa_db_url(db_name): SITE_ID = 1 +USE_TZ=False + # If you set this to False, Django will make some optimizations so as not # 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. diff --git a/specifyweb/specify/migrations/0045_add_indexes.py b/specifyweb/specify/migrations/0045_add_indexes.py index 43715ed0b40..96696d09f99 100644 --- a/specifyweb/specify/migrations/0045_add_indexes.py +++ b/specifyweb/specify/migrations/0045_add_indexes.py @@ -5,7 +5,7 @@ class Migration(migrations.Migration): dependencies = [ - ('specify', '0044_alter_deletion_cascade'), + ('specify', '0044_alter_deletion_cascade') ] operations = [ diff --git a/specifyweb/specify/migrations/0047_alter_many_to_many_through_fields.py b/specifyweb/specify/migrations/0047_alter_many_to_many_through_fields.py new file mode 100644 index 00000000000..a3b25b14ee4 --- /dev/null +++ b/specifyweb/specify/migrations/0047_alter_many_to_many_through_fields.py @@ -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', '0046_alter_specifyuser_spprincipals'), + ] + + 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'), + ), + ] diff --git a/specifyweb/specify/models.py b/specifyweb/specify/models.py index 75f754cbe93..6ab316a15f8 100644 --- a/specifyweb/specify/models.py +++ b/specifyweb/specify/models.py @@ -79,6 +79,7 @@ def custom_save(self, *args, **kwargs): # TODO: Use this everywhere class ModelWithTable(models.Model): specify_model: Table + objects: models.Manager["ModelWithTable"] class Meta: abstract = True # These Django model classes were generated by the specifyweb.specify.sp7_build_models.py script. diff --git a/specifyweb/specify/urls.py b/specifyweb/specify/urls.py index e08c99b9536..de7fa995f9d 100644 --- a/specifyweb/specify/urls.py +++ b/specifyweb/specify/urls.py @@ -1,5 +1,6 @@ # Entrypoint for the routing of the app +from django.urls import path from django.urls import include, re_path from specifyweb.specify import views @@ -7,9 +8,9 @@ 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\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\w+)/(?P\d+)/$', views.resource), # permissions added re_path(r'^specify/(?P\w+)/$', views.collection), # permissions added @@ -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')), ] diff --git a/specifyweb/specify/utils/case_insensitive_bool.py b/specifyweb/specify/utils/case_insensitive_bool.py index 80f48445ef5..bdb79f17e82 100644 --- a/specifyweb/specify/utils/case_insensitive_bool.py +++ b/specifyweb/specify/utils/case_insensitive_bool.py @@ -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 diff --git a/specifyweb/urls.py b/specifyweb/urls.py index 6e165ed7a20..1497e28ca9e 100644 --- a/specifyweb/urls.py +++ b/specifyweb/urls.py @@ -41,7 +41,7 @@ # just redirect root url to the main specify view path('', skip_collection_access_check(RedirectView.as_view(url='/specify/'))), - re_path(r'^opensearch.xml$', search_view), + path('opensearch.xml', search_view), # This is the main specify view. # Every URL beginning with '/specify/' is handled @@ -53,7 +53,7 @@ path('api/', include(api_urls)), path('api/backup/', include(backup_urls)), path('images/', images), - re_path(r'^properties/(?P.+).properties$', properties), # Note fully supported since remmoving dependence on specify.jar + path('properties/.properties', properties), # Note fully supported since remmoving dependence on specify.jar path('documentation/', include(doc_urls)),