From 59ba4ea313f114af7e49328363f05555d82741fb Mon Sep 17 00:00:00 2001 From: Jochen Klar Date: Thu, 8 Jan 2026 11:53:04 +0100 Subject: [PATCH 001/298] Remove unwanted spaces in view tag templates (#556) --- rdmo/views/templates/views/tags/value.html | 6 +----- rdmo/views/templates/views/tags/value_inline_list.html | 4 +--- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/rdmo/views/templates/views/tags/value.html b/rdmo/views/templates/views/tags/value.html index 0e307fa2bd..61f4826ffe 100644 --- a/rdmo/views/templates/views/tags/value.html +++ b/rdmo/views/templates/views/tags/value.html @@ -1,5 +1 @@ -{% if value.file_url %} -{% include 'views/tags/value_file.html' %} -{% else %} -{{ value.value_and_unit }} -{% endif %} +{% if value.file_url %}{% include 'views/tags/value_file.html' %}{% else %}{{ value.value_and_unit }}{% endif %} \ No newline at end of file diff --git a/rdmo/views/templates/views/tags/value_inline_list.html b/rdmo/views/templates/views/tags/value_inline_list.html index b446d846c4..b7ee8b014f 100644 --- a/rdmo/views/templates/views/tags/value_inline_list.html +++ b/rdmo/views/templates/views/tags/value_inline_list.html @@ -1,3 +1 @@ -{% for value in values %} - {{ value.value_and_unit }}{% if not forloop.last %}; {% endif %} -{% endfor %} +{% for value in values %}{{ value.value_and_unit }}{% if not forloop.last %}; {% endif %}{% endfor %} \ No newline at end of file From 83df366b1ecae9bee995419fd292862372dc5cf3 Mon Sep 17 00:00:00 2001 From: Jochen Klar Date: Mon, 19 Jan 2026 11:36:45 +0100 Subject: [PATCH 002/298] Fix project.html test files --- testing/export/project.html | 242 ++++++++++++++++++------------------ 1 file changed, 121 insertions(+), 121 deletions(-) diff --git a/testing/export/project.html b/testing/export/project.html index 956c01e3ce..2094cb854e 100644 --- a/testing/export/project.html +++ b/testing/export/project.html @@ -8,34 +8,34 @@

Single questions

Text

Text?

-Lorem ipsum dolor sit amet + Lorem ipsum dolor sit amet

Textarea

Textarea?

-Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est. Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est. Lorem ipsum dolor sit amet. + Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est. Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est. Lorem ipsum dolor sit amet.

Yes or no

Yes or no?

-Yes + Yes

Radio buttons

Radio buttons?

-Text: Lorem ipsum + Text: Lorem ipsum

Select drop-down

Select drop-down?

-One + One

Select drop-down (free)

Select drop-down (free)?

Range slider

Range slider?

-37 + 37

File

File?

@@ -45,66 +45,66 @@

File

Datetime

Date picker?

-Jan. 1, 2018 + Jan. 1, 2018

Collections

Text

Text?

Textarea

Textarea?

Yes or no

Yes or no?

Radio buttons

Radio buttons?

Select drop-down

Select drop-down?

Select drop-down (free)

@@ -113,26 +113,26 @@

Range slider

Range slider?

Date picker

Date picker?

File

@@ -149,42 +149,42 @@

Checkbox

Checkbox?

Sets

Individual sets I

Text?

-Lorem ipsum dolor sit amet + Lorem ipsum dolor sit amet

Textarea?

-Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est. Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est. Lorem ipsum dolor sit amet. + Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est. Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est. Lorem ipsum dolor sit amet.

Yes or no?

-Yes + Yes

Radio buttons?

-Text: Lorem ipsum + Text: Lorem ipsum

Select drop-down?

-One + One

Select drop-down (free)?

Range slider?

-37 + 37

Date picker?

-Jan. 1, 2018 + Jan. 1, 2018

File?

@@ -194,80 +194,80 @@

Individual sets II

Text?

Textarea?

Yes or no?

Radio buttons?

Select drop-down?

Select drop-down (free)?

Range slider?

Date picker?

File?

@@ -282,96 +282,96 @@

Individual sets II

Checkbox?

Set collections I

Text?

Set "First":  -Lorem ipsum dolor sit amet, consetetur sadipscing elitr + Lorem ipsum dolor sit amet, consetetur sadipscing elitr

Set "Second":  -Lorem ipsum dolor sit amet, consetetur sadipscing elitr + Lorem ipsum dolor sit amet, consetetur sadipscing elitr

Textarea?

Set "First":  -Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est. Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est. Lorem ipsum dolor sit amet. + Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est. Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est. Lorem ipsum dolor sit amet.

Set "Second":  -Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est. Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est. Lorem ipsum dolor sit amet. + Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est. Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est. Lorem ipsum dolor sit amet.

Yes or no?

Set "First":  -Yes + Yes

Set "Second":  -No + No

Radio buttons?

Set "First":  -One + One

Set "Second":  -Two + Two

Select drop-down?

Set "First":  -One + One

Set "Second":  -Two + Two

Select drop-down (free)?

Range slider?

Set "First":  -1 + 1

Set "Second":  -2 + 2

Date picker?

Set "First":  -Jan. 7, 2018 + Jan. 7, 2018

Set "Second":  -Feb. 7, 2018 + Feb. 7, 2018

File?

Set collections II

Text?

Set "First":  -Lorem ipsum dolor sit amet, consetetur sadipscing elitr + Lorem ipsum dolor sit amet, consetetur sadipscing elitr

Set "Second":  -Lorem ipsum dolor sit amet, consetetur sadipscing elitr + Lorem ipsum dolor sit amet, consetetur sadipscing elitr

Textarea?

Set "First":  -Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est. Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est. Lorem ipsum dolor sit amet. + Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est. Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est. Lorem ipsum dolor sit amet.

Set "Second":  -Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est. Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est. Lorem ipsum dolor sit amet. + Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est. Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est. Lorem ipsum dolor sit amet.

Yes or no?

@@ -379,13 +379,13 @@

Set collections II

@@ -393,13 +393,13 @@

Set collections II

Radio buttons?

@@ -408,13 +408,13 @@

Set collections II

@@ -422,13 +422,13 @@

Set collections II

Select drop-down?

@@ -437,15 +437,15 @@

Set collections II

Set "Second":  -Three + Three

Select drop-down (free)?

Range slider?

@@ -454,15 +454,15 @@

Set collections II

Set "Second":  -86 + 86

Date picker?

@@ -470,10 +470,10 @@

Set collections II

@@ -481,10 +481,10 @@

Set collections II

File?

@@ -494,37 +494,37 @@

Set collections II

Set "Second":  -One + One

Conditions

Input

Text

-test + test

Option

-One + One

Text I

text_contains?

-test + test

Text II

text empty?

Text III

text_equal?

-test + test

Text IV

text_greater_than?

@@ -537,7 +537,7 @@

Text VII

Text VIII

text_not_empty?

-test + test

Text IX

text_not_equal?

@@ -546,12 +546,12 @@

Options I

Options II

option_equal?

-One + One

Options III

option_not_empty?

-One + One

Options IV

option_not_equal?

@@ -594,20 +594,20 @@

A set of questionsets and questions

A?

Set "First", Block #1:  -a0 + a0

Set "First", Block #2:  -a1 + a1

B?

Set "First", Block #1:  -b1 + b1

Set "First", Block #2:  -b1 + b1

C?

@@ -615,10 +615,10 @@

A set of questionsets and questions

@@ -626,40 +626,40 @@

A set of questionsets and questions

Y?

Set "First", Block #1, Set #1:  -Three + Three

Set "First", Block #2, Set #1:  -Three + Three

Set "Second", Block #1, Set #1:  -Three + Three

Set "Second", Block #2, Set #1:  -Three + Three

Set "Second", Block #3, Set #1:  -One + One

Set "Second", Block #3, Set #2:  -Two + Two

Set "Second", Block #3, Set #3:  -Three + Three

Question

Block with optional questions

From 84d58cf76ecabc354d3c402bcc950361a5b319bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Heinz-Alexander=20F=C3=BCtterer?= <35225576+afuetterer@users.noreply.github.com> Date: Tue, 28 Oct 2025 09:34:25 +0100 Subject: [PATCH 003/298] build(deps): bump django to >= 5.2.8 --- pyproject.toml | 12 ++++-------- testing/config/settings/base.py | 11 +++++++++++ 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a2db2477da..369dadebc1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,7 @@ requires-python = ">=3.10" classifiers = [ "Development Status :: 5 - Production/Stable", "Environment :: Web Environment", - "Framework :: Django :: 4.2", + "Framework :: Django :: 5.2", "Intended Audience :: Science/Research", "Operating System :: OS Independent", "Programming Language :: Python", @@ -41,7 +41,7 @@ dependencies = [ # in minor version updates anytime "defusedcsv>=2.0,<4.0", "defusedxml>=0.7.1,<1.0", - "django>=4.2,<5.0", + "django>=5.2.8,<6.0", "django-cleanup>=8.0,<10.0", "django-compressor>=4.4,<5.0", "django-extensions>=3.2,<5.0", @@ -203,15 +203,11 @@ markers = [ "e2e: marks tests as end-to-end tests using playwright (deselect with '-m \"not e2e\"')", ] filterwarnings = [ - # fail on RemovedInDjango50Warning exception - "error::django.utils.deprecation.RemovedInDjango50Warning", + # throw an error when using methods deprecated in the next django version + "error::django.utils.deprecation.RemovedInNextVersionWarning", # ignore warnings raised by widget_tweaks.py "ignore:'maxsplit' is passed as positional argument", - - # ignore warnings raised from within django itself - # django/core/files/storage/__init__.py - "ignore:django.core.files.storage.get_storage_class is deprecated:django.utils.deprecation.RemovedInDjango51Warning", ] [tool.coverage.run] diff --git a/testing/config/settings/base.py b/testing/config/settings/base.py index f081074c5a..f4d3f82f9e 100644 --- a/testing/config/settings/base.py +++ b/testing/config/settings/base.py @@ -1,4 +1,5 @@ import os +from warnings import filterwarnings from django.utils.translation import gettext_lazy as _ @@ -105,3 +106,13 @@ PROJECT_CONTACT = True PROJECT_CONTACT_RECIPIENTS = ['email@example.com'] + +# Ref: https://adamj.eu/tech/2023/12/07/django-fix-urlfield-assume-scheme-warnings +filterwarnings( + "ignore", "The FORMS_URLFIELD_ASSUME_HTTPS transitional setting is deprecated." +) +# This value will change from False to True in Django 6.0 +# Refs: +# - https://docs.djangoproject.com/en/5.2/ref/settings/#forms-urlfield-assume-https +# - https://docs.djangoproject.com/en/5.2/ref/forms/fields/#django.forms.URLField.assume_scheme +FORMS_URLFIELD_ASSUME_HTTPS = True From 9759cb991372622afc18826f5d2b042947260b78 Mon Sep 17 00:00:00 2001 From: David Wallace Date: Fri, 16 Jan 2026 14:18:50 +0100 Subject: [PATCH 004/298] tests: make utils parse date test compatible with python 3.14 Signed-off-by: David Wallace --- rdmo/core/tests/test_utils.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/rdmo/core/tests/test_utils.py b/rdmo/core/tests/test_utils.py index 2c446d5192..eae862cbe7 100644 --- a/rdmo/core/tests/test_utils.py +++ b/rdmo/core/tests/test_utils.py @@ -42,7 +42,11 @@ ] invalid_date_strings = [ - ("2025-02-31","day is out of range for month"), + ("2025-02-31", ( + "day is out of range for month", # Python 3.10 + "day 31 must be in range 1..28 for month 2 in year 2025" # Python 3.14 + ) + ), ("2025-17-02", "month must be in 1..12"), ("99/99/9999", "Invalid date format"), ("abcd-ef-gh", "Invalid date format"), @@ -91,11 +95,14 @@ def test_parse_date_from_string_valid_formats(settings, locale, date_string, exp @pytest.mark.parametrize("invalid_date, error_msg", invalid_date_strings) def test_parse_date_from_string_invalid_formats(settings, invalid_date, error_msg): - if not isinstance(invalid_date,str): - with pytest.raises(TypeError, match=error_msg): + patterns = error_msg if isinstance(error_msg, (tuple, list)) else (error_msg,) + match = "|".join(f"(?:{pattern})" for pattern in patterns) + + if not isinstance(invalid_date, str): + with pytest.raises(TypeError, match=match): parse_date_from_string(invalid_date) else: - with pytest.raises(ValueError,match=error_msg): + with pytest.raises(ValueError, match=match): parse_date_from_string(invalid_date) From b0dbb3a393a9a7f5e09340e47b5dcc9de2113ab9 Mon Sep 17 00:00:00 2001 From: Jochen Klar Date: Thu, 8 Jan 2026 17:20:14 +0100 Subject: [PATCH 005/298] Add MESSAGE_STORAGE to settings --- rdmo/core/settings.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/rdmo/core/settings.py b/rdmo/core/settings.py index cd015c38b8..b7c209229b 100644 --- a/rdmo/core/settings.py +++ b/rdmo/core/settings.py @@ -75,6 +75,8 @@ }, ] +MESSAGE_STORAGE = "django.contrib.messages.storage.session.SessionStorage" + COMPRESS_PRECOMPILERS = ( ('text/x-scss', 'django_libsass.SassCompiler'), ) From fccfda0b8931488e86034965e72085639a817a2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Heinz-Alexander=20F=C3=BCtterer?= <35225576+afuetterer@users.noreply.github.com> Date: Wed, 21 Jan 2026 13:04:39 +0100 Subject: [PATCH 006/298] feat: add support for python 3.14 --- .github/workflows/ci.yml | 10 +++++----- pyproject.toml | 1 + 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4b8b6ebbbb..a1cb27c70f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,7 +54,7 @@ jobs: runs-on: ubuntu-24.04 strategy: matrix: - python-version: ['3.10', '3.13'] + python-version: ['3.10', '3.14'] db-backend: [mysql, postgres] steps: - uses: actions/checkout@v6 @@ -94,7 +94,7 @@ jobs: - name: Run package status tests first run: | pytest rdmo/core/tests/test_package_status.py --nomigrations --verbose - if: matrix.python-version == '3.13' && matrix.db-backend == 'postgres' + if: matrix.python-version == '3.14' && matrix.db-backend == 'postgres' - name: Run Tests run: | pytest -p randomly -p no:cacheprovider --cov --reuse-db --numprocesses=auto --dist=loadscope @@ -114,7 +114,7 @@ jobs: runs-on: ubuntu-24.04 strategy: matrix: - python-version: ['3.13'] + python-version: ['3.14'] db-backend: [postgres] steps: - uses: actions/checkout@v6 @@ -184,7 +184,7 @@ jobs: persist-credentials: false - uses: actions/setup-python@v6 with: - python-version: "3.13" + python-version: "3.14" cache: pip - run: python -Im pip install --editable .[dev] - run: python -Ic 'import rdmo; print(rdmo.__version__)' @@ -199,7 +199,7 @@ jobs: persist-credentials: false - uses: actions/setup-python@v6 with: - python-version: "3.13" + python-version: "3.14" cache: pip - name: Download wheel uses: actions/download-artifact@v6 diff --git a/pyproject.toml b/pyproject.toml index 369dadebc1..033123333e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,7 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", ] dynamic = [ "version", From 9685ec2590db98b5b789e485ebc1e5818602e31b Mon Sep 17 00:00:00 2001 From: Jochen Klar Date: Sun, 25 Aug 2024 15:52:59 +0200 Subject: [PATCH 007/298] Prepare new project page --- rdmo/projects/assets/js/project.js | 22 ++++++ .../assets/js/project/actions/actionTypes.js | 3 + .../js/project/actions/projectActions.js | 40 ++++++++++ .../assets/js/project/api/ProjectApi.js | 9 +++ .../assets/js/project/containers/Main.js | 45 ++++++++++++ .../js/project/reducers/projectReducer.js | 22 ++++++ .../assets/js/project/store/configureStore.js | 73 +++++++++++++++++++ rdmo/projects/assets/js/project/utils/meta.js | 3 + rdmo/projects/assets/scss/project.scss | 0 .../projects/old/project_detail.html | 51 +++++++++++++ .../{ => old}/project_detail_header.html | 6 +- .../project_detail_header_catalog.html | 0 .../project_detail_header_description.html | 0 .../project_detail_header_hierarchy.html | 0 .../project_detail_integrations.html | 2 +- .../project_detail_integrations_help.html | 0 .../{ => old}/project_detail_invites.html | 0 .../{ => old}/project_detail_issues.html | 2 +- .../{ => old}/project_detail_issues_help.html | 0 .../{ => old}/project_detail_memberships.html | 4 +- .../project_detail_memberships_help.html | 0 ...ect_detail_memberships_socialaccounts.html | 0 .../{ => old}/project_detail_sidebar.html | 0 .../project_detail_sidebar_parent_import.html | 0 .../{ => old}/project_detail_snapshots.html | 2 +- .../project_detail_snapshots_help.html | 0 .../{ => old}/project_detail_views.html | 2 +- .../{ => old}/project_detail_views_help.html | 0 .../templates/projects/project_detail.html | 52 ++++--------- rdmo/projects/urls/__init__.py | 3 + rdmo/projects/views/__init__.py | 1 + rdmo/projects/views/project.py | 6 ++ webpack.config.js | 4 + 33 files changed, 305 insertions(+), 47 deletions(-) create mode 100644 rdmo/projects/assets/js/project.js create mode 100644 rdmo/projects/assets/js/project/actions/actionTypes.js create mode 100644 rdmo/projects/assets/js/project/actions/projectActions.js create mode 100644 rdmo/projects/assets/js/project/api/ProjectApi.js create mode 100644 rdmo/projects/assets/js/project/containers/Main.js create mode 100644 rdmo/projects/assets/js/project/reducers/projectReducer.js create mode 100644 rdmo/projects/assets/js/project/store/configureStore.js create mode 100644 rdmo/projects/assets/js/project/utils/meta.js create mode 100644 rdmo/projects/assets/scss/project.scss create mode 100644 rdmo/projects/templates/projects/old/project_detail.html rename rdmo/projects/templates/projects/{ => old}/project_detail_header.html (85%) rename rdmo/projects/templates/projects/{ => old}/project_detail_header_catalog.html (100%) rename rdmo/projects/templates/projects/{ => old}/project_detail_header_description.html (100%) rename rdmo/projects/templates/projects/{ => old}/project_detail_header_hierarchy.html (100%) rename rdmo/projects/templates/projects/{ => old}/project_detail_integrations.html (97%) rename rdmo/projects/templates/projects/{ => old}/project_detail_integrations_help.html (100%) rename rdmo/projects/templates/projects/{ => old}/project_detail_invites.html (100%) rename rdmo/projects/templates/projects/{ => old}/project_detail_issues.html (98%) rename rdmo/projects/templates/projects/{ => old}/project_detail_issues_help.html (100%) rename rdmo/projects/templates/projects/{ => old}/project_detail_memberships.html (94%) rename rdmo/projects/templates/projects/{ => old}/project_detail_memberships_help.html (100%) rename rdmo/projects/templates/projects/{ => old}/project_detail_memberships_socialaccounts.html (100%) rename rdmo/projects/templates/projects/{ => old}/project_detail_sidebar.html (100%) rename rdmo/projects/templates/projects/{ => old}/project_detail_sidebar_parent_import.html (100%) rename rdmo/projects/templates/projects/{ => old}/project_detail_snapshots.html (98%) rename rdmo/projects/templates/projects/{ => old}/project_detail_snapshots_help.html (100%) rename rdmo/projects/templates/projects/{ => old}/project_detail_views.html (97%) rename rdmo/projects/templates/projects/{ => old}/project_detail_views_help.html (100%) diff --git a/rdmo/projects/assets/js/project.js b/rdmo/projects/assets/js/project.js new file mode 100644 index 0000000000..c4085a2921 --- /dev/null +++ b/rdmo/projects/assets/js/project.js @@ -0,0 +1,22 @@ +import React from 'react' +import { createRoot } from 'react-dom/client' +import { Provider } from 'react-redux' + +import configureStore from './project/store/configureStore' + +import { DndProvider } from 'react-dnd' +import { HTML5Backend } from 'react-dnd-html5-backend' + +import Main from './project/containers/Main' + +const store = configureStore() + +console.log(document.getElementById('main')) + +createRoot(document.getElementById('main')).render( + + +
+ + +) diff --git a/rdmo/projects/assets/js/project/actions/actionTypes.js b/rdmo/projects/assets/js/project/actions/actionTypes.js new file mode 100644 index 0000000000..26e07aa26e --- /dev/null +++ b/rdmo/projects/assets/js/project/actions/actionTypes.js @@ -0,0 +1,3 @@ +export const FETCH_PROJECT_INIT = 'FETCH_PROJECT_INIT' +export const FETCH_PROJECT_SUCCESS = 'FETCH_PROJECT_SUCCESS' +export const FETCH_PROJECT_ERROR = 'FETCH_PROJECT_ERROR' diff --git a/rdmo/projects/assets/js/project/actions/projectActions.js b/rdmo/projects/assets/js/project/actions/projectActions.js new file mode 100644 index 0000000000..e28110dd17 --- /dev/null +++ b/rdmo/projects/assets/js/project/actions/projectActions.js @@ -0,0 +1,40 @@ +import ProjectsApi from '../api/ProjectApi' + +import { projectId } from '../utils/meta' + +import { + FETCH_PROJECT_INIT, + FETCH_PROJECT_SUCCESS, + FETCH_PROJECT_ERROR +} from './actionTypes' + +import { addToPending, removeFromPending } from 'rdmo/core/assets/js/actions/pendingActions' + +export function fetchProject() { + return (dispatch) => { + dispatch(addToPending('fetchProject')) + dispatch(fetchProjectInit()) + + return ProjectsApi.fetchProject(projectId) + .then((overview) => { + dispatch(removeFromPending('fetchOverview')) + dispatch(fetchProjectSuccess(overview)) + }) + .catch((error) => { + dispatch(removeFromPending('fetchOverview')) + dispatch(fetchProjectError(error)) + }) + } +} + +export function fetchProjectInit() { + return {type: FETCH_PROJECT_INIT} +} + +export function fetchProjectSuccess(project) { + return {type: FETCH_PROJECT_SUCCESS, project} +} + +export function fetchProjectError(error) { + return {type: FETCH_PROJECT_ERROR, error} +} diff --git a/rdmo/projects/assets/js/project/api/ProjectApi.js b/rdmo/projects/assets/js/project/api/ProjectApi.js new file mode 100644 index 0000000000..023e8b2891 --- /dev/null +++ b/rdmo/projects/assets/js/project/api/ProjectApi.js @@ -0,0 +1,9 @@ +import BaseApi from 'rdmo/core/assets/js/api/BaseApi' + +export default class ProjectsApi extends BaseApi { + + static fetchProject(projectId) { + return this.get(`/api/v1/projects/projects/${projectId}/overview/`) + } + +} diff --git a/rdmo/projects/assets/js/project/containers/Main.js b/rdmo/projects/assets/js/project/containers/Main.js new file mode 100644 index 0000000000..1e6d319305 --- /dev/null +++ b/rdmo/projects/assets/js/project/containers/Main.js @@ -0,0 +1,45 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { bindActionCreators } from 'redux' +import { connect } from 'react-redux' + +import * as configActions from 'rdmo/core/assets/js/actions/configActions' +import * as projectActions from '../actions/projectActions' + +const Main = ({ config, settings, templates, user, project, configActions, projectActions }) => { + console.log(config, settings, templates, user, project) + console.log(configActions, projectActions) + + return project && ( + 👍 + ) +} + +Main.propTypes = { + config: PropTypes.object.isRequired, + settings: PropTypes.object.isRequired, + templates: PropTypes.object.isRequired, + user: PropTypes.object.isRequired, + project: PropTypes.object.isRequired, + configActions: PropTypes.object.isRequired, + projectActions: PropTypes.object.isRequired +} + +function mapStateToProps(state) { + return { + config: state.config, + settings: state.settings, + templates: state.templates, + user: state.user, + project: state.project + } +} + +function mapDispatchToProps(dispatch) { + return { + configActions: bindActionCreators(configActions, dispatch), + projectActions: bindActionCreators(projectActions, dispatch) + } +} + +export default connect(mapStateToProps, mapDispatchToProps)(Main) diff --git a/rdmo/projects/assets/js/project/reducers/projectReducer.js b/rdmo/projects/assets/js/project/reducers/projectReducer.js new file mode 100644 index 0000000000..57ea60013b --- /dev/null +++ b/rdmo/projects/assets/js/project/reducers/projectReducer.js @@ -0,0 +1,22 @@ +import { + FETCH_PROJECT_INIT, + FETCH_PROJECT_SUCCESS, + FETCH_PROJECT_ERROR +} from '../actions/actionTypes' + +const initialState = { + project: null +} + +export default function interviewReducer(state = initialState, action) { + switch(action.type) { + case FETCH_PROJECT_SUCCESS: + return { ...state, project: action.project } + case FETCH_PROJECT_INIT: + return { ...state, errors: [] } + case FETCH_PROJECT_ERROR: + return { ...state, errors: [...state.errors, { actionType: action.type, ...action.error }] } + default: + return state + } +} diff --git a/rdmo/projects/assets/js/project/store/configureStore.js b/rdmo/projects/assets/js/project/store/configureStore.js new file mode 100644 index 0000000000..5e03595bae --- /dev/null +++ b/rdmo/projects/assets/js/project/store/configureStore.js @@ -0,0 +1,73 @@ +import { applyMiddleware, createStore, combineReducers } from 'redux' +import thunk from 'redux-thunk' + +import { checkStoreId } from 'rdmo/core/assets/js/utils/store' +import { getConfigFromLocalStorage } from 'rdmo/core/assets/js/utils/config' + +import configReducer from 'rdmo/core/assets/js/reducers/configReducer' +import pendingReducer from 'rdmo/core/assets/js/reducers/pendingReducer' +import settingsReducer from 'rdmo/core/assets/js/reducers/settingsReducer' +import templateReducer from 'rdmo/core/assets/js/reducers/templateReducer' +import userReducer from 'rdmo/core/assets/js/reducers/userReducer' + +import projectReducer from '../reducers/projectReducer' + +import * as configActions from 'rdmo/core/assets/js/actions/configActions' +import * as settingsActions from 'rdmo/core/assets/js/actions/settingsActions' +import * as templateActions from 'rdmo/core/assets/js/actions/templateActions' +import * as userActions from 'rdmo/core/assets/js/actions/userActions' + +import * as projectActions from '../actions/projectActions' + + +export default function configureStore() { + // empty localStorage in new session + checkStoreId() + + const middlewares = [thunk] + + if (process.env.NODE_ENV === 'development') { + const { logger } = require('redux-logger') + middlewares.push(logger) + } + + const rootReducer = combineReducers({ + config: configReducer, + pending: pendingReducer, + project: projectReducer, + settings: settingsReducer, + templates: templateReducer, + user: userReducer, + }) + + const initialState = { + config: { + prefix: 'rdmo.project' + } + } + + const store = createStore( + rootReducer, + initialState, + applyMiddleware(...middlewares) + ) + + // this event is triggered when the page first loads + window.addEventListener('load', () => { + getConfigFromLocalStorage('rdmo.interview').forEach(([path, value]) => { + store.dispatch(configActions.updateConfig(path, value)) + }) + + store.dispatch(settingsActions.fetchSettings()) + store.dispatch(templateActions.fetchTemplates()) + store.dispatch(userActions.fetchCurrentUser()) + store.dispatch(projectActions.fetchProject()) + }) + + // this event is triggered when when the forward/back buttons are used + window.addEventListener('popstate', () => { + + }) + + return store +} diff --git a/rdmo/projects/assets/js/project/utils/meta.js b/rdmo/projects/assets/js/project/utils/meta.js new file mode 100644 index 0000000000..486f1842d5 --- /dev/null +++ b/rdmo/projects/assets/js/project/utils/meta.js @@ -0,0 +1,3 @@ +// take the baseurl from the of the django template +import { toNumber } from 'lodash' +export const projectId = toNumber(document.querySelector('meta[name="project"]').content.replace(/\/+$/, '')) diff --git a/rdmo/projects/assets/scss/project.scss b/rdmo/projects/assets/scss/project.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/rdmo/projects/templates/projects/old/project_detail.html b/rdmo/projects/templates/projects/old/project_detail.html new file mode 100644 index 0000000000..759291d5b0 --- /dev/null +++ b/rdmo/projects/templates/projects/old/project_detail.html @@ -0,0 +1,51 @@ +{% extends 'core/page.html' %} +{% load i18n %} +{% load static %} +{% load compress %} +{% load core_tags %} + +{% block head %} + {% compress css %} + + + {% endcompress %} + {% compress js %} + + {% endcompress %} + +{% endblock %} + +{% block sidebar %} + + {% include 'projects/old/project_detail_sidebar.html' %} + +{% endblock %} + +{% block page %} + + {% include 'projects/old/project_detail_header.html' %} + {% include 'projects/old/project_detail_issues.html' %} + {% include 'projects/old/project_detail_views.html' %} + {% include 'projects/old/project_detail_memberships.html' %} + {% include 'projects/old/project_detail_invites.html' %} + {% include 'projects/old/project_detail_snapshots.html' %} + {% include 'projects/old/project_detail_integrations.html' %} + +
+ + {% render_lang_template 'projects/overlays/project_project_questions' %} + {% render_lang_template 'projects/overlays/project_project_catalog' %} + {% render_lang_template 'projects/overlays/project_project_issues' %} + {% render_lang_template 'projects/overlays/project_project_views' %} + {% render_lang_template 'projects/overlays/project_project_memberships' %} + {% render_lang_template 'projects/overlays/project_project_snapshots' %} + {% render_lang_template 'projects/overlays/project_export_project' %} + {% render_lang_template 'projects/overlays/project_import_project' %} + {% render_lang_template 'projects/overlays/project_support_info' %} + +{% endblock %} diff --git a/rdmo/projects/templates/projects/project_detail_header.html b/rdmo/projects/templates/projects/old/project_detail_header.html similarity index 85% rename from rdmo/projects/templates/projects/project_detail_header.html rename to rdmo/projects/templates/projects/old/project_detail_header.html index c6ca098c90..ca4c10e779 100644 --- a/rdmo/projects/templates/projects/project_detail_header.html +++ b/rdmo/projects/templates/projects/old/project_detail_header.html @@ -16,7 +16,7 @@

{{ project.title }}

{% trans 'Description' %} - {% include 'projects/project_detail_header_description.html' %} + {% include 'projects/old/project_detail_header_description.html' %} @@ -24,7 +24,7 @@

{{ project.title }}

{% trans 'Catalog' %} - {% include 'projects/project_detail_header_catalog.html' %} + {% include 'projects/old/project_detail_header_catalog.html' %} {% if settings.PROJECT_VISIBILITY and project.visibility %} @@ -44,7 +44,7 @@

{{ project.title }}

{% trans 'Project hierarchy' %} - {% include 'projects/project_detail_header_hierarchy.html' %} + {% include 'projects/old/project_detail_header_hierarchy.html' %} {% endif %} diff --git a/rdmo/projects/templates/projects/project_detail_header_catalog.html b/rdmo/projects/templates/projects/old/project_detail_header_catalog.html similarity index 100% rename from rdmo/projects/templates/projects/project_detail_header_catalog.html rename to rdmo/projects/templates/projects/old/project_detail_header_catalog.html diff --git a/rdmo/projects/templates/projects/project_detail_header_description.html b/rdmo/projects/templates/projects/old/project_detail_header_description.html similarity index 100% rename from rdmo/projects/templates/projects/project_detail_header_description.html rename to rdmo/projects/templates/projects/old/project_detail_header_description.html diff --git a/rdmo/projects/templates/projects/project_detail_header_hierarchy.html b/rdmo/projects/templates/projects/old/project_detail_header_hierarchy.html similarity index 100% rename from rdmo/projects/templates/projects/project_detail_header_hierarchy.html rename to rdmo/projects/templates/projects/old/project_detail_header_hierarchy.html diff --git a/rdmo/projects/templates/projects/project_detail_integrations.html b/rdmo/projects/templates/projects/old/project_detail_integrations.html similarity index 97% rename from rdmo/projects/templates/projects/project_detail_integrations.html rename to rdmo/projects/templates/projects/old/project_detail_integrations.html index d348495ae8..4f2d69bcce 100644 --- a/rdmo/projects/templates/projects/project_detail_integrations.html +++ b/rdmo/projects/templates/projects/old/project_detail_integrations.html @@ -10,7 +10,7 @@

{% trans 'Integrations' %}

- {% include 'projects/project_detail_integrations_help.html' %} + {% include 'projects/old/project_detail_integrations_help.html' %} diff --git a/rdmo/projects/templates/projects/project_detail_integrations_help.html b/rdmo/projects/templates/projects/old/project_detail_integrations_help.html similarity index 100% rename from rdmo/projects/templates/projects/project_detail_integrations_help.html rename to rdmo/projects/templates/projects/old/project_detail_integrations_help.html diff --git a/rdmo/projects/templates/projects/project_detail_invites.html b/rdmo/projects/templates/projects/old/project_detail_invites.html similarity index 100% rename from rdmo/projects/templates/projects/project_detail_invites.html rename to rdmo/projects/templates/projects/old/project_detail_invites.html diff --git a/rdmo/projects/templates/projects/project_detail_issues.html b/rdmo/projects/templates/projects/old/project_detail_issues.html similarity index 98% rename from rdmo/projects/templates/projects/project_detail_issues.html rename to rdmo/projects/templates/projects/old/project_detail_issues.html index 3a712aa9d6..65f7c94665 100644 --- a/rdmo/projects/templates/projects/project_detail_issues.html +++ b/rdmo/projects/templates/projects/old/project_detail_issues.html @@ -10,7 +10,7 @@

{% trans 'Tasks' %}

- {% include 'projects/project_detail_issues_help.html' %} + {% include 'projects/old/project_detail_issues_help.html' %} {% if issues %} diff --git a/rdmo/projects/templates/projects/project_detail_issues_help.html b/rdmo/projects/templates/projects/old/project_detail_issues_help.html similarity index 100% rename from rdmo/projects/templates/projects/project_detail_issues_help.html rename to rdmo/projects/templates/projects/old/project_detail_issues_help.html diff --git a/rdmo/projects/templates/projects/project_detail_memberships.html b/rdmo/projects/templates/projects/old/project_detail_memberships.html similarity index 94% rename from rdmo/projects/templates/projects/project_detail_memberships.html rename to rdmo/projects/templates/projects/old/project_detail_memberships.html index bad3d750d5..05edbc5a7d 100644 --- a/rdmo/projects/templates/projects/project_detail_memberships.html +++ b/rdmo/projects/templates/projects/old/project_detail_memberships.html @@ -13,7 +13,7 @@

{% trans 'Members' %}

- {% include 'projects/project_detail_memberships_help.html' %} + {% include 'projects/old/project_detail_memberships_help.html' %}
@@ -33,7 +33,7 @@

{% trans 'Members' %}

{persons?.map((person, index) => { - const isCurrentUser = person.user === currentUserId - const isUserOwner = isMember && isCurrentUser && person.role === 'owner' - const showAction = ((!isOwner && isCurrentUser) || (isUserOwner && !isLastOwner) || (isOwner && !isUserOwner) || isManager) + const isCurrentUser = person.user.id === currentUserId + const isOwner = isCurrentUser && person.role == 'owner' + const showMemberAction = isMember && ((!isCurrentUser && perms.can_delete_membership) || (isCurrentUser && perms.can_leave_project)) + const showInviteAction = !isMember && perms.can_delete_invite + const showAction = showMemberAction || showInviteAction return ( - + ) @@ -97,7 +97,6 @@ const MembershipTable = ({ persons, isMember = false }) => { { - const project = useSelector((state) => state.project) + const { perms, project } = useSelector((state) => state.project) const user = useSelector((state) => state.user) - if (isNil(project.project) || isNil(user.currentUser)) { + if (isNil(project) || isNil(user.currentUser)) { return } - const allowed = userIsManager(user.currentUser) || - getUserRoles(project.project.project, user.currentUser.id, ['owners']).isProjectOwner - return (
- +
- {allowed && ( + {perms.can_delete_project && (
diff --git a/rdmo/projects/assets/js/project/components/pages/ProjectDelete.js b/rdmo/projects/assets/js/project/components/pages/ProjectDelete.js index 24b83e0b83..1d152b2196 100644 --- a/rdmo/projects/assets/js/project/components/pages/ProjectDelete.js +++ b/rdmo/projects/assets/js/project/components/pages/ProjectDelete.js @@ -10,6 +10,7 @@ const ProjectDelete = () => { const handleDelete = () => { if (project?.id) { + // TODO: add a confirmation modal / dialog dispatch(deleteProject(project.id)) .then(() => { window.location.href = '/projects/' diff --git a/rdmo/projects/assets/js/project/reducers/projectReducer.js b/rdmo/projects/assets/js/project/reducers/projectReducer.js index 5104aaf99e..a18bcfe0c2 100644 --- a/rdmo/projects/assets/js/project/reducers/projectReducer.js +++ b/rdmo/projects/assets/js/project/reducers/projectReducer.js @@ -2,6 +2,7 @@ import * as actionTypes from '../actions/actionTypes' const initialState = { project: null, + perms: {}, invites: null, errors: [] } @@ -9,7 +10,7 @@ const initialState = { export default function projectReducer(state = initialState, action) { switch(action.type) { case actionTypes.FETCH_PROJECT_SUCCESS: - return { ...state, project: action.project } + return { ...state, project: action.project, perms: action.project.project.permissions } case actionTypes.FETCH_PROJECT_INIT: return { ...state, errors: [] } case actionTypes.FETCH_PROJECT_ERROR: @@ -108,7 +109,23 @@ export default function projectReducer(state = initialState, action) { return { ...state, errors: [...state.errors, { actionType: action.type, ...action.error }] + } + case actionTypes.LEAVE_PROJECT_INIT: + return { ...state, errors: [] } + case actionTypes.LEAVE_PROJECT_SUCCESS: { + return { + ...state, + project: { + ...state.project, + memberships: state.project.memberships?.filter(m => m.id !== action.membershipId) + } } + } + case actionTypes.LEAVE_PROJECT_ERROR: + return { + ...state, + errors: [...state.errors, { actionType: action.type, ...action.error }] + } case actionTypes.CLEAR_PROJECT_ERRORS: return { ...state, errors: [] } default: diff --git a/rdmo/projects/assets/js/project/store/configureStore.js b/rdmo/projects/assets/js/project/store/configureStore.js index 4013082bd3..fc8a667c2e 100644 --- a/rdmo/projects/assets/js/project/store/configureStore.js +++ b/rdmo/projects/assets/js/project/store/configureStore.js @@ -73,9 +73,15 @@ export default function configureStore() { store.dispatch(settingsActions.fetchSettings()) store.dispatch(templateActions.fetchTemplates()) store.dispatch(userActions.fetchCurrentUser()) - // TODO: add permission logic - store.dispatch(projectActions.fetchProjectInvites(projectId)) - store.dispatch(projectActions.fetchProject()) + + store.dispatch(projectActions.fetchProject()).then(() => { + const { project: projectObj } = store.getState() + const permissions = projectObj.perms || {} + + if (permissions.can_view_invite) { + store.dispatch(projectActions.fetchProjectInvites(projectId)) + } + }) }) // this event is triggered when when the forward/back buttons are used From 3832652695d69ad8ed59f919d217421dec40f605 Mon Sep 17 00:00:00 2001 From: Claudia Malzer Date: Thu, 25 Sep 2025 18:20:49 +0200 Subject: [PATCH 104/298] * remove console.log's --- .../js/project/components/pages/MembershipDeleteModal.js | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/rdmo/projects/assets/js/project/components/pages/MembershipDeleteModal.js b/rdmo/projects/assets/js/project/components/pages/MembershipDeleteModal.js index 771b0afb16..74cf90c077 100644 --- a/rdmo/projects/assets/js/project/components/pages/MembershipDeleteModal.js +++ b/rdmo/projects/assets/js/project/components/pages/MembershipDeleteModal.js @@ -13,10 +13,7 @@ import { useFieldErrors } from '../../hooks/useFieldErrors' const MembershipDeleteModal = ({ show, onClose, person, isMember = false, isCurrentUser = false }) => { const dispatch = useDispatch() const { project } = useSelector((state) => state.project.project) ?? {} - const { perms } = useSelector((state) => state.project) - console.log('perms', perms) - console.log('project', project) - console.log('person', person ) + // const { perms } = useSelector((state) => state.project) const errors = useFieldErrors() const isManager = userIsManager(useSelector((state) => state.user.currentUser)) From fb8ce452e73ea9c7c7670bac2002ece442a44c7d Mon Sep 17 00:00:00 2001 From: Claudia Malzer Date: Fri, 26 Sep 2025 15:10:10 +0200 Subject: [PATCH 105/298] * fix more permission booleans --- .../js/project/components/pages/MembershipDeleteModal.js | 8 ++------ .../assets/js/project/components/pages/MembershipTable.js | 6 ++++-- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/rdmo/projects/assets/js/project/components/pages/MembershipDeleteModal.js b/rdmo/projects/assets/js/project/components/pages/MembershipDeleteModal.js index 74cf90c077..371dc277fb 100644 --- a/rdmo/projects/assets/js/project/components/pages/MembershipDeleteModal.js +++ b/rdmo/projects/assets/js/project/components/pages/MembershipDeleteModal.js @@ -5,19 +5,14 @@ import { useDispatch, useSelector } from 'react-redux' import Html from 'rdmo/core/assets/js/components/Html' import Modal from 'rdmo/core/assets/js/_bs53/components/Modal' -import { userIsManager } from 'rdmo/projects/assets/js/common/utils' - import { deleteProjectMember, deleteProjectInvite, leaveProject } from '../../actions/projectActions' import { useFieldErrors } from '../../hooks/useFieldErrors' -const MembershipDeleteModal = ({ show, onClose, person, isMember = false, isCurrentUser = false }) => { +const MembershipDeleteModal = ({ show, onClose, person, isManager = false, isMember = false, isCurrentUser = false }) => { const dispatch = useDispatch() const { project } = useSelector((state) => state.project.project) ?? {} - // const { perms } = useSelector((state) => state.project) const errors = useFieldErrors() - const isManager = userIsManager(useSelector((state) => state.user.currentUser)) - const name = [person.user.first_name, person.user.last_name].filter(Boolean).join(' ').trim() || person.user.email || '' @@ -79,6 +74,7 @@ const MembershipDeleteModal = ({ show, onClose, person, isMember = false, isCurr MembershipDeleteModal.propTypes = { show: PropTypes.bool.isRequired, onClose: PropTypes.func.isRequired, + isManager: PropTypes.bool, isMember: PropTypes.bool, isCurrentUser: PropTypes.bool, person: PropTypes.shape({ diff --git a/rdmo/projects/assets/js/project/components/pages/MembershipTable.js b/rdmo/projects/assets/js/project/components/pages/MembershipTable.js index e66064315e..0c02314d00 100644 --- a/rdmo/projects/assets/js/project/components/pages/MembershipTable.js +++ b/rdmo/projects/assets/js/project/components/pages/MembershipTable.js @@ -20,6 +20,7 @@ const MembershipTable = ({ persons, isMember = false }) => { const [selected, setSelected] = useState(null) const currentUserId = currentUser?.id + const isManager = currentUser?.is_superuser || currentUser?.is_site_manager const handleOpenConfirm = (person, isCurrentUser) => { setSelected({ person, isCurrentUser }) @@ -48,7 +49,7 @@ const MembershipTable = ({ persons, isMember = false }) => { const isOwner = isCurrentUser && person.role == 'owner' const showMemberAction = isMember && ((!isCurrentUser && perms.can_delete_membership) || (isCurrentUser && perms.can_leave_project)) const showInviteAction = !isMember && perms.can_delete_invite - const showAction = showMemberAction || showInviteAction + const showAction = showMemberAction || showInviteAction || isManager return (
@@ -69,7 +70,7 @@ const MembershipTable = ({ persons, isMember = false }) => { } }} isClearable={false} - isDisabled={(isMember && (!perms.can_change_membership || isOwner) || (!isMember && !perms.can_change_invite))} + isDisabled={(isMember && (!perms.can_change_membership || (isOwner && !isManager)) || (!isMember && !perms.can_change_invite))} /> From 80d716b491f2137584afc3f76b13ef69f78c3d25 Mon Sep 17 00:00:00 2001 From: Jochen Klar Date: Fri, 10 Oct 2025 11:05:53 +0200 Subject: [PATCH 127/298] Fix membership tests, again --- rdmo/projects/tests/test_view_membership.py | 8 +++++++- .../tests/test_viewset_project_membership.py | 12 +++++++++--- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/rdmo/projects/tests/test_view_membership.py b/rdmo/projects/tests/test_view_membership.py index 13f9b446c1..b8cc5e87d5 100644 --- a/rdmo/projects/tests/test_view_membership.py +++ b/rdmo/projects/tests/test_view_membership.py @@ -15,7 +15,13 @@ ('anonymous', None), ) -add_membership_permission_map = change_membership_permission_map = delete_membership_permission_map = { +add_membership_permission_map = { + 'api': [1, 2, 3, 4, 5], + 'site': [1, 2, 3, 4, 5] +} + +change_membership_permission_map = delete_membership_permission_map = { + 'owner': [1, 2, 3, 4, 5], 'api': [1, 2, 3, 4, 5], 'site': [1, 2, 3, 4, 5] } diff --git a/rdmo/projects/tests/test_viewset_project_membership.py b/rdmo/projects/tests/test_viewset_project_membership.py index af5b427fb8..47968cf9f8 100644 --- a/rdmo/projects/tests/test_viewset_project_membership.py +++ b/rdmo/projects/tests/test_viewset_project_membership.py @@ -26,9 +26,15 @@ 'site': [1, 2, 3, 4, 5, 12] } -add_membership_permission_map = change_membership_permission_map = delete_membership_permission_map = { - 'api': [1, 2, 3, 4, 5, 12], - 'site': [1, 2, 3, 4, 5, 12] +add_membership_permission_map = { + 'api': [1, 2, 3, 4, 5], + 'site': [1, 2, 3, 4, 5] +} + +change_membership_permission_map = delete_membership_permission_map = { + 'owner': [1, 2, 3, 4, 5], + 'api': [1, 2, 3, 4, 5], + 'site': [1, 2, 3, 4, 5] } urlnames = { From 0ac96dbe1f9a6959bc428ae3122e78692064fdd0 Mon Sep 17 00:00:00 2001 From: Jochen Klar Date: Fri, 10 Oct 2025 12:57:35 +0200 Subject: [PATCH 128/298] Fix membership tests, some more --- rdmo/projects/tests/test_viewset_project_membership.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/rdmo/projects/tests/test_viewset_project_membership.py b/rdmo/projects/tests/test_viewset_project_membership.py index 47968cf9f8..1b67a20e14 100644 --- a/rdmo/projects/tests/test_viewset_project_membership.py +++ b/rdmo/projects/tests/test_viewset_project_membership.py @@ -27,14 +27,14 @@ } add_membership_permission_map = { - 'api': [1, 2, 3, 4, 5], - 'site': [1, 2, 3, 4, 5] + 'api': [1, 2, 3, 4, 5, 12], + 'site': [1, 2, 3, 4, 5, 12] } change_membership_permission_map = delete_membership_permission_map = { 'owner': [1, 2, 3, 4, 5], - 'api': [1, 2, 3, 4, 5], - 'site': [1, 2, 3, 4, 5] + 'api': [1, 2, 3, 4, 5, 12], + 'site': [1, 2, 3, 4, 5, 12] } urlnames = { @@ -161,7 +161,7 @@ def test_create_lookup(db, client, username, password, project_id, membership_ro ('bad@mail', 'Enter a valid email address.'), ]) def test_create_lookup_error_invalid(db, client, lookup, expected_error): - client.login(username='owner', password='owner') + client.login(username='site', password='site') url = reverse(urlnames['list'], args=[1]) data = { From 7f9d32d426b89d961e0a6bc419bfa143a7551819 Mon Sep 17 00:00:00 2001 From: Jochen Klar Date: Fri, 24 Oct 2025 10:12:26 +0200 Subject: [PATCH 129/298] Remove values when snapshots are removed during a rollback --- rdmo/projects/models/snapshot.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/rdmo/projects/models/snapshot.py b/rdmo/projects/models/snapshot.py index 0403da40cc..ad90da3529 100644 --- a/rdmo/projects/models/snapshot.py +++ b/rdmo/projects/models/snapshot.py @@ -2,6 +2,7 @@ from django.urls import reverse from django.utils.translation import gettext_lazy as _ +from rdmo.core.constants import VALUE_TYPE_FILE from rdmo.core.models import Model from ..managers import SnapshotManager @@ -71,4 +72,8 @@ def rollback(self): # remove all snapshot created later and the current_snapshot # this also removes the values of these snapshots for snapshot in self.project.snapshots.filter(created__gte=self.created): + # remove the files for this snapshot + for value in snapshot.values.filter(value_type=VALUE_TYPE_FILE): + value.file.delete(save=False) + snapshot.delete() From d6ed545f58dd028a9673332d191f1ae6926faf61 Mon Sep 17 00:00:00 2001 From: Jochen Klar Date: Fri, 24 Oct 2025 10:12:37 +0200 Subject: [PATCH 130/298] Add rollback action to ProjectSnapshotViewSet --- rdmo/core/tests/test_openapi.py | 2 +- .../tests/test_viewset_project_snapshot.py | 68 ++++++++++++++++++- rdmo/projects/viewsets.py | 7 ++ 3 files changed, 74 insertions(+), 3 deletions(-) diff --git a/rdmo/core/tests/test_openapi.py b/rdmo/core/tests/test_openapi.py index 1541c7b0b0..e9aa40ce4e 100644 --- a/rdmo/core/tests/test_openapi.py +++ b/rdmo/core/tests/test_openapi.py @@ -10,7 +10,7 @@ 'anonymous' ) -n_path = 127 +n_path = 128 @pytest.mark.parametrize('username', users) def test_openapi_schema(db, client, login, settings, username): diff --git a/rdmo/projects/tests/test_viewset_project_snapshot.py b/rdmo/projects/tests/test_viewset_project_snapshot.py index 89c43de3ae..0dc854583b 100644 --- a/rdmo/projects/tests/test_viewset_project_snapshot.py +++ b/rdmo/projects/tests/test_viewset_project_snapshot.py @@ -30,7 +30,10 @@ 'site': [1, 2, 3, 4, 5, 12] } -add_snapshot_permission_map = change_snapshot_permission_map = delete_snapshot_permission_map = { +add_snapshot_permission_map = \ +change_snapshot_permission_map = \ +rollback_snapshot_permission_map = \ +delete_snapshot_permission_map = { 'owner': [1, 2, 3, 4, 5, 12], 'manager': [1, 3, 5], 'api': [1, 2, 3, 4, 5, 12], @@ -39,7 +42,8 @@ urlnames = { 'list': 'v1-projects:project-snapshot-list', - 'detail': 'v1-projects:project-snapshot-detail' + 'detail': 'v1-projects:project-snapshot-detail', + 'rollback': 'v1-projects:project-snapshot-rollback', } projects = [1, 2, 3, 4, 5, 12] @@ -154,6 +158,66 @@ def test_update(db, client, files, username, password, snapshot_id): assert Path(settings.MEDIA_ROOT).joinpath(file_value).exists() +@pytest.mark.parametrize('username,password', users) +@pytest.mark.parametrize('snapshot_id', snapshots) +def test_rollback(db, client, files, username, password, snapshot_id): + client.login(username=username, password=password) + snapshot = Snapshot.objects.get(id=snapshot_id) + + snapshot_count = snapshot.project.snapshots.count() + values_count = snapshot.project.values.count() + + snapshots_kept = list( + snapshot.project.snapshots.filter(created__lt=snapshot.created).values_list('id', flat=True) + ) + values_kept = list( + snapshot.project.values.filter( + snapshot__in=snapshot.project.snapshots.filter(created__lte=snapshot.created) + ).values_list('id', flat=True) + ) + files_kept = list( + snapshot.project.values.filter( + snapshot__in=snapshot.project.snapshots.filter(created__lt=snapshot.created), + value_type=VALUE_TYPE_FILE + ).values_list('file', flat=True) + ) + files_removed = list( + snapshot.project.values.filter( + snapshot__in=snapshot.project.snapshots.filter(created__gt=snapshot.created), + value_type=VALUE_TYPE_FILE + ).values_list('file', flat=True) + ) + + url = reverse(urlnames['rollback'], args=[snapshot.project_id, snapshot_id]) + response = client.post(url) + + if snapshot.project_id in rollback_snapshot_permission_map.get(username, []): + assert response.status_code == 204 + + # check that we still have all the snapshots before the rolled back snapshot + assert list(snapshot.project.snapshots.values_list('id', flat=True)) == snapshots_kept + + # check that we still have all the values + assert list(snapshot.project.values.values_list('id', flat=True)) == values_kept + + for file_path in files_kept: + assert Path(settings.MEDIA_ROOT).joinpath(file_path).exists() + + for file_path in files_removed: + assert not Path(settings.MEDIA_ROOT).joinpath(file_path).exists() + + elif snapshot.project_id in view_snapshot_permission_map.get(username, []): + assert response.status_code == 403 + else: + assert response.status_code == 404 + + assert snapshot.project.snapshots.count() == snapshot_count + assert snapshot.project.values.count() == values_count + + for file_path in files_kept + files_removed: + assert Path(settings.MEDIA_ROOT).joinpath(file_path).exists() + + @pytest.mark.parametrize('username,password', users) @pytest.mark.parametrize('snapshot_id', snapshots) def test_delete(db, client, files, username, password, snapshot_id): diff --git a/rdmo/projects/viewsets.py b/rdmo/projects/viewsets.py index b244c21c09..cfa87daf99 100644 --- a/rdmo/projects/viewsets.py +++ b/rdmo/projects/viewsets.py @@ -627,6 +627,13 @@ class ProjectSnapshotViewSet(ProjectNestedViewSetMixin, CreateModelMixin, Retrie def get_queryset(self): return self.project.snapshots.all() + @action(detail=True, methods=['POST'], + permission_classes=(HasModelPermission | HasProjectPermission, )) + def rollback(self, request, parent_lookup_project, pk=None): + snapshot = self.get_object() + snapshot.rollback() + return Response(status=status.HTTP_204_NO_CONTENT) + class ProjectValueViewSet(ProjectNestedViewSetMixin, ModelViewSet): permission_classes = (HasModelPermission | HasProjectPermission, ) From d207d23c3a8f825589a552c569b634905a8ac89a Mon Sep 17 00:00:00 2001 From: Jochen Klar Date: Fri, 24 Oct 2025 11:50:35 +0200 Subject: [PATCH 131/298] Improve tests --- rdmo/projects/tests/test_viewset_project_snapshot.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/rdmo/projects/tests/test_viewset_project_snapshot.py b/rdmo/projects/tests/test_viewset_project_snapshot.py index 0dc854583b..023a45634d 100644 --- a/rdmo/projects/tests/test_viewset_project_snapshot.py +++ b/rdmo/projects/tests/test_viewset_project_snapshot.py @@ -167,21 +167,21 @@ def test_rollback(db, client, files, username, password, snapshot_id): snapshot_count = snapshot.project.snapshots.count() values_count = snapshot.project.values.count() - snapshots_kept = list( + snapshots_kept = sorted( snapshot.project.snapshots.filter(created__lt=snapshot.created).values_list('id', flat=True) ) - values_kept = list( + values_kept = sorted( snapshot.project.values.filter( snapshot__in=snapshot.project.snapshots.filter(created__lte=snapshot.created) ).values_list('id', flat=True) ) - files_kept = list( + files_kept = sorted( snapshot.project.values.filter( snapshot__in=snapshot.project.snapshots.filter(created__lt=snapshot.created), value_type=VALUE_TYPE_FILE ).values_list('file', flat=True) ) - files_removed = list( + files_removed = sorted( snapshot.project.values.filter( snapshot__in=snapshot.project.snapshots.filter(created__gt=snapshot.created), value_type=VALUE_TYPE_FILE @@ -195,10 +195,10 @@ def test_rollback(db, client, files, username, password, snapshot_id): assert response.status_code == 204 # check that we still have all the snapshots before the rolled back snapshot - assert list(snapshot.project.snapshots.values_list('id', flat=True)) == snapshots_kept + assert sorted(snapshot.project.snapshots.values_list('id', flat=True)) == snapshots_kept # check that we still have all the values - assert list(snapshot.project.values.values_list('id', flat=True)) == values_kept + assert sorted(snapshot.project.values.values_list('id', flat=True)) == values_kept for file_path in files_kept: assert Path(settings.MEDIA_ROOT).joinpath(file_path).exists() From 90af3f920b9567379c936f2ee80bb7eca47ac3d4 Mon Sep 17 00:00:00 2001 From: David Wallace Date: Wed, 29 Oct 2025 13:42:50 +0100 Subject: [PATCH 132/298] style: do not use backslash for line continuation Signed-off-by: David Wallace --- rdmo/projects/tests/test_viewset_project_snapshot.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/rdmo/projects/tests/test_viewset_project_snapshot.py b/rdmo/projects/tests/test_viewset_project_snapshot.py index 023a45634d..66d2f215dd 100644 --- a/rdmo/projects/tests/test_viewset_project_snapshot.py +++ b/rdmo/projects/tests/test_viewset_project_snapshot.py @@ -30,15 +30,16 @@ 'site': [1, 2, 3, 4, 5, 12] } -add_snapshot_permission_map = \ -change_snapshot_permission_map = \ -rollback_snapshot_permission_map = \ -delete_snapshot_permission_map = { +snapshot_permission_map = { 'owner': [1, 2, 3, 4, 5, 12], 'manager': [1, 3, 5], 'api': [1, 2, 3, 4, 5, 12], - 'site': [1, 2, 3, 4, 5, 12] + 'site': [1, 2, 3, 4, 5, 12], } +add_snapshot_permission_map = snapshot_permission_map +change_snapshot_permission_map = snapshot_permission_map +rollback_snapshot_permission_map = snapshot_permission_map +delete_snapshot_permission_map = snapshot_permission_map urlnames = { 'list': 'v1-projects:project-snapshot-list', From db149d745bc1f17961b776895d3d29aa2bb957f1 Mon Sep 17 00:00:00 2001 From: Jochen Klar Date: Tue, 21 Oct 2025 14:49:50 +0200 Subject: [PATCH 133/298] Add answers and views actions to ProjectViewSet --- .../templates/projects/project_answers.html | 90 ++----------------- .../projects/project_answers_export.html | 9 +- .../projects/project_view_export.html | 9 +- rdmo/projects/viewsets.py | 89 +++++++++++++++++- 4 files changed, 96 insertions(+), 101 deletions(-) diff --git a/rdmo/projects/templates/projects/project_answers.html b/rdmo/projects/templates/projects/project_answers.html index ca3a6b8dd3..8172b6550b 100644 --- a/rdmo/projects/templates/projects/project_answers.html +++ b/rdmo/projects/templates/projects/project_answers.html @@ -1,88 +1,8 @@ -{% extends 'core/page.html' %} {% load i18n %} -{% load core_tags %} -{% block sidebar %} +

{% blocktrans with title=project.title %}Answers for {{ title }}{% endblocktrans %}

+

+ {% trans 'In the following, we have summarized the information about the project as given by you and your collaborators.' %} +

- {% if snapshots %} - -

{% trans 'Snapshots' %}

- - - {% endif %} - - -

{% trans 'Options' %}

- - -

{% trans 'Export' %}

- - - {% if attachments %} - -

{% trans 'Attachments' %}

- - - {% endif %} - -{% endblock %} - - -{% block page %} - - {% if error %} - - {% include 'projects/project_error.html' %} - - {% else %} - -

{% blocktrans with title=project.title %}Answers for {{ title }}{% endblocktrans %}

-

- {% trans 'In the following, we have summarized the information about the project as given by you and your collaborators.' %} -

- - {% include 'projects/project_answers_tree.html' %} - - {% endif %} - - -{% endblock %} +{% include 'projects/project_answers_tree.html' %} diff --git a/rdmo/projects/templates/projects/project_answers_export.html b/rdmo/projects/templates/projects/project_answers_export.html index 03f6cf363c..47e1e9d5cb 100644 --- a/rdmo/projects/templates/projects/project_answers_export.html +++ b/rdmo/projects/templates/projects/project_answers_export.html @@ -1,10 +1,5 @@ -{% extends 'core/export.html' %} {% load i18n %} -{% block body %} +

{% blocktrans with title=project.title %}Answers for {{ title }}{% endblocktrans%}

-

{% blocktrans with title=project.title %}Answers for {{ title }}{% endblocktrans%}

- - {% include 'projects/project_answers_tree.html' %} - -{% endblock %} +{% include 'projects/project_answers_tree.html' %} diff --git a/rdmo/projects/templates/projects/project_view_export.html b/rdmo/projects/templates/projects/project_view_export.html index 2c4b1b8d97..bb6165748d 100644 --- a/rdmo/projects/templates/projects/project_view_export.html +++ b/rdmo/projects/templates/projects/project_view_export.html @@ -1,8 +1 @@ -{% extends 'core/export.html' %} -{% load i18n %} - -{% block body %} - -{{ rendered_view }} - -{% endblock %} +{{ html }} diff --git a/rdmo/projects/viewsets.py b/rdmo/projects/viewsets.py index cfa87daf99..f2fc8b8de5 100644 --- a/rdmo/projects/viewsets.py +++ b/rdmo/projects/viewsets.py @@ -4,6 +4,7 @@ from django.db.models import F, OuterRef, Prefetch, Q, Subquery from django.db.models.functions import Coalesce, Greatest from django.http import Http404, HttpResponseRedirect +from django.template.loader import render_to_string from django.utils.translation import gettext_lazy as _ from rest_framework import serializers, status @@ -22,11 +23,12 @@ from rdmo.conditions.models import Condition from rdmo.core.permissions import HasModelPermission -from rdmo.core.utils import human2bytes, is_truthy, return_file_response +from rdmo.core.utils import human2bytes, is_truthy, render_to_format, return_file_response from rdmo.options.models import OptionSet from rdmo.questions.models import Catalog, Page, Question, QuestionSet from rdmo.tasks.models import Task from rdmo.views.models import View +from rdmo.views.utils import ProjectWrapper from .filters import ( AttributeFilterBackend, @@ -90,6 +92,7 @@ copy_project, get_contact_message, get_upload_accept, + get_value_path, send_contact_message, send_invite_email, ) @@ -425,6 +428,90 @@ def hierarchy(self, request, pk): serializer = ProjectHierarchySerializer(cached_trees[0], context=serializer_context) return Response(serializer.data) + @action(detail=True, methods=['get'], permission_classes=(HasModelPermission | HasProjectPermission, ), + url_path=r'(snapshots/(?P\d+)/)?answers') + def answers(self, request, pk, snapshot_id=None): + project = self.get_object() + project.catalog.prefetch_elements() + + try: + snapshot = project.snapshots.get(pk=snapshot_id) if snapshot_id else None + except Snapshot.DoesNotExist: + snapshot = None + + return Response({ + 'project': pk, + 'snapshot': snapshot_id, + 'html': render_to_string('projects/project_answers.html', { + 'project': project, + 'snapshot': snapshot, + 'project_wrapper': ProjectWrapper(project, snapshot), + 'export_formats': settings.EXPORT_FORMATS + }) + }) + + @action(detail=True, methods=['get'], permission_classes=(HasModelPermission | HasProjectPermission, ), + url_path=r'(snapshots/(?P\d+)/)?answers/export/(?P[a-z]+)') + def answers_export(self, request, pk, export_format, snapshot_id=None): + project = self.get_object() + project.catalog.prefetch_elements() + + try: + snapshot = project.snapshots.get(pk=snapshot_id) if snapshot_id else None + except Snapshot.DoesNotExist: + snapshot = None + + return render_to_format(self.request, export_format, project.title, 'projects/project_answers_export.html', { + 'project': project, + 'snapshot': snapshot, + 'project_wrapper': ProjectWrapper(project, snapshot) + }) + + @action(detail=True, methods=['get'], permission_classes=(HasModelPermission | HasProjectPermission, ), + url_path=r'(snapshots/(?P\d+)/)?views/(?P\d+)') + def views(self, request, pk, view_id, snapshot_id=None): + project = self.get_object() + project.catalog.prefetch_elements() + + try: + view = project.views.get(pk=view_id) + except View.DoesNotExist as e: + raise Http404 from e + + try: + snapshot = project.snapshots.get(pk=snapshot_id) if snapshot_id else None + except Snapshot.DoesNotExist: + snapshot = None + + return Response({ + 'project': pk, + 'snapshot': snapshot_id, + 'html': view.render(project, snapshot) + }) + + @action(detail=True, methods=['get'], permission_classes=(HasModelPermission | HasProjectPermission, ), + url_path=r'(snapshots/(?P\d+)/)?views/(?P\d+)/export/(?P[a-z]+)') + def views_export(self, request, pk, view_id, export_format, snapshot_id=None): + project = self.get_object() + project.catalog.prefetch_elements() + + try: + view = project.views.get(pk=view_id) + except View.DoesNotExist as e: + raise Http404 from e + + try: + snapshot = project.snapshots.get(pk=snapshot_id) if snapshot_id else None + except Snapshot.DoesNotExist: + snapshot = None + + return render_to_format(self.request, export_format, project.title, 'projects/project_view_export.html', { + 'project': project, + 'snapshot': snapshot, + 'html': view.render(project, snapshot), + 'resource_path': get_value_path(project, snapshot) + }) + @action(detail=False, url_path='upload-accept', permission_classes=(IsAuthenticated, )) def upload_accept(self, request): return Response(get_upload_accept()) From c9dd3ae7ab2f25de19f945841a79a83b48c88587 Mon Sep 17 00:00:00 2001 From: Jochen Klar Date: Fri, 24 Oct 2025 10:41:05 +0200 Subject: [PATCH 134/298] Fix export templates --- .../templates/projects/project_answers_export.html | 9 +++++++-- .../projects/templates/projects/project_view_export.html | 7 +++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/rdmo/projects/templates/projects/project_answers_export.html b/rdmo/projects/templates/projects/project_answers_export.html index 47e1e9d5cb..03f6cf363c 100644 --- a/rdmo/projects/templates/projects/project_answers_export.html +++ b/rdmo/projects/templates/projects/project_answers_export.html @@ -1,5 +1,10 @@ +{% extends 'core/export.html' %} {% load i18n %} -

{% blocktrans with title=project.title %}Answers for {{ title }}{% endblocktrans%}

+{% block body %} -{% include 'projects/project_answers_tree.html' %} +

{% blocktrans with title=project.title %}Answers for {{ title }}{% endblocktrans%}

+ + {% include 'projects/project_answers_tree.html' %} + +{% endblock %} diff --git a/rdmo/projects/templates/projects/project_view_export.html b/rdmo/projects/templates/projects/project_view_export.html index bb6165748d..6a4361c64e 100644 --- a/rdmo/projects/templates/projects/project_view_export.html +++ b/rdmo/projects/templates/projects/project_view_export.html @@ -1 +1,8 @@ +{% extends 'core/export.html' %} +{% load i18n %} + +{% block body %} + {{ html }} + +{% endblock %} From 326021ed939b41e24491ca3b6d16ab4aeb900a60 Mon Sep 17 00:00:00 2001 From: Jochen Klar Date: Fri, 24 Oct 2025 10:56:23 +0200 Subject: [PATCH 135/298] Add ProjectViewSerializer and ProjectViewSerializer --- rdmo/projects/serializers/v1/__init__.py | 21 +++++++++++++++++++++ rdmo/projects/viewsets.py | 24 ++++++++++++++++-------- 2 files changed, 37 insertions(+), 8 deletions(-) diff --git a/rdmo/projects/serializers/v1/__init__.py b/rdmo/projects/serializers/v1/__init__.py index 393aebe60e..d975fd5440 100644 --- a/rdmo/projects/serializers/v1/__init__.py +++ b/rdmo/projects/serializers/v1/__init__.py @@ -561,6 +561,27 @@ class Meta: ) +class ProjectAttachmentSerializer(serializers.ModelSerializer): + + class Meta: + model = Value + fields = ( + 'id', + 'created', + 'updated', + 'file_name', + 'file_url' + ) + + +class ProjectViewSerializer(serializers.Serializer): + + project = serializers.PrimaryKeyRelatedField(read_only=True) + snapshot = serializers.PrimaryKeyRelatedField(read_only=True) + html = serializers.CharField(read_only=True) + attachments = ProjectAttachmentSerializer(many=True, read_only=True) + + class MembershipSerializer(serializers.ModelSerializer): class Meta: diff --git a/rdmo/projects/viewsets.py b/rdmo/projects/viewsets.py index f2fc8b8de5..d743457791 100644 --- a/rdmo/projects/viewsets.py +++ b/rdmo/projects/viewsets.py @@ -22,6 +22,7 @@ from rest_framework_extensions.mixins import NestedViewSetMixin from rdmo.conditions.models import Condition +from rdmo.core.constants import VALUE_TYPE_FILE from rdmo.core.permissions import HasModelPermission from rdmo.core.utils import human2bytes, is_truthy, render_to_format, return_file_response from rdmo.options.models import OptionSet @@ -75,6 +76,7 @@ ProjectSerializer, ProjectSnapshotSerializer, ProjectValueSerializer, + ProjectViewSerializer, ProjectVisibilitySerializer, SnapshotSerializer, UserInviteSerializer, @@ -439,16 +441,19 @@ def answers(self, request, pk, snapshot_id=None): except Snapshot.DoesNotExist: snapshot = None - return Response({ - 'project': pk, - 'snapshot': snapshot_id, + serializer = ProjectViewSerializer({ + 'project': project, + 'snapshot': snapshot, 'html': render_to_string('projects/project_answers.html', { 'project': project, 'snapshot': snapshot, 'project_wrapper': ProjectWrapper(project, snapshot), 'export_formats': settings.EXPORT_FORMATS - }) + }), + 'attachments': project.values.filter(snapshot=snapshot).filter(value_type=VALUE_TYPE_FILE).order_by('file') }) + return Response(serializer.data) + @action(detail=True, methods=['get'], permission_classes=(HasModelPermission | HasProjectPermission, ), url_path=r'(snapshots/(?P\d+)/)?answers/export/(?P[a-z]+)') @@ -483,11 +488,14 @@ def views(self, request, pk, view_id, snapshot_id=None): except Snapshot.DoesNotExist: snapshot = None - return Response({ - 'project': pk, - 'snapshot': snapshot_id, - 'html': view.render(project, snapshot) + serializer = ProjectViewSerializer({ + 'project': project, + 'snapshot': snapshot, + 'html': view.render(project, snapshot), + 'attachments': project.values.filter(snapshot=snapshot).filter(value_type=VALUE_TYPE_FILE).order_by('file') }) + return Response(serializer.data) + @action(detail=True, methods=['get'], permission_classes=(HasModelPermission | HasProjectPermission, ), url_path=r'(snapshots/(?P\d+)/)?views/(?P\d+)/export/(?P[a-z]+)') From 91600354fea1873abfc60ce144eddb36b4acb064 Mon Sep 17 00:00:00 2001 From: Jochen Klar Date: Fri, 24 Oct 2025 11:44:00 +0200 Subject: [PATCH 136/298] Use extra methods for snapshot answers and views --- rdmo/projects/viewsets.py | 33 ++++++++++++++++++++++++++++----- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/rdmo/projects/viewsets.py b/rdmo/projects/viewsets.py index d743457791..5625808f97 100644 --- a/rdmo/projects/viewsets.py +++ b/rdmo/projects/viewsets.py @@ -430,8 +430,7 @@ def hierarchy(self, request, pk): serializer = ProjectHierarchySerializer(cached_trees[0], context=serializer_context) return Response(serializer.data) - @action(detail=True, methods=['get'], permission_classes=(HasModelPermission | HasProjectPermission, ), - url_path=r'(snapshots/(?P\d+)/)?answers') + @action(detail=True, methods=['get'], permission_classes=(HasModelPermission | HasProjectPermission, )) def answers(self, request, pk, snapshot_id=None): project = self.get_object() project.catalog.prefetch_elements() @@ -454,9 +453,14 @@ def answers(self, request, pk, snapshot_id=None): }) return Response(serializer.data) + @action(detail=True, methods=['get'], permission_classes=(HasModelPermission | HasProjectPermission, ), + url_path=r'snapshots/(?P\d+)/answers') + def answers_snapshot(self, request, pk, snapshot_id=None): + # extra method since DRF does not officially support optional named parameters inside url_path + return self.answers(request, pk, snapshot_id) @action(detail=True, methods=['get'], permission_classes=(HasModelPermission | HasProjectPermission, ), - url_path=r'(snapshots/(?P\d+)/)?answers/export/(?P[a-z]+)') + url_path=r'answers/export/(?P[a-z]+)') def answers_export(self, request, pk, export_format, snapshot_id=None): project = self.get_object() project.catalog.prefetch_elements() @@ -473,7 +477,13 @@ def answers_export(self, request, pk, export_format, snapshot_id=None): }) @action(detail=True, methods=['get'], permission_classes=(HasModelPermission | HasProjectPermission, ), - url_path=r'(snapshots/(?P\d+)/)?views/(?P\d+)') + url_path=r'snapshots/(?P\d+)/answers/export/(?P[a-z]+)') + def answers_export_snapshot(self, request, pk, export_format, snapshot_id=None): + # extra method since DRF does not officially support optional named parameters inside url_path + return self.answers_export(request, pk, export_format, snapshot_id) + + @action(detail=True, methods=['get'], permission_classes=(HasModelPermission | HasProjectPermission, ), + url_path=r'views/(?P\d+)') def views(self, request, pk, view_id, snapshot_id=None): project = self.get_object() project.catalog.prefetch_elements() @@ -498,7 +508,14 @@ def views(self, request, pk, view_id, snapshot_id=None): @action(detail=True, methods=['get'], permission_classes=(HasModelPermission | HasProjectPermission, ), - url_path=r'(snapshots/(?P\d+)/)?views/(?P\d+)/export/(?P[a-z]+)') + url_path=r'snapshots/(?P\d+)/views/(?P\d+)') + def views_snapshot(self, request, pk, view_id, snapshot_id): + # extra method since DRF does not officially support optional named parameters inside url_path + return self.views(request, pk, view_id, snapshot_id) + + + @action(detail=True, methods=['get'], permission_classes=(HasModelPermission | HasProjectPermission, ), + url_path=r'views/(?P\d+)/export/(?P[a-z]+)') def views_export(self, request, pk, view_id, export_format, snapshot_id=None): project = self.get_object() project.catalog.prefetch_elements() @@ -520,6 +537,12 @@ def views_export(self, request, pk, view_id, export_format, snapshot_id=None): 'resource_path': get_value_path(project, snapshot) }) + @action(detail=True, methods=['get'], permission_classes=(HasModelPermission | HasProjectPermission, ), + url_path=r'snapshots/(?P\d+)/views/(?P\d+)/export/(?P[a-z]+)') + def views_export_snapshot(self, request, pk, view_id, export_format, snapshot_id): + # extra method since DRF does not officially support optional named parameters inside url_path + return self.views_export(request, pk, view_id, export_format, snapshot_id) + @action(detail=False, url_path='upload-accept', permission_classes=(IsAuthenticated, )) def upload_accept(self, request): return Response(get_upload_accept()) From 691e930eb4be50e004a35b778ae16b6f9c4cbe9c Mon Sep 17 00:00:00 2001 From: Jochen Klar Date: Fri, 24 Oct 2025 11:44:12 +0200 Subject: [PATCH 137/298] Update tests --- rdmo/core/tests/test_openapi.py | 2 +- .../tests/test_viewset_project_answers.py | 114 ++++++++++++++++ .../tests/test_viewset_project_views.py | 126 ++++++++++++++++++ 3 files changed, 241 insertions(+), 1 deletion(-) create mode 100644 rdmo/projects/tests/test_viewset_project_answers.py create mode 100644 rdmo/projects/tests/test_viewset_project_views.py diff --git a/rdmo/core/tests/test_openapi.py b/rdmo/core/tests/test_openapi.py index e9aa40ce4e..9c63065ece 100644 --- a/rdmo/core/tests/test_openapi.py +++ b/rdmo/core/tests/test_openapi.py @@ -10,7 +10,7 @@ 'anonymous' ) -n_path = 128 +n_path = 136 @pytest.mark.parametrize('username', users) def test_openapi_schema(db, client, login, settings, username): diff --git a/rdmo/projects/tests/test_viewset_project_answers.py b/rdmo/projects/tests/test_viewset_project_answers.py new file mode 100644 index 0000000000..5538d8b248 --- /dev/null +++ b/rdmo/projects/tests/test_viewset_project_answers.py @@ -0,0 +1,114 @@ +import pytest + +from django.urls import reverse + +from ..models import Snapshot + +users = ( + ('owner', 'owner'), + ('manager', 'manager'), + ('author', 'author'), + ('guest', 'guest'), + ('admin', 'admin'), + ('api', 'api'), + ('site', 'site'), + ('user', 'user'), + ('anonymous', None), +) + +view_project_permission_map = { + 'owner': [1, 2, 3, 4, 5, 10, 12], + 'manager': [1, 3, 5, 7, 12], + 'author': [1, 3, 5, 8, 12], + 'guest': [1, 3, 5, 9, 12], + 'admin': [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], + 'api': [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], + 'site': [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], + 'user': [12] +} + +projects = [1, 2, 3, 4, 5, 12] + +snapshots = [1, 3] + +export_formats = ['html'] + +urlnames = { + 'answers': 'v1-projects:project-answers', + 'answers-snapshot': 'v1-projects:project-answers-snapshot', + 'answers-export': 'v1-projects:project-answers-export', + 'answers-export-snapshot': 'v1-projects:project-answers-export-snapshot', +} + +@pytest.mark.parametrize('username,password', users) +@pytest.mark.parametrize('project_id', projects) +def test_view(db, client, username, password, project_id): + client.login(username=username, password=password) + + url = reverse(urlnames['answers'], args=[project_id]) + response = client.get(url) + + if project_id in view_project_permission_map.get(username, []): + assert response.status_code == 200 + assert isinstance(response.json(), dict) + else: + if password: + assert response.status_code == 404 + else: + assert response.status_code == 401 + + +@pytest.mark.parametrize('username,password', users) +@pytest.mark.parametrize('snapshot_id', snapshots) +def test_view_snapshot(db, client, username, password, snapshot_id): + client.login(username=username, password=password) + snapshot = Snapshot.objects.get(pk=snapshot_id) + + url = reverse(urlnames['answers-snapshot'], args=[snapshot.project.id, snapshot_id]) + response = client.get(url) + + if snapshot.project.id in view_project_permission_map.get(username, []): + assert response.status_code == 200 + assert isinstance(response.json(), dict) + else: + if password: + assert response.status_code == 404 + else: + assert response.status_code == 401 + + +@pytest.mark.parametrize('username,password', users) +@pytest.mark.parametrize('project_id', projects) +@pytest.mark.parametrize('export_format', export_formats) +def test_view_export(db, client, username, password, project_id, export_format): + client.login(username=username, password=password) + + url = reverse(urlnames['answers-export'], args=[project_id, export_format]) + response = client.get(url) + + if project_id in view_project_permission_map.get(username, []): + assert response.status_code == 200 + else: + if password: + assert response.status_code == 404 + else: + assert response.status_code == 401 + + +@pytest.mark.parametrize('username,password', users) +@pytest.mark.parametrize('snapshot_id', snapshots) +@pytest.mark.parametrize('export_format', export_formats) +def test_view_snapshot_export(db, client, username, password, snapshot_id, export_format): + client.login(username=username, password=password) + snapshot = Snapshot.objects.get(pk=snapshot_id) + + url = reverse(urlnames['answers-export-snapshot'], args=[snapshot.project.id, snapshot_id, export_format]) + response = client.get(url) + + if snapshot.project.id in view_project_permission_map.get(username, []): + assert response.status_code == 200 + else: + if password: + assert response.status_code == 404 + else: + assert response.status_code == 401 diff --git a/rdmo/projects/tests/test_viewset_project_views.py b/rdmo/projects/tests/test_viewset_project_views.py new file mode 100644 index 0000000000..9b8b5ec925 --- /dev/null +++ b/rdmo/projects/tests/test_viewset_project_views.py @@ -0,0 +1,126 @@ +import pytest + +from django.urls import reverse + +from ..models import Project, Snapshot + +users = ( + ('owner', 'owner'), + ('manager', 'manager'), + ('author', 'author'), + ('guest', 'guest'), + ('admin', 'admin'), + ('api', 'api'), + ('site', 'site'), + ('user', 'user'), + ('anonymous', None), +) + +views = (1, 2) + +view_project_permission_map = { + 'owner': [1, 2, 3, 4, 5, 10, 12], + 'manager': [1, 3, 5, 7, 12], + 'author': [1, 3, 5, 8, 12], + 'guest': [1, 3, 5, 9, 12], + 'admin': [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], + 'api': [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], + 'site': [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], + 'user': [12] +} + +projects = [1, 2, 3, 4, 5, 12] + +snapshots = [1, 3] + +export_formats = ['html'] + +urlnames = { + 'views': 'v1-projects:project-views', + 'views-snapshot': 'v1-projects:project-views-snapshot', + 'views-export': 'v1-projects:project-views-export', + 'views-export-snapshot': 'v1-projects:project-views-export-snapshot', +} + +@pytest.mark.parametrize('username,password', users) +@pytest.mark.parametrize('project_id', projects) +@pytest.mark.parametrize('view_id', views) +def test_view(db, client, username, password, project_id, view_id): + client.login(username=username, password=password) + project = Project.objects.get(pk=project_id) + project_views = list(project.views.values_list('id', flat=True)) + + url = reverse(urlnames['views'], args=[project_id, view_id]) + response = client.get(url) + + if project_id in view_project_permission_map.get(username, []) and view_id in project_views: + assert response.status_code == 200 + assert isinstance(response.json(), dict) + else: + if password: + assert response.status_code == 404 + else: + assert response.status_code == 401 + + +@pytest.mark.parametrize('username,password', users) +@pytest.mark.parametrize('snapshot_id', snapshots) +@pytest.mark.parametrize('view_id', views) +def test_view_snapshot(db, client, username, password, snapshot_id, view_id): + client.login(username=username, password=password) + snapshot = Snapshot.objects.get(pk=snapshot_id) + project_views = list(snapshot.project.views.values_list('id', flat=True)) + + url = reverse(urlnames['views-snapshot'], args=[snapshot.project.id, snapshot_id, view_id]) + response = client.get(url) + + if snapshot.project.id in view_project_permission_map.get(username, []) and view_id in project_views: + assert response.status_code == 200 + assert isinstance(response.json(), dict) + else: + if password: + assert response.status_code == 404 + else: + assert response.status_code == 401 + + +@pytest.mark.parametrize('username,password', users) +@pytest.mark.parametrize('project_id', projects) +@pytest.mark.parametrize('view_id', views) +@pytest.mark.parametrize('export_format', export_formats) +def test_view_export(db, client, username, password, project_id, view_id, export_format): + client.login(username=username, password=password) + project = Project.objects.get(pk=project_id) + project_views = list(project.views.values_list('id', flat=True)) + + url = reverse(urlnames['views-export'], args=[project_id, view_id, export_format]) + response = client.get(url) + + if project_id in view_project_permission_map.get(username, []) and view_id in project_views: + assert response.status_code == 200 + else: + if password: + assert response.status_code == 404 + else: + assert response.status_code == 401 + + +@pytest.mark.parametrize('username,password', users) +@pytest.mark.parametrize('snapshot_id', snapshots) +@pytest.mark.parametrize('view_id', views) +@pytest.mark.parametrize('export_format', export_formats) +def test_view_snapshot_export(db, client, username, password, snapshot_id, view_id, export_format): + client.login(username=username, password=password) + snapshot = Snapshot.objects.get(pk=snapshot_id) + project_views = list(snapshot.project.views.values_list('id', flat=True)) + + url = reverse(urlnames['views-export-snapshot'], args=[snapshot.project.id, snapshot_id, view_id, export_format]) + response = client.get(url) + + if snapshot.project.id in view_project_permission_map.get(username, []) and view_id in project_views: + assert response.status_code == 200 + else: + if password: + assert response.status_code == 404 + else: + assert response.status_code == 401 From a5708143f097221987c3c597fa83e5f694eb95f0 Mon Sep 17 00:00:00 2001 From: Jochen Klar Date: Thu, 30 Oct 2025 09:39:05 +0100 Subject: [PATCH 138/298] Gardening --- rdmo/projects/viewsets.py | 31 ++++++++++++++++++++++++------- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/rdmo/projects/viewsets.py b/rdmo/projects/viewsets.py index 5625808f97..06713b2edc 100644 --- a/rdmo/projects/viewsets.py +++ b/rdmo/projects/viewsets.py @@ -430,7 +430,12 @@ def hierarchy(self, request, pk): serializer = ProjectHierarchySerializer(cached_trees[0], context=serializer_context) return Response(serializer.data) - @action(detail=True, methods=['get'], permission_classes=(HasModelPermission | HasProjectPermission, )) + @action( + detail=True, + methods=['get'], + url_path=r'answers', + permission_classes=(HasModelPermission | HasProjectPermission, ) + ) def answers(self, request, pk, snapshot_id=None): project = self.get_object() project.catalog.prefetch_elements() @@ -453,14 +458,22 @@ def answers(self, request, pk, snapshot_id=None): }) return Response(serializer.data) - @action(detail=True, methods=['get'], permission_classes=(HasModelPermission | HasProjectPermission, ), - url_path=r'snapshots/(?P\d+)/answers') + @action( + detail=True, + methods=['get'], + url_path=r'snapshots/(?P\d+)/answers', + permission_classes=(HasModelPermission | HasProjectPermission, ) + ) def answers_snapshot(self, request, pk, snapshot_id=None): # extra method since DRF does not officially support optional named parameters inside url_path return self.answers(request, pk, snapshot_id) - @action(detail=True, methods=['get'], permission_classes=(HasModelPermission | HasProjectPermission, ), - url_path=r'answers/export/(?P[a-z]+)') + @action( + detail=True, + methods=['get'], + url_path=r'answers/export/(?P[a-z]+)', + permission_classes=(HasModelPermission | HasProjectPermission, ) + ) def answers_export(self, request, pk, export_format, snapshot_id=None): project = self.get_object() project.catalog.prefetch_elements() @@ -476,8 +489,12 @@ def answers_export(self, request, pk, export_format, snapshot_id=None): 'project_wrapper': ProjectWrapper(project, snapshot) }) - @action(detail=True, methods=['get'], permission_classes=(HasModelPermission | HasProjectPermission, ), - url_path=r'snapshots/(?P\d+)/answers/export/(?P[a-z]+)') + @action( + detail=True, + methods=['get'], + url_path=r'snapshots/(?P\d+)/answers/export/(?P[a-z]+)', + permission_classes=(HasModelPermission | HasProjectPermission, ) + ) def answers_export_snapshot(self, request, pk, export_format, snapshot_id=None): # extra method since DRF does not officially support optional named parameters inside url_path return self.answers_export(request, pk, export_format, snapshot_id) From ec3bd81a6200f1f72fdb60ecb22565fa3bf50619 Mon Sep 17 00:00:00 2001 From: Jochen Klar Date: Thu, 30 Oct 2025 17:44:27 +0100 Subject: [PATCH 139/298] More gardening --- rdmo/projects/serializers/v1/__init__.py | 1 + rdmo/projects/viewsets.py | 5 +++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/rdmo/projects/serializers/v1/__init__.py b/rdmo/projects/serializers/v1/__init__.py index d975fd5440..e3896b65e8 100644 --- a/rdmo/projects/serializers/v1/__init__.py +++ b/rdmo/projects/serializers/v1/__init__.py @@ -578,6 +578,7 @@ class ProjectViewSerializer(serializers.Serializer): project = serializers.PrimaryKeyRelatedField(read_only=True) snapshot = serializers.PrimaryKeyRelatedField(read_only=True) + view = serializers.PrimaryKeyRelatedField(read_only=True) html = serializers.CharField(read_only=True) attachments = ProjectAttachmentSerializer(many=True, read_only=True) diff --git a/rdmo/projects/viewsets.py b/rdmo/projects/viewsets.py index 06713b2edc..01e59e2d6a 100644 --- a/rdmo/projects/viewsets.py +++ b/rdmo/projects/viewsets.py @@ -464,7 +464,7 @@ def answers(self, request, pk, snapshot_id=None): url_path=r'snapshots/(?P\d+)/answers', permission_classes=(HasModelPermission | HasProjectPermission, ) ) - def answers_snapshot(self, request, pk, snapshot_id=None): + def answers_snapshot(self, request, pk, snapshot_id): # extra method since DRF does not officially support optional named parameters inside url_path return self.answers(request, pk, snapshot_id) @@ -495,7 +495,7 @@ def answers_export(self, request, pk, export_format, snapshot_id=None): url_path=r'snapshots/(?P\d+)/answers/export/(?P[a-z]+)', permission_classes=(HasModelPermission | HasProjectPermission, ) ) - def answers_export_snapshot(self, request, pk, export_format, snapshot_id=None): + def answers_export_snapshot(self, request, pk, export_format, snapshot_id): # extra method since DRF does not officially support optional named parameters inside url_path return self.answers_export(request, pk, export_format, snapshot_id) @@ -518,6 +518,7 @@ def views(self, request, pk, view_id, snapshot_id=None): serializer = ProjectViewSerializer({ 'project': project, 'snapshot': snapshot, + 'view': view, 'html': view.render(project, snapshot), 'attachments': project.values.filter(snapshot=snapshot).filter(value_type=VALUE_TYPE_FILE).order_by('file') }) From 5cdefa88be3810f3551d28d4a6155b35e440ac7f Mon Sep 17 00:00:00 2001 From: Jochen Klar Date: Thu, 13 Nov 2025 20:44:02 +0100 Subject: [PATCH 140/298] Add views action to ProjectViewSet and refactor view actions and serializers --- rdmo/core/tests/test_openapi.py | 2 +- rdmo/projects/serializers/v1/__init__.py | 42 +++++++++++++++++-- .../tests/test_viewset_project_views.py | 37 ++++++++++++---- rdmo/projects/viewsets.py | 32 +++++++------- 4 files changed, 86 insertions(+), 27 deletions(-) diff --git a/rdmo/core/tests/test_openapi.py b/rdmo/core/tests/test_openapi.py index 9c63065ece..ff50020e3c 100644 --- a/rdmo/core/tests/test_openapi.py +++ b/rdmo/core/tests/test_openapi.py @@ -10,7 +10,7 @@ 'anonymous' ) -n_path = 136 +n_path = 137 @pytest.mark.parametrize('username', users) def test_openapi_schema(db, client, login, settings, username): diff --git a/rdmo/projects/serializers/v1/__init__.py b/rdmo/projects/serializers/v1/__init__.py index e3896b65e8..ab850b9571 100644 --- a/rdmo/projects/serializers/v1/__init__.py +++ b/rdmo/projects/serializers/v1/__init__.py @@ -9,9 +9,11 @@ from rdmo.accounts.serializers.v1 import UserLookupSerializer from rdmo.accounts.utils import get_full_name +from rdmo.core.serializers import TranslationSerializerMixin from rdmo.domain.models import Attribute from rdmo.questions.models import Catalog from rdmo.services.validators import ProviderValidator +from rdmo.views.models import View from ...models import ( Integration, @@ -561,6 +563,17 @@ class Meta: ) +class ProjectViewsSerializer(serializers.ModelSerializer): + + class Meta: + model = View + fields = ( + 'id', + 'title', + 'help' + ) + + class ProjectAttachmentSerializer(serializers.ModelSerializer): class Meta: @@ -574,15 +587,36 @@ class Meta: ) -class ProjectViewSerializer(serializers.Serializer): +class ProjectAnswersSerializer(serializers.Serializer): - project = serializers.PrimaryKeyRelatedField(read_only=True) - snapshot = serializers.PrimaryKeyRelatedField(read_only=True) - view = serializers.PrimaryKeyRelatedField(read_only=True) html = serializers.CharField(read_only=True) attachments = ProjectAttachmentSerializer(many=True, read_only=True) +class ProjectViewSerializer(serializers.ModelSerializer): + + html = serializers.SerializerMethodField() + attachments = serializers.SerializerMethodField() + + class Meta: + model = View + fields = ( + 'id', + 'title', + 'help', + 'html', + 'attachments' + ) + + def get_html(self, obj): + return self.context.get('html', '') + + def get_attachments(self, obj): + attachments = self.context.get('attachments', []) + serializer = ProjectAttachmentSerializer(attachments, many=True, read_only=True) + return serializer.data + + class MembershipSerializer(serializers.ModelSerializer): class Meta: diff --git a/rdmo/projects/tests/test_viewset_project_views.py b/rdmo/projects/tests/test_viewset_project_views.py index 9b8b5ec925..59c4672192 100644 --- a/rdmo/projects/tests/test_viewset_project_views.py +++ b/rdmo/projects/tests/test_viewset_project_views.py @@ -37,11 +37,34 @@ urlnames = { 'views': 'v1-projects:project-views', - 'views-snapshot': 'v1-projects:project-views-snapshot', - 'views-export': 'v1-projects:project-views-export', - 'views-export-snapshot': 'v1-projects:project-views-export-snapshot', + 'view': 'v1-projects:project-view', + 'view-snapshot': 'v1-projects:project-view-snapshot', + 'view-export': 'v1-projects:project-view-export', + 'view-export-snapshot': 'v1-projects:project-view-export-snapshot', } + +@pytest.mark.parametrize('username,password', users) +@pytest.mark.parametrize('project_id', projects) +def test_views(db, client, username, password, project_id): + client.login(username=username, password=password) + project = Project.objects.get(pk=project_id) + project_views = list(project.views.values_list('id', flat=True)) + + url = reverse(urlnames['views'], args=[project_id]) + response = client.get(url) + + if project_id in view_project_permission_map.get(username, []): + assert response.status_code == 200 + assert isinstance(response.json(), list) + assert [item['id'] for item in response.json()] == project_views + else: + if password: + assert response.status_code == 404 + else: + assert response.status_code == 401 + + @pytest.mark.parametrize('username,password', users) @pytest.mark.parametrize('project_id', projects) @pytest.mark.parametrize('view_id', views) @@ -50,7 +73,7 @@ def test_view(db, client, username, password, project_id, view_id): project = Project.objects.get(pk=project_id) project_views = list(project.views.values_list('id', flat=True)) - url = reverse(urlnames['views'], args=[project_id, view_id]) + url = reverse(urlnames['view'], args=[project_id, view_id]) response = client.get(url) if project_id in view_project_permission_map.get(username, []) and view_id in project_views: @@ -71,7 +94,7 @@ def test_view_snapshot(db, client, username, password, snapshot_id, view_id): snapshot = Snapshot.objects.get(pk=snapshot_id) project_views = list(snapshot.project.views.values_list('id', flat=True)) - url = reverse(urlnames['views-snapshot'], args=[snapshot.project.id, snapshot_id, view_id]) + url = reverse(urlnames['view-snapshot'], args=[snapshot.project.id, snapshot_id, view_id]) response = client.get(url) if snapshot.project.id in view_project_permission_map.get(username, []) and view_id in project_views: @@ -93,7 +116,7 @@ def test_view_export(db, client, username, password, project_id, view_id, export project = Project.objects.get(pk=project_id) project_views = list(project.views.values_list('id', flat=True)) - url = reverse(urlnames['views-export'], args=[project_id, view_id, export_format]) + url = reverse(urlnames['view-export'], args=[project_id, view_id, export_format]) response = client.get(url) if project_id in view_project_permission_map.get(username, []) and view_id in project_views: @@ -114,7 +137,7 @@ def test_view_snapshot_export(db, client, username, password, snapshot_id, view_ snapshot = Snapshot.objects.get(pk=snapshot_id) project_views = list(snapshot.project.views.values_list('id', flat=True)) - url = reverse(urlnames['views-export-snapshot'], args=[snapshot.project.id, snapshot_id, view_id, export_format]) + url = reverse(urlnames['view-export-snapshot'], args=[snapshot.project.id, snapshot_id, view_id, export_format]) response = client.get(url) if snapshot.project.id in view_project_permission_map.get(username, []) and view_id in project_views: diff --git a/rdmo/projects/viewsets.py b/rdmo/projects/viewsets.py index 01e59e2d6a..b8c9572f40 100644 --- a/rdmo/projects/viewsets.py +++ b/rdmo/projects/viewsets.py @@ -61,6 +61,7 @@ InviteSerializer, IssueSerializer, MembershipSerializer, + ProjectAnswersSerializer, ProjectCopySerializer, ProjectHierarchySerializer, ProjectIntegrationSerializer, @@ -77,6 +78,7 @@ ProjectSnapshotSerializer, ProjectValueSerializer, ProjectViewSerializer, + ProjectViewsSerializer, ProjectVisibilitySerializer, SnapshotSerializer, UserInviteSerializer, @@ -445,9 +447,7 @@ def answers(self, request, pk, snapshot_id=None): except Snapshot.DoesNotExist: snapshot = None - serializer = ProjectViewSerializer({ - 'project': project, - 'snapshot': snapshot, + serializer = ProjectAnswersSerializer({ 'html': render_to_string('projects/project_answers.html', { 'project': project, 'snapshot': snapshot, @@ -499,9 +499,16 @@ def answers_export_snapshot(self, request, pk, export_format, snapshot_id): # extra method since DRF does not officially support optional named parameters inside url_path return self.answers_export(request, pk, export_format, snapshot_id) + @action(detail=True, methods=['get'], permission_classes=(HasModelPermission | HasProjectPermission, ), + url_path=r'views') + def views(self, request, pk): + project = self.get_object() + serializer = ProjectViewsSerializer(project.views, many=True) + return Response(serializer.data) + @action(detail=True, methods=['get'], permission_classes=(HasModelPermission | HasProjectPermission, ), url_path=r'views/(?P\d+)') - def views(self, request, pk, view_id, snapshot_id=None): + def view(self, request, pk, view_id, snapshot_id=None): project = self.get_object() project.catalog.prefetch_elements() @@ -515,26 +522,21 @@ def views(self, request, pk, view_id, snapshot_id=None): except Snapshot.DoesNotExist: snapshot = None - serializer = ProjectViewSerializer({ - 'project': project, - 'snapshot': snapshot, - 'view': view, + serializer = ProjectViewSerializer(view, context={ 'html': view.render(project, snapshot), 'attachments': project.values.filter(snapshot=snapshot).filter(value_type=VALUE_TYPE_FILE).order_by('file') }) return Response(serializer.data) - @action(detail=True, methods=['get'], permission_classes=(HasModelPermission | HasProjectPermission, ), url_path=r'snapshots/(?P\d+)/views/(?P\d+)') - def views_snapshot(self, request, pk, view_id, snapshot_id): + def view_snapshot(self, request, pk, view_id, snapshot_id): # extra method since DRF does not officially support optional named parameters inside url_path - return self.views(request, pk, view_id, snapshot_id) - + return self.view(request, pk, view_id, snapshot_id) @action(detail=True, methods=['get'], permission_classes=(HasModelPermission | HasProjectPermission, ), url_path=r'views/(?P\d+)/export/(?P[a-z]+)') - def views_export(self, request, pk, view_id, export_format, snapshot_id=None): + def view_export(self, request, pk, view_id, export_format, snapshot_id=None): project = self.get_object() project.catalog.prefetch_elements() @@ -557,9 +559,9 @@ def views_export(self, request, pk, view_id, export_format, snapshot_id=None): @action(detail=True, methods=['get'], permission_classes=(HasModelPermission | HasProjectPermission, ), url_path=r'snapshots/(?P\d+)/views/(?P\d+)/export/(?P[a-z]+)') - def views_export_snapshot(self, request, pk, view_id, export_format, snapshot_id): + def view_export_snapshot(self, request, pk, view_id, export_format, snapshot_id): # extra method since DRF does not officially support optional named parameters inside url_path - return self.views_export(request, pk, view_id, export_format, snapshot_id) + return self.view_export(request, pk, view_id, export_format, snapshot_id) @action(detail=False, url_path='upload-accept', permission_classes=(IsAuthenticated, )) def upload_accept(self, request): From 505ea590b6be3e80a70b4d1e9a699aaea12c939b Mon Sep 17 00:00:00 2001 From: Claudia Malzer Date: Thu, 30 Oct 2025 13:25:02 +0100 Subject: [PATCH 141/298] * start snapshots --- .../js/project/components/ProjectPage.js | 6 +- .../js/project/components/pages/Snapshots.js | 40 ++++++ .../components/pages/SnapshotsTable.js | 118 ++++++++++++++++++ rdmo/projects/serializers/v1/__init__.py | 4 +- 4 files changed, 164 insertions(+), 4 deletions(-) create mode 100644 rdmo/projects/assets/js/project/components/pages/Snapshots.js create mode 100644 rdmo/projects/assets/js/project/components/pages/SnapshotsTable.js diff --git a/rdmo/projects/assets/js/project/components/ProjectPage.js b/rdmo/projects/assets/js/project/components/ProjectPage.js index a8451a1ce4..388a68c77e 100644 --- a/rdmo/projects/assets/js/project/components/ProjectPage.js +++ b/rdmo/projects/assets/js/project/components/ProjectPage.js @@ -4,7 +4,7 @@ import { useSelector } from 'react-redux' import Dashboard from './pages/Dashboard' // import Interview from '../pages/Interview' // import Documents from '../pages/Documents' -// import Snapshots from '../pages/Snapshots' +import Snapshots from './pages/Snapshots' import Membership from './pages/Membership' import ProjectData from './pages/ProjectData' @@ -37,8 +37,8 @@ const ProjectPage = () => { // return // case 'documents': // return - // case 'snapshots': - // return + case 'snapshots': + return case 'project-information': return case 'membership': diff --git a/rdmo/projects/assets/js/project/components/pages/Snapshots.js b/rdmo/projects/assets/js/project/components/pages/Snapshots.js new file mode 100644 index 0000000000..0c149bbd2e --- /dev/null +++ b/rdmo/projects/assets/js/project/components/pages/Snapshots.js @@ -0,0 +1,40 @@ +import React from 'react' +import { useSelector } from 'react-redux' +import { isEmpty } from 'lodash' + +// import { useModal } from 'rdmo/core/assets/js/hooks' + +import SnapshotsTable from './SnapshotsTable' + +const Snapshots = () => { + // const { show: showSnapshot, open: openSnapshot, close: closeSnapshot } = useModal() + + const { snapshots, project } = useSelector((state) => state.project.project) ?? {} + const perms = project?.permissions ?? {} + + return ( + <> +
+
{gettext('Snapshots')}
+ {perms.can_add_snapshot && ( + + )} +
+ { + !isEmpty(snapshots) && ( + + ) + } + {/* */} + + ) +} + +export default Snapshots diff --git a/rdmo/projects/assets/js/project/components/pages/SnapshotsTable.js b/rdmo/projects/assets/js/project/components/pages/SnapshotsTable.js new file mode 100644 index 0000000000..f3a430d104 --- /dev/null +++ b/rdmo/projects/assets/js/project/components/pages/SnapshotsTable.js @@ -0,0 +1,118 @@ +import React from 'react' +import { useSelector } from 'react-redux' +import PropTypes from 'prop-types' +import { useFormattedDateTime } from 'rdmo/core/assets/js/hooks' +import { language } from 'rdmo/core/assets/js/utils' + + +// import { useModal } from 'rdmo/core/assets/js/hooks' + +// import Select from 'rdmo/core/assets/js/components/Select' + +// import { updateProjectMember, updateProjectInvite } from '../../actions/projectActions' + +// import MembershipDeleteModal from './MembershipDeleteModal' + +const SnapshotsTable = ({ snapshots }) => { + // const dispatch = useDispatch() + // const currentUser = useSelector((state) => state.user.currentUser) + const { project } = useSelector((state) => state.project.project) || {} + const perms = project?.permissions || {} + console.log('perms', perms) + + // const { show: showConfirm, open: openConfirm, close: closeConfirm } = useModal() + // const [modalState, setModalState] = useState(null) + + // const isAdminOrSiteManager = currentUser?.is_superuser || currentUser?.is_site_manager + + // const openDeleteModal = (person, isCurrentUser) => { + // setModalState({ person, isCurrentUser }) + // openConfirm() + // } + + // const closeDeleteModal = () => { + // setModalState(null) + // closeConfirm() + // } + + return ( +
+
{% full_name membership.user %} - {% include 'projects/project_detail_memberships_socialaccounts.html' %} + {% include 'projects/old/project_detail_memberships_socialaccounts.html' %} {{ membership.user.email }} diff --git a/rdmo/projects/templates/projects/project_detail_memberships_help.html b/rdmo/projects/templates/projects/old/project_detail_memberships_help.html similarity index 100% rename from rdmo/projects/templates/projects/project_detail_memberships_help.html rename to rdmo/projects/templates/projects/old/project_detail_memberships_help.html diff --git a/rdmo/projects/templates/projects/project_detail_memberships_socialaccounts.html b/rdmo/projects/templates/projects/old/project_detail_memberships_socialaccounts.html similarity index 100% rename from rdmo/projects/templates/projects/project_detail_memberships_socialaccounts.html rename to rdmo/projects/templates/projects/old/project_detail_memberships_socialaccounts.html diff --git a/rdmo/projects/templates/projects/project_detail_sidebar.html b/rdmo/projects/templates/projects/old/project_detail_sidebar.html similarity index 100% rename from rdmo/projects/templates/projects/project_detail_sidebar.html rename to rdmo/projects/templates/projects/old/project_detail_sidebar.html diff --git a/rdmo/projects/templates/projects/project_detail_sidebar_parent_import.html b/rdmo/projects/templates/projects/old/project_detail_sidebar_parent_import.html similarity index 100% rename from rdmo/projects/templates/projects/project_detail_sidebar_parent_import.html rename to rdmo/projects/templates/projects/old/project_detail_sidebar_parent_import.html diff --git a/rdmo/projects/templates/projects/project_detail_snapshots.html b/rdmo/projects/templates/projects/old/project_detail_snapshots.html similarity index 98% rename from rdmo/projects/templates/projects/project_detail_snapshots.html rename to rdmo/projects/templates/projects/old/project_detail_snapshots.html index c825276b97..c1353c4c3c 100644 --- a/rdmo/projects/templates/projects/project_detail_snapshots.html +++ b/rdmo/projects/templates/projects/old/project_detail_snapshots.html @@ -11,7 +11,7 @@

{% trans 'Snapshots' %}

- {% include 'projects/project_detail_snapshots_help.html' %} + {% include 'projects/old/project_detail_snapshots_help.html' %} {% if snapshots %} diff --git a/rdmo/projects/templates/projects/project_detail_snapshots_help.html b/rdmo/projects/templates/projects/old/project_detail_snapshots_help.html similarity index 100% rename from rdmo/projects/templates/projects/project_detail_snapshots_help.html rename to rdmo/projects/templates/projects/old/project_detail_snapshots_help.html diff --git a/rdmo/projects/templates/projects/project_detail_views.html b/rdmo/projects/templates/projects/old/project_detail_views.html similarity index 97% rename from rdmo/projects/templates/projects/project_detail_views.html rename to rdmo/projects/templates/projects/old/project_detail_views.html index 88563582a4..71e15fc71d 100644 --- a/rdmo/projects/templates/projects/project_detail_views.html +++ b/rdmo/projects/templates/projects/old/project_detail_views.html @@ -10,7 +10,7 @@

{% trans 'Views' %}

- {% include 'projects/project_detail_views_help.html' %} + {% include 'projects/old/project_detail_views_help.html' %} {% if views %} diff --git a/rdmo/projects/templates/projects/project_detail_views_help.html b/rdmo/projects/templates/projects/old/project_detail_views_help.html similarity index 100% rename from rdmo/projects/templates/projects/project_detail_views_help.html rename to rdmo/projects/templates/projects/old/project_detail_views_help.html diff --git a/rdmo/projects/templates/projects/project_detail.html b/rdmo/projects/templates/projects/project_detail.html index 3d21b98044..72f13071cc 100644 --- a/rdmo/projects/templates/projects/project_detail.html +++ b/rdmo/projects/templates/projects/project_detail.html @@ -1,51 +1,27 @@ {% extends 'core/page.html' %} -{% load i18n %} {% load static %} -{% load compress %} -{% load core_tags %} -{% block head %} - {% compress css %} - - - {% endcompress %} - {% compress js %} - - {% endcompress %} - +{% block vendor %} {% endblock %} -{% block sidebar %} +{% block head %} + +{% endblock %} - {% include 'projects/project_detail_sidebar.html' %} +{% block css %} + + + {{ block.super }} +{% endblock %} +{% block js %} + + + {% endblock %} {% block page %} - {% include 'projects/project_detail_header.html' %} - {% include 'projects/project_detail_issues.html' %} - {% include 'projects/project_detail_views.html' %} - {% include 'projects/project_detail_memberships.html' %} - {% include 'projects/project_detail_invites.html' %} - {% include 'projects/project_detail_snapshots.html' %} - {% include 'projects/project_detail_integrations.html' %} - -
- - {% render_lang_template 'projects/overlays/project_project_questions' %} - {% render_lang_template 'projects/overlays/project_project_catalog' %} - {% render_lang_template 'projects/overlays/project_project_issues' %} - {% render_lang_template 'projects/overlays/project_project_views' %} - {% render_lang_template 'projects/overlays/project_project_memberships' %} - {% render_lang_template 'projects/overlays/project_project_snapshots' %} - {% render_lang_template 'projects/overlays/project_export_project' %} - {% render_lang_template 'projects/overlays/project_import_project' %} - {% render_lang_template 'projects/overlays/project_support_info' %} +
{% endblock %} diff --git a/rdmo/projects/urls/__init__.py b/rdmo/projects/urls/__init__.py index 4c11792617..cb9a43b9fb 100644 --- a/rdmo/projects/urls/__init__.py +++ b/rdmo/projects/urls/__init__.py @@ -12,6 +12,7 @@ MembershipCreateView, MembershipDeleteView, MembershipUpdateView, + OldProjectDetailView, ProjectAnswersExportView, ProjectAnswersView, ProjectCancelView, @@ -58,6 +59,8 @@ re_path(r'^(?P[0-9]+)/$', ProjectDetailView.as_view(), name='project'), + re_path(r'^(?P[0-9]+)/old/$', + OldProjectDetailView.as_view(), name='project'), re_path(r'^(?P[0-9]+)/copy/$', ProjectCopyView.as_view(), name='project_copy'), re_path(r'^(?P[0-9]+)/update/$', diff --git a/rdmo/projects/views/__init__.py b/rdmo/projects/views/__init__.py index 6f64247601..28eb4fc93f 100644 --- a/rdmo/projects/views/__init__.py +++ b/rdmo/projects/views/__init__.py @@ -3,6 +3,7 @@ from .issue import IssueDetailView, IssueSendView, IssueUpdateView from .membership import MembershipCreateView, MembershipDeleteView, MembershipUpdateView from .project import ( + OldProjectDetailView, ProjectCancelView, ProjectDeleteView, ProjectDetailView, diff --git a/rdmo/projects/views/project.py b/rdmo/projects/views/project.py index 7b0226b324..b66111e0e6 100644 --- a/rdmo/projects/views/project.py +++ b/rdmo/projects/views/project.py @@ -31,6 +31,11 @@ class ProjectsView(LoginRequiredMixin, CSRFViewMixin, StoreIdViewMixin, Template class ProjectDetailView(ObjectPermissionMixin, DetailView): + model = Project + permission_required = 'projects.view_project_object' + + +class OldProjectDetailView(ObjectPermissionMixin, DetailView): model = Project queryset = Project.objects.prefetch_related( 'issues', @@ -43,6 +48,7 @@ class ProjectDetailView(ObjectPermissionMixin, DetailView): 'values' ) permission_required = 'projects.view_project_object' + template_name = 'projects/old/project_detail.html' def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) diff --git a/webpack.config.js b/webpack.config.js index cbd1d6d81e..d82e412bc4 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -57,6 +57,10 @@ const configList = [ './rdmo/projects/assets/js/projects.js', './rdmo/projects/assets/scss/projects.scss' ], + project: [ + './rdmo/projects/assets/js/project.js', + './rdmo/projects/assets/scss/project.scss' + ], interview: [ './rdmo/projects/assets/js/interview.js', './rdmo/projects/assets/scss/interview.scss' From ccb05f8abdbb21abd73cb9a721171c053b0f52e0 Mon Sep 17 00:00:00 2001 From: Jochen Klar Date: Sun, 25 Aug 2024 16:18:21 +0200 Subject: [PATCH 008/298] Prepare Bootstrap 5.3 --- .gitignore | 3 +++ package.json | 1 + rdmo/core/assets/js/_bs53/base.js | 1 + rdmo/core/assets/scss/_bs53/base.scss | 1 + rdmo/core/templates/core/bs53/base.html | 24 +++++++++++++++++++ .../assets/js/project/containers/Main.js | 4 +++- .../templates/projects/project_detail.html | 8 +++---- webpack.config.js | 4 ++++ 8 files changed, 41 insertions(+), 5 deletions(-) create mode 100644 rdmo/core/assets/js/_bs53/base.js create mode 100644 rdmo/core/assets/scss/_bs53/base.scss create mode 100644 rdmo/core/templates/core/bs53/base.html diff --git a/.gitignore b/.gitignore index ec05075999..3e79017dc2 100644 --- a/.gitignore +++ b/.gitignore @@ -38,7 +38,10 @@ rdmo/management/static rdmo/core/static/core/js/base.js rdmo/core/static/core/js/base.js.LICENSE.txt +rdmo/core/static/core/js/base-bs53.js +rdmo/core/static/core/js/base-bs53.js.LICENSE.txt rdmo/core/static/core/css/base.css +rdmo/core/static/core/css/base-bs53.css rdmo/core/static/core/fonts rdmo/projects/static/projects/css/interview.css diff --git a/package.json b/package.json index b156cfc1f2..16741f0a75 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "@codemirror/lang-javascript": "^6.2.2", "@uiw/react-codemirror": "^4.25.1", "bootstrap-sass": "^3.4.1", + "bootstrap": "^5.3.3", "classnames": "^2.5.1", "date-fns": "^4.1.0", "font-awesome": "4.7.0", diff --git a/rdmo/core/assets/js/_bs53/base.js b/rdmo/core/assets/js/_bs53/base.js new file mode 100644 index 0000000000..696c0a359f --- /dev/null +++ b/rdmo/core/assets/js/_bs53/base.js @@ -0,0 +1 @@ +import 'bootstrap' diff --git a/rdmo/core/assets/scss/_bs53/base.scss b/rdmo/core/assets/scss/_bs53/base.scss new file mode 100644 index 0000000000..5de335035a --- /dev/null +++ b/rdmo/core/assets/scss/_bs53/base.scss @@ -0,0 +1 @@ +@import '~bootstrap/scss/bootstrap'; diff --git a/rdmo/core/templates/core/bs53/base.html b/rdmo/core/templates/core/bs53/base.html new file mode 100644 index 0000000000..1841b6d51d --- /dev/null +++ b/rdmo/core/templates/core/bs53/base.html @@ -0,0 +1,24 @@ +{% load static compress core_tags %} + + + {% include 'core/base_head.html' %} + + {% block css %}{% endblock %} + {% block js %}{% endblock %} + {% block head %}{% endblock %} + + + + +
+ {% block content %}{% endblock %} +
+ + +{% if not debug %} + + {% include 'core/base_analytics.html' %} + +{% endif %} + + diff --git a/rdmo/projects/assets/js/project/containers/Main.js b/rdmo/projects/assets/js/project/containers/Main.js index 1e6d319305..ac85a16694 100644 --- a/rdmo/projects/assets/js/project/containers/Main.js +++ b/rdmo/projects/assets/js/project/containers/Main.js @@ -11,7 +11,9 @@ const Main = ({ config, settings, templates, user, project, configActions, proje console.log(configActions, projectActions) return project && ( - 👍 +
+ 👍 +
) } diff --git a/rdmo/projects/templates/projects/project_detail.html b/rdmo/projects/templates/projects/project_detail.html index 72f13071cc..ea5a513f6c 100644 --- a/rdmo/projects/templates/projects/project_detail.html +++ b/rdmo/projects/templates/projects/project_detail.html @@ -1,4 +1,4 @@ -{% extends 'core/page.html' %} +{% extends 'core/bs53/base.html' %} {% load static %} {% block vendor %} @@ -9,18 +9,18 @@ {% endblock %} {% block css %} - + {{ block.super }} {% endblock %} {% block js %} - + {% endblock %} -{% block page %} +{% block content %}
diff --git a/webpack.config.js b/webpack.config.js index d82e412bc4..d79f785f4e 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -21,6 +21,10 @@ const configList = [ base: [ './rdmo/core/assets/js/base.js', './rdmo/core/assets/scss/base.scss' + ], + 'base-bs53': [ + './rdmo/core/assets/js/_bs53/base.js', + './rdmo/core/assets/scss/_bs53/base.scss' ] }, output: { From 93ec83c070e37ea7b128547d734745411881ab66 Mon Sep 17 00:00:00 2001 From: Jochen Klar Date: Sun, 25 Aug 2024 16:46:27 +0200 Subject: [PATCH 009/298] Add style-bs53.css file and some example css variables --- rdmo/core/static/core/css/style-bs53.css | 14 ++++++++++++++ rdmo/projects/assets/js/project/containers/Main.js | 10 +++++++++- .../templates/projects/project_detail.html | 1 + 3 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 rdmo/core/static/core/css/style-bs53.css diff --git a/rdmo/core/static/core/css/style-bs53.css b/rdmo/core/static/core/css/style-bs53.css new file mode 100644 index 0000000000..641e2e6460 --- /dev/null +++ b/rdmo/core/static/core/css/style-bs53.css @@ -0,0 +1,14 @@ +:root, +[data-bs-theme=light] { + --rdmo-blue: #101F70; + --rdmo-blue-dark: #0d195a; +} + +.btn-primary { + --bs-btn-bg: var(--rdmo-blue); + --bs-btn-border-color: var(--rdmo-blue); + --bs-btn-hover-bg: var(--rdmo-blue-dark); + --bs-btn-hover-border-color: var(--rdmo-blue-dark); + --bs-btn-active-bg: var(--rdmo-blue); + --bs-btn-active-border-color: var(--rdmo-blue); +} diff --git a/rdmo/projects/assets/js/project/containers/Main.js b/rdmo/projects/assets/js/project/containers/Main.js index ac85a16694..1c0286ddf4 100644 --- a/rdmo/projects/assets/js/project/containers/Main.js +++ b/rdmo/projects/assets/js/project/containers/Main.js @@ -12,7 +12,15 @@ const Main = ({ config, settings, templates, user, project, configActions, proje return project && (
- 👍 +

+ 👍 +

+ +

+ +

) } diff --git a/rdmo/projects/templates/projects/project_detail.html b/rdmo/projects/templates/projects/project_detail.html index ea5a513f6c..440c84784a 100644 --- a/rdmo/projects/templates/projects/project_detail.html +++ b/rdmo/projects/templates/projects/project_detail.html @@ -10,6 +10,7 @@ {% block css %} + {{ block.super }} {% endblock %} From 7e904163f04c7f3633927046a2263ff93c882aac Mon Sep 17 00:00:00 2001 From: Jochen Klar Date: Sun, 25 Aug 2024 19:54:35 +0200 Subject: [PATCH 010/298] Fix urls --- rdmo/projects/urls/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rdmo/projects/urls/__init__.py b/rdmo/projects/urls/__init__.py index cb9a43b9fb..8c5788bf6a 100644 --- a/rdmo/projects/urls/__init__.py +++ b/rdmo/projects/urls/__init__.py @@ -60,7 +60,7 @@ re_path(r'^(?P[0-9]+)/$', ProjectDetailView.as_view(), name='project'), re_path(r'^(?P[0-9]+)/old/$', - OldProjectDetailView.as_view(), name='project'), + OldProjectDetailView.as_view(), name='project_old'), re_path(r'^(?P[0-9]+)/copy/$', ProjectCopyView.as_view(), name='project_copy'), re_path(r'^(?P[0-9]+)/update/$', From 38b52fa85a441db0baf5bd47c71c2aa61c8b7753 Mon Sep 17 00:00:00 2001 From: Jochen Klar Date: Fri, 4 Apr 2025 11:45:45 +0200 Subject: [PATCH 011/298] Add form field components --- package.json | 11 ++- rdmo/core/assets/js/components/Input.js | 50 ++++++++++++ .../assets/js/components/InputDebounced.js | 32 ++++++++ rdmo/core/assets/js/components/Textarea.js | 50 ++++++++++++ .../assets/js/components/TextareaDebounced.js | 32 ++++++++ .../assets/js/project/components/Form.js | 78 +++++++++++++++++++ .../assets/js/project/containers/Main.js | 12 +-- 7 files changed, 250 insertions(+), 15 deletions(-) create mode 100644 rdmo/core/assets/js/components/Input.js create mode 100644 rdmo/core/assets/js/components/InputDebounced.js create mode 100644 rdmo/core/assets/js/components/Textarea.js create mode 100644 rdmo/core/assets/js/components/TextareaDebounced.js create mode 100644 rdmo/projects/assets/js/project/components/Form.js diff --git a/package.json b/package.json index 16741f0a75..6a77805d41 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,7 @@ "build:dist": "webpack --config webpack.config.js --mode production --env ignore-perf --fail-on-warnings", "build:prod": "webpack --config webpack.config.js --mode production", "build": "webpack --config webpack.config.js --mode development", - "watch": "webpack --config webpack.config.js --mode development --watch", - "lint": "eslint --ext .js rdmo/" + "watch": "webpack --config webpack.config.js --mode development --watch" }, "author": "RDMO Arbeitsgemeinschaft ", "license": "Apache-2.0", @@ -23,10 +22,10 @@ "@uiw/react-codemirror": "^4.25.1", "bootstrap-sass": "^3.4.1", "bootstrap": "^5.3.3", + "bootstrap-sass": "^3.4.1", "classnames": "^2.5.1", - "date-fns": "^4.1.0", + "date-fns": "^3.6.0", "font-awesome": "4.7.0", - "html-to-text": "^9.0.5", "jquery": "^3.7.1", "js-cookie": "^3.0.5", "lodash": "^4.17.23", @@ -45,7 +44,7 @@ "redux": "^4.1.1", "redux-logger": "^3.0.6", "redux-thunk": "^2.3.0", - "use-debounce": "^10.0.0" + "use-debounce": "^10.0.4" }, "devDependencies": { "@babel/cli": "^7.28.0", @@ -56,7 +55,7 @@ "copy-webpack-plugin": "^14.0.0", "css-loader": "^7.1.1", "eslint": "~8.56.0", - "eslint-plugin-react": "^7.37.2", + "eslint-plugin-react": "^7.35.0", "file-loader": "^6.2.0", "mini-css-extract-plugin": "^2.9.0", "sass": "^1.94.2", diff --git a/rdmo/core/assets/js/components/Input.js b/rdmo/core/assets/js/components/Input.js new file mode 100644 index 0000000000..9bcb67405f --- /dev/null +++ b/rdmo/core/assets/js/components/Input.js @@ -0,0 +1,50 @@ +import React from 'react' +import PropTypes from 'prop-types' +import classNames from 'classnames' +import { isEmpty, uniqueId } from 'lodash' + +const Input = ({ type = 'text', className, label, placeholder, help, disabled, errors, value, onChange }) => { + const id = uniqueId('input-') + + return ( +
+ + + onChange(event.target.value)} + /> + { + errors && ( +
+ {errors.map((error, index) =>
{error}
)} +
+ ) + } + { + help &&
{help}
+ } +
+ ) +} + +Input.propTypes = { + type: PropTypes.string, + className: PropTypes.string, + label: PropTypes.string, + placeholder: PropTypes.string, + help: PropTypes.string, + disabled: PropTypes.bool, + errors: PropTypes.array, + value: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired +} + +export default Input diff --git a/rdmo/core/assets/js/components/InputDebounced.js b/rdmo/core/assets/js/components/InputDebounced.js new file mode 100644 index 0000000000..94e1b01c4d --- /dev/null +++ b/rdmo/core/assets/js/components/InputDebounced.js @@ -0,0 +1,32 @@ +import React, { useEffect, useState } from 'react' +import PropTypes from 'prop-types' + +import { useDebouncedCallback } from 'use-debounce' + +import Input from './Input' + +const InputDebounced = ({ value, onChange, ...props }) => { + + const [inputValue, setInputValue] = useState('') + + useEffect(() => setInputValue(value), [value]) + + const debouncedOnChange = useDebouncedCallback((value) => onChange(value), 500) + + return ( + { + setInputValue(value) + debouncedOnChange(value) + }} + /> + ) +} + +InputDebounced.propTypes = { + value: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired +} + +export default InputDebounced diff --git a/rdmo/core/assets/js/components/Textarea.js b/rdmo/core/assets/js/components/Textarea.js new file mode 100644 index 0000000000..737d7ffb35 --- /dev/null +++ b/rdmo/core/assets/js/components/Textarea.js @@ -0,0 +1,50 @@ +import React from 'react' +import PropTypes from 'prop-types' +import classNames from 'classnames' +import { isEmpty, uniqueId } from 'lodash' + +const Textarea = ({ rows, className, label, placeholder, help, disabled, errors, value, onChange }) => { + const id = uniqueId('input-') + + return ( +
+ + + + + {% for error in field.errors %} +
{{ error }}
+ {% endfor %} +
diff --git a/rdmo/core/templates/core/bs53/home.html b/rdmo/core/templates/core/bs53/home.html index d6ed5ba53f..d6aa512b31 100644 --- a/rdmo/core/templates/core/bs53/home.html +++ b/rdmo/core/templates/core/bs53/home.html @@ -1,116 +1,14 @@ {% extends 'core/bs53/base.html' %} -{% load i18n %} {% load static %} -{% load core_tags %} +{% load i18n %} {% block content %} - - -
-
-
-
-
-

Mit dem Datenmanagement starten

- -

- Nachdem Sie sich angemeldet haben steht Ihnen eine Auswahl verschiedener DMP-Vorlagen zur Verfügung, die Sie an Ihr Projekt anpassen können. -

- -

- Videos zur Einführung in RDMO: -

- -

    -
  • Erste Schritte mit RDMO
  • -
  • Was kann man mit RDMO machen?
  • -
  • RDMO Funktionen im Überblick
  • -
-
-
-
-
- -
-
-
-
-

Haben Sie weitere Fragen, Feedback oder brauchen Hilfe?

- -

- Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. -

- - - Kontakt aufnehmen - -
-
-
-
- -
-
-
-
-

- Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est. Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est. Lorem ipsum dolor sit amet. -

-
-
-
-
-
- -
-
-

- Wartungsfenster: Dienstags 6:30 – 8:30 Uhr -

- -

- Impressum - Nutzungsbedingungen - Datenschutzerklärung -

- -

- Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat. -

- -
- - - -
-
-
- - +{% get_current_language as lang %} +{% if lang == 'en' %} + {% include 'core/bs53/home_en.html' %} +{% elif lang == 'de' %} + {% include 'core/bs53/home_de.html' %} +{% endif %} {% endblock %} diff --git a/rdmo/core/templates/core/bs53/home_de.html b/rdmo/core/templates/core/bs53/home_de.html new file mode 100644 index 0000000000..cbbc93b9c7 --- /dev/null +++ b/rdmo/core/templates/core/bs53/home_de.html @@ -0,0 +1,100 @@ +{% load static %} + + + +
+
+
+
+
+

Lorem ipsum dolor sit amet

+ +

+ Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. +

+ +

+ Lorem ipsum dolor sit amet: +

+ +

    +
  • consetetur sadipscing elitr
  • +
  • sed diam nonumy eirmod
  • +
  • et justo duo dolores et ea rebum
  • +
+
+
+
+
+ +
+
+
+
+

Lorem ipsum dolor sit amet, consetetur sadipscing elitr?

+ +

+ Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. +

+ + + orem ipsum dolor + +
+
+
+
+ +
+
+
+
+

+ Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est. Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est. Lorem ipsum dolor sit amet. +

+
+
+
+
+
+ +
+
+

+ Lorem ipsum: consetetur 6:30 – 8:30 Uhr +

+ +

+ Lorem + consetetur sadipscing + At vero eos et accusam +

+ +

+ Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat. +

+ +
+ + + +
+
+
\ No newline at end of file diff --git a/rdmo/core/templates/core/bs53/home_en.html b/rdmo/core/templates/core/bs53/home_en.html new file mode 100644 index 0000000000..6812ee4d89 --- /dev/null +++ b/rdmo/core/templates/core/bs53/home_en.html @@ -0,0 +1,100 @@ +{% load static %} + + + +
+
+
+
+
+

Lorem ipsum dolor sit amet

+ +

+ Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. +

+ +

+ Lorem ipsum dolor sit amet: +

+ +

    +
  • consetetur sadipscing elitr
  • +
  • sed diam nonumy eirmod
  • +
  • et justo duo dolores et ea rebum
  • +
+
+
+
+
+ +
+
+
+
+

Lorem ipsum dolor sit amet, consetetur sadipscing elitr?

+ +

+ Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. +

+ + + orem ipsum dolor + +
+
+
+
+ +
+
+
+
+

+ Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est. Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est. Lorem ipsum dolor sit amet. +

+
+
+
+
+
+ +
+
+

+ Lorem ipsum: consetetur 6:30 – 8:30 Uhr +

+ +

+ Lorem + consetetur sadipscing + At vero eos et accusam +

+ +

+ Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat. +

+ +
+ + + +
+
+
diff --git a/rdmo/core/templates/core/bs53/home_images.html b/rdmo/core/templates/core/bs53/home_images.html new file mode 100644 index 0000000000..5319bdc959 --- /dev/null +++ b/rdmo/core/templates/core/bs53/home_images.html @@ -0,0 +1,13 @@ +{% load static %} +{% load core_tags %} + +{% for image in settings.HOME_IMAGES %} +
+ {{ image.alt }} +

{{ image.attribution|markdown }}

+
+{% endfor %} + + diff --git a/rdmo/core/templates/core/bs53/home_login.html b/rdmo/core/templates/core/bs53/home_login.html new file mode 100644 index 0000000000..713933256a --- /dev/null +++ b/rdmo/core/templates/core/bs53/home_login.html @@ -0,0 +1,15 @@ +
+ {% if settings.LOGIN_FORM %} + {% include 'account/login_form_inline.html' %} + {% endif %} + + {% if settings.SHIBBOLETH %} + {% include 'account/login_shibboleth.html' %} + {% endif %} + + {% if settings.SOCIALACCOUNT %} +
+ {% include "socialaccount/snippets/provider_list.html" with process="login" button_class="btn-light" %} +
+ {% endif %} +
diff --git a/rdmo/core/templatetags/core_tags.py b/rdmo/core/templatetags/core_tags.py index b8f4626838..0427d1a8de 100644 --- a/rdmo/core/templatetags/core_tags.py +++ b/rdmo/core/templatetags/core_tags.py @@ -50,10 +50,16 @@ def render_lang_template(template_name, escape_html=False): return '' -@register.simple_tag(takes_context=True) -def bootstrap_form_field(context, field, **kwargs): - field_type = field.field.__class__.__name__.lower() - return render_to_string(f'core/bs53/forms/bootstrap_{field_type}.html', {}) +@register.simple_tag() +def bootstrap_form_field(field, **kwargs): + context = { + 'field': field + } + + if field.widget_type in ['text', 'password']: + return render_to_string('core/bs53/forms/bootstrap_input.html', context) + else: + return render_to_string(f'core/bs53/forms/bootstrap_{field.widget_type}.html', context) @register.simple_tag(takes_context=True) From 032f55b7f9795b8260a5c0da2dd2c5010e75bedf Mon Sep 17 00:00:00 2001 From: Jochen Klar Date: Thu, 7 Aug 2025 14:17:22 +0200 Subject: [PATCH 087/298] Add roboto slab as headline font --- package-lock.json | 14 ++++++++++++++ package.json | 1 + rdmo/core/assets/scss/_bs53/base/typography.scss | 15 ++++++++++++--- rdmo/core/assets/scss/_bs53/base/variables.scss | 4 ++-- rdmo/core/assets/scss/_bs53/bootstrap.scss | 1 + rdmo/core/templates/core/bs53/home_de.html | 8 ++++---- rdmo/core/templates/core/bs53/home_en.html | 8 ++++---- 7 files changed, 38 insertions(+), 13 deletions(-) diff --git a/package-lock.json b/package-lock.json index e8af63592a..b4174637af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@codemirror/lang-html": "^6.4.2", "@codemirror/lang-javascript": "^6.2.2", "@fontsource/open-sans": "^5.2.6", + "@fontsource/roboto-slab": "^5.2.6", "@uiw/react-codemirror": "^4.25.1", "bootstrap": "^5.3.3", "bootstrap-icons": "^1.11.3", @@ -2160,6 +2161,14 @@ "url": "https://github.com/sponsors/ayuhito" } }, + "node_modules/@fontsource/roboto-slab": { + "version": "5.2.6", + "resolved": "https://registry.npmjs.org/@fontsource/roboto-slab/-/roboto-slab-5.2.6.tgz", + "integrity": "sha512-srUROPqdczZx5OBlCKojA3C9eNeV3iIAT+nb0YLGb21ZNv58PUf5mom5T5+x6BMaaH1ZuXDi0sT1NaKWuoagYg==", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.13", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.13.tgz", @@ -9644,6 +9653,11 @@ "resolved": "https://registry.npmjs.org/@fontsource/open-sans/-/open-sans-5.2.6.tgz", "integrity": "sha512-mnfnUmBWQ+J220gqbibbzmKcc1kawV+lb3/Pspzu+Opnxza12oUffIg0ufG8g+3j1fnSznEWgyNV40MjtmJj6g==" }, + "@fontsource/roboto-slab": { + "version": "5.2.6", + "resolved": "https://registry.npmjs.org/@fontsource/roboto-slab/-/roboto-slab-5.2.6.tgz", + "integrity": "sha512-srUROPqdczZx5OBlCKojA3C9eNeV3iIAT+nb0YLGb21ZNv58PUf5mom5T5+x6BMaaH1ZuXDi0sT1NaKWuoagYg==" + }, "@humanwhocodes/config-array": { "version": "0.11.13", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.13.tgz", diff --git a/package.json b/package.json index 8e4ae47520..53b5c0eb3e 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "@codemirror/lang-html": "^6.4.2", "@codemirror/lang-javascript": "^6.2.2", "@fontsource/open-sans": "^5.2.6", + "@fontsource/roboto-slab": "^5.2.6", "@uiw/react-codemirror": "^4.25.1", "bootstrap": "^5.3.3", "bootstrap-icons": "^1.11.3", diff --git a/rdmo/core/assets/scss/_bs53/base/typography.scss b/rdmo/core/assets/scss/_bs53/base/typography.scss index 31a2a2da7e..517c64a8b8 100644 --- a/rdmo/core/assets/scss/_bs53/base/typography.scss +++ b/rdmo/core/assets/scss/_bs53/base/typography.scss @@ -16,14 +16,23 @@ h1, .h1 { margin-bottom: 1rem; } h2, .h2 { - font-size: 1.8rem; + font-size: 1.6rem; margin-bottom: 1rem; } -h2, .h2 { - font-size: 1.6rem; +h3, .h3 { + font-size: 1.2rem; margin-bottom: 1rem; } +.home { + h1 { + font-size: 2.6rem; + } + h2 { + font-size: 2rem; + } +} + a, span.link, button.link { diff --git a/rdmo/core/assets/scss/_bs53/base/variables.scss b/rdmo/core/assets/scss/_bs53/base/variables.scss index 5a64457ec7..b6aa7f5bd9 100644 --- a/rdmo/core/assets/scss/_bs53/base/variables.scss +++ b/rdmo/core/assets/scss/_bs53/base/variables.scss @@ -14,6 +14,6 @@ --rdmo-color-footer: #999; --rdmo-color-footer-bg: #001; - --rdmo-font: Open sans, sans-serif; - --rdmo-font-headline: var(--rdmo-font); + --rdmo-font: 'Open sans', sans-serif; + --rdmo-font-headline: 'Roboto Slab', serif; } diff --git a/rdmo/core/assets/scss/_bs53/bootstrap.scss b/rdmo/core/assets/scss/_bs53/bootstrap.scss index 760c71f466..84736258d9 100644 --- a/rdmo/core/assets/scss/_bs53/bootstrap.scss +++ b/rdmo/core/assets/scss/_bs53/bootstrap.scss @@ -4,3 +4,4 @@ $bootstrap-icons-font-dir: '~bootstrap-icons/font/fonts'; @import '~bootstrap-icons/font/bootstrap-icons.scss'; @import '@fontsource/open-sans/index.css'; +@import '@fontsource/roboto-slab/index.css'; diff --git a/rdmo/core/templates/core/bs53/home_de.html b/rdmo/core/templates/core/bs53/home_de.html index cbbc93b9c7..20319453f3 100644 --- a/rdmo/core/templates/core/bs53/home_de.html +++ b/rdmo/core/templates/core/bs53/home_de.html @@ -1,12 +1,12 @@ {% load static %} -
{person?.first_name} {person?.last_name}{person?.user?.first_name} {person?.user?.last_name} - {person.email && {person.email}} + {person.user.email && {person.user.email}} {showAction && ( -
@@ -97,6 +98,7 @@ const MembershipTable = ({ persons, isMember = false }) => { Date: Tue, 30 Sep 2025 11:40:23 +0200 Subject: [PATCH 106/298] * add hierarchy memberships --- .../js/project/actions/projectActions.js | 5 +- .../assets/js/project/api/ProjectApi.js | 4 ++ .../js/project/components/pages/Membership.js | 2 +- .../components/pages/MembershipTable.js | 51 ++++++++++++------- 4 files changed, 41 insertions(+), 21 deletions(-) diff --git a/rdmo/projects/assets/js/project/actions/projectActions.js b/rdmo/projects/assets/js/project/actions/projectActions.js index 5c327287ac..e010894267 100644 --- a/rdmo/projects/assets/js/project/actions/projectActions.js +++ b/rdmo/projects/assets/js/project/actions/projectActions.js @@ -26,14 +26,15 @@ export function fetchProject() { ProjectApi.fetchProjectSnapshots(projectId), ProjectApi.fetchProjectTasks(projectId), ProjectApi.fetchProjectMemberships(projectId), + ProjectApi.fetchProjectMembershipHierarchy(projectId), CatalogsApi.fetchCatalogs() ]) - .then(([project, snapshots, tasks, memberships, catalogs]) => { + .then(([project, snapshots, tasks, memberships,membershipHierarchy, catalogs]) => { const projectData = { project: project, snapshots: snapshots, tasks: tasks, - memberships: memberships, + memberships: [...memberships, ...membershipHierarchy], catalogs: catalogs } diff --git a/rdmo/projects/assets/js/project/api/ProjectApi.js b/rdmo/projects/assets/js/project/api/ProjectApi.js index 81e604e9eb..36e963500a 100644 --- a/rdmo/projects/assets/js/project/api/ProjectApi.js +++ b/rdmo/projects/assets/js/project/api/ProjectApi.js @@ -20,6 +20,10 @@ export default class ProjectApi extends BaseApi { return this.get(`/api/v1/projects/projects/${projectId}/memberships/`) } + static fetchProjectMembershipHierarchy(projectId) { + return this.get(`/api/v1/projects/projects/${projectId}/memberships/hierarchy`) + } + static fetchProjectInvites(projectId) { return this.get(`/api/v1/projects/projects/${projectId}/invites/`) } diff --git a/rdmo/projects/assets/js/project/components/pages/Membership.js b/rdmo/projects/assets/js/project/components/pages/Membership.js index 7201ce7624..fb226f84cc 100644 --- a/rdmo/projects/assets/js/project/components/pages/Membership.js +++ b/rdmo/projects/assets/js/project/components/pages/Membership.js @@ -35,7 +35,7 @@ const Membership = () => {
{gettext('Invites')}
- {/* */} + )} diff --git a/rdmo/projects/assets/js/project/components/pages/MembershipTable.js b/rdmo/projects/assets/js/project/components/pages/MembershipTable.js index 0c02314d00..60a6c8ca5c 100644 --- a/rdmo/projects/assets/js/project/components/pages/MembershipTable.js +++ b/rdmo/projects/assets/js/project/components/pages/MembershipTable.js @@ -32,6 +32,12 @@ const MembershipTable = ({ persons, isMember = false }) => { closeConfirm() } + const uniquePersons = isMember + ? persons.filter( + (p, i, arr) => arr.findIndex(x => x.user?.id === p.user?.id) === i + ) + : persons + return (
@@ -44,34 +50,43 @@ const MembershipTable = ({ persons, isMember = false }) => { - {persons?.map((person, index) => { - const isCurrentUser = person.user.id === currentUserId + {uniquePersons?.map((person, index) => { + const isCurrentUser = person.user?.id === currentUserId const isOwner = isCurrentUser && person.role == 'owner' const showMemberAction = isMember && ((!isCurrentUser && perms.can_delete_membership) || (isCurrentUser && perms.can_leave_project)) const showInviteAction = !isMember && perms.can_delete_invite - const showAction = showMemberAction || showInviteAction || isManager + const showAction = (showMemberAction || showInviteAction || isManager) && !person.project // do not show action buttons for hierarchy roles + + const emailAddress = person.user?.email || person?.email + const hierarchyRole = person?.project + ? `${roleOptions.find(opt => opt.value === person.role).label} ${gettext('of')} ${person.project.title}` + : null return ( @@ -113,7 +113,7 @@ const MembershipTable = ({ persons, isMember = false }) => { Date: Thu, 2 Oct 2025 13:32:22 +0200 Subject: [PATCH 113/298] * add projects/user to serializer --- rdmo/projects/viewsets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rdmo/projects/viewsets.py b/rdmo/projects/viewsets.py index 704c5a94ed..a5f17fc51b 100644 --- a/rdmo/projects/viewsets.py +++ b/rdmo/projects/viewsets.py @@ -158,7 +158,7 @@ def get_queryset(self): return self._cached_queryset def get_serializer_class(self): - if self.action == 'list': + if self.action in ['list', 'user']: return ProjectListSerializer else: return ProjectSerializer From de5260f1714fd1f5920be256cf01e2d3ce083535 Mon Sep 17 00:00:00 2001 From: Claudia Malzer Date: Thu, 2 Oct 2025 14:41:23 +0200 Subject: [PATCH 114/298] * use ancestors and permissions in projects * get rid of unnecessary functions --- .../constants/defaultRoleOptions.js | 2 + .../assets/js/common/utils/constants.js | 7 --- .../assets/js/common/utils/getUserRoles.js | 37 -------------- rdmo/projects/assets/js/common/utils/index.js | 3 -- .../assets/js/common/utils/userIsManager.js | 11 ----- .../components/pages/MembershipInviteModal.js | 2 +- .../components/pages/MembershipTable.js | 2 +- .../js/projects/components/main/Projects.js | 48 ++++++++++++------- .../js/projects/utils/getProjectTitlePath.js | 24 ---------- .../assets/js/projects/utils/getUserRole.js | 14 ++++++ .../assets/js/projects/utils/getUserRoles.js | 37 -------------- .../assets/js/projects/utils/index.js | 4 +- .../assets/js/projects/utils/userIsManager.js | 11 ----- 13 files changed, 50 insertions(+), 152 deletions(-) rename rdmo/projects/assets/js/{project => common}/constants/defaultRoleOptions.js (75%) delete mode 100644 rdmo/projects/assets/js/common/utils/constants.js delete mode 100644 rdmo/projects/assets/js/common/utils/getUserRoles.js delete mode 100644 rdmo/projects/assets/js/common/utils/index.js delete mode 100644 rdmo/projects/assets/js/common/utils/userIsManager.js delete mode 100644 rdmo/projects/assets/js/projects/utils/getProjectTitlePath.js create mode 100644 rdmo/projects/assets/js/projects/utils/getUserRole.js delete mode 100644 rdmo/projects/assets/js/projects/utils/getUserRoles.js delete mode 100644 rdmo/projects/assets/js/projects/utils/userIsManager.js diff --git a/rdmo/projects/assets/js/project/constants/defaultRoleOptions.js b/rdmo/projects/assets/js/common/constants/defaultRoleOptions.js similarity index 75% rename from rdmo/projects/assets/js/project/constants/defaultRoleOptions.js rename to rdmo/projects/assets/js/common/constants/defaultRoleOptions.js index a5fd84360b..31dd5e5252 100644 --- a/rdmo/projects/assets/js/project/constants/defaultRoleOptions.js +++ b/rdmo/projects/assets/js/common/constants/defaultRoleOptions.js @@ -4,3 +4,5 @@ { value: 'author', label: gettext('Author') }, { value: 'guest', label: gettext('Guest') } ] + + export const defaultRoleArrays = ['authors', 'guests', 'managers', 'owners'] diff --git a/rdmo/projects/assets/js/common/utils/constants.js b/rdmo/projects/assets/js/common/utils/constants.js deleted file mode 100644 index 5da968b46a..0000000000 --- a/rdmo/projects/assets/js/common/utils/constants.js +++ /dev/null @@ -1,7 +0,0 @@ -// project roles -export const ROLE_LABELS = { - author: gettext('Author'), - guest: gettext('Guest'), - manager: gettext('Manager'), - owner: gettext('Owner') -} diff --git a/rdmo/projects/assets/js/common/utils/getUserRoles.js b/rdmo/projects/assets/js/common/utils/getUserRoles.js deleted file mode 100644 index 8f8760fe72..0000000000 --- a/rdmo/projects/assets/js/common/utils/getUserRoles.js +++ /dev/null @@ -1,37 +0,0 @@ -import { ROLE_LABELS } from './constants' - -export const getUserRoles = (project, currentUserId, arraysToSearch) => { - if (!arraysToSearch || !arraysToSearch.length) { - arraysToSearch = ['authors', 'guests', 'managers', 'owners'] - } - - const roleDefinitions = { - authors: { roleLabel: ROLE_LABELS.author, roleBoolean: 'isProjectAuthor' }, - guests: { roleLabel: ROLE_LABELS.guest, roleBoolean: 'isProjectGuest' }, - managers: { roleLabel: ROLE_LABELS.manager, roleBoolean: 'isProjectManager' }, - owners: { roleLabel: ROLE_LABELS.owner, roleBoolean: 'isProjectOwner' } - } - - let rolesFound = [] - let roleBooleans = { - isProjectAuthor: false, - isProjectGuest: false, - isProjectManager: false, - isProjectOwner: false - } - - arraysToSearch.forEach(arrayName => { - if (project[arrayName].some(item => item.id === currentUserId)) { - const { roleLabel, roleBoolean } = roleDefinitions[arrayName] - rolesFound.push(roleLabel) - roleBooleans[roleBoolean] = true - } - }) - - return { - rolesString: rolesFound.length > 0 ? rolesFound.join(', ') : null, - ...roleBooleans - } -} - -export default getUserRoles diff --git a/rdmo/projects/assets/js/common/utils/index.js b/rdmo/projects/assets/js/common/utils/index.js deleted file mode 100644 index 0133f257ac..0000000000 --- a/rdmo/projects/assets/js/common/utils/index.js +++ /dev/null @@ -1,3 +0,0 @@ -export * from './constants' -export { default as getUserRoles } from './getUserRoles' -export { default as userIsManager } from './userIsManager' diff --git a/rdmo/projects/assets/js/common/utils/userIsManager.js b/rdmo/projects/assets/js/common/utils/userIsManager.js deleted file mode 100644 index 9b4e77430d..0000000000 --- a/rdmo/projects/assets/js/common/utils/userIsManager.js +++ /dev/null @@ -1,11 +0,0 @@ -import { siteId } from 'rdmo/core/assets/js/utils/meta' - -const userIsManager = (currentUser) => { - if (currentUser.is_superuser || - (currentUser.role && currentUser.role.manager && currentUser.role.manager.some(manager => manager.id === siteId))) { - return true - } - return false -} - -export default userIsManager diff --git a/rdmo/projects/assets/js/project/components/pages/MembershipInviteModal.js b/rdmo/projects/assets/js/project/components/pages/MembershipInviteModal.js index 8962c2c6ce..acd4b55081 100644 --- a/rdmo/projects/assets/js/project/components/pages/MembershipInviteModal.js +++ b/rdmo/projects/assets/js/project/components/pages/MembershipInviteModal.js @@ -7,7 +7,7 @@ import { Modal, Tooltip } from 'rdmo/core/assets/js/_bs53/components' import { createProjectMember, sendProjectInvite, clearProjectErrors } from '../../actions/projectActions' import { useFieldErrors } from '../../hooks/useFieldErrors' -import { defaultRoleOptions as roleOptions } from '../../constants/defaultRoleOptions' +import { defaultRoleOptions as roleOptions } from '../../../common/constants/defaultRoleOptions' const initialForm = { lookup: '', role: 'author' } diff --git a/rdmo/projects/assets/js/project/components/pages/MembershipTable.js b/rdmo/projects/assets/js/project/components/pages/MembershipTable.js index dc8600be6b..7d123a4efe 100644 --- a/rdmo/projects/assets/js/project/components/pages/MembershipTable.js +++ b/rdmo/projects/assets/js/project/components/pages/MembershipTable.js @@ -7,7 +7,7 @@ import { useModal } from 'rdmo/core/assets/js/hooks' import Select from 'rdmo/core/assets/js/components/Select' import { updateProjectMember, updateProjectInvite } from '../../actions/projectActions' -import { defaultRoleOptions as roleOptions } from '../../constants/defaultRoleOptions' +import { defaultRoleOptions as roleOptions } from '../../../common/constants/defaultRoleOptions' import MembershipDeleteModal from './MembershipDeleteModal' diff --git a/rdmo/projects/assets/js/projects/components/main/Projects.js b/rdmo/projects/assets/js/projects/components/main/Projects.js index 73acfc6d48..241c67da74 100644 --- a/rdmo/projects/assets/js/projects/components/main/Projects.js +++ b/rdmo/projects/assets/js/projects/components/main/Projects.js @@ -8,7 +8,7 @@ import { language } from 'rdmo/core/assets/js/utils' import { baseUrl } from 'rdmo/core/assets/js/utils/meta' import { PendingInvitations, ProjectFilters, ProjectImport, Table } from '../helper' -import { getTitlePath, getUserRoles, userIsManager, HEADER_FORMATTERS, SORTABLE_COLUMNS } from '../../utils' +import { getUserRole, HEADER_FORMATTERS, SORTABLE_COLUMNS } from '../../utils' const Projects = ({ config, configActions, currentUserObject, projectsActions, projectsObject }) => { const { allowedTypes, catalogs, importUrls, invites, projects, projectsCount, hasNext } = projectsObject @@ -40,7 +40,7 @@ const Projects = ({ config, configActions, currentUserObject, projectsActions, p } const currentUserId = currentUser.id - const isManager = userIsManager(currentUser) + const isManager = currentUser.is_superuser || currentUser.is_site_manager const searchString = get(config, 'params.search', '') const updateSearchString = (value) => { @@ -70,20 +70,35 @@ const Projects = ({ config, configActions, currentUserObject, projectsActions, p projectsActions.uploadProject('/projects/import/', file) } - const renderTitle = (title, row) => { - const pathArray = getTitlePath(projects, title, row).split(' / ') - const lastChild = pathArray.pop() + const buildAncestorLink = (ancestors) => { + if (!Array.isArray(ancestors) || ancestors.length === 0) return null + const current = ancestors[ancestors.length - 1] + const href = `${baseUrl}/projects/${current.id}` + + const parts = ancestors.map((a, idx) => { + const isLast = idx === ancestors.length - 1 + const content = isLast + ? {a.title} + : a.title + + return ( + + {idx > 0 && ' / '} + {content} + + ) + }) + + return {parts} + } + + const renderTitle = (row) => { const catalog = catalogs.find(c => c.id === row.catalog) return (
- - {pathArray.map((path, index) => ( - {path} / - ))} - {lastChild} - + {buildAncestorLink(row.ancestors)} { catalog && (
@@ -131,9 +146,9 @@ const Projects = ({ config, configActions, currentUserObject, projectsActions, p } const cellFormatters = { - title: (content, row) => renderTitle(content, row), + title: (_content, row) => renderTitle(row), role: (_content, row) => { - const { rolesString } = getUserRoles(row, currentUserId) + const rolesString = getUserRole(row, currentUserId) return <> { rolesString &&

{rolesString}

@@ -159,8 +174,7 @@ const Projects = ({ config, configActions, currentUserObject, projectsActions, p actions: (_content, row) => { const rowUrl = `${baseUrl}/projects/${row.id}` const params = `?next=${window.location.pathname}` - const { isProjectManager, isProjectOwner } = getUserRoles(row, currentUserId, ['managers', 'owners']) - + const perms = row.permissions || {} return (
window.location.href = `${rowUrl}/copy/${params}`} /> - {(isProjectManager || isProjectOwner || isManager) && + {perms.can_change_project && window.location.href = `${rowUrl}/update/${params}`} /> } - {(isProjectOwner || isManager) && + {perms.can_delete_project && { - const parent = projects.find((project) => project.id === parentId) - if (parent) { - const { title: parentTitle, parent: grandParentId } = parent - pathArray.unshift(parentTitle) - if (!isNil(grandParentId) && typeof grandParentId === 'number') { - return getParentPath(projects, grandParentId, pathArray) - } - } - return pathArray -} - -export const getTitlePath = (projects, title, row) => { - let parentPath = '' - if (row.parent) { - const path = getParentPath(projects, row.parent) - parentPath = path.join(' / ') - } - - const pathArray = parentPath ? [parentPath, title] : [title] - return pathArray.join(' / ') -} diff --git a/rdmo/projects/assets/js/projects/utils/getUserRole.js b/rdmo/projects/assets/js/projects/utils/getUserRole.js new file mode 100644 index 0000000000..6d390bcbaa --- /dev/null +++ b/rdmo/projects/assets/js/projects/utils/getUserRole.js @@ -0,0 +1,14 @@ +import { defaultRoleOptions as roleOptions, defaultRoleArrays as roleArrays } from '../../common/constants/defaultRoleOptions' + +export const getUserRole = (project, currentUserId) => { + let roleLabel = null + roleArrays.forEach(arrayName => { + if (project[arrayName].some(item => item.id === currentUserId)) { + roleLabel = roleOptions.find(opt => opt.value === arrayName.slice(0, -1)).label + } + }) + + return roleLabel +} + +export default getUserRole diff --git a/rdmo/projects/assets/js/projects/utils/getUserRoles.js b/rdmo/projects/assets/js/projects/utils/getUserRoles.js deleted file mode 100644 index 8f8760fe72..0000000000 --- a/rdmo/projects/assets/js/projects/utils/getUserRoles.js +++ /dev/null @@ -1,37 +0,0 @@ -import { ROLE_LABELS } from './constants' - -export const getUserRoles = (project, currentUserId, arraysToSearch) => { - if (!arraysToSearch || !arraysToSearch.length) { - arraysToSearch = ['authors', 'guests', 'managers', 'owners'] - } - - const roleDefinitions = { - authors: { roleLabel: ROLE_LABELS.author, roleBoolean: 'isProjectAuthor' }, - guests: { roleLabel: ROLE_LABELS.guest, roleBoolean: 'isProjectGuest' }, - managers: { roleLabel: ROLE_LABELS.manager, roleBoolean: 'isProjectManager' }, - owners: { roleLabel: ROLE_LABELS.owner, roleBoolean: 'isProjectOwner' } - } - - let rolesFound = [] - let roleBooleans = { - isProjectAuthor: false, - isProjectGuest: false, - isProjectManager: false, - isProjectOwner: false - } - - arraysToSearch.forEach(arrayName => { - if (project[arrayName].some(item => item.id === currentUserId)) { - const { roleLabel, roleBoolean } = roleDefinitions[arrayName] - rolesFound.push(roleLabel) - roleBooleans[roleBoolean] = true - } - }) - - return { - rolesString: rolesFound.length > 0 ? rolesFound.join(', ') : null, - ...roleBooleans - } -} - -export default getUserRoles diff --git a/rdmo/projects/assets/js/projects/utils/index.js b/rdmo/projects/assets/js/projects/utils/index.js index 4927271517..9467d79a1c 100644 --- a/rdmo/projects/assets/js/projects/utils/index.js +++ b/rdmo/projects/assets/js/projects/utils/index.js @@ -1,5 +1,3 @@ export * from './constants' -export * from './getProjectTitlePath' -export { default as getUserRoles } from './getUserRoles' -export { default as userIsManager } from './userIsManager' +export { default as getUserRole } from './getUserRole' export { default as TRANSLATIONS } from './translations' diff --git a/rdmo/projects/assets/js/projects/utils/userIsManager.js b/rdmo/projects/assets/js/projects/utils/userIsManager.js deleted file mode 100644 index 9b4e77430d..0000000000 --- a/rdmo/projects/assets/js/projects/utils/userIsManager.js +++ /dev/null @@ -1,11 +0,0 @@ -import { siteId } from 'rdmo/core/assets/js/utils/meta' - -const userIsManager = (currentUser) => { - if (currentUser.is_superuser || - (currentUser.role && currentUser.role.manager && currentUser.role.manager.some(manager => manager.id === siteId))) { - return true - } - return false -} - -export default userIsManager From 57037163fd14d191abafb4a4c27cf4b670bc0c58 Mon Sep 17 00:00:00 2001 From: Claudia Malzer Date: Mon, 6 Oct 2025 12:35:50 +0200 Subject: [PATCH 115/298] * fix permissions change on last owner <-> owner cases --- .../js/project/actions/projectActions.js | 18 ++++++++++++++++-- .../js/project/components/pages/Membership.js | 5 +++-- .../components/pages/MembershipInviteModal.js | 3 ++- .../components/pages/MembershipTable.js | 3 ++- .../js/project/components/pages/ProjectData.js | 5 +++-- .../js/project/reducers/projectReducer.js | 3 +-- .../assets/js/project/store/configureStore.js | 3 +-- 7 files changed, 28 insertions(+), 12 deletions(-) diff --git a/rdmo/projects/assets/js/project/actions/projectActions.js b/rdmo/projects/assets/js/project/actions/projectActions.js index 3644933f18..dd3a56c2e7 100644 --- a/rdmo/projects/assets/js/project/actions/projectActions.js +++ b/rdmo/projects/assets/js/project/actions/projectActions.js @@ -210,14 +210,28 @@ export function createProjectMemberError(error) { } export function updateProjectMember(membershipId, data) { - return function(dispatch) { + return function(dispatch, getState) { dispatch(addToPending('updateProjectMember')) dispatch(updateProjectMemberInit()) return ProjectApi.updateMember(projectId, membershipId, data) .then(member => { - dispatch(removeFromPending('updateProjectMember')) dispatch(updateProjectMemberSuccess({ ...member, id: membershipId })) + + // membership updates can lead to a permission change for owner <-> last owner cases + // project with permissions needs to be fetched + const state = getState() + const currentBundle = state.project.project + return ProjectApi.fetchProject(projectId).then(project => ({ project, currentBundle })) + }) + .then(({ project, currentBundle }) => { + const updatedBundle = { + ...currentBundle, + project + } + + dispatch(removeFromPending('updateProjectMember')) + dispatch(updateProjectSuccess(updatedBundle)) }) .catch(error => { dispatch(removeFromPending('updateProjectMember')) diff --git a/rdmo/projects/assets/js/project/components/pages/Membership.js b/rdmo/projects/assets/js/project/components/pages/Membership.js index fb226f84cc..ec34037214 100644 --- a/rdmo/projects/assets/js/project/components/pages/Membership.js +++ b/rdmo/projects/assets/js/project/components/pages/Membership.js @@ -9,8 +9,9 @@ import MembershipTable from './MembershipTable' const Membership = () => { const { show: showInvite, open: openInvite, close: closeInvite } = useModal() - const { memberships } = useSelector((state) => state.project.project) ?? {} - const { invites, perms } = useSelector((state) => state.project) + const { memberships, project } = useSelector((state) => state.project.project) ?? {} + const { invites } = useSelector((state) => state.project) + const perms = project?.permissions ?? {} return ( <> diff --git a/rdmo/projects/assets/js/project/components/pages/MembershipInviteModal.js b/rdmo/projects/assets/js/project/components/pages/MembershipInviteModal.js index acd4b55081..e101597cfa 100644 --- a/rdmo/projects/assets/js/project/components/pages/MembershipInviteModal.js +++ b/rdmo/projects/assets/js/project/components/pages/MembershipInviteModal.js @@ -14,8 +14,9 @@ const initialForm = { lookup: '', role: 'author' } const MembershipInviteModal = ({ show, onClose }) => { const dispatch = useDispatch() const templates = useSelector((state) => state.templates) - const perms = useSelector((state) => state.project.perms) + const { project } = useSelector((state) => state.project.project) || {} const errors = useFieldErrors() + const perms = project?.permissions || {} const [formData, setFormData] = useState(initialForm) const [silently, setSilently] = useState(false) diff --git a/rdmo/projects/assets/js/project/components/pages/MembershipTable.js b/rdmo/projects/assets/js/project/components/pages/MembershipTable.js index 7d123a4efe..a2bf40b0e7 100644 --- a/rdmo/projects/assets/js/project/components/pages/MembershipTable.js +++ b/rdmo/projects/assets/js/project/components/pages/MembershipTable.js @@ -14,7 +14,8 @@ import MembershipDeleteModal from './MembershipDeleteModal' const MembershipTable = ({ persons, isMember = false }) => { const dispatch = useDispatch() const currentUser = useSelector((state) => state.user.currentUser) - const { perms } = useSelector((state) => state.project) + const { project } = useSelector((state) => state.project.project) || {} + const perms = project?.permissions || {} const { show: showConfirm, open: openConfirm, close: closeConfirm } = useModal() const [selected, setSelected] = useState(null) diff --git a/rdmo/projects/assets/js/project/components/pages/ProjectData.js b/rdmo/projects/assets/js/project/components/pages/ProjectData.js index ecd4b108c7..5a9ab12f48 100644 --- a/rdmo/projects/assets/js/project/components/pages/ProjectData.js +++ b/rdmo/projects/assets/js/project/components/pages/ProjectData.js @@ -10,9 +10,10 @@ import ProjectDelete from './ProjectDelete' const ProjectData = () => { const config = useSelector((state) => state.config) - const { perms, project } = useSelector((state) => state.project) + const { hierarchy, project } = useSelector((state) => state.project.project) ?? {} const user = useSelector((state) => state.user) const dispatch = useDispatch() + const perms = project?.permissions ?? {} const showHierarchy = String(get(config, 'showHierarchy', false)) === 'true' const toggleHierarchy = () => dispatch(updateConfig('showHierarchy', !showHierarchy)) @@ -29,7 +30,7 @@ const ProjectData = () => { { showHierarchy && - + } diff --git a/rdmo/projects/assets/js/project/reducers/projectReducer.js b/rdmo/projects/assets/js/project/reducers/projectReducer.js index a18bcfe0c2..d48342b8d2 100644 --- a/rdmo/projects/assets/js/project/reducers/projectReducer.js +++ b/rdmo/projects/assets/js/project/reducers/projectReducer.js @@ -2,7 +2,6 @@ import * as actionTypes from '../actions/actionTypes' const initialState = { project: null, - perms: {}, invites: null, errors: [] } @@ -10,7 +9,7 @@ const initialState = { export default function projectReducer(state = initialState, action) { switch(action.type) { case actionTypes.FETCH_PROJECT_SUCCESS: - return { ...state, project: action.project, perms: action.project.project.permissions } + return { ...state, project: action.project} case actionTypes.FETCH_PROJECT_INIT: return { ...state, errors: [] } case actionTypes.FETCH_PROJECT_ERROR: diff --git a/rdmo/projects/assets/js/project/store/configureStore.js b/rdmo/projects/assets/js/project/store/configureStore.js index fc8a667c2e..4e77a423f1 100644 --- a/rdmo/projects/assets/js/project/store/configureStore.js +++ b/rdmo/projects/assets/js/project/store/configureStore.js @@ -76,8 +76,7 @@ export default function configureStore() { store.dispatch(projectActions.fetchProject()).then(() => { const { project: projectObj } = store.getState() - const permissions = projectObj.perms || {} - + const permissions = projectObj.project.project.permissions || {} if (permissions.can_view_invite) { store.dispatch(projectActions.fetchProjectInvites(projectId)) } From 292826ebeaacc7abb6beb7f5e9cd40eba849cb02 Mon Sep 17 00:00:00 2001 From: Jochen Klar Date: Thu, 9 Oct 2025 13:25:40 +0200 Subject: [PATCH 116/298] Fix redirect after leave --- rdmo/projects/assets/js/project/actions/projectActions.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rdmo/projects/assets/js/project/actions/projectActions.js b/rdmo/projects/assets/js/project/actions/projectActions.js index dd3a56c2e7..cee3515325 100644 --- a/rdmo/projects/assets/js/project/actions/projectActions.js +++ b/rdmo/projects/assets/js/project/actions/projectActions.js @@ -1,7 +1,7 @@ import ProjectApi from '../api/ProjectApi' import CatalogsApi from '/rdmo/projects/assets/js/common/api/CatalogsApi' -import { projectId } from '../utils/meta' +import { baseUrl, projectId } from '../utils/meta' import * as actionTypes from './actionTypes' import { addToPending, removeFromPending } from 'rdmo/core/assets/js/actions/pendingActions' @@ -383,7 +383,7 @@ export function leaveProject(membershipId, { redirect = false } = {}) { dispatch(removeFromPending('leaveProject')) dispatch(leaveProjectSuccess(membershipId)) if (redirect) { - window.location.href = '/projects/' + window.location.href = `${baseUrl}/projects/` return } }) From dfc2227180fef8c1bed2cc5d55ba3ad42ce4fb22 Mon Sep 17 00:00:00 2001 From: Jochen Klar Date: Thu, 9 Oct 2025 15:01:07 +0200 Subject: [PATCH 117/298] Refactor MembershipTable and MembershipDeleteModal --- .../js/project/actions/projectActions.js | 13 +-- .../js/project/components/pages/Membership.js | 27 +++--- .../components/pages/MembershipDeleteModal.js | 61 ++++++------- .../components/pages/MembershipTable.js | 89 +++++++++++-------- 4 files changed, 105 insertions(+), 85 deletions(-) diff --git a/rdmo/projects/assets/js/project/actions/projectActions.js b/rdmo/projects/assets/js/project/actions/projectActions.js index cee3515325..c0640d0b3d 100644 --- a/rdmo/projects/assets/js/project/actions/projectActions.js +++ b/rdmo/projects/assets/js/project/actions/projectActions.js @@ -1,14 +1,17 @@ -import ProjectApi from '../api/ProjectApi' -import CatalogsApi from '/rdmo/projects/assets/js/common/api/CatalogsApi' - -import { baseUrl, projectId } from '../utils/meta' -import * as actionTypes from './actionTypes' +import CatalogsApi from 'rdmo/projects/assets/js/common/api/CatalogsApi' import { addToPending, removeFromPending } from 'rdmo/core/assets/js/actions/pendingActions' import { updateConfig } from 'rdmo/core/assets/js/actions/configActions' +import { baseUrl } from 'rdmo/core/assets/js/utils/meta' +import { projectId } from '../utils/meta' import { updateLocation } from '../utils/location' +import ProjectApi from '../api/ProjectApi' + +import * as actionTypes from './actionTypes' + + export function setPage(page) { return function(dispatch) { dispatch(updateConfig('page', page)) diff --git a/rdmo/projects/assets/js/project/components/pages/Membership.js b/rdmo/projects/assets/js/project/components/pages/Membership.js index ec34037214..7cc9b0e518 100644 --- a/rdmo/projects/assets/js/project/components/pages/Membership.js +++ b/rdmo/projects/assets/js/project/components/pages/Membership.js @@ -1,5 +1,6 @@ import React from 'react' import { useSelector } from 'react-redux' +import { isEmpty } from 'lodash' import { useModal } from 'rdmo/core/assets/js/hooks' @@ -28,17 +29,21 @@ const Membership = () => { )} - {memberships?.length > 0 && ( - - )} - {invites?.length > 0 && ( - <> -
-
{gettext('Invites')}
-
- - - )} + { + !isEmpty(memberships) && ( + + ) + } + { + !isEmpty(invites) && ( + <> +
+
{gettext('Invites')}
+
+ + + ) + } diff --git a/rdmo/projects/assets/js/project/components/pages/MembershipDeleteModal.js b/rdmo/projects/assets/js/project/components/pages/MembershipDeleteModal.js index 1bb6bbcec3..91b55caa96 100644 --- a/rdmo/projects/assets/js/project/components/pages/MembershipDeleteModal.js +++ b/rdmo/projects/assets/js/project/components/pages/MembershipDeleteModal.js @@ -8,18 +8,17 @@ import Modal from 'rdmo/core/assets/js/_bs53/components/Modal' import { deleteProjectMember, deleteProjectInvite, leaveProject } from '../../actions/projectActions' import { useFieldErrors } from '../../hooks/useFieldErrors' -const MembershipDeleteModal = ({ show, onClose, person, isAdmin = false, isMember = false, isCurrentUser = false }) => { +const MembershipDeleteModal = ({ type, show, person, onClose, isAdminOrSiteManager = false, + isCurrentUser = false }) => { const dispatch = useDispatch() const { project } = useSelector((state) => state.project.project) ?? {} const errors = useFieldErrors() - const name = - [person.user.first_name, person.user.last_name].filter(Boolean).join(' ').trim() || - person.user.email || '' + const name = person.user?.full_name || person.email || '' - const text = !isMember ? gettext('Delete invite') : ( + const text = (type == 'memberships') ? ( isCurrentUser ? gettext('Leave project') : gettext('Delete membership') - ) + ) : gettext('Delete invite') return ( { try { - if (isMember) { - isCurrentUser ? - await dispatch(leaveProject( - person.id, - !isAdmin && { redirect: true })) : - await dispatch(deleteProjectMember(person.id)) + if (type == 'memberships') { + isCurrentUser ? await dispatch(leaveProject(person.id, { redirect: !isAdminOrSiteManager })) + : await dispatch(deleteProjectMember(person.id)) } else { await dispatch(deleteProjectInvite(person.id)) } @@ -50,18 +46,20 @@ const MembershipDeleteModal = ({ show, onClose, person, isAdmin = false, isMembe html={ isCurrentUser ? interpolate( - gettext('You are about to leave the project %s. If you want to access this project again, somebody will need to invite you!'), - [project?.title ?? ''] - ) - : isMember - ? interpolate( - gettext('You are about to remove the user %s from the project %s.'), - [name, project?.title ?? ''] - ) - : interpolate( - gettext('You are about to remove the invite of %s from the project %s.'), - [name, project?.title ?? ''] - ) + gettext('You are about to leave the project %s. If you want to access this project again, ' + + 'somebody will need to invite you!'), + [project?.title ?? ''] + ) : ( + (type == 'memberships') + ? interpolate( + gettext('You are about to remove the user %s from the project %s.'), + [name, project?.title ?? ''] + ) + : interpolate( + gettext('You are about to remove the invite of %s from the project %s.'), + [name, project?.title ?? ''] + ) + ) } /> {errors.non_field_errors?.map((err, i) => ( @@ -72,19 +70,22 @@ const MembershipDeleteModal = ({ show, onClose, person, isAdmin = false, isMembe } MembershipDeleteModal.propTypes = { + type: PropTypes.oneOf(['memberships', 'invites']), show: PropTypes.bool.isRequired, - onClose: PropTypes.func.isRequired, - isAdmin: PropTypes.bool, - isMember: PropTypes.bool, - isCurrentUser: PropTypes.bool, person: PropTypes.shape({ id: PropTypes.number.isRequired, user: PropTypes.shape({ first_name: PropTypes.string, last_name: PropTypes.string, + full_name: PropTypes.string, email: PropTypes.string, - }) - }) + }), + email: PropTypes.string, + }), + onClose: PropTypes.func.isRequired, + isAdminOrSiteManager: PropTypes.bool, + isMember: PropTypes.bool, + isCurrentUser: PropTypes.bool, } export default MembershipDeleteModal diff --git a/rdmo/projects/assets/js/project/components/pages/MembershipTable.js b/rdmo/projects/assets/js/project/components/pages/MembershipTable.js index a2bf40b0e7..8f82ef852c 100644 --- a/rdmo/projects/assets/js/project/components/pages/MembershipTable.js +++ b/rdmo/projects/assets/js/project/components/pages/MembershipTable.js @@ -11,33 +11,30 @@ import { defaultRoleOptions as roleOptions } from '../../../common/constants/def import MembershipDeleteModal from './MembershipDeleteModal' -const MembershipTable = ({ persons, isMember = false }) => { +const MembershipTable = ({ persons, type }) => { const dispatch = useDispatch() const currentUser = useSelector((state) => state.user.currentUser) const { project } = useSelector((state) => state.project.project) || {} const perms = project?.permissions || {} const { show: showConfirm, open: openConfirm, close: closeConfirm } = useModal() - const [selected, setSelected] = useState(null) + const [modalState, setModalState] = useState(null) - const currentUserId = currentUser?.id - const isAdmin = currentUser?.is_superuser || currentUser?.is_site_manager + const isAdminOrSiteManager = currentUser?.is_superuser || currentUser?.is_site_manager - const handleOpenConfirm = (person, isCurrentUser) => { - setSelected({ person, isCurrentUser }) + const openDeleteModal = (person, isCurrentUser) => { + setModalState({ person, isCurrentUser }) openConfirm() } - const handleCloseConfirm = () => { - setSelected(null) + const closeDeleteModal = () => { + setModalState(null) closeConfirm() } - const uniquePersons = isMember - ? persons.filter( - (p, i, arr) => arr.findIndex(x => x.user?.id === p.user?.id) === i - ) - : persons + const uniquePersons = (type === 'memberships') ? persons.filter( + (p, i, arr) => arr.findIndex(x => x.user?.id === p.user?.id) === i + ) : persons return (
@@ -52,22 +49,33 @@ const MembershipTable = ({ persons, isMember = false }) => {
{uniquePersons?.map((person, index) => { - const isCurrentUser = person.user?.id === currentUserId + const isCurrentUser = person.user?.id === currentUser?.id const isOwner = isCurrentUser && person.role == 'owner' - const showMemberAction = isMember && ((!isCurrentUser && perms.can_delete_membership) || (isCurrentUser && perms.can_leave_project)) - const showInviteAction = !isMember && perms.can_delete_invite - const showAction = (showMemberAction || showInviteAction || isAdmin) && !person.project // do not show action buttons for hierarchy roles + + const showMemberAction = (type === 'memberships') && ( + isCurrentUser ? perms.can_leave_project : perms.can_delete_membership + ) + const showInviteAction = (type === 'invites') && perms.can_delete_invite + const showActions = ( + showMemberAction || showInviteAction || currentUser?.is_superuser_or_site_manager + ) && !person.project // do not show action buttons for hierarchy roles const emailAddress = person.user?.email || person?.email const hierarchyRole = person?.project - ? `${roleOptions.find(opt => opt.value === person.role).label} ${gettext('of')} ${person.project.title}` - : null + ? `${roleOptions.find(opt => opt.value === person.role).label} ${gettext('of')} ${person.project.title}` + : null return (
{person?.user?.first_name} {person?.user?.last_name} - {person.user.email && {person.user.email}} + {emailAddress && {emailAddress}} - { + if (!newRole) return + if (isMember) { + dispatch(updateProjectMember(person.id, { role: newRole })) + } else { + dispatch(updateProjectInvite(person.id, { role: newRole })) + } + }} + isClearable={false} + isDisabled={(isMember && (!perms.can_change_membership || (isOwner && !isManager)) || (!isMember && !perms.can_change_invite))} + /> + } {showAction && ( From 21d2611aaad7e8d83eaf73b3ba331559616bd39f Mon Sep 17 00:00:00 2001 From: Claudia Malzer Date: Tue, 30 Sep 2025 16:58:49 +0200 Subject: [PATCH 107/298] * add project hierarchy --- .../js/project/actions/projectActions.js | 21 +++- .../assets/js/project/api/ProjectApi.js | 6 +- .../components/helper/HierarchyTree.js | 105 ++++++++++++++++++ .../js/project/components/helper/index.js | 1 + .../components/pages/MembershipInviteModal.js | 1 + .../project/components/pages/ProjectData.js | 44 +++++--- .../project/components/pages/ProjectForm.js | 11 +- .../assets/js/project/utils/findById.js | 14 +++ 8 files changed, 180 insertions(+), 23 deletions(-) create mode 100644 rdmo/projects/assets/js/project/components/helper/HierarchyTree.js create mode 100644 rdmo/projects/assets/js/project/utils/findById.js diff --git a/rdmo/projects/assets/js/project/actions/projectActions.js b/rdmo/projects/assets/js/project/actions/projectActions.js index e010894267..c2620d4b47 100644 --- a/rdmo/projects/assets/js/project/actions/projectActions.js +++ b/rdmo/projects/assets/js/project/actions/projectActions.js @@ -23,15 +23,17 @@ export function fetchProject() { return Promise.all([ ProjectApi.fetchProject(projectId), + ProjectApi.fetchProjectHierarchy(projectId), ProjectApi.fetchProjectSnapshots(projectId), ProjectApi.fetchProjectTasks(projectId), ProjectApi.fetchProjectMemberships(projectId), ProjectApi.fetchProjectMembershipHierarchy(projectId), CatalogsApi.fetchCatalogs() ]) - .then(([project, snapshots, tasks, memberships,membershipHierarchy, catalogs]) => { + .then(([project, hierarchy, snapshots, tasks, memberships,membershipHierarchy, catalogs]) => { const projectData = { project: project, + hierarchy: hierarchy, snapshots: snapshots, tasks: tasks, memberships: [...memberships, ...membershipHierarchy], @@ -76,10 +78,23 @@ export function updateProject(data) { dispatch(updateProjectInit()) return ProjectApi.updateProject(id, data) - .then((updatedProject) => { + .then(() => + Promise.all([ + ProjectApi.fetchProject(id), + ProjectApi.fetchProjectHierarchy(id), + ]) + ) + .then(([project, hierarchy]) => { const updatedBundle = { ...currentBundle, - project: updatedProject + // only these two are refreshed from server: + project, + hierarchy, + // everything else stays untouched: + // snapshots: currentBundle.snapshots, + // tasks: currentBundle.tasks, + // memberships: currentBundle.memberships, + // catalogs: currentBundle.catalogs, } dispatch(removeFromPending('updateProject')) diff --git a/rdmo/projects/assets/js/project/api/ProjectApi.js b/rdmo/projects/assets/js/project/api/ProjectApi.js index 36e963500a..9919f7cbce 100644 --- a/rdmo/projects/assets/js/project/api/ProjectApi.js +++ b/rdmo/projects/assets/js/project/api/ProjectApi.js @@ -8,6 +8,10 @@ export default class ProjectApi extends BaseApi { return this.get(`/api/v1/projects/projects/${projectId}/`) } + static fetchProjectHierarchy(projectId) { + return this.get(`/api/v1/projects/projects/${projectId}/hierarchy/`) + } + static fetchProjectSnapshots(projectId) { return this.get(`/api/v1/projects/projects/${projectId}/snapshots/`) } @@ -21,7 +25,7 @@ export default class ProjectApi extends BaseApi { } static fetchProjectMembershipHierarchy(projectId) { - return this.get(`/api/v1/projects/projects/${projectId}/memberships/hierarchy`) + return this.get(`/api/v1/projects/projects/${projectId}/memberships/hierarchy/`) } static fetchProjectInvites(projectId) { diff --git a/rdmo/projects/assets/js/project/components/helper/HierarchyTree.js b/rdmo/projects/assets/js/project/components/helper/HierarchyTree.js new file mode 100644 index 0000000000..5740c19dae --- /dev/null +++ b/rdmo/projects/assets/js/project/components/helper/HierarchyTree.js @@ -0,0 +1,105 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { baseUrl } from 'rdmo/core/assets/js/utils/meta' + +const HierarchyTree = ({ hierarchy }) => { + const bulletStyle = { listStyleType: 'disc' } + const isCurrentNode = (node) => node?.current === true || node?.current === 'true' + + const linkOrText = (node) => { + const isCurrent = isCurrentNode(node) + const content = (node?.permissions?.can_view_project && !isCurrent) + ? {node.title} + : <>{node.title} + + return isCurrent ? {content} : content + } + + const renderFullSubtree = (node) => { + if (!node?.children?.length) return null + return ( +
    + {node.children.map(child => ( +
  • + {linkOrText(child)} + {renderFullSubtree(child)} +
  • + ))} +
+ ) + } + + const pathToCurrent = (node) => { + if (!node) return null + if (node.current) return [node] + for (const child of (node.children || [])) { + const path = pathToCurrent(child) + if (path) return [node, ...path] + } + return null + } + + const pathToCurrentFromRoot = (root) => { + if (Array.isArray(root)) { + for (const n of root) { + const p = pathToCurrent(n) + if (p) return p + } + return null + } + return pathToCurrent(root) + } + + const path = pathToCurrentFromRoot(hierarchy) + if (!path || path.length === 0) return null + + const renderPath = (idx) => { + const node = path[idx] + const isAtCurrentInPath = idx === path.length - 1 + + if (idx === 0) { + return ( + <> + {linkOrText(node)} + {isAtCurrentInPath + ? renderFullSubtree(node) + :
    {renderPath(idx + 1)}
} + + ) + } + + return ( +
  • + {linkOrText(node)} + {isAtCurrentInPath + ? renderFullSubtree(node) + :
      {renderPath(idx + 1)}
    } +
  • + ) + } + + return ( +
    {renderPath(0)}
    + ) +} + +const nodeShape = PropTypes.shape({ + id: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired, + title: PropTypes.string.isRequired, + current: PropTypes.bool, + permissions: PropTypes.shape({ + can_view_project: PropTypes.bool, + can_change_project: PropTypes.bool, + can_delete_project: PropTypes.bool + }), + children: PropTypes.array +}) + PropTypes.arrayOf(() => nodeShape) +HierarchyTree.propTypes = { + hierarchy: PropTypes.oneOfType([ + nodeShape, + PropTypes.arrayOf(nodeShape) + ]).isRequired +} + +export default HierarchyTree diff --git a/rdmo/projects/assets/js/project/components/helper/index.js b/rdmo/projects/assets/js/project/components/helper/index.js index 8dcf81c77f..8db8f81d50 100644 --- a/rdmo/projects/assets/js/project/components/helper/index.js +++ b/rdmo/projects/assets/js/project/components/helper/index.js @@ -1,3 +1,4 @@ export { default as ProjectBadge } from './ProjectBadge' export { default as Tile } from './Tile' export { default as TileGrid } from './TileGrid' +export { default as HierarchyTree } from './HierarchyTree' diff --git a/rdmo/projects/assets/js/project/components/pages/MembershipInviteModal.js b/rdmo/projects/assets/js/project/components/pages/MembershipInviteModal.js index 548b64243e..b7b3c8bf5f 100644 --- a/rdmo/projects/assets/js/project/components/pages/MembershipInviteModal.js +++ b/rdmo/projects/assets/js/project/components/pages/MembershipInviteModal.js @@ -90,6 +90,7 @@ const MembershipInviteModal = ({ show, onClose }) => { + {/* TODO: add Tooltip for roles */} ))} {errors.role?.map((err, i) => ( diff --git a/rdmo/projects/assets/js/project/components/pages/ProjectData.js b/rdmo/projects/assets/js/project/components/pages/ProjectData.js index 39e0fe3d0a..ecd4b108c7 100644 --- a/rdmo/projects/assets/js/project/components/pages/ProjectData.js +++ b/rdmo/projects/assets/js/project/components/pages/ProjectData.js @@ -1,36 +1,50 @@ import React from 'react' -import { useSelector } from 'react-redux' -import { isNil } from 'lodash' +import { useDispatch, useSelector } from 'react-redux' +import { get, isNil } from 'lodash' -import { Tile } from '../helper' +import { updateConfig } from 'rdmo/core/assets/js/actions/configActions' +import { Link } from 'rdmo/core/assets/js/components' +import { HierarchyTree, Tile } from '../helper' import ProjectForm from './ProjectForm' import ProjectDelete from './ProjectDelete' const ProjectData = () => { + const config = useSelector((state) => state.config) const { perms, project } = useSelector((state) => state.project) const user = useSelector((state) => state.user) + const dispatch = useDispatch() + + const showHierarchy = String(get(config, 'showHierarchy', false)) === 'true' + const toggleHierarchy = () => dispatch(updateConfig('showHierarchy', !showHierarchy)) if (isNil(project) || isNil(user.currentUser)) { return } return ( -
    -
    - - - -
    - - {perms.can_delete_project && ( +
    - - + + {showHierarchy ? gettext('Hide project hierarchy') : gettext('Show project hierarchy')} + + { showHierarchy && + + + + } + +
    - )} -
    + {perms.can_delete_project && ( +
    + + + +
    + )} +
    ) } diff --git a/rdmo/projects/assets/js/project/components/pages/ProjectForm.js b/rdmo/projects/assets/js/project/components/pages/ProjectForm.js index eb978c006d..8a9359e437 100644 --- a/rdmo/projects/assets/js/project/components/pages/ProjectForm.js +++ b/rdmo/projects/assets/js/project/components/pages/ProjectForm.js @@ -10,11 +10,12 @@ import Textarea from 'rdmo/core/assets/js/components/forms/Textarea' import { updateProject } from '../../actions/projectActions' import { useFieldErrors } from '../../hooks/useFieldErrors' +import { findById } from '../../utils/findById' import ProjectApi from '../../api/ProjectApi' const ProjectForm = ({ disabled }) => { - const { project, catalogs } = useSelector((state) => state.project.project) + const { project, hierarchy, catalogs } = useSelector((state) => state.project.project) const templates = useSelector((state) => state.templates) const dispatch = useDispatch() const errors = useFieldErrors() @@ -23,6 +24,8 @@ const ProjectForm = ({ disabled }) => { const [enableParent, setEnableParent] = useState(!!project.parent) const [parentOptions, setParentOptions] = useState([]) + const parentProject = project?.parent ? findById(hierarchy, project.parent) : null + const saveProject = (newFormData) => { dispatch(updateProject(newFormData)) } @@ -70,10 +73,10 @@ const ProjectForm = ({ disabled }) => { useEffect(() => { if (formData.parent && !parentOptions.some(p => p.value === formData.parent)) { - ProjectApi.fetchProject(formData.parent).then((project) => { - const option = { value: project.id, label: project.title } + if (parentProject) { + const option = { value: parentProject.id, label: parentProject.title } setParentOptions((prev) => [...prev, option]) - }) + } } }, [formData.parent, parentOptions]) diff --git a/rdmo/projects/assets/js/project/utils/findById.js b/rdmo/projects/assets/js/project/utils/findById.js new file mode 100644 index 0000000000..9a5cfcbc52 --- /dev/null +++ b/rdmo/projects/assets/js/project/utils/findById.js @@ -0,0 +1,14 @@ +export const findById = (node, id) => { + if (node.id === id) { + return node + } + + if (node.children && node.children.length > 0) { + for (const child of node.children) { + const found = findById(child, id) + if (found) return found + } + } + + return null +} From 8bd31ede9f01c4b53932104d661c3a53777eb951 Mon Sep 17 00:00:00 2001 From: Claudia Malzer Date: Tue, 30 Sep 2025 17:04:00 +0200 Subject: [PATCH 108/298] * fix typo --- rdmo/projects/assets/js/project/actions/projectActions.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rdmo/projects/assets/js/project/actions/projectActions.js b/rdmo/projects/assets/js/project/actions/projectActions.js index c2620d4b47..3644933f18 100644 --- a/rdmo/projects/assets/js/project/actions/projectActions.js +++ b/rdmo/projects/assets/js/project/actions/projectActions.js @@ -30,7 +30,7 @@ export function fetchProject() { ProjectApi.fetchProjectMembershipHierarchy(projectId), CatalogsApi.fetchCatalogs() ]) - .then(([project, hierarchy, snapshots, tasks, memberships,membershipHierarchy, catalogs]) => { + .then(([project, hierarchy, snapshots, tasks, memberships, membershipHierarchy, catalogs]) => { const projectData = { project: project, hierarchy: hierarchy, From 9e012ea8b593ed6bec85f9e8dc39a43fc1ce5fad Mon Sep 17 00:00:00 2001 From: Claudia Malzer Date: Wed, 1 Oct 2025 11:29:16 +0200 Subject: [PATCH 109/298] * add Tooltip for roles --- .../assets/js/_bs53/components/Tooltip.js | 2 +- rdmo/core/assets/js/_bs53/components/index.js | 2 ++ .../components/pages/MembershipInviteModal.js | 23 +++++++++++++++---- .../projects/project_view_author_info.html | 4 ++-- .../projects/project_view_guest_info.html | 4 ++-- .../projects/project_view_manager_info.html | 4 ++-- .../projects/project_view_owner_info.html | 4 ++-- 7 files changed, 30 insertions(+), 13 deletions(-) create mode 100644 rdmo/core/assets/js/_bs53/components/index.js diff --git a/rdmo/core/assets/js/_bs53/components/Tooltip.js b/rdmo/core/assets/js/_bs53/components/Tooltip.js index 2cc308629d..d4711fb279 100644 --- a/rdmo/core/assets/js/_bs53/components/Tooltip.js +++ b/rdmo/core/assets/js/_bs53/components/Tooltip.js @@ -8,7 +8,7 @@ const Tooltip = ({ title, children, placement = 'bottom', tooltipProps = {} }) = useEffect(() => { if (title) { - console.log(renderToString(title)) + // console.log(renderToString(title)) const t = new BootstrapTooltip(ref.current, { title: renderToString(title), placement, diff --git a/rdmo/core/assets/js/_bs53/components/index.js b/rdmo/core/assets/js/_bs53/components/index.js new file mode 100644 index 0000000000..8306153fc5 --- /dev/null +++ b/rdmo/core/assets/js/_bs53/components/index.js @@ -0,0 +1,2 @@ +export { default as Modal } from './Modal' +export { default as Tooltip } from './Tooltip' diff --git a/rdmo/projects/assets/js/project/components/pages/MembershipInviteModal.js b/rdmo/projects/assets/js/project/components/pages/MembershipInviteModal.js index b7b3c8bf5f..8962c2c6ce 100644 --- a/rdmo/projects/assets/js/project/components/pages/MembershipInviteModal.js +++ b/rdmo/projects/assets/js/project/components/pages/MembershipInviteModal.js @@ -3,7 +3,7 @@ import PropTypes from 'prop-types' import { useDispatch, useSelector } from 'react-redux' import Html from 'rdmo/core/assets/js/components/Html' -import Modal from 'rdmo/core/assets/js/_bs53/components/Modal' +import { Modal, Tooltip } from 'rdmo/core/assets/js/_bs53/components' import { createProjectMember, sendProjectInvite, clearProjectErrors } from '../../actions/projectActions' import { useFieldErrors } from '../../hooks/useFieldErrors' @@ -77,7 +77,7 @@ const MembershipInviteModal = ({ show, onClose }) => {
    {roleOptions.map(({ value, label }) => ( -
    +
    { checked={formData.role === value} onChange={() => setField('role', value)} /> -
    ))} {errors.role?.map((err, i) => ( diff --git a/rdmo/projects/templates/projects/project_view_author_info.html b/rdmo/projects/templates/projects/project_view_author_info.html index db3efbe093..0b2592246d 100644 --- a/rdmo/projects/templates/projects/project_view_author_info.html +++ b/rdmo/projects/templates/projects/project_view_author_info.html @@ -1,7 +1,7 @@ {% load i18n %} -

    +

    {% blocktrans trimmed %} - + Like guest, but can edit datasets and questionnaires {% endblocktrans %}

    diff --git a/rdmo/projects/templates/projects/project_view_guest_info.html b/rdmo/projects/templates/projects/project_view_guest_info.html index db3efbe093..417b227c87 100644 --- a/rdmo/projects/templates/projects/project_view_guest_info.html +++ b/rdmo/projects/templates/projects/project_view_guest_info.html @@ -1,7 +1,7 @@ {% load i18n %} -

    +

    {% blocktrans trimmed %} - + Can view datasets, questionnaire, and documents {% endblocktrans %}

    diff --git a/rdmo/projects/templates/projects/project_view_manager_info.html b/rdmo/projects/templates/projects/project_view_manager_info.html index db3efbe093..96551a1c92 100644 --- a/rdmo/projects/templates/projects/project_view_manager_info.html +++ b/rdmo/projects/templates/projects/project_view_manager_info.html @@ -1,7 +1,7 @@ {% load i18n %} -

    +

    {% blocktrans trimmed %} - + Like author, but can edit snapshots, project data, and the project team {% endblocktrans %}

    diff --git a/rdmo/projects/templates/projects/project_view_owner_info.html b/rdmo/projects/templates/projects/project_view_owner_info.html index db3efbe093..6af9f335af 100644 --- a/rdmo/projects/templates/projects/project_view_owner_info.html +++ b/rdmo/projects/templates/projects/project_view_owner_info.html @@ -1,7 +1,7 @@ {% load i18n %} -

    +

    {% blocktrans trimmed %} - + Has full rights {% endblocktrans %}

    From 3f3030cbb9b53d59a51070f87524315b5ff9333e Mon Sep 17 00:00:00 2001 From: Claudia Malzer Date: Wed, 1 Oct 2025 11:57:08 +0200 Subject: [PATCH 110/298] * fix add member silently --- .../js/project/components/pages/MembershipInviteModal.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/rdmo/projects/assets/js/project/components/pages/MembershipInviteModal.js b/rdmo/projects/assets/js/project/components/pages/MembershipInviteModal.js index 8962c2c6ce..dde7a3064d 100644 --- a/rdmo/projects/assets/js/project/components/pages/MembershipInviteModal.js +++ b/rdmo/projects/assets/js/project/components/pages/MembershipInviteModal.js @@ -14,12 +14,14 @@ const initialForm = { lookup: '', role: 'author' } const MembershipInviteModal = ({ show, onClose }) => { const dispatch = useDispatch() const templates = useSelector((state) => state.templates) - const perms = useSelector((state) => state.project.perms) const errors = useFieldErrors() + const currentUser = useSelector((state) => state.user.currentUser) const [formData, setFormData] = useState(initialForm) const [silently, setSilently] = useState(false) + const isManager = currentUser?.is_superuser || currentUser?.is_site_manager + useEffect(() => { if (show) { setFormData(initialForm) @@ -113,7 +115,7 @@ const MembershipInviteModal = ({ show, onClose }) => { ))}
    {/* Add member silently */} - {perms.can_add_membership && ( + {isManager && (
    From f8d78ce655800d0ce3b031698dcc89135a48d2f0 Mon Sep 17 00:00:00 2001 From: Claudia Malzer Date: Wed, 1 Oct 2025 12:19:27 +0200 Subject: [PATCH 111/298] * add confirmation modal for project delete --- .../project/components/pages/ProjectDelete.js | 71 +++++++++++++------ 1 file changed, 49 insertions(+), 22 deletions(-) diff --git a/rdmo/projects/assets/js/project/components/pages/ProjectDelete.js b/rdmo/projects/assets/js/project/components/pages/ProjectDelete.js index 1d152b2196..d3caff8323 100644 --- a/rdmo/projects/assets/js/project/components/pages/ProjectDelete.js +++ b/rdmo/projects/assets/js/project/components/pages/ProjectDelete.js @@ -1,40 +1,67 @@ -import React from 'react' +import React, { useState } from 'react' import { useDispatch, useSelector } from 'react-redux' import { deleteProject } from '../../actions/projectActions' +import Modal from 'rdmo/core/assets/js/_bs53/components/Modal' const ProjectDelete = () => { const dispatch = useDispatch() const { project } = useSelector((state) => state.project.project) + const [showConfirm, setShowConfirm] = useState(false) + + const openConfirm = () => setShowConfirm(true) + const closeConfirm = () => setShowConfirm(false) const handleDelete = () => { - if (project?.id) { - // TODO: add a confirmation modal / dialog - dispatch(deleteProject(project.id)) - .then(() => { - window.location.href = '/projects/' - }) - .catch((error) => { - console.error('Failed to delete project:', error) - }) - } + if (!project?.id) return + dispatch(deleteProject(project.id)) + .then(() => { + window.location.href = '/projects/' + }) + .catch((error) => { + console.error('Failed to delete project:', error) + }) + .finally(() => { + setShowConfirm(false) + }) } return (
    -
    -
    {gettext('Delete project')}
    -
    {gettext('This action cannot be undone. The project will be permanently removed!')}
    -
    -
    - -
    +
    +
    {gettext('Delete project')}
    +
    {gettext('This action cannot be undone. The project will be permanently removed!')}
    +
    + +
    +
    + + +

    + {interpolate(gettext('Are you sure you want to delete the project "%s"?'), [project?.title ?? ''])} +
    + {gettext('This action cannot be undone.')} +

    +
    +
    ) } From e1c4215715f4c904899dff578b0f03bac15d8859 Mon Sep 17 00:00:00 2001 From: Claudia Malzer Date: Thu, 2 Oct 2025 12:08:46 +0200 Subject: [PATCH 112/298] * change rule can_add_membership * rename isManager to isAdmin in project branch --- .../js/project/components/pages/MembershipDeleteModal.js | 6 +++--- .../js/project/components/pages/MembershipInviteModal.js | 6 ++---- .../assets/js/project/components/pages/MembershipTable.js | 8 ++++---- rdmo/projects/rules.py | 2 +- 4 files changed, 10 insertions(+), 12 deletions(-) diff --git a/rdmo/projects/assets/js/project/components/pages/MembershipDeleteModal.js b/rdmo/projects/assets/js/project/components/pages/MembershipDeleteModal.js index 371dc277fb..1bb6bbcec3 100644 --- a/rdmo/projects/assets/js/project/components/pages/MembershipDeleteModal.js +++ b/rdmo/projects/assets/js/project/components/pages/MembershipDeleteModal.js @@ -8,7 +8,7 @@ import Modal from 'rdmo/core/assets/js/_bs53/components/Modal' import { deleteProjectMember, deleteProjectInvite, leaveProject } from '../../actions/projectActions' import { useFieldErrors } from '../../hooks/useFieldErrors' -const MembershipDeleteModal = ({ show, onClose, person, isManager = false, isMember = false, isCurrentUser = false }) => { +const MembershipDeleteModal = ({ show, onClose, person, isAdmin = false, isMember = false, isCurrentUser = false }) => { const dispatch = useDispatch() const { project } = useSelector((state) => state.project.project) ?? {} const errors = useFieldErrors() @@ -32,7 +32,7 @@ const MembershipDeleteModal = ({ show, onClose, person, isManager = false, isMem isCurrentUser ? await dispatch(leaveProject( person.id, - !isManager && { redirect: true })) : + !isAdmin && { redirect: true })) : await dispatch(deleteProjectMember(person.id)) } else { await dispatch(deleteProjectInvite(person.id)) @@ -74,7 +74,7 @@ const MembershipDeleteModal = ({ show, onClose, person, isManager = false, isMem MembershipDeleteModal.propTypes = { show: PropTypes.bool.isRequired, onClose: PropTypes.func.isRequired, - isManager: PropTypes.bool, + isAdmin: PropTypes.bool, isMember: PropTypes.bool, isCurrentUser: PropTypes.bool, person: PropTypes.shape({ diff --git a/rdmo/projects/assets/js/project/components/pages/MembershipInviteModal.js b/rdmo/projects/assets/js/project/components/pages/MembershipInviteModal.js index dde7a3064d..8962c2c6ce 100644 --- a/rdmo/projects/assets/js/project/components/pages/MembershipInviteModal.js +++ b/rdmo/projects/assets/js/project/components/pages/MembershipInviteModal.js @@ -14,14 +14,12 @@ const initialForm = { lookup: '', role: 'author' } const MembershipInviteModal = ({ show, onClose }) => { const dispatch = useDispatch() const templates = useSelector((state) => state.templates) + const perms = useSelector((state) => state.project.perms) const errors = useFieldErrors() - const currentUser = useSelector((state) => state.user.currentUser) const [formData, setFormData] = useState(initialForm) const [silently, setSilently] = useState(false) - const isManager = currentUser?.is_superuser || currentUser?.is_site_manager - useEffect(() => { if (show) { setFormData(initialForm) @@ -115,7 +113,7 @@ const MembershipInviteModal = ({ show, onClose }) => { ))}
    {/* Add member silently */} - {isManager && ( + {perms.can_add_membership && (
    diff --git a/rdmo/projects/assets/js/project/components/pages/MembershipTable.js b/rdmo/projects/assets/js/project/components/pages/MembershipTable.js index 60a6c8ca5c..dc8600be6b 100644 --- a/rdmo/projects/assets/js/project/components/pages/MembershipTable.js +++ b/rdmo/projects/assets/js/project/components/pages/MembershipTable.js @@ -20,7 +20,7 @@ const MembershipTable = ({ persons, isMember = false }) => { const [selected, setSelected] = useState(null) const currentUserId = currentUser?.id - const isManager = currentUser?.is_superuser || currentUser?.is_site_manager + const isAdmin = currentUser?.is_superuser || currentUser?.is_site_manager const handleOpenConfirm = (person, isCurrentUser) => { setSelected({ person, isCurrentUser }) @@ -55,7 +55,7 @@ const MembershipTable = ({ persons, isMember = false }) => { const isOwner = isCurrentUser && person.role == 'owner' const showMemberAction = isMember && ((!isCurrentUser && perms.can_delete_membership) || (isCurrentUser && perms.can_leave_project)) const showInviteAction = !isMember && perms.can_delete_invite - const showAction = (showMemberAction || showInviteAction || isManager) && !person.project // do not show action buttons for hierarchy roles + const showAction = (showMemberAction || showInviteAction || isAdmin) && !person.project // do not show action buttons for hierarchy roles const emailAddress = person.user?.email || person?.email const hierarchyRole = person?.project @@ -84,7 +84,7 @@ const MembershipTable = ({ persons, isMember = false }) => { } }} isClearable={false} - isDisabled={(isMember && (!perms.can_change_membership || (isOwner && !isManager)) || (!isMember && !perms.can_change_invite))} + isDisabled={(isMember && (!perms.can_change_membership || (isOwner && !isAdmin)) || (!isMember && !perms.can_change_invite))} /> }
    {person?.user?.first_name} {person?.user?.last_name} - {emailAddress && {emailAddress}} + { + emailAddress && ( + + {emailAddress} + + ) + } {hierarchyRole ? @@ -78,25 +86,26 @@ const MembershipTable = ({ persons, isMember = false }) => { value={person.role} onChange={(newRole) => { if (!newRole) return - if (isMember) { - dispatch(updateProjectMember(person.id, { role: newRole })) - } else { - dispatch(updateProjectInvite(person.id, { role: newRole })) - } + (type === 'memberships') ? dispatch(updateProjectMember(person.id, { role: newRole })) + : dispatch(updateProjectInvite(person.id, { role: newRole })) }} isClearable={false} - isDisabled={(isMember && (!perms.can_change_membership || (isOwner && !isAdmin)) || (!isMember && !perms.can_change_invite))} + isDisabled={( + (type === 'memberships') ? ( + !perms.can_change_membership || (isOwner && !isAdminOrSiteManager) + ) : !perms.can_change_invite + )} /> } - {showAction && ( + {showActions && (
    - {selected && ( - - )} + { + modalState && ( + + ) + }
    ) } MembershipTable.propTypes = { persons: PropTypes.array.isRequired, - isMember: PropTypes.bool + type: PropTypes.oneOf(['memberships', 'invites']) } export default MembershipTable From e913cdc31ba562dd276b1cccb7d64a8ee652f61a Mon Sep 17 00:00:00 2001 From: Claudia Malzer Date: Thu, 9 Oct 2025 20:18:17 +0200 Subject: [PATCH 118/298] * fix error --- .../assets/js/project/components/pages/MembershipTable.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rdmo/projects/assets/js/project/components/pages/MembershipTable.js b/rdmo/projects/assets/js/project/components/pages/MembershipTable.js index 8f82ef852c..813d84a323 100644 --- a/rdmo/projects/assets/js/project/components/pages/MembershipTable.js +++ b/rdmo/projects/assets/js/project/components/pages/MembershipTable.js @@ -57,7 +57,7 @@ const MembershipTable = ({ persons, type }) => { ) const showInviteAction = (type === 'invites') && perms.can_delete_invite const showActions = ( - showMemberAction || showInviteAction || currentUser?.is_superuser_or_site_manager + showMemberAction || showInviteAction || isAdminOrSiteManager ) && !person.project // do not show action buttons for hierarchy roles const emailAddress = person.user?.email || person?.email From bb753ac5929627f4dccc1e9a66960f69ada335e5 Mon Sep 17 00:00:00 2001 From: Jochen Klar Date: Thu, 9 Oct 2025 18:14:34 +0200 Subject: [PATCH 119/298] Simplify ProjectDelete --- .../js/project/actions/projectActions.js | 2 ++ .../project/components/pages/ProjectDelete.js | 26 ++++++------------- 2 files changed, 10 insertions(+), 18 deletions(-) diff --git a/rdmo/projects/assets/js/project/actions/projectActions.js b/rdmo/projects/assets/js/project/actions/projectActions.js index c0640d0b3d..773674c6ed 100644 --- a/rdmo/projects/assets/js/project/actions/projectActions.js +++ b/rdmo/projects/assets/js/project/actions/projectActions.js @@ -132,6 +132,8 @@ export function deleteProject() { .then(() => { dispatch(removeFromPending('deleteProject')) dispatch(deleteProjectSuccess(projectId)) + + window.location.href = `${baseUrl}/projects/` }) .catch((error) => { dispatch(removeFromPending('deleteProject')) diff --git a/rdmo/projects/assets/js/project/components/pages/ProjectDelete.js b/rdmo/projects/assets/js/project/components/pages/ProjectDelete.js index d3caff8323..edd5f446fe 100644 --- a/rdmo/projects/assets/js/project/components/pages/ProjectDelete.js +++ b/rdmo/projects/assets/js/project/components/pages/ProjectDelete.js @@ -1,8 +1,10 @@ import React, { useState } from 'react' import { useDispatch, useSelector } from 'react-redux' -import { deleteProject } from '../../actions/projectActions' import Modal from 'rdmo/core/assets/js/_bs53/components/Modal' +import Html from 'rdmo/core/assets/js/components/Html' + +import { deleteProject } from '../../actions/projectActions' const ProjectDelete = () => { const dispatch = useDispatch() @@ -14,17 +16,7 @@ const ProjectDelete = () => { const closeConfirm = () => setShowConfirm(false) const handleDelete = () => { - if (!project?.id) return dispatch(deleteProject(project.id)) - .then(() => { - window.location.href = '/projects/' - }) - .catch((error) => { - console.error('Failed to delete project:', error) - }) - .finally(() => { - setShowConfirm(false) - }) } return ( @@ -49,15 +41,13 @@ const ProjectDelete = () => { onClose={closeConfirm} onSubmit={handleDelete} submitLabel={gettext('Delete')} - submitProps={{ - className: 'btn btn-danger', - 'data-testid': 'confirm-delete-button' - }} + submitProps={{className: 'btn btn-danger'}} size="" > -

    - {interpolate(gettext('Are you sure you want to delete the project "%s"?'), [project?.title ?? ''])} -
    + %s?'), [project.title ?? ''] + )} /> +

    {gettext('This action cannot be undone.')}

    From e410cd82d971c209894ca998fe40160a5b4fd587 Mon Sep 17 00:00:00 2001 From: Jochen Klar Date: Thu, 9 Oct 2025 18:56:15 +0200 Subject: [PATCH 120/298] Add parent_title to ProjectSerializer --- rdmo/projects/serializers/v1/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/rdmo/projects/serializers/v1/__init__.py b/rdmo/projects/serializers/v1/__init__.py index 1d094953f4..b6bee50af9 100644 --- a/rdmo/projects/serializers/v1/__init__.py +++ b/rdmo/projects/serializers/v1/__init__.py @@ -123,7 +123,9 @@ def get_queryset(self): return Project.objects.filter_user(self.context['request'].user) catalog = CatalogField(required=True) + parent = ParentField(required=False, allow_null=True) + parent_title = serializers.CharField(source='parent.title', read_only=True) owners = ProjectUserSerializer(many=True, read_only=True) managers = ProjectUserSerializer(many=True, read_only=True) @@ -146,6 +148,7 @@ class Meta: 'catalog_uri', 'snapshots', 'parent', + 'parent_title', 'owners', 'managers', 'authors', From 55ae4269adbbe2c943741920d5252107301a59e6 Mon Sep 17 00:00:00 2001 From: Jochen Klar Date: Thu, 9 Oct 2025 18:57:53 +0200 Subject: [PATCH 121/298] Refactor ProjectForm --- .../project/components/pages/ProjectForm.js | 23 ++++++------------- .../assets/js/project/utils/findById.js | 14 ----------- 2 files changed, 7 insertions(+), 30 deletions(-) delete mode 100644 rdmo/projects/assets/js/project/utils/findById.js diff --git a/rdmo/projects/assets/js/project/components/pages/ProjectForm.js b/rdmo/projects/assets/js/project/components/pages/ProjectForm.js index 8a9359e437..2656805ecc 100644 --- a/rdmo/projects/assets/js/project/components/pages/ProjectForm.js +++ b/rdmo/projects/assets/js/project/components/pages/ProjectForm.js @@ -1,8 +1,9 @@ -import React, { useEffect, useState } from 'react' +import React, { useState } from 'react' import PropTypes from 'prop-types' import { useDispatch, useSelector } from 'react-redux' import AsyncSelect from 'react-select/async' import { useDebouncedCallback } from 'use-debounce' +import { isEmpty } from 'lodash' import Html from 'rdmo/core/assets/js/components/Html' import Input from 'rdmo/core/assets/js/components/forms/Input' @@ -10,12 +11,11 @@ import Textarea from 'rdmo/core/assets/js/components/forms/Textarea' import { updateProject } from '../../actions/projectActions' import { useFieldErrors } from '../../hooks/useFieldErrors' -import { findById } from '../../utils/findById' import ProjectApi from '../../api/ProjectApi' const ProjectForm = ({ disabled }) => { - const { project, hierarchy, catalogs } = useSelector((state) => state.project.project) + const { project, catalogs } = useSelector((state) => state.project.project) const templates = useSelector((state) => state.templates) const dispatch = useDispatch() const errors = useFieldErrors() @@ -24,8 +24,6 @@ const ProjectForm = ({ disabled }) => { const [enableParent, setEnableParent] = useState(!!project.parent) const [parentOptions, setParentOptions] = useState([]) - const parentProject = project?.parent ? findById(hierarchy, project.parent) : null - const saveProject = (newFormData) => { dispatch(updateProject(newFormData)) } @@ -71,17 +69,7 @@ const ProjectForm = ({ disabled }) => { } } - useEffect(() => { - if (formData.parent && !parentOptions.some(p => p.value === formData.parent)) { - if (parentProject) { - const option = { value: parentProject.id, label: parentProject.title } - setParentOptions((prev) => [...prev, option]) - } - } - }, [formData.parent, parentOptions]) - return ( - // { noOptionsMessage={() => gettext('No projects matching your search.')} loadingMessage={() => gettext('Loading ...')} defaultOptions={parentOptions} - value={parentOptions.find(p => p.value === formData.parent) || null} + value={isEmpty(parentOptions) ? { + value: project.parent, + label: project.parent_title + } : parentOptions.find(p => p.value === formData.parent)} onChange={(option) => handleChange('parent', option ? option.value : null)} getOptionValue={(project) => project.value} getOptionLabel={(project) => project.label} diff --git a/rdmo/projects/assets/js/project/utils/findById.js b/rdmo/projects/assets/js/project/utils/findById.js deleted file mode 100644 index 9a5cfcbc52..0000000000 --- a/rdmo/projects/assets/js/project/utils/findById.js +++ /dev/null @@ -1,14 +0,0 @@ -export const findById = (node, id) => { - if (node.id === id) { - return node - } - - if (node.children && node.children.length > 0) { - for (const child of node.children) { - const found = findById(child, id) - if (found) return found - } - } - - return null -} From 9af5fd9ef2fdc3d24a34ccd5c4e2160b42fe5e21 Mon Sep 17 00:00:00 2001 From: Jochen Klar Date: Thu, 9 Oct 2025 19:02:08 +0200 Subject: [PATCH 122/298] Use isAdminOrSiteManager in projects --- .../js/projects/components/helper/ProjectFilters.js | 10 +++++----- .../assets/js/projects/components/main/Projects.js | 6 +++--- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/rdmo/projects/assets/js/projects/components/helper/ProjectFilters.js b/rdmo/projects/assets/js/projects/components/helper/ProjectFilters.js index de71c31f15..df713345cd 100644 --- a/rdmo/projects/assets/js/projects/components/helper/ProjectFilters.js +++ b/rdmo/projects/assets/js/projects/components/helper/ProjectFilters.js @@ -8,7 +8,7 @@ import { getDateFormat, getLocale, parseDate } from 'rdmo/core/assets/js/utils/d import { Link, Select } from 'rdmo/core/assets/js/components' -const ProjectFilters = ({ catalogs, config, configActions, isManager, projectsActions }) => { +const ProjectFilters = ({ catalogs, config, configActions, isAdminOrSiteManager, projectsActions }) => { const showFilters = [true, 'true'].includes(get(config, 'showFilters', false)) const toggleFilters = () => configActions.updateConfig('showFilters', !showFilters) @@ -22,7 +22,7 @@ const ProjectFilters = ({ catalogs, config, configActions, isManager, projectsAc projectsActions.fetchProjects() } - const catalogOptions = catalogs?.filter(catalog => isManager || catalog.available) + const catalogOptions = catalogs?.filter(catalog => isAdminOrSiteManager || catalog.available) .map(catalog => ({ value: catalog.id.toString(), label: ( @@ -76,7 +76,7 @@ const ProjectFilters = ({ catalogs, config, configActions, isManager, projectsAc
    -
    +
    + + + + + + + + + + {snapshots?.map((snapshot, index) => { + + return ( + + + + + + + ) + })} + +
    {gettext('Snapshot').toUpperCase()}{gettext('Description').toUpperCase()}{gettext('Created').toUpperCase()}
    {snapshot.title} + {snapshot.description} + + {useFormattedDateTime(snapshot.created, language)} + + {perms.can_view_snapshot && ( +
    + + ) +} + +SnapshotsTable.propTypes = { + snapshots: PropTypes.array.isRequired, +} + +export default SnapshotsTable diff --git a/rdmo/projects/serializers/v1/__init__.py b/rdmo/projects/serializers/v1/__init__.py index ab850b9571..377212a960 100644 --- a/rdmo/projects/serializers/v1/__init__.py +++ b/rdmo/projects/serializers/v1/__init__.py @@ -529,7 +529,9 @@ class Meta: fields = ( 'id', 'title', - 'description' + 'description', + 'created', + 'updated' ) From 90dc577ce22d0a798ffbe118a576150a21a90160 Mon Sep 17 00:00:00 2001 From: Claudia Malzer Date: Thu, 13 Nov 2025 17:54:26 +0100 Subject: [PATCH 142/298] * add snapshots and documents components * add views api * extend actions and reducer files * simplify reducer cases --- .../assets/js/project/actions/actionTypes.js | 12 ++ .../js/project/actions/projectActions.js | 176 +++++++++++++----- .../assets/js/project/api/ProjectApi.js | 16 +- .../assets/js/project/api/ViewsApi.js | 12 ++ .../js/project/components/ProjectPage.js | 6 +- .../js/project/components/pages/Documents.js | 111 +++++++++++ .../project/components/pages/SnapshotModal.js | 109 +++++++++++ .../js/project/components/pages/Snapshots.js | 27 +-- .../components/pages/SnapshotsTable.js | 61 +++--- .../js/project/reducers/projectReducer.js | 146 +++++++-------- .../assets/js/project/store/configureStore.js | 4 + 11 files changed, 507 insertions(+), 173 deletions(-) create mode 100644 rdmo/projects/assets/js/project/api/ViewsApi.js create mode 100644 rdmo/projects/assets/js/project/components/pages/Documents.js create mode 100644 rdmo/projects/assets/js/project/components/pages/SnapshotModal.js diff --git a/rdmo/projects/assets/js/project/actions/actionTypes.js b/rdmo/projects/assets/js/project/actions/actionTypes.js index aee5aa9754..9297a3a0f7 100644 --- a/rdmo/projects/assets/js/project/actions/actionTypes.js +++ b/rdmo/projects/assets/js/project/actions/actionTypes.js @@ -32,3 +32,15 @@ export const LEAVE_PROJECT_INIT = 'LEAVE_PROJECT_INIT' export const LEAVE_PROJECT_SUCCESS = 'LEAVE_PROJECT_SUCCESS' export const LEAVE_PROJECT_ERROR = 'LEAVE_PROJECT_ERROR' export const CLEAR_PROJECT_ERRORS = 'CLEAR_PROJECT_ERRORS' +export const CREATE_SNAPSHOT_INIT = 'CREATE_SNAPSHOT_INIT' +export const CREATE_SNAPSHOT_SUCCESS = 'CREATE_SNAPSHOT_SUCCESS' +export const CREATE_SNAPSHOT_ERROR = 'CREATE_SNAPSHOT_ERROR' +export const UPDATE_SNAPSHOT_INIT = 'UPDATE_SNAPSHOT_INIT' +export const UPDATE_SNAPSHOT_SUCCESS = 'UPDATE_SNAPSHOT_SUCCESS' +export const UPDATE_SNAPSHOT_ERROR = 'UPDATE_SNAPSHOT_ERROR' +export const ROLLBACK_SNAPSHOT_INIT = 'ROLLBACK_SNAPSHOT_INIT' +export const ROLLBACK_SNAPSHOT_SUCCESS = 'ROLLBACK_SNAPSHOT_SUCCESS' +export const ROLLBACK_SNAPSHOT_ERROR = 'ROLLBACK_SNAPSHOT_ERROR' +export const FETCH_PROJECT_VIEWS_INIT = 'FETCH_PROJECT_VIEWS_INIT' +export const FETCH_PROJECT_VIEWS_SUCCESS = 'FETCH_PROJECT_VIEWS_SUCCESS' +export const FETCH_PROJECT_VIEWS_ERROR = 'FETCH_PROJECT_VIEWS_ERROR' diff --git a/rdmo/projects/assets/js/project/actions/projectActions.js b/rdmo/projects/assets/js/project/actions/projectActions.js index 773674c6ed..1609e5860a 100644 --- a/rdmo/projects/assets/js/project/actions/projectActions.js +++ b/rdmo/projects/assets/js/project/actions/projectActions.js @@ -8,19 +8,19 @@ import { projectId } from '../utils/meta' import { updateLocation } from '../utils/location' import ProjectApi from '../api/ProjectApi' +import ViewsApi from '../api/ViewsApi' import * as actionTypes from './actionTypes' - export function setPage(page) { - return function(dispatch) { + return function (dispatch) { dispatch(updateConfig('page', page)) updateLocation(page) } } export function fetchProject() { - return function(dispatch) { + return function (dispatch) { dispatch(addToPending('fetchProject')) dispatch(fetchProjectInit()) @@ -33,24 +33,24 @@ export function fetchProject() { ProjectApi.fetchProjectMembershipHierarchy(projectId), CatalogsApi.fetchCatalogs() ]) - .then(([project, hierarchy, snapshots, tasks, memberships, membershipHierarchy, catalogs]) => { - const projectData = { - project: project, - hierarchy: hierarchy, - snapshots: snapshots, - tasks: tasks, - memberships: [...memberships, ...membershipHierarchy], - catalogs: catalogs - } - - dispatch(removeFromPending('fetchProject')) - dispatch(fetchProjectSuccess(projectData)) - }) - .catch(error => { - dispatch(removeFromPending('fetchProject')) - dispatch(fetchProjectError(error)) - throw error - }) + .then(([project, hierarchy, snapshots, tasks, memberships, membershipHierarchy, catalogs]) => { + const projectData = { + project: project, + hierarchy: hierarchy, + snapshots: snapshots, + tasks: tasks, + memberships: [...memberships, ...membershipHierarchy], + catalogs: catalogs + } + + dispatch(removeFromPending('fetchProject')) + dispatch(fetchProjectSuccess(projectData)) + }) + .catch(error => { + dispatch(removeFromPending('fetchProject')) + dispatch(fetchProjectError(error)) + throw error + }) } } @@ -67,7 +67,7 @@ export function fetchProjectError(error) { } export function updateProject(data) { - return function(dispatch, getState) { + return function (dispatch, getState) { const state = getState() const currentBundle = state.project.project const id = currentBundle?.project?.id @@ -124,7 +124,7 @@ export function updateProjectError(error) { } export function deleteProject() { - return function(dispatch) { + return function (dispatch) { dispatch(addToPending('deleteProject')) dispatch(deleteProjectInit()) @@ -156,7 +156,7 @@ export function deleteProjectError(error) { } export function fetchProjectInvites() { - return function(dispatch) { + return function (dispatch) { dispatch(addToPending('fetchProjectInvites')) dispatch(fetchProjectInvitesInit()) @@ -185,7 +185,7 @@ export function fetchProjectInvitesError(error) { } export function createProjectMember(data) { - return function(dispatch) { + return function (dispatch) { dispatch(addToPending('createProjectMember')) dispatch(createProjectMemberInit()) @@ -215,7 +215,7 @@ export function createProjectMemberError(error) { } export function updateProjectMember(membershipId, data) { - return function(dispatch, getState) { + return function (dispatch, getState) { dispatch(addToPending('updateProjectMember')) dispatch(updateProjectMemberInit()) @@ -259,7 +259,7 @@ export function updateProjectMemberError(error) { } export function deleteProjectMember(membershipId) { - return function(dispatch) { + return function (dispatch) { dispatch(addToPending('deleteProjectMember')) dispatch(deleteProjectMemberInit()) @@ -272,7 +272,7 @@ export function deleteProjectMember(membershipId) { dispatch(removeFromPending('deleteProjectMember')) dispatch(deleteProjectMemberError(error)) throw error - }) + }) } } @@ -289,7 +289,7 @@ export function deleteProjectMemberError(error) { } export function sendProjectInvite(data) { - return function(dispatch) { + return function (dispatch) { dispatch(addToPending('sendInvite')) dispatch(sendProjectInviteInit()) @@ -319,14 +319,14 @@ export function sendProjectInviteError(error) { } export function updateProjectInvite(inviteId, data) { - return function(dispatch) { + return function (dispatch) { dispatch(addToPending('updateProjectInvite')) dispatch(updateProjectInviteInit()) return ProjectApi.updateInvite(projectId, inviteId, data) .then(invite => { dispatch(removeFromPending('updateProjectInvite')) - dispatch(updateProjectInviteSuccess({...invite, id: inviteId})) + dispatch(updateProjectInviteSuccess({ ...invite, id: inviteId })) }) .catch(error => { dispatch(removeFromPending('updateProjectInvite')) @@ -349,7 +349,7 @@ export function updateProjectInviteError(error) { } export function deleteProjectInvite(inviteId) { - return function(dispatch) { + return function (dispatch) { dispatch(addToPending('deleteProjectInvite')) dispatch(deleteProjectInviteInit()) @@ -379,24 +379,24 @@ export function deleteProjectInviteError(error) { } export function leaveProject(membershipId, { redirect = false } = {}) { - return function(dispatch) { + return function (dispatch) { dispatch(addToPending('leaveProject')) dispatch(leaveProjectInit()) return ProjectApi.leaveProject(projectId) - .then(() => { - dispatch(removeFromPending('leaveProject')) - dispatch(leaveProjectSuccess(membershipId)) - if (redirect) { - window.location.href = `${baseUrl}/projects/` - return - } - }) - .catch(error => { - dispatch(removeFromPending('leaveProject')) - dispatch(leaveProjectError(error)) - throw error - }) + .then(() => { + dispatch(removeFromPending('leaveProject')) + dispatch(leaveProjectSuccess(membershipId)) + if (redirect) { + window.location.href = `${baseUrl}/projects/` + return + } + }) + .catch(error => { + dispatch(removeFromPending('leaveProject')) + dispatch(leaveProjectError(error)) + throw error + }) } } @@ -415,3 +415,87 @@ export function leaveProjectError(error) { export function clearProjectErrors() { return { type: actionTypes.CLEAR_PROJECT_ERRORS } } + +export function createSnapshot(data) { + return function (dispatch) { + dispatch(addToPending('createSnapshot')) + dispatch({ type: actionTypes.CREATE_SNAPSHOT_INIT }) + + return ProjectApi.createSnapshot(projectId, data) + .then(snapshot => { + dispatch(removeFromPending('createSnapshot')) + dispatch({ type: actionTypes.CREATE_SNAPSHOT_SUCCESS, snapshot }) + }) + .catch(error => { + dispatch(removeFromPending('createSnapshot')) + dispatch({ type: actionTypes.CREATE_SNAPSHOT_ERROR, error }) + throw error + }) + } +} + +export function updateSnapshot(snapshotId, data) { + return function (dispatch) { + dispatch(addToPending('updateSnapshot')) + dispatch({ type: actionTypes.UPDATE_SNAPSHOT_INIT }) + + return ProjectApi.updateSnapshot(projectId, snapshotId, data) + .then(snapshot => { + dispatch(removeFromPending('updateSnapshot')) + dispatch({ type: actionTypes.UPDATE_SNAPSHOT_SUCCESS, snapshot }) + }) + .catch(error => { + dispatch(removeFromPending('updateSnapshot')) + dispatch({ type: actionTypes.UPDATE_SNAPSHOT_ERROR, error }) + throw error + }) + } +} + +export function rollbackSnapshot(snapshotId, data) { + return function (dispatch) { + dispatch(addToPending('rollbackSnapshot')) + dispatch({ type: actionTypes.ROLLBACK_SNAPSHOT_INIT }) + + return ProjectApi.rollbackSnapshot(projectId, snapshotId, data) + .then(snapshot => { + dispatch(removeFromPending('rollbackSnapshot')) + dispatch({ type: actionTypes.ROLLBACK_SNAPSHOT_SUCCESS, snapshot }) + }) + .catch(error => { + dispatch(removeFromPending('rollbackSnapshot')) + dispatch({ type: actionTypes.ROLLBACK_SNAPSHOT_ERROR, error }) + throw error + }) + } +} + +export function fetchProjectViews(viewIds) { + return function (dispatch) { + dispatch(addToPending('fetchProjectViews')) + dispatch(fetchProjectViewsInit()) + + return Promise.all(viewIds.map(id => ViewsApi.fetchView(id))) + .then(projectViews => { + dispatch(removeFromPending('fetchProjectViews')) + dispatch(fetchProjectViewsSuccess(projectViews)) + }) + .catch(error => { + dispatch(removeFromPending('fetchProjectViews')) + dispatch(fetchProjectViewsError(error)) + throw error + }) + } +} + +export function fetchProjectViewsInit() { + return { type: actionTypes.FETCH_PROJECT_VIEWS_INIT } +} + +export function fetchProjectViewsSuccess(projectViews) { + return { type: actionTypes.FETCH_PROJECT_VIEWS_SUCCESS, projectViews } +} + +export function fetchProjectViewsError(error) { + return { type: actionTypes.FETCH_PROJECT_VIEWS_ERROR, error } +} diff --git a/rdmo/projects/assets/js/project/api/ProjectApi.js b/rdmo/projects/assets/js/project/api/ProjectApi.js index 9919f7cbce..727fb5b827 100644 --- a/rdmo/projects/assets/js/project/api/ProjectApi.js +++ b/rdmo/projects/assets/js/project/api/ProjectApi.js @@ -32,10 +32,6 @@ export default class ProjectApi extends BaseApi { return this.get(`/api/v1/projects/projects/${projectId}/invites/`) } - static fetchViews() { - return this.get('/api/v1/projects/views/views/') - } - static fetchProjects(params) { return this.get(`/api/v1/projects/projects/?${encodeParams(params)}`) } @@ -75,4 +71,16 @@ export default class ProjectApi extends BaseApi { static deleteInvite(projectId, inviteId) { return this.delete(`/api/v1/projects/projects/${projectId}/invites/${inviteId}/`) } + + static createSnapshot(projectId, data) { + return this.post(`/api/v1/projects/projects/${projectId}/snapshots/`, data) + } + + static updateSnapshot(projectId, snapshotId, data) { + return this.put(`/api/v1/projects/projects/${projectId}/snapshots/${snapshotId}/`, data) + } + + static rollbackSnapshot(projectId, snapshotId, data) { + return this.post(`/api/v1/projects/projects/${projectId}/snapshots/${snapshotId}/rollback`, data) + } } diff --git a/rdmo/projects/assets/js/project/api/ViewsApi.js b/rdmo/projects/assets/js/project/api/ViewsApi.js new file mode 100644 index 0000000000..d40f1f832f --- /dev/null +++ b/rdmo/projects/assets/js/project/api/ViewsApi.js @@ -0,0 +1,12 @@ +import BaseApi from 'rdmo/core/assets/js/api/BaseApi' + +class ViewsApi extends BaseApi { + static fetchViews() { + return this.get('/api/v1/projects/views/views/') + } + static fetchView(viewId) { + return this.get(`/api/v1/views/views/${viewId}/`) + } +} + +export default ViewsApi diff --git a/rdmo/projects/assets/js/project/components/ProjectPage.js b/rdmo/projects/assets/js/project/components/ProjectPage.js index 388a68c77e..2abfd72cc1 100644 --- a/rdmo/projects/assets/js/project/components/ProjectPage.js +++ b/rdmo/projects/assets/js/project/components/ProjectPage.js @@ -3,7 +3,7 @@ import { useSelector } from 'react-redux' import Dashboard from './pages/Dashboard' // import Interview from '../pages/Interview' -// import Documents from '../pages/Documents' +import Documents from './pages/Documents' import Snapshots from './pages/Snapshots' import Membership from './pages/Membership' import ProjectData from './pages/ProjectData' @@ -35,8 +35,8 @@ const ProjectPage = () => { return // case 'interview': // return - // case 'documents': - // return + case 'documents': + return case 'snapshots': return case 'project-information': diff --git a/rdmo/projects/assets/js/project/components/pages/Documents.js b/rdmo/projects/assets/js/project/components/pages/Documents.js new file mode 100644 index 0000000000..d12d748e9e --- /dev/null +++ b/rdmo/projects/assets/js/project/components/pages/Documents.js @@ -0,0 +1,111 @@ +import React from 'react' +import { useSelector } from 'react-redux' +// import { isEmpty } from 'lodash' +import { Tile } from '../helper' + +const Documents = () => { + const { projectViews } = useSelector((state) => state.project) ?? {} + const { project } = useSelector((state) => state.project.project) ?? {} + const perms = project?.permissions ?? {} + + // const renderView = (view) => { + // return ( + //
    + //
    {view.title}
    + + // {view.description && ( + //
    + // {view.description} + //
    + // )} + + // + // {gettext('Download')} + // + //
    + // ) + // } + + const renderView = (view) => { + return ( +
    +
    + +
    +
    +
    {view.title}
    + {view.description && ( +
    + {view.description} +
    + )} + + {gettext('Download')} + +
    +
    + ) + } + + return ( + <> +
    +
    {gettext('Documents')}
    + {/* */} + {/* + +
      +
    • null}> + null}>{'dummy1'} +
    • +
    • null}> + null}>{'dummy2'} +
    • +
    +
    */} + {perms.can_view_snapshot && ( + + + + )} +
    +
    +
    + {projectViews.map((view, index) => ( + // + + {renderView(view)} + + ))} +
    +
    + {'Work in progress: Documents Page '} + + ) +} + +export default Documents diff --git a/rdmo/projects/assets/js/project/components/pages/SnapshotModal.js b/rdmo/projects/assets/js/project/components/pages/SnapshotModal.js new file mode 100644 index 0000000000..26cc068a4e --- /dev/null +++ b/rdmo/projects/assets/js/project/components/pages/SnapshotModal.js @@ -0,0 +1,109 @@ +import React, { useEffect, useState } from 'react' +import PropTypes from 'prop-types' +import { useDispatch } from 'react-redux' + +import { Modal } from 'rdmo/core/assets/js/_bs53/components' + +import { + createSnapshot, + updateSnapshot, + clearProjectErrors +} from '../../actions/projectActions' +import { useFieldErrors } from '../../hooks/useFieldErrors' + +const initialForm = { title: '', description: '' } + +const SnapshotModal = ({ show, onClose, snapshot }) => { + const dispatch = useDispatch() + const errors = useFieldErrors() + + const [formData, setFormData] = useState(initialForm) + + const isEdit = !!(snapshot && snapshot.id) + const formId = isEdit ? 'update-snapshot-form' : 'create-snapshot-form' + + useEffect(() => { + if (show) { + if (isEdit) { + setFormData({ + title: snapshot.title, + description: snapshot.description + }) + } else { + setFormData(initialForm) + } + dispatch(clearProjectErrors()) + } + }, [show, snapshot, dispatch]) + + const setField = (key, value) => { + setFormData(prev => ({ ...prev, [key]: value })) + } + + const handleSubmit = (e) => { + e.preventDefault() + try { + if (snapshot && snapshot.id) { + dispatch(updateSnapshot(snapshot.id, formData)) + } else { + dispatch(createSnapshot(formData)) + } + onClose() + } catch { + // keep modal open; errors are shown via useFieldErrors + } + } + + return ( + { }} // render the Modal's submit button + submitLabel={isEdit ? gettext('Update snapshot') : gettext('Create snapshot')} + submitProps={{ type: 'submit', form: formId }} + size="modal-lg" + > + + +
    {gettext('The title for this snapshot.')}
    + setField('title', e.target.value)} + /> + + +
    {gettext('The description for this snapshot.')}
    + setField('description', e.target.value)} + /> + + {errors.non_field_errors?.map((err, i) => ( +
    {err}
    + ))} + +
    + ) +} + +SnapshotModal.propTypes = { + show: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, + snapshot: PropTypes.object, +} + +export default SnapshotModal diff --git a/rdmo/projects/assets/js/project/components/pages/Snapshots.js b/rdmo/projects/assets/js/project/components/pages/Snapshots.js index 0c149bbd2e..3a15c51a1c 100644 --- a/rdmo/projects/assets/js/project/components/pages/Snapshots.js +++ b/rdmo/projects/assets/js/project/components/pages/Snapshots.js @@ -2,12 +2,13 @@ import React from 'react' import { useSelector } from 'react-redux' import { isEmpty } from 'lodash' -// import { useModal } from 'rdmo/core/assets/js/hooks' +import { useModal } from 'rdmo/core/assets/js/hooks' import SnapshotsTable from './SnapshotsTable' +import SnapshotModal from './SnapshotModal' const Snapshots = () => { - // const { show: showSnapshot, open: openSnapshot, close: closeSnapshot } = useModal() + const { show: showSnapshot, open: openSnapshot, close: closeSnapshot } = useModal() const { snapshots, project } = useSelector((state) => state.project.project) ?? {} const perms = project?.permissions ?? {} @@ -17,22 +18,24 @@ const Snapshots = () => {
    {gettext('Snapshots')}
    {perms.can_add_snapshot && ( - + <> + + )}
    { - !isEmpty(snapshots) && ( + !isEmpty(snapshots) && perms.can_view_snapshot && ( ) } - {/* */} + ) } diff --git a/rdmo/projects/assets/js/project/components/pages/SnapshotsTable.js b/rdmo/projects/assets/js/project/components/pages/SnapshotsTable.js index f3a430d104..80dc712fb9 100644 --- a/rdmo/projects/assets/js/project/components/pages/SnapshotsTable.js +++ b/rdmo/projects/assets/js/project/components/pages/SnapshotsTable.js @@ -1,39 +1,30 @@ -import React from 'react' +import React, { useState } from 'react' import { useSelector } from 'react-redux' import PropTypes from 'prop-types' import { useFormattedDateTime } from 'rdmo/core/assets/js/hooks' import { language } from 'rdmo/core/assets/js/utils' +import { useModal } from 'rdmo/core/assets/js/hooks' -// import { useModal } from 'rdmo/core/assets/js/hooks' - -// import Select from 'rdmo/core/assets/js/components/Select' - -// import { updateProjectMember, updateProjectInvite } from '../../actions/projectActions' - -// import MembershipDeleteModal from './MembershipDeleteModal' +import SnapshotModal from './SnapshotModal' const SnapshotsTable = ({ snapshots }) => { - // const dispatch = useDispatch() - // const currentUser = useSelector((state) => state.user.currentUser) const { project } = useSelector((state) => state.project.project) || {} const perms = project?.permissions || {} - console.log('perms', perms) - // const { show: showConfirm, open: openConfirm, close: closeConfirm } = useModal() - // const [modalState, setModalState] = useState(null) + const { show: showUpdate, open: openUpdate, close: closeUpdate } = useModal() + // const { show: showRollback, open: openRollback, close: closeRollback } = useModal() + const [selectedSnapshot, setSelectedSnapshot] = useState(null) - // const isAdminOrSiteManager = currentUser?.is_superuser || currentUser?.is_site_manager + const openUpdateModal = (snapshot) => { + setSelectedSnapshot(snapshot) + openUpdate() + } - // const openDeleteModal = (person, isCurrentUser) => { - // setModalState({ person, isCurrentUser }) - // openConfirm() - // } - - // const closeDeleteModal = () => { - // setModalState(null) - // closeConfirm() - // } + const closeUpdateModal = () => { + setSelectedSnapshot(null) + closeUpdate() + } return (
    @@ -47,10 +38,9 @@ const SnapshotsTable = ({ snapshots }) => { - {snapshots?.map((snapshot, index) => { - + {snapshots?.map((snapshot) => { return ( - + {snapshot.title} {snapshot.description} @@ -65,12 +55,10 @@ const SnapshotsTable = ({ snapshots }) => { className="btn btn-link p-0" aria-label={gettext('View answers')} title={gettext('View answers')} - // onClick={() => openDeleteModal(person, isCurrentUser)} >