diff --git a/.eslintrc.js b/.eslintrc.js deleted file mode 100644 index 8b2e9dc2..00000000 --- a/.eslintrc.js +++ /dev/null @@ -1,41 +0,0 @@ -module.exports = { - 'root': true, - 'globals': { - '$': true - }, - 'env': { - 'browser': true, - 'es2021': true, - 'node': true - }, - 'extends': [ - 'eslint:recommended', - 'plugin:react/recommended' - ], - 'overrides': [], - 'parserOptions': { - 'ecmaVersion': 'latest', - 'sourceType': 'module' - }, - 'plugins': [ - 'react' - ], - 'ignorePatterns': ['**/static/**/*.js'], - 'rules': { - 'indent': 'off', - 'no-empty-pattern': 'off', - 'linebreak-style': [ - 'error', - 'unix' - ], - 'semi': [ - 'error', - 'never' - ] - }, - 'settings': { - 'react': { - 'version': 'detect' - } - } -} diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index a50fa808..300ae635 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -18,7 +18,7 @@ permissions: env: PYTHONDONTWRITEBYTECODE: 1 FORCE_COLOR: 1 - PYTHON_VERSION: "3.11" + PYTHON_VERSION: "3.12" jobs: pytest: diff --git a/.gitignore b/.gitignore index 9874f20f..6aa0bc32 100644 --- a/.gitignore +++ b/.gitignore @@ -7,7 +7,8 @@ __pycache__/ *.save .DS_Store -/config/settings/local.py +/config/settings/local* + /static /static_root /media_root diff --git a/.nvmrc b/.nvmrc index b460d6f2..5bf4400f 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -18.12.1 +24.15.0 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9db359e0..bf15d622 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,7 +9,7 @@ repos: - id: check-hooks-apply - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v6.0.0 + rev: 3e8a8703264a2f4a69428a0aa4dcb512790b2c8c # frozen: v6.0.0 hooks: - id: check-ast - id: check-json @@ -22,13 +22,27 @@ repos: - id: debug-statements - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.15.5 + rev: aca6d4c8045a504e2812ea4bedff1d0a09e437bc # frozen: v0.15.8 hooks: - id: ruff-check args: [--fix, --exit-non-zero-on-fix] + - id: ruff-format + +- repo: https://github.com/pre-commit/mirrors-eslint + rev: d72e5408a82df0b0cb6b9c878fb8cb4e947c1859 # frozen: v10.2.0 + hooks: + - id: eslint + args: [--fix, --color] + files: \.[j]sx?$ # *.js, *.jsx + types: [file] + additional_dependencies: + - eslint@9.39.4 + - eslint-plugin-react@7.37.5 + - eslint-plugin-simple-import-sort@12.1.1 + - react@18.3.1 - repo: https://github.com/crate-ci/typos - rev: v1.44.0 + rev: 5745f2a8dd91cd7b684680e2e10a2b388ba6e5cf # frozen: v1 hooks: - id: typos exclude: | diff --git a/.ruff.toml b/.ruff.toml index 255cdee3..aa84e82d 100644 --- a/.ruff.toml +++ b/.ruff.toml @@ -45,3 +45,6 @@ rest_framework = ["rest_framework"] "F405", "F821", ] + +[format] +quote-style = "single" diff --git a/README.md b/README.md index 276c83fe..1732bd03 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,12 @@ ISIMIP data =========== -[![Python Version](https://img.shields.io/badge/python->=3.11-blue)](https://www.python.org/) +[![Python Version](https://img.shields.io/badge/python->=3.12-blue)](https://www.python.org/) [![License](https://img.shields.io/github/license/ISI-MIP/isimip-data?style=flat)](https://github.com/ISI-MIP/isimip-data/blob/main/LICENSE) [![CI status](https://github.com/ISI-MIP/isimip-data/actions/workflows/ci.yaml/badge.svg)](https://github.com/ISI-MIP/isimip-data/actions/workflows/ci.yaml) [![Latest release](https://shields.io/github/v/release/ISI-MIP/isimip-data)](https://github.com/ISI-MIP/isimip-data/releases) -[![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.18468008.svg)](https://doi.org/10.5281/zenodo.18468008) +[![DOI](https://img.shields.io/badge/DOI-10.5281/zenodo.18468008-blue)](https://doi.org/10.5281/zenodo.18468008) + The [Django](https://www.djangoproject.com/) web app that powers the [ISIMIP repository](https://data.isimip.org). diff --git a/config/settings/__init__.py b/config/settings/__init__.py index fd9035d1..b11edd9b 100644 --- a/config/settings/__init__.py +++ b/config/settings/__init__.py @@ -2,5 +2,6 @@ include( 'base.py', - optional('local.py') + optional('local*.py'), + optional('local_*.py'), ) diff --git a/config/settings/base.py b/config/settings/base.py index 43461dae..d8e626b7 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -30,7 +30,8 @@ 'rest_framework', 'rest_framework.authtoken', 'django_cleanup', - 'django_extensions' + 'django_extensions', + 'django_vite', ] MIDDLEWARE = [ @@ -43,7 +44,7 @@ 'django.contrib.messages.middleware.MessageMiddleware', 'django.contrib.sites.middleware.CurrentSiteMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', - 'isimip_data.metadata.middleware.MetadataCacheMiddleware' + 'isimip_data.metadata.middleware.MetadataCacheMiddleware', ] ROOT_URLCONF = 'config.urls' @@ -70,12 +71,15 @@ DATABASES = { 'default': { 'ENGINE': 'django.db.backends.postgresql_psycopg2', - 'NAME': 'isimip_data' + 'NAME': 'isimip_data', }, 'metadata': { 'ENGINE': 'django.db.backends.postgresql_psycopg2', - 'NAME': 'isimip_metadata' - } + 'NAME': 'isimip_metadata', + 'TEST': { + 'NAME': 'test_isimip_metadata', + }, + }, } DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' @@ -83,7 +87,7 @@ LANGUAGE_CODE = 'en-us' LANGUAGES = [ - ('en', _('English')), + ('en', _('English')), ] TIME_ZONE = 'Europe/Berlin' @@ -96,9 +100,7 @@ STATIC_URL = '/static/' STATIC_ROOT = BASE_DIR / 'static_root/' -STATICFILES_DIRS = [ - BASE_DIR / 'static/' -] +STATICFILES_DIRS = [BASE_DIR / 'static/'] MEDIA_URL = '/media/' MEDIA_ROOT = BASE_DIR / 'media_root/' @@ -108,9 +110,13 @@ 'django.contrib.staticfiles.finders.AppDirectoriesFinder', ) -FIXTURE_DIRS = ( - BASE_DIR / 'testing' / 'fixtures', -) +DJANGO_VITE = { + 'default': { + 'manifest_path': BASE_DIR / 'static' / '.vite' / 'manifest.json', + } +} + +FIXTURE_DIRS = (BASE_DIR / 'testing' / 'fixtures',) LOGIN_URL = '/account/login/' LOGIN_REDIRECT_URL = '/' @@ -121,9 +127,7 @@ EMAIL_FROM = 'noreply@isimip.org' REST_FRAMEWORK = { - 'DEFAULT_AUTHENTICATION_CLASSES': [ - 'rest_framework.authentication.SessionAuthentication' - ], + 'DEFAULT_AUTHENTICATION_CLASSES': ['rest_framework.authentication.SessionAuthentication'], 'DEFAULT_PERMISSION_CLASSES': [], } @@ -170,38 +174,41 @@ LABEL = None ALERT = None -TERMS_OF_USE = 'When using ISIMIP data for your research, please appropriately credit ' \ - 'the data providers, e.g. either by citing the DOI for the dataset, or ' \ - 'by appropriate acknowledgment. We strongly encourage to offer co-authorship ' \ - 'to at least a representative of the data providers.' +TERMS_OF_USE = ( + 'When using ISIMIP data for your research, please appropriately credit ' + 'the data providers, e.g. either by citing the DOI for the dataset, or ' + 'by appropriate acknowledgment. We strongly encourage to offer co-authorship ' + 'to at least a representative of the data providers.' +) -TERMS_OF_USE_URL = 'https://www.isimip.org/gettingstarted/terms-of-use/' \ - '#general-terms-of-use-for-all-isimip-data-on-the-esg-server' +TERMS_OF_USE_URL = ( + 'https://www.isimip.org/gettingstarted/terms-of-use/#general-terms-of-use-for-all-isimip-data-on-the-esg-server' +) NAVIGATION = [ { 'title': 'Documentation', - 'href': 'https://www.isimip.org/outputdata/isimip-repository/' + 'href': 'https://www.isimip.org/outputdata/isimip-repository/', }, { 'title': 'Terms of Use', - 'href': TERMS_OF_USE_URL - } + 'href': TERMS_OF_USE_URL, + }, ] FILES_BASE_URL = 'https://files.isimip.org' FILES_API_URL = 'https://files.isimip.org/api/v2' -ACCESS_REPLY_TO = ( +ACCESS_REPLY_TO = [ 'ISIMIP data ', -) +] -CAVEATS_REPLY_TO = ( +CAVEATS_REPLY_TO = [ 'ISIMIP data ', -) -CAVEATS_DEFAULT_RECIPIENTS = ( +] +CAVEATS_DEFAULT_RECIPIENTS = [ 'isimip-data-issues-notes@listserv.dfn.de', -) +] CAVEATS_LIST_SUBSCRIBE_URL = 'https://www.listserv.dfn.de/sympa/subscribe/isimip-data-issues-notes' CAVEATS_LIST_ARCHIVE_URL = 'https://www.listserv.dfn.de/sympa/arc/isimip-data-issues-notes' @@ -214,14 +221,14 @@ PROTOCOL_LOCATIONS = [ 'https://protocol.isimip.org', - 'https://protocol2.isimip.org' + 'https://protocol2.isimip.org', ] -DOWNLOAD_OPERATIONS_HELP = ''' +DOWNLOAD_OPERATIONS_HELP = """ Please select one of the operations using the button below. You can select multiple operations which are applied subsequently. If you are interested in a time series of a variable, please select the corresponding operation for corresponding area or point and then check the boxes for averaging and creating a CSV. -''' +""" DOWNLOAD_OPERATIONS = [ { @@ -231,56 +238,66 @@ 'template': 'download/operations/select_bbox.html', 'resolutions': ['15arcmin', '30arcmin', '60arcmin', '120arcmin'], 'initial': { - 'bbox': [-180,180,-90,90], + 'bbox': [-180, 180, -90, 90], 'compute_mean': False, 'output_csv': False, }, 'next': [ 'mask_mask', 'mask_shape', - ] + ], }, { 'operation': 'select_point', 'title': 'Select point', 'label': '**Select a point** using `cdo`.', 'template': 'download/operations/select_point.html', - 'resolutions': ['15arcmin', '30arcmin', '60arcmin', '120arcmin'], + 'resolutions': [ + '15arcmin', + '30arcmin', + '60arcmin', + '120arcmin', + ], 'initial': { 'point': [13.064332, 52.38051], - 'output_csv': False - } + 'output_csv': False, + }, }, { 'operation': 'mask_bbox', 'title': 'Mask by bounding box', 'label': '**Mask a rectangular box** using `cdo`,' - ' keeping the grid and setting everything outside to `missing_value`.', + ' keeping the grid and setting everything outside to `missing_value`.', 'template': 'download/operations/mask_bbox.html', 'resolutions': ['15arcmin', '30arcmin', '60arcmin', '120arcmin'], 'initial': { - 'bbox': [-180,180,-90,90], + 'bbox': [-180, 180, -90, 90], 'compute_mean': False, - 'output_csv': False + 'output_csv': False, }, 'next': [ 'mask_country', 'mask_landonly', 'mask_mask', - 'mask_shape' - ] + 'mask_shape', + ], }, { 'operation': 'mask_country', 'title': 'Mask by country', 'label': '**Mask a country** using `cdo` and the ISIMIP countrymask,' - ' keeping the grid and setting everything outside to `missing_value`.', + ' keeping the grid and setting everything outside to `missing_value`.', 'template': 'download/operations/mask_country.html', - 'resolutions': ['15arcmin', '30arcmin', '60arcmin', '120arcmin'], + 'resolutions': [ + '15arcmin', + '30arcmin', + '60arcmin', + '120arcmin', + ], 'initial': { 'country': 'aus', 'compute_mean': False, - 'output_csv': False + 'output_csv': False, }, 'next': [ 'select_bbox', @@ -289,38 +306,46 @@ 'mask_landonly', 'mask_mask', 'mask_shape', - ] + ], }, { 'operation': 'mask_landonly', 'title': 'Mask only land data', 'label': '**Mask only the land data** using `cdo` and the ISIMIP landseamask,' - ' keeping the grid and setting everything outside to `missing_value`.', + ' keeping the grid and setting everything outside to `missing_value`.', 'template': 'download/operations/mask_landonly.html', - 'resolutions': ['30arcmin'], + 'resolutions': [ + '30arcmin', + ], 'next': [ 'select_bbox', 'mask_bbox', 'mask_country', 'mask_landonly', 'mask_mask', - 'mask_shape' - ] + 'mask_shape', + ], }, { 'operation': 'mask_mask', 'title': 'Mask using a NetCDF custom mask', 'label': '**Mask using a custom NetCDF mask** using `cdo`,' - ' keeping the grid and setting everything outside to `missing_value`.', + ' keeping the grid and setting everything outside to `missing_value`.', 'template': 'download/operations/mask_mask.html', - 'accept': { - 'application/x-hdf': ['.nc', '.nc4'] - }, - 'resolutions': ['30arcsec', '90arcsec', '300arcsec', '1800arcsec', - '15arcmin', '30arcmin', '60arcmin', '120arcmin'], + 'accept': {'application/x-hdf': ['.nc', '.nc4']}, + 'resolutions': [ + '30arcsec', + '90arcsec', + '300arcsec', + '1800arcsec', + '15arcmin', + '30arcmin', + '60arcmin', + '120arcmin', + ], 'initial': { 'mask': None, - 'var': '' + 'var': '', }, 'next': [ 'select_bbox', @@ -328,24 +353,32 @@ 'mask_country', 'mask_landonly', 'mask_mask', - 'mask_shape' - ] + 'mask_shape', + ], }, { 'operation': 'mask_shape', 'title': 'Mask using a custom Shapefile or GeoJSON', 'label': '**Mask using a custom Shapefile or GeoJSON** using `cdo`,' - ' keeping the grid and setting everything outside to `missing_value`.', + ' keeping the grid and setting everything outside to `missing_value`.', 'template': 'download/operations/mask_shape.html', 'accept': { 'application/json': ['.json', '.geojson'], - 'application/zip': ['.zip'] - }, - 'resolutions': ['30arcsec', '90arcsec', '300arcsec', '1800arcsec', - '15arcmin', '30arcmin', '60arcmin', '120arcmin'], + 'application/zip': ['.zip'], + }, + 'resolutions': [ + '30arcsec', + '90arcsec', + '300arcsec', + '1800arcsec', + '15arcmin', + '30arcmin', + '60arcmin', + '120arcmin', + ], 'initial': { 'shape': None, - 'layer': 0 + 'layer': 0, }, 'next': [ 'select_bbox', @@ -353,38 +386,54 @@ 'mask_country', 'mask_landonly', 'mask_mask', - 'mask_shape' - ] + 'mask_shape', + ], }, { 'operation': 'cutout_bbox', 'title': 'Cut out bounding box', 'label': '**Cut out a rectangular box** using `ncks` (preferred for high-resolution datasets).', 'template': 'download/operations/cutout_bbox.html', - 'resolutions': ['30arcsec', '90arcsec', '300arcsec', '1800arcsec', - '15arcmin', '30arcmin', '60arcmin', '120arcmin'], + 'resolutions': [ + '30arcsec', + '90arcsec', + '300arcsec', + '1800arcsec', + '15arcmin', + '30arcmin', + '60arcmin', + '120arcmin', + ], 'initial': { - 'bbox': [-180,180,-90,90], + 'bbox': [-180, 180, -90, 90], 'compute_mean': False, - 'output_csv': False + 'output_csv': False, }, 'next': [ 'mask_mask', - 'mask_shape' - ] + 'mask_shape', + ], }, { 'operation': 'cutout_point', 'title': 'Cut out point', 'label': '**Cut out a point** using `ncks` (preferred for high-resolution datasets).', 'template': 'download/operations/cutout_point.html', - 'resolutions': ['30arcsec', '90arcsec', '300arcsec', '1800arcsec', - '15arcmin', '30arcmin', '60arcmin', '120arcmin'], + 'resolutions': [ + '30arcsec', + '90arcsec', + '300arcsec', + '1800arcsec', + '15arcmin', + '30arcmin', + '60arcmin', + '120arcmin', + ], 'initial': { 'point': [13.064332, 52.38051], - 'output_csv': False - } - } + 'output_csv': False, + }, + }, ] DOWNLOAD_ERRORS = { @@ -394,140 +443,50 @@ 'mask': ['Please select a valid NetCDF file.'], 'shape': ['Please select a valid Shapefile or GeoJSON file.'], 'var': ['Please enter a valid name for a variable.'], - 'layer': ['Please enter a layer.'] + 'layer': ['Please enter a layer.'], } SEARCH_FACETS = [ [ - { - 'identifier': 'simulation_round', - 'title': 'ISIMIP simulation round' - }, - { - 'identifier': 'product', - 'title': 'Data product' - }, - { - 'identifier': 'period', - 'title': 'Simulation period' - }, - { - 'identifier': 'sector', - 'title': 'Sector' - } + {'identifier': 'simulation_round', 'title': 'ISIMIP simulation round'}, + {'identifier': 'product', 'title': 'Data product'}, + {'identifier': 'period', 'title': 'Simulation period'}, + {'identifier': 'sector', 'title': 'Sector'}, ], [ - { - 'identifier': 'climate_scenario', - 'title': 'Climate related forcing' - }, - { - 'identifier': 'climate_forcing', - 'title': 'Climate forcing dataset' - }, - { - 'identifier': 'soc_scenario', - 'title': 'Direct human forcing' - }, - { - 'identifier': 'sens_scenario', - 'title': 'Sensitivity experiment' - }, + {'identifier': 'climate_scenario', 'title': 'Climate related forcing'}, + {'identifier': 'climate_forcing', 'title': 'Climate forcing dataset'}, + {'identifier': 'soc_scenario', 'title': 'Direct human forcing'}, + {'identifier': 'sens_scenario', 'title': 'Sensitivity experiment'}, ], [ - { - 'identifier': 'model', - 'title': 'Impact model' - }, - { - 'identifier': 'variable', - 'title': 'Output variable' - }, - { - 'identifier': 'pft', - 'title': 'PFT' - }, - { - 'identifier': 'crop', - 'title': 'Crop' - }, - { - 'identifier': 'irrigation', - 'title': 'Irrigation' - }, - { - 'identifier': 'forest_stand', - 'title': 'Forest stand' - }, - { - 'identifier': 'lake_site', - 'title': 'Lake site' - }, - { - 'identifier': 'ocean_region', - 'title': 'Ocean region' - }, - { - 'identifier': 'pool', - 'title': 'Soil organic carbon pools' - }, - { - 'identifier': 'river', - 'title': 'River' - }, - { - 'identifier': 'station', - 'title': 'River station' - }, - { - 'identifier': 'species', - 'title': 'Tree species' - } + {'identifier': 'model', 'title': 'Impact model'}, + {'identifier': 'variable', 'title': 'Output variable'}, + {'identifier': 'pft', 'title': 'PFT'}, + {'identifier': 'crop', 'title': 'Crop'}, + {'identifier': 'irrigation', 'title': 'Irrigation'}, + {'identifier': 'forest_stand', 'title': 'Forest stand'}, + {'identifier': 'lake_site', 'title': 'Lake site'}, + {'identifier': 'ocean_region', 'title': 'Ocean region'}, + {'identifier': 'pool', 'title': 'Soil organic carbon pools'}, + {'identifier': 'river', 'title': 'River'}, + {'identifier': 'station', 'title': 'River station'}, + {'identifier': 'species', 'title': 'Tree species'}, ], [ - { - 'identifier': 'time_step', - 'title': 'Temporal resolution' - }, - { - 'identifier': 'resolution', - 'title': 'Spatial resolution' - } + {'identifier': 'time_step', 'title': 'Temporal resolution'}, + {'identifier': 'resolution', 'title': 'Spatial resolution'}, ], [ - { - 'identifier': 'category', - 'title': 'Input category' - }, - { - 'identifier': 'subcategory', - 'title': 'Input subcategory ' - }, - { - 'identifier': 'climate_variable', - 'title': 'Climate variable' - }, - { - 'identifier': 'climate_dataset', - 'title': 'Climate dataset' - }, - { - 'identifier': 'soc_variable', - 'title': 'Direct human forcing variable' - }, - { - 'identifier': 'soc_dataset', - 'title': 'Direct human forcing dataset' - }, - { - 'identifier': 'geo_dataset', - 'title': 'Geographic dataset' - } + {'identifier': 'category', 'title': 'Input category'}, + {'identifier': 'subcategory', 'title': 'Input subcategory '}, + {'identifier': 'climate_variable', 'title': 'Climate variable'}, + {'identifier': 'climate_dataset', 'title': 'Climate dataset'}, + {'identifier': 'soc_variable', 'title': 'Direct human forcing variable'}, + {'identifier': 'soc_dataset', 'title': 'Direct human forcing dataset'}, + {'identifier': 'geo_dataset', 'title': 'Geographic dataset'}, ], [ - { - 'identifier': 'publication', - 'title': 'Publication' - } - ] + {'identifier': 'publication', 'title': 'Publication'}, + ], ] diff --git a/config/settings/environments/dev.py b/config/settings/environments/dev.py index 7229f71a..d26e3bef 100644 Binary files a/config/settings/environments/dev.py and b/config/settings/environments/dev.py differ diff --git a/config/settings/environments/github.py b/config/settings/environments/github.py index c23b160d..42243b8e 100644 --- a/config/settings/environments/github.py +++ b/config/settings/environments/github.py @@ -8,7 +8,7 @@ 'NAME': 'isimip_data', 'USER': 'postgres', 'PASSWORD': 'postgres_password', - 'HOST': '127.0.0.1' + 'HOST': '127.0.0.1', }, 'metadata': { 'ENGINE': 'django.db.backends.postgresql_psycopg2', @@ -16,10 +16,8 @@ 'USER': 'postgres', 'PASSWORD': 'postgres_password', 'HOST': '127.0.0.1', - 'TEST': { - 'NAME': 'test_isimip_metadata' - } - } + 'TEST': {'NAME': 'test_isimip_metadata'}, + }, } FILES_BASE_URL = 'http://isimip/' diff --git a/config/settings/environments/local.py b/config/settings/environments/local.py index f757777e..e4b6e80d 100644 --- a/config/settings/environments/local.py +++ b/config/settings/environments/local.py @@ -5,7 +5,7 @@ CACHES = { 'default': { 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', - 'LOCATION': 'isimip-data' + 'LOCATION': 'isimip-data', } } @@ -19,15 +19,12 @@ 'version': 1, 'disable_existing_loggers': True, 'handlers': { - 'rich': { - 'level': 'DEBUG', - 'class': 'rich.logging.RichHandler' - } + 'rich': {'level': 'DEBUG', 'class': 'rich.logging.RichHandler'}, }, 'loggers': { 'django': { 'handlers': ['rich'], - 'level': 'INFO' + 'level': 'INFO', }, 'django.db.backends': { 'handlers': ['rich'], @@ -35,7 +32,7 @@ }, 'isimip_data': { 'handlers': ['rich'], - 'level': 'DEBUG' - } - } + 'level': 'DEBUG', + }, + }, } diff --git a/config/settings/environments/prod.py b/config/settings/environments/prod.py index 72da94af..7b4fc2a3 100644 Binary files a/config/settings/environments/prod.py and b/config/settings/environments/prod.py differ diff --git a/config/urls.py b/config/urls.py index 90f6ba2e..a472662c 100644 --- a/config/urls.py +++ b/config/urls.py @@ -59,8 +59,8 @@ router.register(r'settings', SettingsViewSet, basename='setting') router.register(r'access', AccessViewSet, basename='access') -class StaticSitemap(Sitemap): +class StaticSitemap(Sitemap): def items(self): return [ 'metadata', @@ -69,7 +69,7 @@ def items(self): 'download', 'issues-and-notes', 'caveats', # legacy - 'home' + 'home', ] def location(self, item): @@ -82,50 +82,52 @@ def location(self, item): 'caveats': CaveatSitemap, 'datasets': DatasetSitemap, 'files': FileSitemap, - 'resources': ResourceSitemap + 'resources': ResourceSitemap, } urlpatterns = [ - path('admin/', admin.site.urls), - path('api/v1/', include(router.urls)), - + # home app + path('', home, name='home'), + # metadata app path('metadata/', metadata, name='metadata'), path('doi/', resources, name='resources'), - path('datasets//', dataset, name='dataset'), path('files//', file, name='file'), path('resources//', resource, name='resource'), - re_path(r'^(?P\d{2}\.\d+\/[A-Za-z0-9_.\-\/]+).bib', resource_bibtex, name='resource_bibtex'), re_path(r'^(?P\d{2}\.\d+\/[A-Za-z0-9_.\-\/]+).xml', resource_xml, name='resource_xml'), re_path(r'^(?P\d{2}\.\d+\/[A-Za-z0-9_.\-\/]+).json', resource_json, name='resource_json'), re_path(r'^(?P\d{2}\.\d+\/[A-Za-z0-9_.\-\/]+)', resource, name='resource'), - path('identifiers/', identifiers, name='identifiers'), path('identifiers/', identifier, name='identifier'), - + # search app path('search/', search, name='search'), path('search//', search, name='search'), - + # download app path('download/', download, name='download'), path('download//', download, name='download'), - + # caveats app path('issues-and-notes/', caveats, name='issues_and_notes'), path('caveats/', caveats, name='caveats'), # legacy - path('issues//', caveat, name='issue'), path('notes//', caveat, name='note'), path('caveats//', caveat, name='caveat'), # legacy - + # access app path('access/token//', token, name='token'), path('access//', access, name='access'), - - path('', home, name='home'), + # admin + path('admin/', admin.site.urls), + # api + path('api/v1/', include(router.urls)), + # robots and sitemap path('robots.txt', TemplateView.as_view(template_name='core/robots.txt'), name='robots.txt'), - path('sitemap.xml', sitemaps_views.index, {'sitemaps': sitemaps}), - path('sitemap-
.xml', sitemaps_views.sitemap, {'sitemaps': sitemaps}, - name='django.contrib.sitemaps.views.sitemap') + path( + 'sitemap-
.xml', + sitemaps_views.sitemap, + {'sitemaps': sitemaps}, + name='django.contrib.sitemaps.views.sitemap', + ), ] handler400 = 'isimip_data.core.views.bad_request' @@ -135,8 +137,9 @@ def location(self, item): if settings.DEBUG_TOOLBAR: import debug_toolbar + urlpatterns = [ path('__debug__/', include(debug_toolbar.urls)), *urlpatterns, - *static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) + *static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT), ] diff --git a/conftest.py b/conftest.py index 8acccf88..d7ade311 100644 --- a/conftest.py +++ b/conftest.py @@ -11,13 +11,12 @@ def django_db_setup(django_db_setup, django_db_blocker): from django.db import connections from django.test import TestCase - connections['metadata'].settings_dict['NAME'] = \ - settings.DATABASES['metadata']['TEST']['NAME'] + connections['metadata'].settings_dict['NAME'] = settings.DATABASES['metadata']['TEST']['NAME'] TestCase.databases = ('default', 'metadata') with django_db_blocker.unblock(): - call_command('loaddata', *[ - str(f) for fixture_dir in settings.FIXTURE_DIRS - for f in Path(fixture_dir).iterdir() - ]) + call_command( + 'loaddata', + *[str(f) for fixture_dir in settings.FIXTURE_DIRS for f in Path(fixture_dir).iterdir()], + ) diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 00000000..3feb0df2 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,99 @@ +import js from '@eslint/js' +import { defineConfig, globalIgnores } from 'eslint/config' +import react from 'eslint-plugin-react' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import simpleImportSort from 'eslint-plugin-simple-import-sort' +import globals from 'globals' + +export default defineConfig([ + globalIgnores(['static']), + { + files: ['**/*.{js,jsx}'], + extends: [ + js.configs.recommended, + react.configs.flat.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + plugins: { + react, + 'simple-import-sort': simpleImportSort, + }, + languageOptions: { + globals: globals.browser, + parserOptions: { + ecmaVersion: 'latest', + ecmaFeatures: { jsx: true }, + sourceType: 'module', + }, + }, + rules: { + 'no-empty-pattern': 'off', + 'no-unused-vars': ['error', { varsIgnorePattern: '^_' }], + 'linebreak-style': ['error', 'unix'], + 'quotes': [ + 'error', + 'single', + { 'avoidEscape': true } + ], + 'semi': ['error', 'never'], + 'max-len': [ + 'error', + { + 'code': 120, + 'ignoreUrls': true, + 'ignoreRegExpLiterals': true + } + ], + 'indent': ['error', 2, { + 'SwitchCase': 1, + }], + 'space-infix-ops': 'error', + 'one-var': ['error', 'never'], + 'comma-spacing': ['error', { before: false, after: true }], + 'no-multiple-empty-lines': ['error', { 'max': 1 }], + 'multiline-ternary': ['error', 'never'], + 'object-curly-newline': ['error', { + multiline: true, + consistent: true + }], + 'simple-import-sort/imports': ['error', { + groups: [ + // external imports in the given order + ['^react$', '^prop-types$', '^react', '^redux', '^[a-z@]', '^lodash'], + ['^isimip_data'], + // parent imports: lowercase first, then capitalized components + ['^\\.\\..*\\/[a-z]'], + ['^\\.\\..*\\/[A-Z]'], + // sibling imports: lowercase first, then capitalized components + ['^\\./[a-z]', '^\\./.*\\/[a-z]'], + ['^\\./[A-Z]', '^\\./.*\\/[A-Z]'], + ], + }], + 'simple-import-sort/exports': 'error', + 'jsx-quotes': ['error', 'prefer-double'], + 'react/jsx-first-prop-new-line': ['error', 'multiline-multiprop'], + 'react/jsx-closing-bracket-location': ['error', 'line-aligned'], + 'react/jsx-curly-newline': [ + 'error', + { + 'multiline': 'require', + 'singleline': 'consistent' + } + ], + 'react/jsx-curly-spacing': [ + 'error', + { + 'when': 'never', + 'children': true + } + ], + }, + settings: { + react: { + version: 'detect', + }, + }, + }, +]) diff --git a/isimip_data/access/admin.py b/isimip_data/access/admin.py index d5d99c08..c0fb586a 100644 --- a/isimip_data/access/admin.py +++ b/isimip_data/access/admin.py @@ -42,7 +42,7 @@ class ResourceAdmin(admin.ModelAdmin): class TokenAdmin(admin.ModelAdmin): list_display_links = ('id', 'subject') list_display = ('id', 'subject', 'resource', 'created') - readonly_fields = ('created', 'updated', 'as_json_pre', 'as_jwt_pre') + readonly_fields = ('created', 'updated', 'as_json_pre', 'as_jwt_pre') def as_json_pre(self, obj): return mark_safe(f'
{obj.as_json}
') diff --git a/isimip_data/access/forms.py b/isimip_data/access/forms.py index 6b51efa6..b6162e14 100644 --- a/isimip_data/access/forms.py +++ b/isimip_data/access/forms.py @@ -4,9 +4,8 @@ class AccessForm(forms.ModelForm): - consent = forms.BooleanField(required=True) class Meta: model = Token - fields = ["subject"] + fields = ['subject'] diff --git a/isimip_data/access/managers.py b/isimip_data/access/managers.py index 20b8d270..30f704b9 100644 --- a/isimip_data/access/managers.py +++ b/isimip_data/access/managers.py @@ -2,7 +2,6 @@ class ResourceManager(models.Manager): - def find_by_path(self, path): for resource in self.all(): for resource_path in resource.paths: diff --git a/isimip_data/access/models.py b/isimip_data/access/models.py index 0117c6b0..d9a2bbe8 100644 --- a/isimip_data/access/models.py +++ b/isimip_data/access/models.py @@ -16,7 +16,6 @@ def generate_token(): class Resource(models.Model): - objects = ResourceManager() title = models.CharField(max_length=512) @@ -27,14 +26,13 @@ class Resource(models.Model): updated = models.DateTimeField(auto_now=True) class Meta: - ordering = ('title', ) + ordering = ('title',) def __str__(self): return self.title class Token(models.Model): - resource = models.ForeignKey('Resource', null=True, on_delete=models.SET_NULL, related_name='tokens') subject = models.EmailField() @@ -42,7 +40,7 @@ class Token(models.Model): updated = models.DateTimeField(auto_now=True) class Meta: - ordering = ('subject', ) + ordering = ('subject',) def __str__(self): return self.subject @@ -55,10 +53,10 @@ def expires(self): @property def as_dict(self): return { - "sub": self.subject, - "iat": self.updated, - "exp": self.expires, - "paths": self.resource.paths if self.resource else [], + 'sub': self.subject, + 'iat': self.updated, + 'exp': self.expires, + 'paths': self.resource.paths if self.resource else [], } @property diff --git a/isimip_data/access/serializers.py b/isimip_data/access/serializers.py index 630e334e..c9e054b6 100644 --- a/isimip_data/access/serializers.py +++ b/isimip_data/access/serializers.py @@ -2,6 +2,5 @@ class AccessSerializer(serializers.Serializer): - sub = serializers.EmailField() paths = serializers.ListField() diff --git a/isimip_data/access/utils.py b/isimip_data/access/utils.py index 0764b69d..a2423f5a 100644 --- a/isimip_data/access/utils.py +++ b/isimip_data/access/utils.py @@ -4,17 +4,17 @@ def encode_token(payload): - return jwt.encode(payload, settings.FILES_AUTH_SECRET, algorithm="HS256") + return jwt.encode(payload, settings.FILES_AUTH_SECRET, algorithm='HS256') def decode_token(jwt_string): try: - payload = jwt.decode(jwt_string, settings.FILES_AUTH_SECRET, algorithms=["HS256"]) + payload = jwt.decode(jwt_string, settings.FILES_AUTH_SECRET, algorithms=['HS256']) return payload, None except jwt.ExpiredSignatureError: - return None, "The provided token is expired." + return None, 'The provided token is expired.' except jwt.InvalidTokenError: - return None, "The provided token is invalid." + return None, 'The provided token is invalid.' def get_access_tokens(request): diff --git a/isimip_data/access/views.py b/isimip_data/access/views.py index 1ed0d892..d3da1588 100644 --- a/isimip_data/access/views.py +++ b/isimip_data/access/views.py @@ -17,48 +17,32 @@ def access(request, path): if request.method == 'POST' and form.is_valid(): token, _ = Token.objects.update_or_create( subject=form.cleaned_data['subject'], - resource=resource + resource=resource, ) token.save() - context = { - 'resource': resource, - 'token': token, - 'token_url': token.get_absolute_url(request) - } + context = {'resource': resource, 'token': token, 'token_url': token.get_absolute_url(request)} subject = render_to_string('access/email/access_confirmation_subject.txt', context) message = render_to_string('access/email/access_confirmation_message.txt', context) send_mail(subject, message, to=[form.instance.subject], reply_to=settings.ACCESS_REPLY_TO) - return render(request, 'access/access.html', { - 'resource': resource, - 'form': form - }) + return render(request, 'access/access.html', {'resource': resource, 'form': form}) else: - return render(request, 'access/access_error.html', { - 'path': path - }) + return render(request, 'access/access_error.html', {'path': path}) def token(request, jwt): payload, error = decode_token(jwt) if not payload: - return render(request, 'access/token_error.html', { - 'error': error - }) + return render(request, 'access/token_error.html', {'error': error}) try: token = Token.objects.get(subject=payload['sub'], resource__paths=payload['paths']) except Token.DoesNotExist: - return render(request, 'access/token_error.html', { - 'error': 'The no matching token was found in our database.' - }) + return render(request, 'access/token_error.html', {'error': 'The no matching token was found in our database.'}) - response = render(request, 'access/token.html', { - 'resource': token.resource, - 'token': token - }) + response = render(request, 'access/token.html', {'resource': token.resource, 'token': token}) for path in token.resource.paths: response.set_cookie( @@ -69,7 +53,7 @@ def token(request, jwt): domain=settings.FILES_AUTH_DOMAIN, secure=not settings.DEBUG, httponly=False, - samesite='Lax' + samesite='Lax', ) return response diff --git a/isimip_data/access/viewsets.py b/isimip_data/access/viewsets.py index 3187d7d2..76614d2f 100644 --- a/isimip_data/access/viewsets.py +++ b/isimip_data/access/viewsets.py @@ -6,7 +6,6 @@ class AccessViewSet(ListModelMixin, GenericViewSet): - serializer_class = AccessSerializer def get_queryset(self): diff --git a/isimip_data/annotations/admin.py b/isimip_data/annotations/admin.py index cb170dac..05712ab3 100644 --- a/isimip_data/annotations/admin.py +++ b/isimip_data/annotations/admin.py @@ -8,7 +8,6 @@ class AnnotationAdminForm(forms.ModelForm): - def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.fields['specifiers'].widget = SpecifierWidget() @@ -44,27 +43,37 @@ class AnnotationAdmin(admin.ModelAdmin): form = AnnotationAdminForm inlines = [FigureInline, DownloadInline, ReferenceInline] - search_fields = ('title', ) - list_display = ('title', ) - readonly_fields = ('affected_datasets', ) + search_fields = ('title',) + list_display = ('title',) + readonly_fields = ('affected_datasets',) exclude = ('datasets', 'figures', 'downloads') fieldsets = ( - (None, { - 'fields': ('title', ) - }), - ('Specifiers', { - 'classes': ('collapse',), - 'fields': ('specifiers', ), - }), - ('Versions', { - 'classes': ('collapse',), - 'fields': ('version_after', 'version_before'), - }), - ('Datasets', { - 'classes': ('collapse',), - 'fields': ('affected_datasets', ), - }) + ( + None, + {'fields': ('title',)}, + ), + ( + 'Specifiers', + { + 'classes': ('collapse',), + 'fields': ('specifiers',), + }, + ), + ( + 'Versions', + { + 'classes': ('collapse',), + 'fields': ('version_after', 'version_before'), + }, + ), + ( + 'Datasets', + { + 'classes': ('collapse',), + 'fields': ('affected_datasets',), + }, + ), ) class Media: @@ -75,7 +84,7 @@ def affected_datasets(self, instance): class DownloadAdmin(admin.ModelAdmin): - search_fields = ('title', ) + search_fields = ('title',) list_display = ('title', 'created', 'updated') diff --git a/isimip_data/annotations/models.py b/isimip_data/annotations/models.py index 8189f72b..aafb0e66 100644 --- a/isimip_data/annotations/models.py +++ b/isimip_data/annotations/models.py @@ -20,7 +20,7 @@ class Annotation(models.Model): references = models.ManyToManyField('Reference', related_name='annotations') class Meta: - ordering = ('title', ) + ordering = ('title',) def __str__(self): return self.title @@ -45,7 +45,7 @@ class Figure(models.Model): updated = models.DateTimeField(auto_now=True) class Meta: - ordering = ('title', ) + ordering = ('title',) def __str__(self): return self.title @@ -69,19 +69,18 @@ class Download(models.Model): updated = models.DateTimeField(auto_now=True) class Meta: - ordering = ('title', ) + ordering = ('title',) def __str__(self): return self.title class Reference(models.Model): - IDENTIFIER_TYPE_DOI = 'doi' IDENTIFIER_TYPE_URL = 'url' IDENTIFIER_TYPE_CHOICES = ( (IDENTIFIER_TYPE_DOI, _('DOI')), - (IDENTIFIER_TYPE_URL, _('URL')) + (IDENTIFIER_TYPE_URL, _('URL')), ) REFERENCE_TYPE_ISIPEDIA = 'ISIPEDIA' @@ -90,7 +89,7 @@ class Reference(models.Model): REFERENCE_TYPE_CHOICES = ( (REFERENCE_TYPE_ISIPEDIA, _('ISIpedia')), (REFERENCE_TYPE_EVALUATION, _('Evaluation')), - (REFERENCE_TYPE_OTHER, _('Other')) + (REFERENCE_TYPE_OTHER, _('Other')), ) title = models.TextField() @@ -99,7 +98,7 @@ class Reference(models.Model): reference_type = models.TextField(choices=REFERENCE_TYPE_CHOICES) class Meta: - ordering = ('title', ) + ordering = ('title',) def __str__(self): return self.title diff --git a/isimip_data/annotations/tests/test_admin.py b/isimip_data/annotations/tests/test_admin.py index 1000eca8..028cd072 100644 --- a/isimip_data/annotations/tests/test_admin.py +++ b/isimip_data/annotations/tests/test_admin.py @@ -15,35 +15,36 @@ def test_annotation_add_post(db, client): client.login(username='admin', password='admin') url = reverse('admin:annotations_annotation_add') - response = client.post(url, { - 'title': 'New Annotation', - 'specifiers_model': 'model', - 'Annotation_figures-TOTAL_FORMS': 0, - 'Annotation_figures-INITIAL_FORMS': 0, - 'Annotation_figures-MIN_NUM_FORMS': 0, - 'Annotation_figures-MAX_NUM_FORMS': 1000, - 'Annotation_figures-__prefix__-id': '', - 'Annotation_figures-__prefix__-annotation': 1, - 'Annotation_figures-__prefix__-figure': '', - 'Annotation_downloads-TOTAL_FORMS': 0, - 'Annotation_downloads-INITIAL_FORMS': 0, - 'Annotation_downloads-MIN_NUM_FORMS': 0, - 'Annotation_downloads-MAX_NUM_FORMS': 1000, - 'Annotation_downloads-__prefix__-id': '', - 'Annotation_downloads-__prefix__-annotation': 1, - 'Annotation_downloads-__prefix__-download': '', - 'Annotation_references-TOTAL_FORMS': 0, - 'Annotation_references-INITIAL_FORMS': 0, - 'Annotation_references-MIN_NUM_FORMS': 0, - 'Annotation_references-MAX_NUM_FORMS': 1000, - 'Annotation_references-__prefix__-id': '', - 'Annotation_references-__prefix__-annotation': 1, - 'Annotation_references-__prefix__-reference': '' - }) + response = client.post( + url, + { + 'title': 'New Annotation', + 'specifiers_model': 'model', + 'Annotation_figures-TOTAL_FORMS': 0, + 'Annotation_figures-INITIAL_FORMS': 0, + 'Annotation_figures-MIN_NUM_FORMS': 0, + 'Annotation_figures-MAX_NUM_FORMS': 1000, + 'Annotation_figures-__prefix__-id': '', + 'Annotation_figures-__prefix__-annotation': 1, + 'Annotation_figures-__prefix__-figure': '', + 'Annotation_downloads-TOTAL_FORMS': 0, + 'Annotation_downloads-INITIAL_FORMS': 0, + 'Annotation_downloads-MIN_NUM_FORMS': 0, + 'Annotation_downloads-MAX_NUM_FORMS': 1000, + 'Annotation_downloads-__prefix__-id': '', + 'Annotation_downloads-__prefix__-annotation': 1, + 'Annotation_downloads-__prefix__-download': '', + 'Annotation_references-TOTAL_FORMS': 0, + 'Annotation_references-INITIAL_FORMS': 0, + 'Annotation_references-MIN_NUM_FORMS': 0, + 'Annotation_references-MAX_NUM_FORMS': 1000, + 'Annotation_references-__prefix__-id': '', + 'Annotation_references-__prefix__-annotation': 1, + 'Annotation_references-__prefix__-reference': '', + }, + ) assert response.status_code == 302 - assert Annotation.objects.get(title='New Annotation').specifiers == { - 'model': ['model'] - } + assert Annotation.objects.get(title='New Annotation').specifiers == {'model': ['model']} def test_annotation_change_get(db, client): @@ -58,32 +59,33 @@ def test_annotation_change_post(db, client): client.login(username='admin', password='admin') url = reverse('admin:annotations_annotation_change', args=[1]) - response = client.post(url, { - 'title': 'Annotation', - 'specifiers_model': 'model3', - 'Annotation_figures-TOTAL_FORMS': 0, - 'Annotation_figures-INITIAL_FORMS': 0, - 'Annotation_figures-MIN_NUM_FORMS': 0, - 'Annotation_figures-MAX_NUM_FORMS': 1000, - 'Annotation_figures-__prefix__-id': '', - 'Annotation_figures-__prefix__-annotation': 1, - 'Annotation_figures-__prefix__-figure': '', - 'Annotation_downloads-TOTAL_FORMS': 0, - 'Annotation_downloads-INITIAL_FORMS': 0, - 'Annotation_downloads-MIN_NUM_FORMS': 0, - 'Annotation_downloads-MAX_NUM_FORMS': 1000, - 'Annotation_downloads-__prefix__-id': '', - 'Annotation_downloads-__prefix__-annotation': 1, - 'Annotation_downloads-__prefix__-download': '', - 'Annotation_references-TOTAL_FORMS': 0, - 'Annotation_references-INITIAL_FORMS': 0, - 'Annotation_references-MIN_NUM_FORMS': 0, - 'Annotation_references-MAX_NUM_FORMS': 1000, - 'Annotation_references-__prefix__-id': '', - 'Annotation_references-__prefix__-annotation': 1, - 'Annotation_references-__prefix__-reference': '' - }) + response = client.post( + url, + { + 'title': 'Annotation', + 'specifiers_model': 'model3', + 'Annotation_figures-TOTAL_FORMS': 0, + 'Annotation_figures-INITIAL_FORMS': 0, + 'Annotation_figures-MIN_NUM_FORMS': 0, + 'Annotation_figures-MAX_NUM_FORMS': 1000, + 'Annotation_figures-__prefix__-id': '', + 'Annotation_figures-__prefix__-annotation': 1, + 'Annotation_figures-__prefix__-figure': '', + 'Annotation_downloads-TOTAL_FORMS': 0, + 'Annotation_downloads-INITIAL_FORMS': 0, + 'Annotation_downloads-MIN_NUM_FORMS': 0, + 'Annotation_downloads-MAX_NUM_FORMS': 1000, + 'Annotation_downloads-__prefix__-id': '', + 'Annotation_downloads-__prefix__-annotation': 1, + 'Annotation_downloads-__prefix__-download': '', + 'Annotation_references-TOTAL_FORMS': 0, + 'Annotation_references-INITIAL_FORMS': 0, + 'Annotation_references-MIN_NUM_FORMS': 0, + 'Annotation_references-MAX_NUM_FORMS': 1000, + 'Annotation_references-__prefix__-id': '', + 'Annotation_references-__prefix__-annotation': 1, + 'Annotation_references-__prefix__-reference': '', + }, + ) assert response.status_code == 302 - assert Annotation.objects.get(pk=1).specifiers == { - 'model': ['model3'] - } + assert Annotation.objects.get(pk=1).specifiers == {'model': ['model3']} diff --git a/isimip_data/annotations/utils.py b/isimip_data/annotations/utils.py index edefd6b0..f6029a5f 100644 --- a/isimip_data/annotations/utils.py +++ b/isimip_data/annotations/utils.py @@ -45,11 +45,13 @@ def query_resources(datasets): def format_affected_datasets(dataset_ids): - datasets = Dataset.objects.using('metadata').filter(target=None, id__in=dataset_ids[:settings.CAVEATS_MAX_DATASETS]) + datasets = Dataset.objects.using('metadata').filter( + target=None, id__in=dataset_ids[: settings.CAVEATS_MAX_DATASETS] + ) lines = format_html_join( mark_safe('
'), '{}#{}', - ((dataset.path, dataset.version) for dataset in datasets) + ((dataset.path, dataset.version) for dataset in datasets), ) count = len(dataset_ids) @@ -64,5 +66,5 @@ def format_affected_resources(resources): return format_html_join( mark_safe('
'), '{}', - ((resource.doi, ) for resource in resources) + ((resource.doi,) for resource in resources), ) diff --git a/isimip_data/caveats/admin.py b/isimip_data/caveats/admin.py index 0b7b3a30..3e906dd9 100644 --- a/isimip_data/caveats/admin.py +++ b/isimip_data/caveats/admin.py @@ -19,13 +19,14 @@ class CaveatAdminForm(forms.ModelForm): - def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.fields['description'].help_text = 'Please describe only the initial problem. Subsequent updates ' \ - 'should be added as comments. For the information on whether ' \ - 'the data should be used for simulations or research, use the ' \ - 'severity field.' + self.fields['description'].help_text = ( + 'Please describe only the initial problem. Subsequent updates ' + 'should be added as comments. For the information on whether ' + 'the data should be used for simulations or research, use the ' + 'severity field.' + ) self.fields['specifiers'].widget = SpecifierWidget() self.fields['specifiers'].required = False @@ -49,19 +50,20 @@ class DownloadInline(admin.TabularInline): class AnnouncementAdminForm(forms.Form): - subject = forms.CharField(widget=forms.Textarea(attrs={ - 'class': 'vLargeTextField', - 'rows': 2 - }), required=True) - message = forms.CharField(widget=forms.Textarea(attrs={ - 'class': 'vLargeTextField', - 'rows': 20 - }), required=True) - recipients = forms.CharField(widget=forms.Textarea(attrs={ - 'class': 'vLargeTextField', - 'rows': 4 - }), required=True, help_text=_('You can add multiple recipients line by line.'), - initial='\n'.join(settings.CAVEATS_DEFAULT_RECIPIENTS)) + subject = forms.CharField( + widget=forms.Textarea(attrs={'class': 'vLargeTextField', 'rows': 2}), + required=True, + ) + message = forms.CharField( + widget=forms.Textarea(attrs={'class': 'vLargeTextField', 'rows': 20}), + required=True, + ) + recipients = forms.CharField( + widget=forms.Textarea(attrs={'class': 'vLargeTextField', 'rows': 4}), + required=True, + help_text=_('You can add multiple recipients line by line.'), + initial='\n'.join(settings.CAVEATS_DEFAULT_RECIPIENTS), + ) def __init__(self, *args, **kwargs): self.object = kwargs.pop('object') @@ -90,30 +92,57 @@ class CaveatAdmin(admin.ModelAdmin): exclude = ('datasets', 'figures', 'downloads') fieldsets = ( - (None, { - 'fields': ('public', 'email', 'title', 'description', 'creator', 'category', - 'severity', 'status', 'message') - }), - ('Specifiers', { - 'classes': ('collapse',), - 'fields': ('specifiers', ), - }), - ('Versions', { - 'classes': ('collapse',), - 'fields': ('version_after', 'version_before'), - }), - ('Include/Exclude', { - 'classes': ('collapse',), - 'fields': ('include', 'exclude'), - }), - ('Affected datasets', { - 'classes': ('collapse',), - 'fields': ('affected_datasets', ), - }), - ('Affected resources', { - 'classes': ('collapse',), - 'fields': ('affected_resources', ), - }) + ( + None, + { + 'fields': ( + 'public', + 'email', + 'title', + 'description', + 'creator', + 'category', + 'severity', + 'status', + 'message', + ) + }, + ), + ( + 'Specifiers', + { + 'classes': ('collapse',), + 'fields': ('specifiers',), + }, + ), + ( + 'Versions', + { + 'classes': ('collapse',), + 'fields': ('version_after', 'version_before'), + }, + ), + ( + 'Include/Exclude', + { + 'classes': ('collapse',), + 'fields': ('include', 'exclude'), + }, + ), + ( + 'Affected datasets', + { + 'classes': ('collapse',), + 'fields': ('affected_datasets',), + }, + ), + ( + 'Affected resources', + { + 'classes': ('collapse',), + 'fields': ('affected_resources',), + }, + ), ) class Media: @@ -131,16 +160,15 @@ def caveats_caveat_send(self, request, pk): context = { 'caveat': caveat, 'caveat_url': request.build_absolute_uri(caveat.get_absolute_url()), - 'datasets': datasets + 'datasets': datasets, } subject = render_to_string('caveats/email/caveat_announcement_subject.txt', context, request=request) message = render_to_string('caveats/email/caveat_announcement_message.txt', context, request=request) - form = AnnouncementAdminForm(request.POST or None, object=caveat, initial={ - 'subject': subject, - 'message': message - }) + form = AnnouncementAdminForm( + request.POST or None, object=caveat, initial={'subject': subject, 'message': message} + ) if request.method == 'POST': if '_back' in request.POST: @@ -151,15 +179,17 @@ def caveats_caveat_send(self, request, pk): caveat.save() for recipient in form.cleaned_data['recipients']: - send_mail(form.cleaned_data['subject'], form.cleaned_data['message'], - to=[recipient], reply_to=settings.CAVEATS_REPLY_TO) + send_mail( + form.cleaned_data['subject'], + form.cleaned_data['message'], + to=[recipient], + reply_to=settings.CAVEATS_REPLY_TO, + ) self.message_user(request, 'An email has been send.', level=INFO) return redirect('admin:caveats_caveat_change', object_id=caveat.id) - return render(request, 'admin/caveats/caveat_send.html', context={ - 'form': form - }) + return render(request, 'admin/caveats/caveat_send.html', context={'form': form}) def affected_datasets(self, instance): return format_affected_datasets(instance.datasets) @@ -172,8 +202,8 @@ class CommentAdmin(admin.ModelAdmin): search_fields = ('caveat', 'creator', 'text') list_display = ('id', 'caveat', 'creator', 'created', 'public', 'email') list_display_links = ('id', 'caveat') - list_filter = ('public', ) - readonly_fields = ('created', ) + list_filter = ('public',) + readonly_fields = ('created',) def get_urls(self): view = self.admin_site.admin_view(self.caveats_comment_send) @@ -184,8 +214,7 @@ def caveats_comment_send(self, request, pk): quotes = [] level = 0 - for previous_comment in comment.caveat.comments.exclude(created__gte=comment.created) \ - .order_by('created'): + for previous_comment in comment.caveat.comments.exclude(created__gte=comment.created).order_by('created'): quotes.append(previous_comment.get_quote(level=level)) level += 1 quotes.append(comment.caveat.get_quote(level=level)) @@ -193,16 +222,15 @@ def caveats_comment_send(self, request, pk): context = { 'comment': comment, 'caveat_url': request.build_absolute_uri(comment.get_absolute_url()), - 'quotes': quotes + 'quotes': quotes, } subject = render_to_string('caveats/email/comment_announcement_subject.txt', context, request=request) message = render_to_string('caveats/email/comment_announcement_message.txt', context, request=request) - form = AnnouncementAdminForm(request.POST or None, object=comment, initial={ - 'subject': subject, - 'message': message - }) + form = AnnouncementAdminForm( + request.POST or None, object=comment, initial={'subject': subject, 'message': message} + ) if request.method == 'POST': if '_back' in request.POST: @@ -213,15 +241,17 @@ def caveats_comment_send(self, request, pk): comment.save() for recipient in form.cleaned_data['recipients']: - send_mail(form.cleaned_data['subject'], form.cleaned_data['message'], - to=[recipient], reply_to=settings.CAVEATS_REPLY_TO) + send_mail( + form.cleaned_data['subject'], + form.cleaned_data['message'], + to=[recipient], + reply_to=settings.CAVEATS_REPLY_TO, + ) self.message_user(request, 'An email has been send.', level=INFO) return redirect('admin:caveats_comment_change', object_id=comment.id) - return render(request, 'admin/caveats/comment_send.html', context={ - 'form': form - }) + return render(request, 'admin/caveats/comment_send.html', context={'form': form}) admin.site.register(Caveat, CaveatAdmin) diff --git a/isimip_data/caveats/assets/js/api/CaveatsApi.js b/isimip_data/caveats/assets/js/api/CaveatsApi.js index 31691dc3..379e1f77 100644 --- a/isimip_data/caveats/assets/js/api/CaveatsApi.js +++ b/isimip_data/caveats/assets/js/api/CaveatsApi.js @@ -1,6 +1,5 @@ import { encodeParams } from 'isimip_data/core/assets/js/utils/api' - class CaveatsApi { static fetchCaveats(params, fetchParams = {}) { diff --git a/isimip_data/caveats/assets/js/caveats.js b/isimip_data/caveats/assets/js/caveats.jsx similarity index 83% rename from isimip_data/caveats/assets/js/caveats.js rename to isimip_data/caveats/assets/js/caveats.jsx index 3c52c076..0c0e8a97 100644 --- a/isimip_data/caveats/assets/js/caveats.js +++ b/isimip_data/caveats/assets/js/caveats.jsx @@ -1,10 +1,11 @@ -import 'bootstrap' - import React from 'react' import { createRoot } from 'react-dom/client' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import Caveats from './components/Caveats.js' +import Caveats from './components/Caveats' + +import 'bootstrap' +import '../scss/caveats.scss' const queryClient = new QueryClient() diff --git a/isimip_data/caveats/assets/js/components/Badge.js b/isimip_data/caveats/assets/js/components/Badge.jsx similarity index 99% rename from isimip_data/caveats/assets/js/components/Badge.js rename to isimip_data/caveats/assets/js/components/Badge.jsx index 6cfd8f16..f59f393d 100644 --- a/isimip_data/caveats/assets/js/components/Badge.js +++ b/isimip_data/caveats/assets/js/components/Badge.jsx @@ -1,7 +1,6 @@ import React from 'react' import PropTypes from 'prop-types' - const Badge = ({ label, color }) => (
{label} diff --git a/isimip_data/caveats/assets/js/components/Caveat.js b/isimip_data/caveats/assets/js/components/Caveat.jsx similarity index 100% rename from isimip_data/caveats/assets/js/components/Caveat.js rename to isimip_data/caveats/assets/js/components/Caveat.jsx diff --git a/isimip_data/caveats/assets/js/components/Caveats.js b/isimip_data/caveats/assets/js/components/Caveats.jsx similarity index 92% rename from isimip_data/caveats/assets/js/components/Caveats.js rename to isimip_data/caveats/assets/js/components/Caveats.jsx index b985560e..6a061f6e 100644 --- a/isimip_data/caveats/assets/js/components/Caveats.js +++ b/isimip_data/caveats/assets/js/components/Caveats.jsx @@ -42,7 +42,7 @@ const Caveats = () => {
{ - filteredCaveats.map((caveat, caveatIndex) => ) + filteredCaveats.map((caveat, caveatIndex) => ) }
) diff --git a/isimip_data/caveats/assets/js/components/Filter.js b/isimip_data/caveats/assets/js/components/Filter.jsx similarity index 100% rename from isimip_data/caveats/assets/js/components/Filter.js rename to isimip_data/caveats/assets/js/components/Filter.jsx diff --git a/isimip_data/caveats/assets/js/components/FilterCategory.js b/isimip_data/caveats/assets/js/components/FilterCategory.jsx similarity index 82% rename from isimip_data/caveats/assets/js/components/FilterCategory.js rename to isimip_data/caveats/assets/js/components/FilterCategory.jsx index c096b0d3..16709bb1 100644 --- a/isimip_data/caveats/assets/js/components/FilterCategory.js +++ b/isimip_data/caveats/assets/js/components/FilterCategory.jsx @@ -2,10 +2,10 @@ import React from 'react' import PropTypes from 'prop-types' import { omit } from 'lodash' -import { useCategoryQuery } from '../hooks/queries' - import Icon from 'isimip_data/core/assets/js/components/Icon' +import { useCategoryQuery } from '../hooks/queries' + import Badge from './Badge' const FilterCategory = ({ values, setValues }) => { @@ -27,15 +27,19 @@ const FilterCategory = ({ values, setValues }) => { return categories && (
-
{ categories.map((category, categoryIndex) => ( - diff --git a/isimip_data/caveats/assets/js/components/FilterInput.js b/isimip_data/caveats/assets/js/components/FilterInput.jsx similarity index 72% rename from isimip_data/caveats/assets/js/components/FilterInput.js rename to isimip_data/caveats/assets/js/components/FilterInput.jsx index 559c8c9c..86847d06 100644 --- a/isimip_data/caveats/assets/js/components/FilterInput.js +++ b/isimip_data/caveats/assets/js/components/FilterInput.jsx @@ -4,7 +4,6 @@ import { useDebouncedCallback } from 'use-debounce' import Icon from 'isimip_data/core/assets/js/components/Icon' - const FilterInput = ({ values, setValues }) => { const [input, setInput] = useState(values.filterString || '') @@ -24,16 +23,20 @@ const FilterInput = ({ values, setValues }) => { return (
- +
- +
) diff --git a/isimip_data/caveats/assets/js/components/FilterOrder.js b/isimip_data/caveats/assets/js/components/FilterOrder.jsx similarity index 75% rename from isimip_data/caveats/assets/js/components/FilterOrder.js rename to isimip_data/caveats/assets/js/components/FilterOrder.jsx index d4ab213d..786d9bd0 100644 --- a/isimip_data/caveats/assets/js/components/FilterOrder.js +++ b/isimip_data/caveats/assets/js/components/FilterOrder.jsx @@ -19,15 +19,19 @@ const FilterOrder = ({ values, setValues }) => { return (
-
{ Object.keys(orderings).map((order, index) => ( - diff --git a/isimip_data/caveats/assets/js/components/FilterSeverity.js b/isimip_data/caveats/assets/js/components/FilterSeverity.jsx similarity index 82% rename from isimip_data/caveats/assets/js/components/FilterSeverity.js rename to isimip_data/caveats/assets/js/components/FilterSeverity.jsx index 732fd016..7763453a 100644 --- a/isimip_data/caveats/assets/js/components/FilterSeverity.js +++ b/isimip_data/caveats/assets/js/components/FilterSeverity.jsx @@ -2,10 +2,10 @@ import React from 'react' import PropTypes from 'prop-types' import { omit } from 'lodash' -import { useSeverityQuery } from '../hooks/queries' - import Icon from 'isimip_data/core/assets/js/components/Icon' +import { useSeverityQuery } from '../hooks/queries' + import Badge from './Badge' const FilterSeverity = ({ values, setValues }) => { @@ -27,15 +27,19 @@ const FilterSeverity = ({ values, setValues }) => { return severities && (
-
{ severities.map((severity, severityIndex) => ( - diff --git a/isimip_data/caveats/assets/js/components/FilterStatus.js b/isimip_data/caveats/assets/js/components/FilterStatus.jsx similarity index 82% rename from isimip_data/caveats/assets/js/components/FilterStatus.js rename to isimip_data/caveats/assets/js/components/FilterStatus.jsx index 6d1c8784..9f9ec61b 100644 --- a/isimip_data/caveats/assets/js/components/FilterStatus.js +++ b/isimip_data/caveats/assets/js/components/FilterStatus.jsx @@ -2,10 +2,10 @@ import React from 'react' import PropTypes from 'prop-types' import { omit } from 'lodash' -import { useStatusQuery } from '../hooks/queries' - import Icon from 'isimip_data/core/assets/js/components/Icon' +import { useStatusQuery } from '../hooks/queries' + import Badge from './Badge' const FilterStatus = ({ values, setValues }) => { @@ -27,15 +27,19 @@ const FilterStatus = ({ values, setValues }) => { return status && (
-
{ status.map((status, statusIndex) => ( - diff --git a/isimip_data/caveats/assets/js/components/Versions.js b/isimip_data/caveats/assets/js/components/Versions.jsx similarity index 99% rename from isimip_data/caveats/assets/js/components/Versions.js rename to isimip_data/caveats/assets/js/components/Versions.jsx index de3a1755..aee5d38d 100644 --- a/isimip_data/caveats/assets/js/components/Versions.js +++ b/isimip_data/caveats/assets/js/components/Versions.jsx @@ -1,7 +1,6 @@ import React from 'react' import PropTypes from 'prop-types' - const Versions = ({ caveat }) => { if (caveat.version_after && caveat.version_before) { return ( diff --git a/isimip_data/caveats/managers.py b/isimip_data/caveats/managers.py index 64fed795..bc9d2cdb 100644 --- a/isimip_data/caveats/managers.py +++ b/isimip_data/caveats/managers.py @@ -2,7 +2,6 @@ class ModerationQuerySet(models.QuerySet): - def public(self, user): if user.is_authenticated and user.is_staff: return self.all() @@ -15,7 +14,6 @@ def public(self, user): class ModerationManager(models.Manager): - def public(self, user): return self.get_queryset().public(user) diff --git a/isimip_data/caveats/models.py b/isimip_data/caveats/models.py index c454a95c..e6d0ec68 100644 --- a/isimip_data/caveats/models.py +++ b/isimip_data/caveats/models.py @@ -15,19 +15,18 @@ class Caveat(models.Model): - CATEGORY_NOTE = 'note' CATEGORY_ISSUE = 'issue' CATEGORY_CHOICES = ( (CATEGORY_NOTE, _('note')), - (CATEGORY_ISSUE, _('issue')) + (CATEGORY_ISSUE, _('issue')), ) SEVERITY_LOW = 'low' SEVERITY_HIGH = 'high' SEVERITY_CHOICES = ( (SEVERITY_LOW, _('low')), - (SEVERITY_HIGH, _('high')) + (SEVERITY_HIGH, _('high')), ) STATUS_NEW = 'new' @@ -38,7 +37,7 @@ class Caveat(models.Model): (STATUS_NEW, _('new')), (STATUS_ON_HOLD, _('on hold')), (STATUS_RESOLVED, _('resolved')), - (STATUS_WONT_FIX, _('won\'t fix')), + (STATUS_WONT_FIX, _("won't fix")), ) MESSAGE_CAN_BE_USED = 'can_be_used' @@ -47,45 +46,41 @@ class Caveat(models.Model): MESSAGE_CHOICES = ( (MESSAGE_CAN_BE_USED, _('Affected datasets can still be used for simulations or research.')), (MESSAGE_DO_NOT_USE, _('Affected datasets should not be used until this issue is resolved.')), - (MESSAGE_REPLACED, _('Please use the replaced datasets for new simulations or research.')) + (MESSAGE_REPLACED, _('Please use the replaced datasets for new simulations or research.')), ) CATEGORY_SYMBOL = { CATEGORY_NOTE: 'info', - CATEGORY_ISSUE: 'warning' + CATEGORY_ISSUE: 'warning', } CATEGORY_COLOR = { CATEGORY_NOTE: 'light', - CATEGORY_ISSUE: 'dark' + CATEGORY_ISSUE: 'dark', } SEVERITY_LEVEL = { SEVERITY_LOW: 1, - SEVERITY_HIGH: 2 + SEVERITY_HIGH: 2, } SEVERITY_COLOR = { SEVERITY_LOW: 'success', - SEVERITY_HIGH: 'danger' + SEVERITY_HIGH: 'danger', } STATUS_COLOR = { STATUS_NEW: 'primary', STATUS_ON_HOLD: 'secondary', STATUS_RESOLVED: 'success', - STATUS_WONT_FIX: 'dark' + STATUS_WONT_FIX: 'dark', } objects = ModerationManager() - public = models.BooleanField( - default=False, - help_text=_('Designates whether this caveat is publicly visible.') - ) + public = models.BooleanField(default=False, help_text=_('Designates whether this caveat is publicly visible.')) email = models.BooleanField( - default=False, - help_text=_('Designates whether an announcement mail for this caveat has been send.') + default=False, help_text=_('Designates whether an announcement mail for this caveat has been send.') ) title = models.CharField(max_length=512) description = models.TextField() @@ -100,11 +95,13 @@ class Caveat(models.Model): include = models.TextField( blank=True, help_text='You can add multiple paths line by line. If paths are provided, datasets will ' - 'only be included if their path starts with one of the paths given.') + 'only be included if their path starts with one of the paths given.', + ) exclude = models.TextField( blank=True, help_text='You can add multiple paths line by line. Datasets will be excluded ' - 'if their path starts with one of the paths given.') + 'if their path starts with one of the paths given.', + ) datasets = ArrayField(models.UUIDField(), blank=True, default=list) resources = ArrayField(models.UUIDField(), blank=True, default=list) version_after = models.CharField(max_length=8, blank=True) @@ -113,14 +110,19 @@ class Caveat(models.Model): downloads = models.ManyToManyField(Download, related_name='caveats') class Meta: - ordering = ('-created', ) + ordering = ('-created',) def __str__(self): return self.title def save(self, *args, **kwargs): - self.datasets = query_datasets(self.specifiers, self.version_after, self.version_before, - self.include, self.exclude) + self.datasets = query_datasets( + self.specifiers, + self.version_after, + self.version_before, + self.include, + self.exclude, + ) self.resources = query_resources(self.datasets) cache.clear() super().save(*args, **kwargs) @@ -204,16 +206,11 @@ def get_quote(self, level): class Comment(models.Model): - objects = ModerationManager() - public = models.BooleanField( - default=False, - help_text=_('Designates whether this comment is publicly visible.') - ) + public = models.BooleanField(default=False, help_text=_('Designates whether this comment is publicly visible.')) email = models.BooleanField( - default=False, - help_text=_('Designates whether an announcement mail for this comment has been send.') + default=False, help_text=_('Designates whether an announcement mail for this comment has been send.') ) text = models.TextField() creator = models.ForeignKey(User, null=True, on_delete=models.SET_NULL, related_name='comments') @@ -222,7 +219,7 @@ class Comment(models.Model): caveat = models.ForeignKey(Caveat, on_delete=models.CASCADE, related_name='comments') class Meta: - ordering = ('-created', ) + ordering = ('-created',) def __str__(self): return f'{self.caveat} {self.creator} {self.created}' diff --git a/isimip_data/caveats/serializers.py b/isimip_data/caveats/serializers.py index 2c943a72..bba00e6e 100644 --- a/isimip_data/caveats/serializers.py +++ b/isimip_data/caveats/serializers.py @@ -4,7 +4,6 @@ class CaveatSerializer(serializers.ModelSerializer): - creator_display = serializers.SerializerMethodField() url = serializers.URLField(source='get_absolute_url') @@ -47,7 +46,7 @@ class Meta: 'datasets', 'resources', 'version_after', - 'version_before' + 'version_before', ) def get_creator_display(self, obj): @@ -55,7 +54,6 @@ def get_creator_display(self, obj): class CaveatIndexSerializer(serializers.ModelSerializer): - creator_display = serializers.SerializerMethodField() url = serializers.URLField(source='get_absolute_url') @@ -93,7 +91,7 @@ class Meta: 'message', 'message_display', 'version_after', - 'version_before' + 'version_before', ) def get_creator_display(self, obj): @@ -101,7 +99,6 @@ def get_creator_display(self, obj): class CaveatChoicesSerializer(serializers.Serializer): - value = serializers.CharField() display = serializers.CharField() color = serializers.CharField() diff --git a/isimip_data/caveats/templates/caveats/caveats.html b/isimip_data/caveats/templates/caveats/caveats.html index 0387fa3a..bc33cf4a 100644 --- a/isimip_data/caveats/templates/caveats/caveats.html +++ b/isimip_data/caveats/templates/caveats/caveats.html @@ -1,13 +1,9 @@ {% extends 'core/base.html' %} -{% load static %} +{% load django_vite %} -{% block style %} +{% block assets %} {{ block.super }} - -{% endblock %} - -{% block script %} - + {% vite_asset 'isimip_data/caveats/assets/js/caveats.jsx' %} {% endblock %} {% block main %} diff --git a/isimip_data/caveats/tests/test_admin.py b/isimip_data/caveats/tests/test_admin.py index fb91ce4c..67a67472 100644 --- a/isimip_data/caveats/tests/test_admin.py +++ b/isimip_data/caveats/tests/test_admin.py @@ -17,33 +17,34 @@ def test_annotation_add_post(db, client): client.login(username='admin', password='admin') url = reverse('admin:caveats_caveat_add') - response = client.post(url, { - 'title': 'New Caveat', - 'description': 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr', - 'creator': 1, - 'category': 'issue', - 'severity': 'low', - 'status': 'new', - 'specifiers_model': 'model', - 'Caveat_figures-TOTAL_FORMS': 0, - 'Caveat_figures-INITIAL_FORMS': 0, - 'Caveat_figures-MIN_NUM_FORMS': 0, - 'Caveat_figures-MAX_NUM_FORMS': 1000, - 'Caveat_figures-__prefix__-id': '', - 'Caveat_figures-__prefix__-caveat': '', - 'Caveat_figures-__prefix__-figure': '', - 'Caveat_downloads-TOTAL_FORMS': 0, - 'Caveat_downloads-INITIAL_FORMS': 0, - 'Caveat_downloads-MIN_NUM_FORMS': 0, - 'Caveat_downloads-MAX_NUM_FORMS': 1000, - 'Caveat_downloads-__prefix__-id': '', - 'Caveat_downloads-__prefix__-caveat': '', - 'Caveat_downloads-__prefix__-download': '' - }) + response = client.post( + url, + { + 'title': 'New Caveat', + 'description': 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr', + 'creator': 1, + 'category': 'issue', + 'severity': 'low', + 'status': 'new', + 'specifiers_model': 'model', + 'Caveat_figures-TOTAL_FORMS': 0, + 'Caveat_figures-INITIAL_FORMS': 0, + 'Caveat_figures-MIN_NUM_FORMS': 0, + 'Caveat_figures-MAX_NUM_FORMS': 1000, + 'Caveat_figures-__prefix__-id': '', + 'Caveat_figures-__prefix__-caveat': '', + 'Caveat_figures-__prefix__-figure': '', + 'Caveat_downloads-TOTAL_FORMS': 0, + 'Caveat_downloads-INITIAL_FORMS': 0, + 'Caveat_downloads-MIN_NUM_FORMS': 0, + 'Caveat_downloads-MAX_NUM_FORMS': 1000, + 'Caveat_downloads-__prefix__-id': '', + 'Caveat_downloads-__prefix__-caveat': '', + 'Caveat_downloads-__prefix__-download': '', + }, + ) assert response.status_code == 302 - assert Caveat.objects.get(title='New Caveat').specifiers == { - 'model': ['model'] - } + assert Caveat.objects.get(title='New Caveat').specifiers == {'model': ['model']} def test_annotation_change_get(db, client): @@ -58,33 +59,34 @@ def test_annotation_change_post(db, client): client.login(username='admin', password='admin') url = reverse('admin:caveats_caveat_change', args=[1]) - response = client.post(url, { - 'title': 'Caveat', - 'description': 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr', - 'creator': 1, - 'category': 'issue', - 'severity': 'low', - 'status': 'new', - 'specifiers_model': 'model3', - 'Caveat_figures-TOTAL_FORMS': 0, - 'Caveat_figures-INITIAL_FORMS': 0, - 'Caveat_figures-MIN_NUM_FORMS': 0, - 'Caveat_figures-MAX_NUM_FORMS': 1000, - 'Caveat_figures-__prefix__-id': '', - 'Caveat_figures-__prefix__-caveat': '', - 'Caveat_figures-__prefix__-figure': '', - 'Caveat_downloads-TOTAL_FORMS': 0, - 'Caveat_downloads-INITIAL_FORMS': 0, - 'Caveat_downloads-MIN_NUM_FORMS': 0, - 'Caveat_downloads-MAX_NUM_FORMS': 1000, - 'Caveat_downloads-__prefix__-id': '', - 'Caveat_downloads-__prefix__-caveat': '', - 'Caveat_downloads-__prefix__-download': '' - }) + response = client.post( + url, + { + 'title': 'Caveat', + 'description': 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr', + 'creator': 1, + 'category': 'issue', + 'severity': 'low', + 'status': 'new', + 'specifiers_model': 'model3', + 'Caveat_figures-TOTAL_FORMS': 0, + 'Caveat_figures-INITIAL_FORMS': 0, + 'Caveat_figures-MIN_NUM_FORMS': 0, + 'Caveat_figures-MAX_NUM_FORMS': 1000, + 'Caveat_figures-__prefix__-id': '', + 'Caveat_figures-__prefix__-caveat': '', + 'Caveat_figures-__prefix__-figure': '', + 'Caveat_downloads-TOTAL_FORMS': 0, + 'Caveat_downloads-INITIAL_FORMS': 0, + 'Caveat_downloads-MIN_NUM_FORMS': 0, + 'Caveat_downloads-MAX_NUM_FORMS': 1000, + 'Caveat_downloads-__prefix__-id': '', + 'Caveat_downloads-__prefix__-caveat': '', + 'Caveat_downloads-__prefix__-download': '', + }, + ) assert response.status_code == 302 - assert Caveat.objects.get(pk=1).specifiers == { - 'model': ['model3'] - } + assert Caveat.objects.get(pk=1).specifiers == {'model': ['model3']} def test_caveat_add_get(db, client): @@ -111,12 +113,15 @@ def test_caveat_send_post(db, client): caveat.save() url = reverse('admin:caveats_caveat_send', args=[1]) - response = client.post(url, { - 'subject': 'Subject', - 'message': 'Message', - 'recipients': 'mail@example.com\nmail2@example.com', - '_send': 'Send email' - }) + response = client.post( + url, + { + 'subject': 'Subject', + 'message': 'Message', + 'recipients': 'mail@example.com\nmail2@example.com', + '_send': 'Send email', + }, + ) assert response.status_code == 302 assert len(mail.outbox) == 2 @@ -134,12 +139,15 @@ def test_caveat_send_post_error(db, client): client.login(username='admin', password='admin') url = reverse('admin:caveats_caveat_send', args=[1]) - response = client.post(url, { - 'subject': 'Subject', - 'message': 'Message', - 'recipients': 'mail@example.com\nmail2@example.com', - '_send': 'Send email' - }) + response = client.post( + url, + { + 'subject': 'Subject', + 'message': 'Message', + 'recipients': 'mail@example.com\nmail2@example.com', + '_send': 'Send email', + }, + ) assert response.status_code == 200 assert b'No email can been send, since the email flag was set before.' in response.content assert len(mail.outbox) == 0 @@ -149,12 +157,15 @@ def test_caveat_send_post_back(db, client): client.login(username='admin', password='admin') url = reverse('admin:caveats_caveat_send', args=[1]) - response = client.post(url, { - 'subject': 'Subject', - 'message': 'Message', - 'recipients': 'mail@example.com\nmail2@example.com', - '_back': 'Back' - }) + response = client.post( + url, + { + 'subject': 'Subject', + 'message': 'Message', + 'recipients': 'mail@example.com\nmail2@example.com', + '_back': 'Back', + }, + ) assert response.status_code == 302 assert len(mail.outbox) == 0 @@ -175,12 +186,15 @@ def test_comment_send_post(db, client): comment.save() url = reverse('admin:caveats_comment_send', args=[1]) - response = client.post(url, { - 'subject': 'Subject', - 'message': 'Message', - 'recipients': 'mail@example.com\nmail2@example.com', - '_send': 'Send email' - }) + response = client.post( + url, + { + 'subject': 'Subject', + 'message': 'Message', + 'recipients': 'mail@example.com\nmail2@example.com', + '_send': 'Send email', + }, + ) assert response.status_code == 302 assert len(mail.outbox) == 2 @@ -198,12 +212,15 @@ def test_comment_send_post_error(db, client): client.login(username='admin', password='admin') url = reverse('admin:caveats_comment_send', args=[1]) - response = client.post(url, { - 'subject': 'Subject', - 'message': 'Message', - 'recipients': 'mail@example.com\nmail2@example.com', - '_send': 'Send email' - }) + response = client.post( + url, + { + 'subject': 'Subject', + 'message': 'Message', + 'recipients': 'mail@example.com\nmail2@example.com', + '_send': 'Send email', + }, + ) assert response.status_code == 200 assert b'No email can been send, since the email flag was set before.' in response.content assert len(mail.outbox) == 0 @@ -213,11 +230,14 @@ def test_comment_send_post_back(db, client): client.login(username='admin', password='admin') url = reverse('admin:caveats_comment_send', args=[1]) - response = client.post(url, { - 'subject': 'Subject', - 'message': 'Message', - 'recipients': 'mail@example.com\nmail2@example.com', - '_back': 'Back' - }) + response = client.post( + url, + { + 'subject': 'Subject', + 'message': 'Message', + 'recipients': 'mail@example.com\nmail2@example.com', + '_back': 'Back', + }, + ) assert response.status_code == 302 assert len(mail.outbox) == 0 diff --git a/isimip_data/caveats/views.py b/isimip_data/caveats/views.py index 1ed99526..4f9cb66d 100644 --- a/isimip_data/caveats/views.py +++ b/isimip_data/caveats/views.py @@ -10,13 +10,9 @@ def caveats(request): if request.resolver_match.url_name != 'issues_and_notes': return redirect('issues_and_notes') - caveats = Caveat.objects.public(request.user) \ - .select_related('creator') + caveats = Caveat.objects.public(request.user).select_related('creator') - return render(request, 'caveats/caveats.html', { - 'title': 'Caveats', - 'caveats': caveats - }) + return render(request, 'caveats/caveats.html', {'title': 'Caveats', 'caveats': caveats}) def caveat(request, pk=None): @@ -27,15 +23,21 @@ def caveat(request, pk=None): return redirect(caveat) comments = caveat.comments.public(request.user) - datasets = Dataset.objects.using('metadata') \ - .filter(target=None, id__in=caveat.datasets[:settings.CAVEATS_MAX_DATASETS]) \ - .prefetch_related('links') - - return render(request, 'caveats/caveat.html', { - 'title': caveat.title, - 'caveat': caveat, - 'comments': comments, - 'count': len(caveat.datasets), - 'datasets': datasets, - 'search_url': request.build_absolute_uri(caveat.get_search_url()), - }) + datasets = ( + Dataset.objects.using('metadata') + .filter(target=None, id__in=caveat.datasets[: settings.CAVEATS_MAX_DATASETS]) + .prefetch_related('links') + ) + + return render( + request, + 'caveats/caveat.html', + { + 'title': caveat.title, + 'caveat': caveat, + 'comments': comments, + 'count': len(caveat.datasets), + 'datasets': datasets, + 'search_url': request.build_absolute_uri(caveat.get_search_url()), + }, + ) diff --git a/isimip_data/caveats/viewsets.py b/isimip_data/caveats/viewsets.py index 76c76a0b..18171d09 100644 --- a/isimip_data/caveats/viewsets.py +++ b/isimip_data/caveats/viewsets.py @@ -12,13 +12,11 @@ class CaveatViewSet(ReadOnlyModelViewSet): - serializer_class = CaveatSerializer pagination_class = PageNumberPagination def get_queryset(self): - return Caveat.objects.public(self.request.user) \ - .select_related('creator') + return Caveat.objects.public(self.request.user).select_related('creator') @action(detail=False) def index(self, request): @@ -31,15 +29,18 @@ def detail_datasets(self, request, pk): caveat = self.get_object() base_url = request.build_absolute_uri() datasets = Dataset.objects.using('metadata').filter(id__in=caveat.datasets) - response = Response([ - { - 'id': dataset.id, - 'path': dataset.path, - 'version': dataset.version, - 'public': dataset.public, - 'metadata_url': base_url + dataset.get_absolute_url(), - } for dataset in datasets - ]) + response = Response( + [ + { + 'id': dataset.id, + 'path': dataset.path, + 'version': dataset.version, + 'public': dataset.public, + 'metadata_url': base_url + dataset.get_absolute_url(), + } + for dataset in datasets + ] + ) response['Content-Disposition'] = f'attachment; filename=caveat-{caveat.id}.datasets.json' return response @@ -48,35 +49,40 @@ def detail_files(self, request, pk): caveat = self.get_object() base_url = request.build_absolute_uri() files = File.objects.using('metadata').select_related('dataset').filter(dataset__id__in=caveat.datasets) - response = Response([ - { - 'id': file.id, - 'dataset_id': file.dataset_id, - 'path': file.path, - 'version': file.version, - 'public': file.public, - 'metadata_url': base_url + file.get_absolute_url(), - 'file_url': file.file_url, - 'json_url': file.json_url - } for file in files - ]) + response = Response( + [ + { + 'id': file.id, + 'dataset_id': file.dataset_id, + 'path': file.path, + 'version': file.version, + 'public': file.public, + 'metadata_url': base_url + file.get_absolute_url(), + 'file_url': file.file_url, + 'json_url': file.json_url, + } + for file in files + ] + ) response['Content-Disposition'] = f'attachment; filename=caveat-{caveat.id}.files.json' return response @action(detail=True, url_path='filelist', renderer_classes=[TemplateHTMLRenderer]) def detail_filelist(self, request, pk): caveat = self.get_object() - files = File.objects.using('metadata').select_related('dataset') \ - .filter(dataset__id__in=caveat.datasets, dataset__public=True) - response = Response({ - 'files': files - }, template_name='metadata/filelist.txt', content_type='text/plain; charset=utf-8') + files = ( + File.objects.using('metadata') + .select_related('dataset') + .filter(dataset__id__in=caveat.datasets, dataset__public=True) + ) + response = Response( + {'files': files}, template_name='metadata/filelist.txt', content_type='text/plain; charset=utf-8' + ) response['Content-Disposition'] = f'attachment; filename=caveat-{caveat.id}.txt' return response class CategoryViewSet(ListModelMixin, GenericViewSet): - serializer_class = CaveatChoicesSerializer def get_queryset(self): @@ -84,13 +90,13 @@ def get_queryset(self): { 'value': category, 'display': category_display, - 'color': Caveat.CATEGORY_COLOR.get(category) - } for category, category_display in Caveat.CATEGORY_CHOICES + 'color': Caveat.CATEGORY_COLOR.get(category), + } + for category, category_display in Caveat.CATEGORY_CHOICES ] class SeverityViewSet(ListModelMixin, GenericViewSet): - serializer_class = CaveatChoicesSerializer def get_queryset(self): @@ -98,13 +104,13 @@ def get_queryset(self): { 'value': severity, 'display': severity_display, - 'color': Caveat.SEVERITY_COLOR.get(severity) - } for severity, severity_display in Caveat.SEVERITY_CHOICES + 'color': Caveat.SEVERITY_COLOR.get(severity), + } + for severity, severity_display in Caveat.SEVERITY_CHOICES ] class StatusViewSet(ListModelMixin, GenericViewSet): - serializer_class = CaveatChoicesSerializer def get_queryset(self): @@ -113,5 +119,6 @@ def get_queryset(self): 'value': status, 'display': status_display, 'color': Caveat.STATUS_COLOR.get(status), - } for status, status_display in Caveat.STATUS_CHOICES + } + for status, status_display in Caveat.STATUS_CHOICES ] diff --git a/isimip_data/core/assets/js/base.js b/isimip_data/core/assets/js/base.js index cafc5047..7be8259f 100644 --- a/isimip_data/core/assets/js/base.js +++ b/isimip_data/core/assets/js/base.js @@ -1,9 +1,11 @@ +import '../scss/base.scss' + document.addEventListener('DOMContentLoaded', () => { - for (const element of document.getElementsByClassName('copy-to-clipboard')) { - element.addEventListener('click', () => { - const code = element.getElementsByTagName('code')[0] - const text = code.textContent - navigator.clipboard.writeText(text) - }) - } + for (const element of document.getElementsByClassName('copy-to-clipboard')) { + element.addEventListener('click', () => { + const code = element.getElementsByTagName('code')[0] + const text = code.textContent + navigator.clipboard.writeText(text) + }) + } }) diff --git a/isimip_data/core/assets/js/bootstrap.js b/isimip_data/core/assets/js/bootstrap.js index 1afc5365..bd4dd314 100644 --- a/isimip_data/core/assets/js/bootstrap.js +++ b/isimip_data/core/assets/js/bootstrap.js @@ -1,5 +1,7 @@ /* eslint-disable no-unused-vars */ import { Tooltip } from 'bootstrap' +import '../scss/bootstrap.scss' + const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]') const tooltipList = [...tooltipTriggerList].map(tooltipTriggerEl => new Tooltip(tooltipTriggerEl)) diff --git a/isimip_data/core/assets/js/components/Checkbox.js b/isimip_data/core/assets/js/components/Checkbox.jsx similarity index 91% rename from isimip_data/core/assets/js/components/Checkbox.js rename to isimip_data/core/assets/js/components/Checkbox.jsx index 1ff2ee43..2ddf9a47 100644 --- a/isimip_data/core/assets/js/components/Checkbox.js +++ b/isimip_data/core/assets/js/components/Checkbox.jsx @@ -7,7 +7,7 @@ const Checkbox = ({ children, className, checked, onChange }) => { const id = uniqueId('checkbox') return ( -
+
diff --git a/isimip_data/download/assets/js/components/form/Operation.js b/isimip_data/download/assets/js/components/form/Operation.js deleted file mode 100644 index 6c0453bc..00000000 --- a/isimip_data/download/assets/js/components/form/Operation.js +++ /dev/null @@ -1,133 +0,0 @@ -import React from 'react' -import PropTypes from 'prop-types' -import { isUndefined } from 'lodash' - -import Html from 'isimip_data/core/assets/js/components/Html' - -import BBox from './widgets/BBox' -import Country from './widgets/Country' -import Csv from './widgets/Csv' -import Layer from './widgets/Layer' -import Mask from './widgets/Mask' -import Mean from './widgets/Mean' -import Point from './widgets/Point' -import Var from './widgets/Var' - -const Operation = ({ operation, index, isLast, values, errors, updateOperation, removeOperation }) => { - return ( -
-
- - -
{operation.title}
-
- -
- -
- { - !isUndefined(values.bbox) && ( - updateOperation(index, {...values, bbox})} - /> - ) - } - { - !isUndefined(values.point) && ( - updateOperation(index, {...values, point})} - /> - ) - } - { - !isUndefined(values.country) && ( - updateOperation(index, {...values, country})} - /> - ) - } - { - !isUndefined(values.mask) && ( - updateOperation(index, {...values, mask})} - /> - ) - } - { - !isUndefined(values.shape) && ( - updateOperation(index, {...values, shape})} - /> - ) - } - { - !isUndefined(values.var) && ( - updateOperation(index, {...values, var: value})} - /> - ) - } - { - !isUndefined(values.layer) && ( - updateOperation(index, {...values, layer})} - /> - ) - } - { - isLast && !isUndefined(values.compute_mean) && ( - updateOperation(index, { - ...values, - compute_mean, - output_csv: !compute_mean ? false : values.output_csv // unset output_csv if compute_mean is unset - })} - /> - ) - } - { - isLast && values.compute_mean && !isUndefined(values.output_csv) && ( - updateOperation(index, {...values, output_csv})} - /> - ) - } -
-
-
- ) -} - -Operation.propTypes = { - operation: PropTypes.object.isRequired, - index: PropTypes.number.isRequired, - isLast: PropTypes.bool.isRequired, - values: PropTypes.object.isRequired, - errors: PropTypes.object.isRequired, - updateOperation: PropTypes.func.isRequired, - removeOperation: PropTypes.func.isRequired -} - -export default Operation diff --git a/isimip_data/download/assets/js/components/form/Operation.jsx b/isimip_data/download/assets/js/components/form/Operation.jsx new file mode 100644 index 00000000..e64d8c9d --- /dev/null +++ b/isimip_data/download/assets/js/components/form/Operation.jsx @@ -0,0 +1,135 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { isUndefined } from 'lodash' + +import Html from 'isimip_data/core/assets/js/components/Html' + +import BBox from './widgets/BBox' +import Country from './widgets/Country' +import Csv from './widgets/Csv' +import Layer from './widgets/Layer' +import Mask from './widgets/Mask' +import Mean from './widgets/Mean' +import Point from './widgets/Point' +import Var from './widgets/Var' + +const Operation = ({ operation, index, isLast, values, errors, updateOperation, removeOperation }) => { + return ( +
+
+ + +
{operation.title}
+
+ +
+ +
+ { + !isUndefined(values.bbox) && ( + updateOperation(index, {...values, bbox})} + /> + ) + } + { + !isUndefined(values.point) && ( + updateOperation(index, {...values, point})} + /> + ) + } + { + !isUndefined(values.country) && ( + updateOperation(index, {...values, country})} + /> + ) + } + { + !isUndefined(values.mask) && ( + updateOperation(index, {...values, mask})} + /> + ) + } + { + !isUndefined(values.shape) && ( + updateOperation(index, {...values, shape})} + /> + ) + } + { + !isUndefined(values.var) && ( + updateOperation(index, {...values, var: value})} + /> + ) + } + { + !isUndefined(values.layer) && ( + updateOperation(index, {...values, layer})} + /> + ) + } + { + isLast && !isUndefined(values.compute_mean) && ( + updateOperation(index, { + ...values, + compute_mean, + output_csv: !compute_mean ? false : values.output_csv // unset output_csv if compute_mean is unset + }) + } + /> + ) + } + { + isLast && values.compute_mean && !isUndefined(values.output_csv) && ( + updateOperation(index, {...values, output_csv})} + /> + ) + } +
+
+
+ ) +} + +Operation.propTypes = { + operation: PropTypes.object.isRequired, + index: PropTypes.number.isRequired, + isLast: PropTypes.bool.isRequired, + values: PropTypes.object.isRequired, + errors: PropTypes.object.isRequired, + updateOperation: PropTypes.func.isRequired, + removeOperation: PropTypes.func.isRequired +} + +export default Operation diff --git a/isimip_data/download/assets/js/components/form/Operations.js b/isimip_data/download/assets/js/components/form/Operations.jsx similarity index 93% rename from isimip_data/download/assets/js/components/form/Operations.js rename to isimip_data/download/assets/js/components/form/Operations.jsx index 97cefaab..5f7fc70e 100644 --- a/isimip_data/download/assets/js/components/form/Operations.js +++ b/isimip_data/download/assets/js/components/form/Operations.jsx @@ -23,6 +23,7 @@ const Operations = ({ operationsConfig, operationsHelp, operations, errors, setO )).map(operation => operation.operation) useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect setDisabled( lastOperation && ( lastOperation.compute_mean || lastOperation.output_csv || ( @@ -120,9 +121,11 @@ const Operations = ({ operationsConfig, operationsHelp, operations, errors, setO } { operationsConfig.map(operation => ( - +
) diff --git a/isimip_data/metadata/assets/js/components/FilterOrder.js b/isimip_data/metadata/assets/js/components/FilterOrder.jsx similarity index 75% rename from isimip_data/metadata/assets/js/components/FilterOrder.js rename to isimip_data/metadata/assets/js/components/FilterOrder.jsx index d295cd99..6f9b92c4 100644 --- a/isimip_data/metadata/assets/js/components/FilterOrder.js +++ b/isimip_data/metadata/assets/js/components/FilterOrder.jsx @@ -11,7 +11,6 @@ const orderings = { oldest: 'Order by oldest', } - const FilterOrder = ({ values, setValues }) => { const handleClick = (order) => { @@ -20,15 +19,19 @@ const FilterOrder = ({ values, setValues }) => { return (
-
{ Object.keys(orderings).map((order, index) => ( - diff --git a/isimip_data/metadata/assets/js/components/Metadata.js b/isimip_data/metadata/assets/js/components/Metadata.jsx similarity index 94% rename from isimip_data/metadata/assets/js/components/Metadata.js rename to isimip_data/metadata/assets/js/components/Metadata.jsx index 1cd7aa86..7e65eaca 100644 --- a/isimip_data/metadata/assets/js/components/Metadata.js +++ b/isimip_data/metadata/assets/js/components/Metadata.jsx @@ -1,12 +1,11 @@ import React, { useState } from 'react' import { useDropzone } from 'react-dropzone' -import { isEmpty, first, trim } from 'lodash' import classNames from 'classnames' import sha512 from 'js-sha512' +import { first, isEmpty, trim } from 'lodash' import DatasetApi from '../api/DatasetApi' - const Metadata = () => { const [query, setQuery] = useState('') @@ -73,9 +72,11 @@ const Metadata = () => {