From 8886c579c32dd84b34f9263697eb856742ab335c Mon Sep 17 00:00:00 2001 From: Jochen Klar Date: Mon, 22 Dec 2025 13:47:51 +0100 Subject: [PATCH 001/198] Update CHANGELOG.md --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 170b542dd5..3b51214069 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog 📔 +## [RDMO 2.5.0](https://github.com/rdmorganiser/rdmo/releases/tag/2.5.0) + +**Milestone**: [2.5.0](https://github.com/rdmorganiser/rdmo/milestone/26) + +**Commit history**: [2.4.0...2.5.0](https://github.com/rdmorganiser/rdmo/compare/2.4.0...2.5.0) + + ## [RDMO 2.4.0](https://github.com/rdmorganiser/rdmo/releases/tag/2.4.0) (December 15, 2025) ### Main improvements ⭐ From 94cb0f0eb609d7ca88260d9a2fecd4c5df2fe61d Mon Sep 17 00:00:00 2001 From: Jochen Klar Date: Thu, 8 Jan 2026 11:53:04 +0100 Subject: [PATCH 002/198] 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 5f9ab1c936f2fb9e01ef65bbef3a012cbb6fddbe Mon Sep 17 00:00:00 2001 From: Jochen Klar Date: Thu, 8 Jan 2026 17:20:14 +0100 Subject: [PATCH 003/198] 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 237846cef6..2531e8c4b4 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 a171e80a270743d449b37500f51e6db900454b9c 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 004/198] 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 92ab2654e5..221a2ef7ee 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 23cd12fa07..d53b5f583f 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 _ @@ -110,3 +111,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 ab36c2fe55a0799296b0dcd3f8039ce7e314bd79 Mon Sep 17 00:00:00 2001 From: David Wallace Date: Fri, 16 Jan 2026 14:18:50 +0100 Subject: [PATCH 005/198] 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 b7eadf66ceb15c285c785a6137232fb8804410b9 Mon Sep 17 00:00:00 2001 From: Jochen Klar Date: Mon, 19 Jan 2026 11:36:45 +0100 Subject: [PATCH 006/198] 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?

  • -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 + sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua

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
  • -No + No
  • -Yes + Yes

Radio buttons

Radio buttons?

  • -One + One
  • -Two + Two
  • -Three + Three

Select drop-down

Select drop-down?

  • -One + One
  • -Two + Two
  • -Three + Three

Select drop-down (free)

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

Range slider

Range slider?

  • -0 + 0
  • -50 + 50
  • -100 + 100

Date picker

Date picker?

  • -April 1, 2017 + April 1, 2017
  • -April 2, 2017 + April 2, 2017
  • -April 3, 2017 + April 3, 2017

File

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

Checkbox

Checkbox?

  • -One + One
  • -Three + Three

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?

  • -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 + sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua

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
  • -No + No
  • -Yes + Yes

Radio buttons?

  • -One + One
  • -Two + Two
  • -Three + Three

Select drop-down?

  • -One + One
  • -Two + Two
  • -Three + Three

Select drop-down (free)?

Range slider?

  • -0 + 0
  • -50 + 50
  • -100 + 100

Date picker?

  • -April 1, 2017 + April 1, 2017
  • -April 2, 2017 + April 2, 2017
  • -April 3, 2017 + April 3, 2017

File?

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

Individual sets II

Checkbox?

  • -One + One
  • -Three + Three

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

  • -Yes + Yes
  • -No + No
  • -Yes + Yes

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

Set collections II

  • -No + No
  • -Yes + Yes
  • -No + No

Radio buttons?

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

Set collections II

  • -One + One
  • -Two + Two
  • -Three + Three

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

Set collections II

  • -Three + Three
  • -Two + Two
  • -One + One

Select drop-down?

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

Set collections II

  • -One + One
  • -Two + Two

Set "Second":  -Three + Three

Select drop-down (free)?

Range slider?

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

Set collections II

  • -16 + 16
  • -31 + 31

Set "Second":  -86 + 86

Date picker?

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

Set collections II

  • -Jan. 7, 2018 + Jan. 7, 2018
  • -Feb. 7, 2018 + Feb. 7, 2018

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

Set collections II

  • -Oct. 7, 2018 + Oct. 7, 2018
  • -Nov. 7, 2018 + Nov. 7, 2018

File?

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

Set collections II

  • -Two + Two
  • -Three + Three

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

  • -c01 + c01
  • -c02 + c02

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

A set of questionsets and questions

  • -c10 + c10
  • -c11 + c11

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 6e30a5f5e254964ec383f85bdbf2fa9307923533 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 007/198] 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 1d55b1752c..544d71fedb 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 221a2ef7ee..9cb07c90c7 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 00288eaf5f13823d12d0b936d2da231a8e63b492 Mon Sep 17 00:00:00 2001 From: Jochen Klar Date: Sun, 25 Aug 2024 15:52:59 +0200 Subject: [PATCH 008/198] 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 bab90e7a6897e4308a0b6c9bbb973e8a64ae40e8 Mon Sep 17 00:00:00 2001 From: Claudia Malzer Date: Thu, 25 Sep 2025 18:20:49 +0200 Subject: [PATCH 105/198] * 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 22e6012a1d26561b0eb4b2f8a70d7b271635474f Mon Sep 17 00:00:00 2001 From: Claudia Malzer Date: Fri, 26 Sep 2025 15:10:10 +0200 Subject: [PATCH 106/198] * 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 3bfce45438025d6b14e58c446755b8ad2b7dc3dd Mon Sep 17 00:00:00 2001 From: Jochen Klar Date: Fri, 10 Oct 2025 11:05:53 +0200 Subject: [PATCH 128/198] 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 4303ca9dfe107df49ad66db913f6eb69a58d787a Mon Sep 17 00:00:00 2001 From: Jochen Klar Date: Fri, 10 Oct 2025 12:57:35 +0200 Subject: [PATCH 129/198] 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 88cdfdcc5cc430417cf25d379a3acfc44e502aa1 Mon Sep 17 00:00:00 2001 From: Jochen Klar Date: Fri, 24 Oct 2025 10:12:26 +0200 Subject: [PATCH 130/198] 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 beca4f29d40b5151156916a2e534ec508a400c40 Mon Sep 17 00:00:00 2001 From: Jochen Klar Date: Fri, 24 Oct 2025 10:12:37 +0200 Subject: [PATCH 131/198] 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 5e066e231e..97c2f5e44e 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 aa1975fa94a0f7d578174f482c7e8ce51e9e20fc Mon Sep 17 00:00:00 2001 From: Jochen Klar Date: Fri, 24 Oct 2025 11:50:35 +0200 Subject: [PATCH 132/198] 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 398793de2c7c758c003c71536759dfd7dd625f49 Mon Sep 17 00:00:00 2001 From: David Wallace Date: Wed, 29 Oct 2025 13:42:50 +0100 Subject: [PATCH 133/198] 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 ed08f4fbd12fab153d74184b7f8d180b51beaf0d Mon Sep 17 00:00:00 2001 From: Jochen Klar Date: Tue, 21 Oct 2025 14:49:50 +0200 Subject: [PATCH 134/198] 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 97c2f5e44e..af18648771 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, @@ -89,6 +91,7 @@ filter_tasks_or_views_for_project, get_contact_message, get_upload_accept, + get_value_path, send_contact_message, send_invite_email, ) @@ -424,6 +427,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 91cc736e648900e03241802a9bc9bcf678c2ed73 Mon Sep 17 00:00:00 2001 From: Jochen Klar Date: Fri, 24 Oct 2025 10:41:05 +0200 Subject: [PATCH 135/198] 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 a9e7254c4b87c02ccb87c91cdf8061de3d8e99fe Mon Sep 17 00:00:00 2001 From: Jochen Klar Date: Fri, 24 Oct 2025 10:56:23 +0200 Subject: [PATCH 136/198] 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 af18648771..c6de6542ab 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, @@ -438,16 +440,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]+)') @@ -482,11 +487,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 11cb43f56370c2edeb11f62127456079805952cb Mon Sep 17 00:00:00 2001 From: Jochen Klar Date: Fri, 24 Oct 2025 11:44:00 +0200 Subject: [PATCH 137/198] 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 c6de6542ab..38cbf6bd57 100644 --- a/rdmo/projects/viewsets.py +++ b/rdmo/projects/viewsets.py @@ -429,8 +429,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() @@ -453,9 +452,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() @@ -472,7 +476,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() @@ -497,7 +507,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() @@ -519,6 +536,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 4a55b0248b60a53710634fbd2300a49dee5ff222 Mon Sep 17 00:00:00 2001 From: Jochen Klar Date: Fri, 24 Oct 2025 11:44:12 +0200 Subject: [PATCH 138/198] 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 761399842af97a49439a6666a67901496b4dcd7c Mon Sep 17 00:00:00 2001 From: Jochen Klar Date: Thu, 30 Oct 2025 09:39:05 +0100 Subject: [PATCH 139/198] 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 38cbf6bd57..d33831b58c 100644 --- a/rdmo/projects/viewsets.py +++ b/rdmo/projects/viewsets.py @@ -429,7 +429,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() @@ -452,14 +457,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() @@ -475,8 +488,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 d8e507b2e31dd198d26dbff2fa08a0d96f3d0490 Mon Sep 17 00:00:00 2001 From: Jochen Klar Date: Thu, 30 Oct 2025 17:44:27 +0100 Subject: [PATCH 140/198] 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 d33831b58c..88b2c8a22a 100644 --- a/rdmo/projects/viewsets.py +++ b/rdmo/projects/viewsets.py @@ -463,7 +463,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) @@ -494,7 +494,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) @@ -517,6 +517,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 1af9cc4d84e116638f6ca73dd2f701f023e7f3c4 Mon Sep 17 00:00:00 2001 From: Jochen Klar Date: Thu, 13 Nov 2025 20:44:02 +0100 Subject: [PATCH 141/198] 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 88b2c8a22a..e8aae6f508 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, @@ -444,9 +446,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, @@ -498,9 +498,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() @@ -514,26 +521,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() @@ -556,9 +558,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 646497b382b989e32b1fe997b8d641b6240a0225 Mon Sep 17 00:00:00 2001 From: Claudia Malzer Date: Thu, 30 Oct 2025 13:25:02 +0100 Subject: [PATCH 142/198] * 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 04600c6c16..c791cf960a 100644 --- a/rdmo/projects/views/project.py +++ b/rdmo/projects/views/project.py @@ -30,6 +30,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', @@ -42,6 +47,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 b896a65f0c..93d59d9591 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -49,6 +49,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 73c1d5b53f3adb11fc441f23ec171cec66969e3a Mon Sep 17 00:00:00 2001 From: Jochen Klar Date: Sun, 25 Aug 2024 16:18:21 +0200 Subject: [PATCH 009/198] 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 b9411d6c06..e86f6e9b5a 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 93d59d9591..db3673f726 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -13,6 +13,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 80e3139bdceef210b9a3ef4785a928116b4aaf5a Mon Sep 17 00:00:00 2001 From: Jochen Klar Date: Sun, 25 Aug 2024 16:46:27 +0200 Subject: [PATCH 010/198] 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 2fc13444074eb26aa42c0c154a538c408ca2ef24 Mon Sep 17 00:00:00 2001 From: Jochen Klar Date: Sun, 25 Aug 2024 19:54:35 +0200 Subject: [PATCH 011/198] 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 fcd5b3a58769f41b4578c402f0a16b1854823f76 Mon Sep 17 00:00:00 2001 From: Jochen Klar Date: Fri, 4 Apr 2025 11:45:45 +0200 Subject: [PATCH 012/198] 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 e86f6e9b5a..ba0e1f4c78 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.21", @@ -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": "^13.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 828b7204c540e87f0bb3809663b00cce98c15e0a Mon Sep 17 00:00:00 2001 From: Jochen Klar Date: Thu, 7 Aug 2025 14:17:22 +0200 Subject: [PATCH 088/198] 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 4af698e4c1..9b3fec9d6c 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", @@ -9641,6 +9650,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 aafc309251..4346f689f4 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 107/198] * 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 114/198] * 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 ac7a0182de..84faedcd7e 100644 --- a/rdmo/projects/viewsets.py +++ b/rdmo/projects/viewsets.py @@ -157,7 +157,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 fe6e88cb7362049fa14f5b08c55b6b31464337ec Mon Sep 17 00:00:00 2001 From: Claudia Malzer Date: Thu, 2 Oct 2025 14:41:23 +0200 Subject: [PATCH 115/198] * 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 c67421e164566b325f664f2886b923addcc1f2fa Mon Sep 17 00:00:00 2001 From: Claudia Malzer Date: Mon, 6 Oct 2025 12:35:50 +0200 Subject: [PATCH 116/198] * 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 22f91851925d4f6a9869353c86cf132f4ecf087e Mon Sep 17 00:00:00 2001 From: Jochen Klar Date: Thu, 9 Oct 2025 13:25:40 +0200 Subject: [PATCH 117/198] 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 dc8d3d7eb0ddb6476f1127cc658009164e539f83 Mon Sep 17 00:00:00 2001 From: Jochen Klar Date: Thu, 9 Oct 2025 15:01:07 +0200 Subject: [PATCH 118/198] 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 fd228c4b6c97af682ecd8ce74772e594c7a1efb0 Mon Sep 17 00:00:00 2001 From: Claudia Malzer Date: Tue, 30 Sep 2025 16:58:49 +0200 Subject: [PATCH 108/198] * 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 2d09cdc4b30161f99855241ec3be419fcdb8fe2b Mon Sep 17 00:00:00 2001 From: Claudia Malzer Date: Tue, 30 Sep 2025 17:04:00 +0200 Subject: [PATCH 109/198] * 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 4326deb82d14736abfd28ad53696f2abd88c97df Mon Sep 17 00:00:00 2001 From: Claudia Malzer Date: Wed, 1 Oct 2025 11:29:16 +0200 Subject: [PATCH 110/198] * 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 6a11e27c0e007cedd47930b183a672ecb81ddc5a Mon Sep 17 00:00:00 2001 From: Claudia Malzer Date: Wed, 1 Oct 2025 11:57:08 +0200 Subject: [PATCH 111/198] * 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 5a08d28780a950d23345538f1737ba4067d6f3ca Mon Sep 17 00:00:00 2001 From: Claudia Malzer Date: Wed, 1 Oct 2025 12:19:27 +0200 Subject: [PATCH 112/198] * 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 daf3ac92b72f0d789c5863ebe50a6c56d25c9691 Mon Sep 17 00:00:00 2001 From: Claudia Malzer Date: Thu, 2 Oct 2025 12:08:46 +0200 Subject: [PATCH 113/198] * 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 65fab28a2c4759cc0f15b31de6d815cadc902859 Mon Sep 17 00:00:00 2001 From: Claudia Malzer Date: Thu, 9 Oct 2025 20:18:17 +0200 Subject: [PATCH 119/198] * 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 b5462cfcbe75fe057f994458f0e3ed03abd504e6 Mon Sep 17 00:00:00 2001 From: Jochen Klar Date: Thu, 9 Oct 2025 18:14:34 +0200 Subject: [PATCH 120/198] 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 2e6ab47fbdd14ffe315721284fcb11aa48ddf988 Mon Sep 17 00:00:00 2001 From: Jochen Klar Date: Thu, 9 Oct 2025 18:56:15 +0200 Subject: [PATCH 121/198] 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 44a59fe6c2a3412dbb7b7f41c72766322f956199 Mon Sep 17 00:00:00 2001 From: Jochen Klar Date: Thu, 9 Oct 2025 18:57:53 +0200 Subject: [PATCH 122/198] 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 af56b74b062ed24da9e3f54d397008734bc00b0e Mon Sep 17 00:00:00 2001 From: Jochen Klar Date: Thu, 9 Oct 2025 19:02:08 +0200 Subject: [PATCH 123/198] Use isAdminOrSiteManager in projects --- .../components/helper/ProjectFilters.js | 26 +++++++++---------- .../js/projects/components/main/Projects.js | 6 ++--- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/rdmo/projects/assets/js/projects/components/helper/ProjectFilters.js b/rdmo/projects/assets/js/projects/components/helper/ProjectFilters.js index a6d23a5bfa..23794af9a8 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 { formatISO, set } from 'date-fns' import { Link, Select } from 'rdmo/core/assets/js/components' import useDatePicker from '../../hooks/useDatePicker' -const ProjectFilters = ({ catalogs, config, configActions, isManager, projectsActions }) => { +const ProjectFilters = ({ catalogs, config, configActions, isAdminOrSiteManager, projectsActions }) => { const { dateRange, dateFormat, @@ -34,15 +34,15 @@ const ProjectFilters = ({ catalogs, config, configActions, isManager, projectsAc projectsActions.fetchProjects() } - const catalogOptions = catalogs?.filter(catalog => isManager || catalog.available) - .map(catalog => ({ - value: catalog.id.toString(), - label: ( - - {catalog.title} - - ), - })) + const catalogOptions = catalogs?.filter(catalog => isAdminOrSiteManager || catalog.available) + .map(catalog => ({ + value: catalog.id.toString(), + label: ( + + {catalog.title} + + ), + })) const selectedCatalog = get(config, 'params.catalog', '') const updateCatalogFilter = (value) => { value ? configActions.updateConfig('params.catalog', value) : configActions.deleteConfig('params.catalog') @@ -77,7 +77,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 aeb5ba01af110de1d7a8988b2b352af1d8220b20 Mon Sep 17 00:00:00 2001 From: Claudia Malzer Date: Thu, 13 Nov 2025 17:54:26 +0100 Subject: [PATCH 143/198] * 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)} >