%s"
+"code>."
+msgstr ""
+
+#: management/assets/js/components/edit/EditTask.js:44
+#: management/assets/js/components/element/Task.js:28
+msgid "This task is read only"
+msgstr ""
+
+#: management/assets/js/components/edit/EditTask.js:51
+#: management/assets/js/components/element/Task.js:42
+#: management/assets/js/components/import/ImportTask.js:30
+#: management/assets/js/constants/elements.js:53
+msgid "Task"
+msgstr ""
+
+#: management/assets/js/components/edit/EditTask.js:53
+msgid "Create task"
+msgstr ""
+
+#: management/assets/js/components/edit/EditView.js:38
+#: management/assets/js/components/element/View.js:28
+msgid "This view is read only"
+msgstr ""
+
+#: management/assets/js/components/edit/EditView.js:45
+#: management/assets/js/components/element/View.js:42
+#: management/assets/js/components/import/ImportView.js:30
+#: management/assets/js/constants/elements.js:54
+msgid "View"
+msgstr ""
+
+#: management/assets/js/components/edit/EditView.js:47
+msgid "Create view"
+msgstr ""
+
+#: management/assets/js/components/edit/common/MultiSelect.js:69
+#: management/assets/js/components/edit/common/OrderedMultiSelect.js:63
+#: management/assets/js/components/edit/common/Select.js:56
+msgid "Edit"
+msgstr ""
+
+#: management/assets/js/components/edit/common/MultiSelect.js:70
+#: management/assets/js/components/edit/common/OrderedMultiSelect.js:65
+msgid "Remove"
+msgstr ""
+
+#: management/assets/js/components/edit/common/MultiSelect.js:85
+#, javascript-format
+msgid "Add %s"
+msgstr ""
+
+#: management/assets/js/components/edit/common/MultiSelect.js:90
+#: management/assets/js/components/edit/common/Select.js:68
+#, javascript-format
+msgid "Create new %s"
+msgstr ""
+
+#: management/assets/js/components/edit/common/UriPrefix.js:35
+#: management/assets/js/components/import/common/UriPrefix.js:21
+#: management/assets/js/components/sidebar/ImportSidebar.js:70
+msgid "Insert default URI Prefix"
+msgstr ""
+
+#: management/assets/js/components/element/Attribute.js:32
+msgid "View attribute nested"
+msgstr ""
+
+#: management/assets/js/components/element/Attribute.js:34
+msgid "Edit attribute"
+msgstr ""
+
+#: management/assets/js/components/element/Attribute.js:35
+msgid "Copy attribute"
+msgstr ""
+
+#: management/assets/js/components/element/Attribute.js:36
+msgid "Add attribute"
+msgstr ""
+
+#: management/assets/js/components/element/Attribute.js:37
+msgid "Unlock attribute"
+msgstr ""
+
+#: management/assets/js/components/element/Attribute.js:37
+msgid "Lock attribute"
+msgstr ""
+
+#: management/assets/js/components/element/Attribute.js:39
+msgid "Export attribute"
+msgstr ""
+
+#: management/assets/js/components/element/Catalog.js:36
+msgid "View catalog nested"
+msgstr ""
+
+#: management/assets/js/components/element/Catalog.js:37
+msgid "Edit catalog"
+msgstr ""
+
+#: management/assets/js/components/element/Catalog.js:38
+msgid "Copy catalog"
+msgstr ""
+
+#: management/assets/js/components/element/Catalog.js:39
+msgid "Add section"
+msgstr ""
+
+#: management/assets/js/components/element/Catalog.js:40
+msgid "Make catalog unavailable"
+msgstr ""
+
+#: management/assets/js/components/element/Catalog.js:41
+msgid "Make catalog available"
+msgstr ""
+
+#: management/assets/js/components/element/Catalog.js:44
+msgid "Unlock catalog"
+msgstr ""
+
+#: management/assets/js/components/element/Catalog.js:44
+msgid "Lock catalog"
+msgstr ""
+
+#: management/assets/js/components/element/Catalog.js:46
+msgid "Export catalog"
+msgstr ""
+
+#: management/assets/js/components/element/Condition.js:28
+msgid "Edit condition"
+msgstr ""
+
+#: management/assets/js/components/element/Condition.js:29
+msgid "Copy condition"
+msgstr ""
+
+#: management/assets/js/components/element/Condition.js:30
+msgid "Unlock condition"
+msgstr ""
+
+#: management/assets/js/components/element/Condition.js:30
+msgid "Lock condition"
+msgstr ""
+
+#: management/assets/js/components/element/Condition.js:32
+msgid "Export condition"
+msgstr ""
+
+#: management/assets/js/components/element/Option.js:28
+msgid "Edit option"
+msgstr ""
+
+#: management/assets/js/components/element/Option.js:29
+msgid "Copy option"
+msgstr ""
+
+#: management/assets/js/components/element/Option.js:30
+msgid "Unlock option"
+msgstr ""
+
+#: management/assets/js/components/element/Option.js:30
+msgid "Lock option"
+msgstr ""
+
+#: management/assets/js/components/element/Option.js:32
+msgid "Export option"
+msgstr ""
+
+#: management/assets/js/components/element/OptionSet.js:32
+msgid "View option set nested"
+msgstr ""
+
+#: management/assets/js/components/element/OptionSet.js:33
+msgid "Edit option set"
+msgstr ""
+
+#: management/assets/js/components/element/OptionSet.js:34
+msgid "Copy option set"
+msgstr ""
+
+#: management/assets/js/components/element/OptionSet.js:35
+msgid "Add option"
+msgstr ""
+
+#: management/assets/js/components/element/OptionSet.js:36
+msgid "Unlock option set"
+msgstr ""
+
+#: management/assets/js/components/element/OptionSet.js:36
+msgid "Lock option set"
+msgstr ""
+
+#: management/assets/js/components/element/OptionSet.js:38
+msgid "Export option set"
+msgstr ""
+
+#: management/assets/js/components/element/Page.js:43
+msgid "View page nested"
+msgstr ""
+
+#: management/assets/js/components/element/Page.js:45
+msgid "Edit page"
+msgstr ""
+
+#: management/assets/js/components/element/Page.js:46
+msgid "Copy page"
+msgstr ""
+
+#: management/assets/js/components/element/Page.js:47
+#: management/assets/js/components/element/QuestionSet.js:46
+msgid "Add question"
+msgstr ""
+
+#: management/assets/js/components/element/Page.js:47
+#: management/assets/js/components/element/QuestionSet.js:46
+msgid "Add question set"
+msgstr ""
+
+#: management/assets/js/components/element/Page.js:49
+msgid "Unlock page"
+msgstr ""
+
+#: management/assets/js/components/element/Page.js:49
+msgid "Lock page"
+msgstr ""
+
+#: management/assets/js/components/element/Page.js:51
+msgid "Export page"
+msgstr ""
+
+#: management/assets/js/components/element/Question.js:34
+msgid "Edit question"
+msgstr ""
+
+#: management/assets/js/components/element/Question.js:35
+msgid "Copy question"
+msgstr ""
+
+#: management/assets/js/components/element/Question.js:36
+msgid "Unlock question"
+msgstr ""
+
+#: management/assets/js/components/element/Question.js:36
+msgid "Lock question"
+msgstr ""
+
+#: management/assets/js/components/element/Question.js:38
+msgid "Export question"
+msgstr ""
+
+#: management/assets/js/components/element/QuestionSet.js:42
+msgid "View question set nested"
+msgstr ""
+
+#: management/assets/js/components/element/QuestionSet.js:44
+msgid "Edit question set"
+msgstr ""
+
+#: management/assets/js/components/element/QuestionSet.js:45
+msgid "Copy question set"
+msgstr ""
+
+#: management/assets/js/components/element/QuestionSet.js:48
+msgid "Unlock question set"
+msgstr ""
+
+#: management/assets/js/components/element/QuestionSet.js:48
+msgid "Lock question set"
+msgstr ""
+
+#: management/assets/js/components/element/QuestionSet.js:50
+msgid "Export question set"
+msgstr ""
+
+#: management/assets/js/components/element/Section.js:40
+msgid "View section nested"
+msgstr ""
+
+#: management/assets/js/components/element/Section.js:42
+msgid "Edit section"
+msgstr ""
+
+#: management/assets/js/components/element/Section.js:43
+msgid "Copy section"
+msgstr ""
+
+#: management/assets/js/components/element/Section.js:44
+msgid "Add page"
+msgstr ""
+
+#: management/assets/js/components/element/Section.js:45
+msgid "Unlock section"
+msgstr ""
+
+#: management/assets/js/components/element/Section.js:46
+msgid "Lock section"
+msgstr ""
+
+#: management/assets/js/components/element/Section.js:48
+msgid "Export section"
+msgstr ""
+
+#: management/assets/js/components/element/Task.js:29
+msgid "Edit task"
+msgstr ""
+
+#: management/assets/js/components/element/Task.js:30
+msgid "Copy task"
+msgstr ""
+
+#: management/assets/js/components/element/Task.js:31
+msgid "Make task unavailable"
+msgstr ""
+
+#: management/assets/js/components/element/Task.js:32
+msgid "Make task available"
+msgstr ""
+
+#: management/assets/js/components/element/Task.js:35
+msgid "Unlock task"
+msgstr ""
+
+#: management/assets/js/components/element/Task.js:35
+msgid "Lock task"
+msgstr ""
+
+#: management/assets/js/components/element/Task.js:37
+msgid "Export task"
+msgstr ""
+
+#: management/assets/js/components/element/View.js:29
+msgid "Edit view"
+msgstr ""
+
+#: management/assets/js/components/element/View.js:30
+msgid "Copy view"
+msgstr ""
+
+#: management/assets/js/components/element/View.js:31
+msgid "Make view unavailable"
+msgstr ""
+
+#: management/assets/js/components/element/View.js:32
+msgid "Make view available"
+msgstr ""
+
+#: management/assets/js/components/element/View.js:35
+msgid "Unlock view"
+msgstr ""
+
+#: management/assets/js/components/element/View.js:35
+msgid "Lock view"
+msgstr ""
+
+#: management/assets/js/components/element/View.js:37
+msgid "Export view"
+msgstr ""
+
+#: management/assets/js/components/elements/Attributes.js:26
+#: management/assets/js/components/elements/Pages.js:56
+#: management/assets/js/components/elements/QuestionSets.js:56
+#: management/assets/js/components/elements/Questions.js:57
+#: management/assets/js/components/nested/NestedCatalog.js:75
+#: management/assets/js/components/nested/NestedPage.js:67
+#: management/assets/js/components/nested/NestedQuestionSet.js:63
+#: management/assets/js/components/nested/NestedSection.js:71
+msgid "Attributes"
+msgstr ""
+
+#: management/assets/js/components/elements/Attributes.js:33
+#: management/assets/js/components/nested/NestedAttribute.js:32
+msgid "Filter attributes"
+msgstr ""
+
+#: management/assets/js/components/elements/Attributes.js:42
+#: management/assets/js/components/elements/Catalogs.js:51
+#: management/assets/js/components/elements/Conditions.js:43
+#: management/assets/js/components/elements/OptionSets.js:43
+#: management/assets/js/components/elements/Options.js:46
+#: management/assets/js/components/elements/Pages.js:48
+#: management/assets/js/components/elements/QuestionSets.js:48
+#: management/assets/js/components/elements/Questions.js:49
+#: management/assets/js/components/elements/Sections.js:46
+#: management/assets/js/components/elements/Tasks.js:49
+#: management/assets/js/components/elements/Views.js:49
+msgid "All editors"
+msgstr ""
+
+#: management/assets/js/components/elements/Catalogs.js:30
+#: management/assets/js/components/elements/Catalogs.js:58
+#: management/assets/js/components/nested/NestedCatalog.js:65
+msgid "Catalogs"
+msgstr ""
+
+#: management/assets/js/components/elements/Catalogs.js:37
+#: management/assets/js/components/nested/NestedCatalog.js:50
+msgid "Filter catalogs"
+msgstr ""
+
+#: management/assets/js/components/elements/Catalogs.js:57
+#: management/assets/js/components/elements/Options.js:51
+#: management/assets/js/components/elements/Pages.js:53
+#: management/assets/js/components/elements/QuestionSets.js:53
+#: management/assets/js/components/elements/Questions.js:54
+#: management/assets/js/components/elements/Sections.js:51
+#: management/assets/js/components/nested/NestedCatalog.js:64
+#: management/assets/js/components/nested/NestedOptionSet.js:44
+#: management/assets/js/components/nested/NestedPage.js:60
+#: management/assets/js/components/nested/NestedQuestionSet.js:58
+#: management/assets/js/components/nested/NestedSection.js:62
+msgid "Show URIs:"
+msgstr ""
+
+#: management/assets/js/components/elements/Conditions.js:27
+#: management/assets/js/components/elements/Pages.js:58
+#: management/assets/js/components/elements/QuestionSets.js:58
+#: management/assets/js/components/elements/Questions.js:59
+#: management/assets/js/components/nested/NestedCatalog.js:77
+#: management/assets/js/components/nested/NestedPage.js:69
+#: management/assets/js/components/nested/NestedQuestionSet.js:65
+#: management/assets/js/components/nested/NestedSection.js:73
+msgid "Conditions"
+msgstr ""
+
+#: management/assets/js/components/elements/Conditions.js:34
+msgid "Filter conditions"
+msgstr ""
+
+#: management/assets/js/components/elements/OptionSets.js:27
+#: management/assets/js/components/elements/Questions.js:61
+#: management/assets/js/components/nested/NestedCatalog.js:79
+#: management/assets/js/components/nested/NestedPage.js:71
+#: management/assets/js/components/nested/NestedQuestionSet.js:67
+#: management/assets/js/components/nested/NestedSection.js:75
+msgid "Option sets"
+msgstr ""
+
+#: management/assets/js/components/elements/OptionSets.js:34
+#: management/assets/js/components/nested/NestedOptionSet.js:36
+msgid "Filter option sets"
+msgstr ""
+
+#: management/assets/js/components/elements/Options.js:30
+#: management/assets/js/components/elements/Options.js:52
+#: management/assets/js/components/nested/NestedOptionSet.js:45
+msgid "Options"
+msgstr ""
+
+#: management/assets/js/components/elements/Options.js:37
+msgid "Filter options"
+msgstr ""
+
+#: management/assets/js/components/elements/Pages.js:32
+#: management/assets/js/components/elements/Pages.js:54
+#: management/assets/js/components/nested/NestedCatalog.js:60
+#: management/assets/js/components/nested/NestedCatalog.js:69
+#: management/assets/js/components/nested/NestedPage.js:61
+#: management/assets/js/components/nested/NestedSection.js:58
+#: management/assets/js/components/nested/NestedSection.js:65
+msgid "Pages"
+msgstr ""
+
+#: management/assets/js/components/elements/Pages.js:39
+#: management/assets/js/components/nested/NestedPage.js:48
+msgid "Filter pages"
+msgstr ""
+
+#: management/assets/js/components/elements/QuestionSets.js:32
+#: management/assets/js/components/elements/QuestionSets.js:54
+#: management/assets/js/components/nested/NestedCatalog.js:61
+#: management/assets/js/components/nested/NestedCatalog.js:71
+#: management/assets/js/components/nested/NestedPage.js:57
+#: management/assets/js/components/nested/NestedPage.js:63
+#: management/assets/js/components/nested/NestedQuestionSet.js:55
+#: management/assets/js/components/nested/NestedQuestionSet.js:59
+#: management/assets/js/components/nested/NestedSection.js:59
+#: management/assets/js/components/nested/NestedSection.js:67
+msgid "Question sets"
+msgstr ""
+
+#: management/assets/js/components/elements/QuestionSets.js:39
+#: management/assets/js/components/nested/NestedQuestionSet.js:46
+msgid "Filter question sets"
+msgstr ""
+
+#: management/assets/js/components/elements/Questions.js:33
+#: management/assets/js/components/elements/Questions.js:55
+#: management/assets/js/components/nested/NestedCatalog.js:73
+#: management/assets/js/components/nested/NestedPage.js:65
+#: management/assets/js/components/nested/NestedQuestionSet.js:61
+#: management/assets/js/components/nested/NestedSection.js:69
+msgid "Questions"
+msgstr ""
+
+#: management/assets/js/components/elements/Questions.js:40
+msgid "Filter questions"
+msgstr ""
+
+#: management/assets/js/components/elements/Sections.js:30
+#: management/assets/js/components/elements/Sections.js:52
+#: management/assets/js/components/nested/NestedCatalog.js:59
+#: management/assets/js/components/nested/NestedCatalog.js:67
+#: management/assets/js/components/nested/NestedSection.js:63
+msgid "Sections"
+msgstr ""
+
+#: management/assets/js/components/elements/Sections.js:37
+#: management/assets/js/components/nested/NestedSection.js:49
+msgid "Filter sections"
+msgstr ""
+
+#: management/assets/js/components/elements/Tasks.js:28
+msgid "Tasks"
+msgstr ""
+
+#: management/assets/js/components/elements/Tasks.js:35
+msgid "Filter tasks"
+msgstr ""
+
+#: management/assets/js/components/elements/Views.js:28
+msgid "Views"
+msgstr ""
+
+#: management/assets/js/components/elements/Views.js:35
+msgid "Filter views"
+msgstr ""
+
+#: management/assets/js/components/import/ImportCatalog.js:22
+msgid "catalog"
+msgstr ""
+
+#: management/assets/js/components/import/ImportTask.js:22
+msgid "task"
+msgstr ""
+
+#: management/assets/js/components/import/ImportView.js:22
+msgid "view"
+msgstr ""
+
+#: management/assets/js/components/import/common/Errors.js:9
+msgid "Errors"
+msgstr ""
+
+#: management/assets/js/components/import/common/Key.js:12
+msgid "Key"
+msgstr ""
+
+#: management/assets/js/components/import/common/UriPath.js:12
+msgid "URI path"
+msgstr ""
+
+#: management/assets/js/components/import/common/UriPrefix.js:12
+#: management/assets/js/components/sidebar/ImportSidebar.js:61
+#: management/assets/js/components/sidebar/ImportSidebar.js:65
+msgid "URI prefix"
+msgstr ""
+
+#: management/assets/js/components/import/common/Warnings.js:9
+msgid "Warnings"
+msgstr ""
+
+#: management/assets/js/components/info/AttributeInfo.js:36
+#, javascript-format
+msgid "This attribute is used for %s values in one project ."
+msgid_plural ""
+"This attribute is used for %s values in %s projects ."
+msgstr[0] ""
+msgstr[1] ""
+
+#: management/assets/js/components/info/AttributeInfo.js:43
+#, javascript-format
+msgid "This attribute has one decendant ."
+msgid_plural "This attribute has %s decendants ."
+msgstr[0] ""
+msgstr[1] ""
+
+#: management/assets/js/components/info/AttributeInfo.js:59
+#, javascript-format
+msgid "This attribute is used in one condition ."
+msgid_plural "This attribute is used in %s conditions ."
+msgstr[0] ""
+msgstr[1] ""
+
+#: management/assets/js/components/info/AttributeInfo.js:74
+#, javascript-format
+msgid "This attribute is used in one page ."
+msgid_plural "This attribute is used in %s pages ."
+msgstr[0] ""
+msgstr[1] ""
+
+#: management/assets/js/components/info/AttributeInfo.js:89
+#, javascript-format
+msgid "This attribute is used in one questionset ."
+msgid_plural "This attribute is used in %s questionsets ."
+msgstr[0] ""
+msgstr[1] ""
+
+#: management/assets/js/components/info/AttributeInfo.js:104
+#, javascript-format
+msgid "This attribute is used in one question ."
+msgid_plural "This attribute is used in %s questions ."
+msgstr[0] ""
+msgstr[1] ""
+
+#: management/assets/js/components/info/AttributeInfo.js:119
+#, javascript-format
+msgid "This attribute is used in one task ."
+msgid_plural "This attribute is used in %s tasks ."
+msgstr[0] ""
+msgstr[1] ""
+
+#: management/assets/js/components/info/CatalogInfo.js:9
+#, javascript-format
+msgid "This catalog is used in one project ."
+msgid_plural "This catalog is used in %s projects ."
+msgstr[0] ""
+msgstr[1] ""
+
+#: management/assets/js/components/info/ConditionInfo.js:33
+#, javascript-format
+msgid "This condition is used for one optionset ."
+msgid_plural "This condition is used for %s optionsets ."
+msgstr[0] ""
+msgstr[1] ""
+
+#: management/assets/js/components/info/ConditionInfo.js:48
+#, javascript-format
+msgid "This condition is used for one page ."
+msgid_plural "This condition is used for %s pages ."
+msgstr[0] ""
+msgstr[1] ""
+
+#: management/assets/js/components/info/ConditionInfo.js:63
+#, javascript-format
+msgid "This condition is used for one questionset ."
+msgid_plural "This condition is used for %s questionsets ."
+msgstr[0] ""
+msgstr[1] ""
+
+#: management/assets/js/components/info/ConditionInfo.js:78
+#, javascript-format
+msgid "This condition is used for one question ."
+msgid_plural "This condition is used for %s questions ."
+msgstr[0] ""
+msgstr[1] ""
+
+#: management/assets/js/components/info/ConditionInfo.js:93
+#, javascript-format
+msgid "This condition is used for one task ."
+msgid_plural "This condition is used for %s tasks ."
+msgstr[0] ""
+msgstr[1] ""
+
+#: management/assets/js/components/info/OptionInfo.js:20
+#, javascript-format
+msgid "This option is used for %s values in one project ."
+msgid_plural "This option is used for %s values in %s projects ."
+msgstr[0] ""
+msgstr[1] ""
+
+#: management/assets/js/components/info/OptionInfo.js:26
+#, javascript-format
+msgid "This option is used in one condition ."
+msgid_plural "This option is used in %s conditions ."
+msgstr[0] ""
+msgstr[1] ""
+
+#: management/assets/js/components/info/OptionSetInfo.js:21
+#, javascript-format
+msgid "This option set is used in one question ."
+msgid_plural "This option set is used in %s questions ."
+msgstr[0] ""
+msgstr[1] ""
+
+#: management/assets/js/components/info/PageInfo.js:21
+#, javascript-format
+msgid "This page is used in one section ."
+msgid_plural "This page is used in %s sections ."
+msgstr[0] ""
+msgstr[1] ""
+
+#: management/assets/js/components/info/QuestionInfo.js:24
+#, javascript-format
+msgid "This question is used in one page ."
+msgid_plural "This question is used in %s pages ."
+msgstr[0] ""
+msgstr[1] ""
+
+#: management/assets/js/components/info/QuestionInfo.js:39
+#: management/assets/js/components/info/QuestionSetInfo.js:39
+#, javascript-format
+msgid "This question set is used in one question set ."
+msgid_plural "This question set is used in %s question sets ."
+msgstr[0] ""
+msgstr[1] ""
+
+#: management/assets/js/components/info/QuestionSetInfo.js:24
+#, javascript-format
+msgid "This question set is used in one page ."
+msgid_plural "This question set is used in %s pages ."
+msgstr[0] ""
+msgstr[1] ""
+
+#: management/assets/js/components/info/SectionInfo.js:21
+#, javascript-format
+msgid "This section is used in one catalog ."
+msgid_plural "This section is used in %s catalogs ."
+msgstr[0] ""
+msgstr[1] ""
+
+#: management/assets/js/components/info/TaskInfo.js:9
+#, javascript-format
+msgid "This task is used in one project ."
+msgid_plural "This task is used in %s projects ."
+msgstr[0] ""
+msgstr[1] ""
+
+#: management/assets/js/components/info/ViewInfo.js:9
+#, javascript-format
+msgid "This view is used in one project ."
+msgid_plural "This view is used in %s projects ."
+msgstr[0] ""
+msgstr[1] ""
+
+#: management/assets/js/components/main/Import.js:26
+msgid "Import"
+msgstr ""
+
+#: management/assets/js/components/main/Import.js:38
+msgid "created"
+msgstr ""
+
+#: management/assets/js/components/main/Import.js:39
+msgid "updated"
+msgstr ""
+
+#: management/assets/js/components/main/Import.js:42
+msgid "could not be imported"
+msgstr ""
+
+#: management/assets/js/components/main/Import.js:46
+msgid "but could not be added to parent element"
+msgstr ""
+
+#: management/assets/js/components/modals/DeleteAttributeModal.js:7
+msgid "Delete attribute"
+msgstr ""
+
+#: management/assets/js/components/modals/DeleteAttributeModal.js:9
+msgid "You are about to permanently delete the attribute:"
+msgstr ""
+
+#: management/assets/js/components/modals/DeleteAttributeModal.js:16
+#: management/assets/js/components/modals/DeleteCatalogModal.js:16
+#: management/assets/js/components/modals/DeleteConditionModal.js:16
+#: management/assets/js/components/modals/DeleteOptionModal.js:16
+#: management/assets/js/components/modals/DeleteOptionSetModal.js:16
+#: management/assets/js/components/modals/DeletePageModal.js:16
+#: management/assets/js/components/modals/DeleteQuestionModal.js:16
+#: management/assets/js/components/modals/DeleteQuestionSetModal.js:16
+#: management/assets/js/components/modals/DeleteSectionModal.js:16
+#: management/assets/js/components/modals/DeleteTaskModal.js:16
+#: management/assets/js/components/modals/DeleteViewModal.js:16
+msgid "This action cannot be undone!"
+msgstr ""
+
+#: management/assets/js/components/modals/DeleteCatalogModal.js:7
+#: management/assets/js/components/modals/DeleteTaskModal.js:7
+#: management/assets/js/components/modals/DeleteViewModal.js:7
+msgid "Delete catalog"
+msgstr ""
+
+#: management/assets/js/components/modals/DeleteCatalogModal.js:9
+msgid "You are about to permanently delete the catalog:"
+msgstr ""
+
+#: management/assets/js/components/modals/DeleteCatalogModal.js:16
+msgid "Those projects will not be usable afterwards."
+msgstr ""
+
+#: management/assets/js/components/modals/DeleteConditionModal.js:7
+msgid "Delete condition"
+msgstr ""
+
+#: management/assets/js/components/modals/DeleteConditionModal.js:9
+msgid "You are about to permanently delete the condition:"
+msgstr ""
+
+#: management/assets/js/components/modals/DeleteOptionModal.js:7
+msgid "Delete option"
+msgstr ""
+
+#: management/assets/js/components/modals/DeleteOptionModal.js:9
+msgid "You are about to permanently delete the option:"
+msgstr ""
+
+#: management/assets/js/components/modals/DeleteOptionSetModal.js:7
+msgid "Delete option set"
+msgstr ""
+
+#: management/assets/js/components/modals/DeleteOptionSetModal.js:9
+msgid "You are about to permanently delete the option set:"
+msgstr ""
+
+#: management/assets/js/components/modals/DeletePageModal.js:7
+msgid "Delete page"
+msgstr ""
+
+#: management/assets/js/components/modals/DeletePageModal.js:9
+msgid "You are about to permanently delete the page:"
+msgstr ""
+
+#: management/assets/js/components/modals/DeleteQuestionModal.js:7
+msgid "Delete question"
+msgstr ""
+
+#: management/assets/js/components/modals/DeleteQuestionModal.js:9
+msgid "You are about to permanently delete the question:"
+msgstr ""
+
+#: management/assets/js/components/modals/DeleteQuestionSetModal.js:7
+msgid "Delete question set"
+msgstr ""
+
+#: management/assets/js/components/modals/DeleteQuestionSetModal.js:9
+msgid "You are about to permanently delete the question set:"
+msgstr ""
+
+#: management/assets/js/components/modals/DeleteSectionModal.js:7
+msgid "Delete section"
+msgstr ""
+
+#: management/assets/js/components/modals/DeleteSectionModal.js:9
+msgid "You are about to permanently delete the section:"
+msgstr ""
+
+#: management/assets/js/components/modals/DeleteTaskModal.js:9
+msgid "You are about to permanently delete the task:"
+msgstr ""
+
+#: management/assets/js/components/modals/DeleteTaskModal.js:16
+msgid "The task will be removed from these projects."
+msgstr ""
+
+#: management/assets/js/components/modals/DeleteViewModal.js:9
+msgid "You are about to permanently delete the view:"
+msgstr ""
+
+#: management/assets/js/components/modals/DeleteViewModal.js:16
+msgid "The view will be removed from these projects."
+msgstr ""
+
+#: management/assets/js/components/nested/NestedCatalog.js:58
+msgid "Toggle elements:"
+msgstr ""
+
+#: management/assets/js/components/nested/NestedPage.js:56
+#: management/assets/js/components/nested/NestedQuestionSet.js:54
+#: management/assets/js/components/nested/NestedSection.js:57
+msgid "Show elements:"
+msgstr ""
+
+#: management/assets/js/components/sidebar/ImportSidebar.js:23
+msgid "Import successful"
+msgstr ""
+
+#: management/assets/js/components/sidebar/ImportSidebar.js:35
+msgid "Import elements"
+msgstr ""
+
+#: management/assets/js/components/sidebar/ImportSidebar.js:39
+#, javascript-format
+msgid "Import one element"
+msgid_plural "Import %s elements"
+msgstr[0] ""
+msgstr[1] ""
+
+#: management/assets/js/components/sidebar/ImportSidebar.js:46
+msgid "Selection"
+msgstr ""
+
+#: management/assets/js/components/sidebar/ImportSidebar.js:51
+msgid "Select all"
+msgstr ""
+
+#: management/assets/js/components/sidebar/ImportSidebar.js:56
+msgid "Unselect all"
+msgstr ""
+
+#: management/assets/js/components/sidebar/ImportSidebar.js:75
+msgid "Set URI prefix for all elements"
+msgstr ""
diff --git a/rdmo/management/apps.py b/rdmo/management/apps.py
index b2a1f9ab09..40349d3b19 100644
--- a/rdmo/management/apps.py
+++ b/rdmo/management/apps.py
@@ -5,3 +5,6 @@
class ManagementConfig(AppConfig):
name = 'rdmo.management'
verbose_name = _('Management')
+
+ def ready(self):
+ from . import rules # noqa: F401
diff --git a/rdmo/management/assets/js/actions/configActions.js b/rdmo/management/assets/js/actions/configActions.js
new file mode 100644
index 0000000000..e3c57f2452
--- /dev/null
+++ b/rdmo/management/assets/js/actions/configActions.js
@@ -0,0 +1,53 @@
+import get from 'lodash/get'
+
+import CoreApi from 'rdmo/core/assets/js/api/CoreApi'
+import ManagementApi from '../api/ManagementApi'
+import ConditionsApi from '../api/ConditionsApi'
+import OptionsApi from '../api/OptionsApi'
+import QuestionsApi from '../api/QuestionsApi'
+
+import { elementTypes } from '../constants/elements'
+import { findDescendants } from '../utils/elements'
+
+
+export function fetchConfig() {
+ return (dispatch) => Promise.all([
+ ConditionsApi.fetchRelations(),
+ CoreApi.fetchGroups(),
+ CoreApi.fetchSettings(),
+ CoreApi.fetchSites(),
+ ManagementApi.fetchMeta(),
+ OptionsApi.fetchProviders(),
+ QuestionsApi.fetchValueTypes(),
+ QuestionsApi.fetchWidgetTypes()
+ ]).then(([relations, groups, settings, sites, meta, providers,
+ valueTypes, widgetTypes]) => dispatch(fetchConfigSuccess({
+ relations, groups, settings, sites, meta, providers, valueTypes, widgetTypes
+ })))
+}
+
+export function fetchConfigSuccess(config) {
+ return {type: 'config/fetchConfigSuccess', config}
+}
+
+export function fetchConfigError(errors) {
+ return {type: 'elements/fetchConfigError', errors}
+}
+
+export function updateConfig(path, value) {
+ return {type: 'config/updateConfig', path, value}
+}
+
+export function toggleElements(element) {
+ return (dispatch, getState) => {
+ const path = `display.elements.${elementTypes[element.model]}.${element.id}`
+ const value = !get(getState().config, path, true)
+ dispatch(updateConfig(path, value))
+ }
+}
+
+export function toggleDescandants(element, elementType) {
+ return (dispatch) => {
+ findDescendants(element, elementType).forEach(e => dispatch(toggleElements(e)))
+ }
+}
diff --git a/rdmo/management/assets/js/actions/elementActions.js b/rdmo/management/assets/js/actions/elementActions.js
new file mode 100644
index 0000000000..1c320eee1e
--- /dev/null
+++ b/rdmo/management/assets/js/actions/elementActions.js
@@ -0,0 +1,632 @@
+import isNil from 'lodash/isNil'
+
+import ConditionsApi from '../api/ConditionsApi'
+import DomainApi from '../api/DomainApi'
+import OptionsApi from '../api/OptionsApi'
+import QuestionsApi from '../api/QuestionsApi'
+import TasksApi from '../api/TasksApi'
+import ViewsApi from '../api/ViewsApi'
+
+import ConditionsFactory from '../factories/ConditionsFactory'
+import DomainFactory from '../factories/DomainFactory'
+import OptionsFactory from '../factories/OptionsFactory'
+import QuestionsFactory from '../factories/QuestionsFactory'
+import TasksFactory from '../factories/TasksFactory'
+import ViewsFactory from '../factories/ViewsFactory'
+
+import { elementTypes } from '../constants/elements'
+import { updateLocation } from '../utils/location'
+import { canMoveElement, moveElement } from '../utils/elements'
+
+export function fetchElements(elementType) {
+ return function(dispatch, getState) {
+ updateLocation(getState().config.baseUrl, elementType)
+
+ dispatch(fetchElementsInit(elementType))
+
+ let action
+ switch (elementType) {
+ case 'catalogs':
+ action = (dispatch) => QuestionsApi.fetchCatalogs(true)
+ .then(catalogs => dispatch(fetchElementsSuccess({ catalogs })))
+ break
+
+ case 'sections':
+ action = (dispatch) => QuestionsApi.fetchSections(true)
+ .then(sections => dispatch(fetchElementsSuccess({ sections })))
+ break
+
+ case 'pages':
+ action = (dispatch) => QuestionsApi.fetchPages(true)
+ .then(pages => dispatch(fetchElementsSuccess({ pages })))
+ break
+
+ case 'questionsets':
+ action = (dispatch) => QuestionsApi.fetchQuestionSets(true)
+ .then(questionsets => dispatch(fetchElementsSuccess({ questionsets })))
+ break
+
+ case 'questions':
+ action = (dispatch) => QuestionsApi.fetchQuestions(true)
+ .then(questions => dispatch(fetchElementsSuccess({ questions })))
+ break
+
+ case 'attributes':
+ action = (dispatch) => DomainApi.fetchAttributes(true)
+ .then(attributes => dispatch(fetchElementsSuccess({ attributes })))
+ break
+
+ case 'optionsets':
+ action = (dispatch) => OptionsApi.fetchOptionSets(true)
+ .then(optionsets => dispatch(fetchElementsSuccess({ optionsets })))
+ break
+
+ case 'options':
+ action = (dispatch) => OptionsApi.fetchOptions(true)
+ .then(options => dispatch(fetchElementsSuccess({ options })))
+ break
+
+ case 'conditions':
+ action = (dispatch) => ConditionsApi.fetchConditions(true)
+ .then(conditions => dispatch(fetchElementsSuccess({ conditions })))
+ break
+
+ case 'tasks':
+ action = (dispatch) => TasksApi.fetchTasks(true)
+ .then(tasks => dispatch(fetchElementsSuccess({ tasks })))
+ break
+
+ case 'views':
+ action = (dispatch) => ViewsApi.fetchViews(true)
+ .then(views => dispatch(fetchElementsSuccess({ views })))
+ break
+ }
+
+ return dispatch(action)
+ .catch(error => dispatch(fetchElementsError(error)))
+ }
+}
+
+export function fetchElementsInit(elementType) {
+ return {type: 'elements/fetchElementsInit', elementType}
+}
+
+export function fetchElementsSuccess(elements) {
+ return {type: 'elements/fetchElementsSuccess', elements}
+}
+
+export function fetchElementsError(error) {
+ return {type: 'elements/fetchElementsError', error}
+}
+
+// fetch element
+
+export function fetchElement(elementType, elementId, elementAction=null) {
+ return function(dispatch, getState) {
+ updateLocation(getState().config.baseUrl, elementType, elementId, elementAction)
+
+ dispatch(fetchElementInit(elementType, elementId, elementAction))
+
+ let action
+ switch (elementType) {
+ case 'catalogs':
+ if (elementAction == 'nested') {
+ action = () => QuestionsApi.fetchCatalog(elementId, 'nested')
+ .then(element => ({ element }))
+ } else {
+ action = () => Promise.all([
+ QuestionsApi.fetchCatalog(elementId),
+ QuestionsApi.fetchSections('index')
+ ]).then(([element, sections]) => ({
+ element, sections
+ }))
+ }
+ break
+
+ case 'sections':
+ if (elementAction == 'nested') {
+ action = () => QuestionsApi.fetchSection(elementId, 'nested')
+ .then(element => ({ element }))
+ } else {
+ action = () => Promise.all([
+ QuestionsApi.fetchSection(elementId),
+ QuestionsApi.fetchCatalogs('index'),
+ QuestionsApi.fetchPages('index'),
+ ]).then(([element, catalogs, pages]) => ({
+ element, catalogs, pages
+ }))
+ }
+ break
+
+ case 'pages':
+ if (elementAction == 'nested') {
+ action = () => QuestionsApi.fetchPage(elementId, 'nested')
+ .then(element => ({ element }))
+ } else {
+ action = () => Promise.all([
+ QuestionsApi.fetchPage(elementId),
+ DomainApi.fetchAttributes('index'),
+ ConditionsApi.fetchConditions('index'),
+ QuestionsApi.fetchSections('index'),
+ QuestionsApi.fetchQuestionSets('index'),
+ QuestionsApi.fetchQuestions('index')
+ ]).then(([element, attributes, conditions, sections,
+ questionsets, questions]) => ({
+ element, attributes, conditions, sections, questionsets, questions
+ }))
+ }
+ break
+
+ case 'questionsets':
+ if (elementAction == 'nested') {
+ action = () => QuestionsApi.fetchQuestionSet(elementId, 'nested')
+ .then(element => ({ element }))
+ } else {
+ action = () => Promise.all([
+ QuestionsApi.fetchQuestionSet(elementId),
+ DomainApi.fetchAttributes('index'),
+ ConditionsApi.fetchConditions('index'),
+ QuestionsApi.fetchPages('index'),
+ QuestionsApi.fetchQuestionSets('index'),
+ QuestionsApi.fetchQuestions('index')
+ ]).then(([element, attributes, conditions, pages,
+ questionsets, questions]) => ({
+ element, attributes, conditions, pages, questionsets, questions
+ }))
+ }
+ break
+
+ case 'questions':
+ if (elementAction == 'nested') {
+ action = () => QuestionsApi.fetchQuestion(elementId, 'nested')
+ .then(element => ({ element }))
+ } else {
+ action = () => Promise.all([
+ QuestionsApi.fetchQuestion(elementId),
+ DomainApi.fetchAttributes('index'),
+ OptionsApi.fetchOptionSets('index'),
+ OptionsApi.fetchOptions('index'),
+ ConditionsApi.fetchConditions('index'),
+ QuestionsApi.fetchPages('index'),
+ QuestionsApi.fetchQuestionSets('index')
+ ]).then(([element, attributes, optionsets, options, conditions,
+ pages, questionsets]) => ({
+ element, attributes, optionsets, options, conditions, pages, questionsets
+ }))
+ }
+ break
+
+ case 'attributes':
+ if (elementAction == 'nested') {
+ action = () => DomainApi.fetchAttribute(elementId, 'nested')
+ .then(element => ({ element }))
+ } else {
+ action = () => Promise.all([
+ DomainApi.fetchAttribute(elementId),
+ DomainApi.fetchAttributes('index'),
+ ConditionsApi.fetchConditions('index'),
+ QuestionsApi.fetchPages('index'),
+ QuestionsApi.fetchQuestionSets('index'),
+ QuestionsApi.fetchQuestions('index'),
+ TasksApi.fetchTasks('index'),
+ ]).then(([element, attributes, conditions, pages, questionsets,
+ questions, tasks]) => ({
+ element, attributes, conditions, pages, questionsets, questions, tasks
+ }))
+ }
+ break
+
+ case 'optionsets':
+ if (elementAction == 'nested') {
+ action = () => OptionsApi.fetchOptionSet(elementId, 'nested')
+ .then(element => ({ element }))
+ } else {
+ action = () => Promise.all([
+ OptionsApi.fetchOptionSet(elementId),
+ ConditionsApi.fetchConditions('index'),
+ OptionsApi.fetchOptions('index'),
+ QuestionsApi.fetchQuestions('index')
+ ]).then(([element, conditions, options, questions]) => ({
+ element, conditions, options, questions
+ }))
+ }
+ break
+
+ case 'options':
+ action = () => Promise.all([
+ OptionsApi.fetchOption(elementId),
+ OptionsApi.fetchOptionSets('index'),
+ ConditionsApi.fetchConditions('index'),
+ ]).then(([element, optionsets, conditions]) => ({
+ element, optionsets, conditions
+ }))
+ break
+
+ case 'conditions':
+ action = () => Promise.all([
+ ConditionsApi.fetchCondition(elementId),
+ DomainApi.fetchAttributes('index'),
+ OptionsApi.fetchOptionSets('index'),
+ OptionsApi.fetchOptions('index'),
+ QuestionsApi.fetchPages('index'),
+ QuestionsApi.fetchQuestionSets('index'),
+ QuestionsApi.fetchQuestions('index'),
+ TasksApi.fetchTasks('index'),
+ ]).then(([element, attributes, optionsets, options,
+ pages, questionsets, questions, tasks]) => ({
+ element, attributes, optionsets, options, pages, questionsets, questions, tasks
+ }))
+ break
+
+ case 'tasks':
+ action = () => Promise.all([
+ TasksApi.fetchTask(elementId),
+ DomainApi.fetchAttributes('index'),
+ ConditionsApi.fetchConditions('index'),
+ QuestionsApi.fetchCatalogs('index')
+ ]).then(([element, attributes, conditions, catalogs]) => ({
+ element, attributes, conditions, catalogs
+ }))
+ break
+
+ case 'views':
+ action = () => Promise.all([
+ ViewsApi.fetchView(elementId),
+ QuestionsApi.fetchCatalogs('index')
+ ]).then(([element, catalogs]) => ({
+ element, catalogs
+ }))
+ break
+ }
+
+ return dispatch(action)
+ .then(elements => {
+ if (elementAction == 'copy') {
+ const { settings, currentSite } = getState().config
+
+ elements.element.id = null
+ elements.element.read_only = false
+
+ if (settings.multisite) {
+ elements.element.sites = [currentSite.id]
+ elements.element.editors = [currentSite.id]
+ }
+ }
+ return dispatch(fetchElementSuccess({ ...elements }))
+ })
+ .catch(error => dispatch(fetchElementError(error)))
+ }
+}
+
+export function fetchElementInit(elementType, elementId, elementAction) {
+ return {type: 'elements/fetchElementInit', elementType, elementId, elementAction}
+}
+
+export function fetchElementSuccess(elements) {
+ return {type: 'elements/fetchElementSuccess', elements}
+}
+
+export function fetchElementError(error) {
+ return {type: 'elements/fetchElementError', error}
+}
+
+// store element
+
+export function storeElement(elementType, element, back) {
+ return function(dispatch, getState) {
+ dispatch(storeElementInit(element))
+
+ let action
+ switch (elementType) {
+ case 'catalogs':
+ action = () => QuestionsApi.storeCatalog(element)
+ break
+
+ case 'sections':
+ action = () => QuestionsApi.storeSection(element)
+ break
+
+ case 'pages':
+ action = () => QuestionsApi.storePage(element)
+ break
+
+ case 'questionsets':
+ action = () => QuestionsApi.storeQuestionSet(element)
+ break
+
+ case 'questions':
+ action = () => QuestionsApi.storeQuestion(element)
+ break
+
+ case 'attributes':
+ action = () => DomainApi.storeAttribute(element)
+ break
+
+ case 'optionsets':
+ action = () => OptionsApi.storeOptionSet(element)
+ break
+
+ case 'options':
+ action = () => OptionsApi.storeOption(element)
+ break
+
+ case 'conditions':
+ action = () => ConditionsApi.storeCondition(element)
+ break
+
+ case 'tasks':
+ action = () => TasksApi.storeTask(element)
+ break
+
+ case 'views':
+ action = () => ViewsApi.storeView(element)
+ break
+ }
+
+ return dispatch(action)
+ .then(element => {
+ dispatch(storeElementSuccess(element))
+ if (back) {
+ history.back()
+ } else if (getState().elements.elementAction == 'create') {
+ dispatch(fetchElement(getState().elements.elementType, element.id))
+ }
+ })
+ .catch(error => dispatch(storeElementError(element, error)))
+ }
+}
+
+export function storeElementInit(element) {
+ return {type: 'elements/storeElementInit', element}
+}
+
+export function storeElementSuccess(element) {
+ return {type: 'elements/storeElementSuccess', element}
+}
+
+export function storeElementError(element, error) {
+ return {type: 'elements/storeElementError', element, error}
+}
+
+// createElement
+
+export function createElement(elementType, parent={}) {
+ return function(dispatch, getState) {
+ updateLocation(getState().config.baseUrl, elementType, null, 'create')
+
+ dispatch(createElementInit(elementType))
+
+ let action
+ switch (elementType) {
+ case 'catalogs':
+ action = () => Promise.all([
+ QuestionsFactory.createCatalog(getState().config),
+ QuestionsApi.fetchSections('index')
+ ]).then(([element, sections]) => ({
+ element, sections
+ }))
+ break
+
+ case 'sections':
+ action = () => Promise.all([
+ QuestionsFactory.createSection(getState().config, parent),
+ QuestionsApi.fetchPages('index'),
+ ]).then(([element, pages]) => ({
+ element, parent, pages
+ }))
+ break
+
+ case 'pages':
+ action = () => Promise.all([
+ QuestionsFactory.createPage(getState().config, parent),
+ DomainApi.fetchAttributes('index'),
+ ConditionsApi.fetchConditions('index'),
+ QuestionsApi.fetchQuestionSets('index'),
+ QuestionsApi.fetchQuestions('index')
+ ]).then(([element, attributes, conditions,
+ questionsets, questions]) => ({
+ element, parent, attributes, conditions, questionsets, questions
+ }))
+ break
+
+ case 'questionsets':
+ action = () => Promise.all([
+ QuestionsFactory.createQuestionSet(getState().config, parent),
+ DomainApi.fetchAttributes('index'),
+ ConditionsApi.fetchConditions('index'),
+ QuestionsApi.fetchQuestionSets('index'),
+ QuestionsApi.fetchQuestions('index')
+ ]).then(([element, attributes, conditions,
+ questionsets, questions]) => ({
+ element, parent, attributes, conditions, questionsets, questions
+ }))
+ break
+
+ case 'questions':
+ action = () => Promise.all([
+ QuestionsFactory.createQuestion(getState().config, parent),
+ DomainApi.fetchAttributes('index'),
+ OptionsApi.fetchOptionSets('index'),
+ OptionsApi.fetchOptions('index'),
+ ConditionsApi.fetchConditions('index'),
+ QuestionsApi.fetchWidgetTypes(),
+ QuestionsApi.fetchValueTypes()
+ ]).then(([element, attributes, optionsets,
+ options, conditions]) => ({
+ element, parent, attributes, optionsets, options, conditions
+ }))
+ break
+
+ case 'attributes':
+ action = () => Promise.all([
+ DomainFactory.createAttribute(getState().config, parent),
+ DomainApi.fetchAttributes('index'),
+ ]).then(([element, attributes]) => ({
+ element, parent, attributes
+ }))
+ break
+
+ case 'optionsets':
+ action = () => Promise.all([
+ OptionsFactory.createOptionSet(getState().config, parent),
+ OptionsApi.fetchOptions('index'),
+ ]).then(([element, options]) => ({
+ element, parent, options
+ }))
+ break
+
+ case 'options':
+ action = () => Promise.all([
+ OptionsFactory.createOption(getState().config, parent),
+ OptionsApi.fetchOptionSets('index'),
+ ]).then(([element, optionsets]) => ({
+ element, parent, optionsets
+ }))
+ break
+
+ case 'conditions':
+ action = () => Promise.all([
+ ConditionsFactory.createCondition(getState().config, parent),
+ DomainApi.fetchAttributes('index'),
+ OptionsApi.fetchOptions('index'),
+ ]).then(([element, attributes, options]) => ({
+ element, parent, attributes, options
+ }))
+ break
+
+ case 'tasks':
+ action = () => Promise.all([
+ TasksFactory.createTask(getState().config),
+ DomainApi.fetchAttributes('index'),
+ ConditionsApi.fetchConditions('index'),
+ QuestionsApi.fetchCatalogs('index')
+ ]).then(([element, attributes, conditions, catalogs]) => ({
+ element, attributes, conditions, catalogs
+ }))
+ break
+
+ case 'views':
+ action = () => Promise.all([
+ ViewsFactory.createView(getState().config),
+ QuestionsApi.fetchCatalogs('index')
+ ]).then(([element, catalogs]) => ({
+ element, catalogs
+ }))
+ break
+ }
+
+ return dispatch(action)
+ .then(elements => dispatch(createElementSuccess({...elements})))
+ .catch(error => dispatch(createElementError(error)))
+ }
+}
+
+export function createElementInit(elementType) {
+ return {type: 'elements/createElementInit', elementType}
+}
+
+export function createElementSuccess(elements) {
+ return {type: 'elements/createElementSuccess', elements}
+}
+
+export function createElementError(error) {
+ return {type: 'elements/createElementError', error}
+}
+
+// delete element
+
+export function deleteElement(elementType, element) {
+ return function(dispatch) {
+ dispatch(deleteElementInit(element))
+
+ let action
+ switch (elementType) {
+ case 'catalogs':
+ action = () => QuestionsApi.deleteCatalog(element)
+ break
+
+ case 'sections':
+ action = () => QuestionsApi.deleteSection(element)
+ break
+
+ case 'pages':
+ action = () => QuestionsApi.deletePage(element)
+ break
+
+ case 'questionsets':
+ action = () => QuestionsApi.deleteQuestionSet(element)
+ break
+
+ case 'questions':
+ action = () => QuestionsApi.deleteQuestion(element)
+ break
+
+ case 'attributes':
+ action = () => DomainApi.deleteAttribute(element)
+ break
+
+ case 'optionsets':
+ action = () => OptionsApi.deleteOptionSet(element)
+ break
+
+ case 'options':
+ action = () => OptionsApi.deleteOption(element)
+ break
+
+ case 'conditions':
+ action = () => ConditionsApi.deleteCondition(element)
+
+ break
+
+ case 'tasks':
+ action = () => TasksApi.deleteTask(element)
+ break
+
+ case 'views':
+ action = () => ViewsApi.deleteView(element)
+ break
+ }
+
+ return dispatch(action)
+ .then(() => {
+ dispatch(deleteElementSuccess(element))
+ history.back()
+ })
+ .catch(error => dispatch(deleteElementError(element, error)))
+ }
+}
+
+export function deleteElementInit(element) {
+ return {type: 'elements/deleteElementInit', element}
+}
+
+export function deleteElementSuccess(element) {
+ return {type: 'elements/deleteElementSuccess', element}
+}
+
+export function deleteElementError(element, error) {
+ return {type: 'elements/deleteElementError', element, error}
+}
+
+// update elements
+
+export function updateElement(element, values) {
+ return {type: 'elements/updateElement', element, values}
+}
+
+// move elements
+
+export function dropElement(dragElement, dropElement, mode) {
+ return function(dispatch, getState) {
+ // an element cannot be dropped on itself or on one of its descendants
+ if (canMoveElement(dragElement, dropElement)) {
+ const element = {...getState().elements.element}
+ const { dragParent, dropParent } = moveElement(element, dragElement, dropElement, mode)
+
+ dispatch(storeElement(elementTypes[dragParent.model], dragParent))
+ if (!isNil(dropParent)) {
+ dispatch(storeElement(elementTypes[dropParent.model], dropParent))
+ }
+ }
+ }
+}
diff --git a/rdmo/management/assets/js/actions/importActions.js b/rdmo/management/assets/js/actions/importActions.js
new file mode 100644
index 0000000000..fa88c51870
--- /dev/null
+++ b/rdmo/management/assets/js/actions/importActions.js
@@ -0,0 +1,83 @@
+import isNil from 'lodash/isNil'
+
+import ManagementApi from '../api/ManagementApi'
+
+import { fetchElements, fetchElement } from './elementActions'
+
+// upload file
+
+export function uploadFile(file) {
+ return function(dispatch) {
+ dispatch(uploadFileInit())
+
+ return ManagementApi.uploadFile(file)
+ .then(elements => dispatch(uploadFileSuccess(elements)))
+ .catch(error => {
+ dispatch(uploadFileError(error))
+ })
+ }
+}
+
+export function uploadFileInit() {
+ return {type: 'import/uploadFileInit'}
+}
+
+export function uploadFileSuccess(elements) {
+ return {type: 'import/uploadFileSuccess', elements}
+}
+
+export function uploadFileError(error) {
+ return {type: 'import/uploadFileError', error}
+}
+
+// import elements
+
+export function importElements() {
+ return function(dispatch, getState) {
+ const elements = getState().imports.elements.filter(element => element.import)
+
+ dispatch(importElementsInit())
+
+ return ManagementApi.importElements(elements)
+ .then(elements => dispatch(importElementsSuccess(elements)))
+ .catch(error => dispatch(importElementsError(error)))
+ }
+}
+
+export function importElementsInit() {
+ return {type: 'import/importElementsInit'}
+}
+
+export function importElementsSuccess(elements) {
+ return {type: 'import/importElementsSuccess', elements}
+}
+
+export function importElementsError(error) {
+ return {type: 'import/importElementsError', error}
+}
+
+// update elements
+
+export function updateElement(element, values) {
+ return {type: 'import/updateElement', element, values}
+}
+
+export function selectElements(value) {
+ return {type: 'import/selectElements', value}
+}
+
+export function updateUriPrefix(uriPrefix) {
+ return {type: 'import/updateUriPrefix', uriPrefix}
+}
+
+export function resetElements() {
+ return function(dispatch, getState) {
+ const { elementType, elementId, elementAction} = getState().elements
+ if (isNil(elementId)) {
+ dispatch(fetchElements(elementType, elementAction))
+ } else {
+ dispatch(fetchElement(elementType, elementId, elementAction))
+ }
+ dispatch({type: 'import/resetElements'})
+ }
+}
diff --git a/rdmo/management/assets/js/api/ConditionsApi.js b/rdmo/management/assets/js/api/ConditionsApi.js
new file mode 100644
index 0000000000..ab5242116a
--- /dev/null
+++ b/rdmo/management/assets/js/api/ConditionsApi.js
@@ -0,0 +1,35 @@
+import isNil from 'lodash/isNil'
+
+import BaseApi from 'rdmo/core/assets/js/api/BaseApi'
+
+class ConditionsApi extends BaseApi {
+
+ static fetchConditions(action) {
+ let url = '/api/v1/conditions/conditions/'
+ if (action == 'index') url += 'index/'
+ return this.get(url)
+ }
+
+ static fetchCondition(id) {
+ return this.get(`/api/v1/conditions/conditions/${id}/`)
+ }
+
+ static storeCondition(condition) {
+ if (isNil(condition.id)) {
+ return this.post('/api/v1/conditions/conditions/', condition)
+ } else {
+ return this.put(`/api/v1/conditions/conditions/${condition.id}/`, condition)
+ }
+ }
+
+ static deleteCondition(question) {
+ return this.delete(`/api/v1/conditions/conditions/${question.id}/`)
+ }
+
+ static fetchRelations() {
+ return this.get('/api/v1/conditions/relations/')
+ }
+
+}
+
+export default ConditionsApi
diff --git a/rdmo/management/assets/js/api/DomainApi.js b/rdmo/management/assets/js/api/DomainApi.js
new file mode 100644
index 0000000000..0455e4a4f1
--- /dev/null
+++ b/rdmo/management/assets/js/api/DomainApi.js
@@ -0,0 +1,34 @@
+import isNil from 'lodash/isNil'
+
+import BaseApi from 'rdmo/core/assets/js/api/BaseApi'
+
+class DomainApi extends BaseApi {
+
+ static fetchAttributes(action) {
+ let url = '/api/v1/domain/attributes/'
+ if (action == 'index') url += 'index/'
+ if (action == 'nested') url += 'nested/'
+ return this.get(url)
+ }
+
+ static fetchAttribute(id, action) {
+ let url = `/api/v1/domain/attributes/${id}/`
+ if (action == 'nested') url += 'nested/'
+ return this.get(url)
+ }
+
+ static storeAttribute(attribute) {
+ if (isNil(attribute.id)) {
+ return this.post('/api/v1/domain/attributes/', attribute)
+ } else {
+ return this.put(`/api/v1/domain/attributes/${attribute.id}/`, attribute)
+ }
+ }
+
+ static deleteAttribute(attribute) {
+ return this.delete(`/api/v1/domain/attributes/${attribute.id}/`)
+ }
+
+}
+
+export default DomainApi
diff --git a/rdmo/management/assets/js/api/ManagementApi.js b/rdmo/management/assets/js/api/ManagementApi.js
new file mode 100644
index 0000000000..20c5df4370
--- /dev/null
+++ b/rdmo/management/assets/js/api/ManagementApi.js
@@ -0,0 +1,19 @@
+import BaseApi from 'rdmo/core/assets/js/api/BaseApi'
+
+class ManagementApi extends BaseApi {
+
+ static fetchMeta() {
+ return this.get('/api/v1/management/meta/')
+ }
+
+ static uploadFile(file) {
+ return this.upload('/api/v1/management/upload/', file)
+ }
+
+ static importElements(elements) {
+ return this.post('/api/v1/management/import/', { elements })
+ }
+
+}
+
+export default ManagementApi
diff --git a/rdmo/management/assets/js/api/OptionsApi.js b/rdmo/management/assets/js/api/OptionsApi.js
new file mode 100644
index 0000000000..550e050bf3
--- /dev/null
+++ b/rdmo/management/assets/js/api/OptionsApi.js
@@ -0,0 +1,60 @@
+import isNil from 'lodash/isNil'
+
+import BaseApi from 'rdmo/core/assets/js/api/BaseApi'
+
+class OptionsApi extends BaseApi {
+
+ static fetchOptionSets(action) {
+ let url = '/api/v1/options/optionsets/'
+ if (action == 'index') url += 'index/'
+ if (action == 'nested') url += 'nested/'
+ return this.get(url)
+ }
+
+ static fetchOptionSet(id, action) {
+ let url = `/api/v1/options/optionsets/${id}/`
+ if (action == 'nested') url += 'nested/'
+ return this.get(url)
+ }
+
+ static storeOptionSet(optionset) {
+ if (isNil(optionset.id)) {
+ return this.post('/api/v1/options/optionsets/', optionset)
+ } else {
+ return this.put(`/api/v1/options/optionsets/${optionset.id}/`, optionset)
+ }
+ }
+
+ static deleteOptionSet(optionset) {
+ return this.delete(`/api/v1/options/optionsets/${optionset.id}/`)
+ }
+
+ static fetchOptions(action) {
+ let url = '/api/v1/options/options/'
+ if (action == 'index') url += 'index/'
+ return this.get(url)
+ }
+
+ static fetchOption(id) {
+ return this.get(`/api/v1/options/options/${id}/`)
+ }
+
+ static storeOption(option) {
+ if (isNil(option.id)) {
+ return this.post('/api/v1/options/options/', option)
+ } else {
+ return this.put(`/api/v1/options/options/${option.id}/`, option)
+ }
+ }
+
+ static deleteOption(option) {
+ return this.delete(`/api/v1/options/options/${option.id}/`)
+ }
+
+ static fetchProviders() {
+ return this.get('/api/v1/options/providers/')
+ }
+
+}
+
+export default OptionsApi
diff --git a/rdmo/management/assets/js/api/QuestionsApi.js b/rdmo/management/assets/js/api/QuestionsApi.js
new file mode 100644
index 0000000000..2a3b6f99c9
--- /dev/null
+++ b/rdmo/management/assets/js/api/QuestionsApi.js
@@ -0,0 +1,139 @@
+import isNil from 'lodash/isNil'
+
+import BaseApi from 'rdmo/core/assets/js/api/BaseApi'
+
+class QuestionsApi extends BaseApi {
+
+ static fetchCatalogs(action) {
+ let url = '/api/v1/questions/catalogs/'
+ if (action == 'index') url += 'index/'
+ if (action == 'nested') url += 'nested/'
+ return this.get(url)
+ }
+
+ static fetchCatalog(id, action) {
+ let url = `/api/v1/questions/catalogs/${id}/`
+ if (action == 'nested') url += 'nested/'
+ return this.get(url)
+ }
+
+ static storeCatalog(catalog) {
+ if (isNil(catalog.id)) {
+ return this.post('/api/v1/questions/catalogs/', catalog)
+ } else {
+ return this.put(`/api/v1/questions/catalogs/${catalog.id}/`, catalog)
+ }
+ }
+
+ static deleteCatalog(catalog) {
+ return this.delete(`/api/v1/questions/catalogs/${catalog.id}/`)
+ }
+
+ static fetchSections(action) {
+ let url = '/api/v1/questions/sections/'
+ if (action == 'index') url += 'index/'
+ if (action == 'nested') url += 'nested/'
+ return this.get(url)
+ }
+
+ static fetchSection(id, action) {
+ let url = `/api/v1/questions/sections/${id}/`
+ if (action == 'nested') url += 'nested/'
+ return this.get(url)
+ }
+
+ static storeSection(section) {
+ if (isNil(section.id)) {
+ return this.post('/api/v1/questions/sections/', section)
+ } else {
+ return this.put(`/api/v1/questions/sections/${section.id}/`, section)
+ }
+ }
+
+ static deleteSection(section) {
+ return this.delete(`/api/v1/questions/sections/${section.id}/`)
+ }
+
+ static fetchPages(action) {
+ let url = '/api/v1/questions/pages/'
+ if (action == 'index') url += 'index/'
+ if (action == 'nested') url += 'nested/'
+ return this.get(url)
+ }
+
+ static fetchPage(id, action) {
+ let url = `/api/v1/questions/pages/${id}/`
+ if (action == 'nested') url += 'nested/'
+ return this.get(url)
+ }
+
+ static storePage(page) {
+ if (isNil(page.id)) {
+ return this.post('/api/v1/questions/pages/', page)
+ } else {
+ return this.put(`/api/v1/questions/pages/${page.id}/`, page)
+ }
+ }
+
+ static deletePage(page) {
+ return this.delete(`/api/v1/questions/pages/${page.id}/`)
+ }
+
+ static fetchQuestionSets(action) {
+ let url = '/api/v1/questions/questionsets/'
+ if (action == 'index') url += 'index/'
+ if (action == 'nested') url += 'nested/'
+ return this.get(url)
+ }
+
+ static fetchQuestionSet(id, action) {
+ let url = `/api/v1/questions/questionsets/${id}/`
+ if (action == 'nested') url += 'nested/'
+ return this.get(url)
+ }
+
+ static storeQuestionSet(questionset) {
+ if (isNil(questionset.id)) {
+ return this.post('/api/v1/questions/questionsets/', questionset)
+ } else {
+ return this.put(`/api/v1/questions/questionsets/${questionset.id}/`, questionset)
+ }
+ }
+
+ static deleteQuestionSet(questionset) {
+ return this.delete(`/api/v1/questions/questionsets/${questionset.id}/`)
+ }
+
+ static fetchQuestions(action) {
+ let url = '/api/v1/questions/questions/'
+ if (action == 'index') url += 'index/'
+ return this.get(url)
+ }
+
+ static fetchQuestion(id) {
+ return this.get(`/api/v1/questions/questions/${id}/`)
+ }
+
+ static storeQuestion(question) {
+ if (isNil(question.id)) {
+ return this.post('/api/v1/questions/questions/', question)
+ } else {
+ return this.put(`/api/v1/questions/questions/${question.id}/`, question)
+ }
+ }
+
+ static deleteQuestion(question) {
+ return this.delete(`/api/v1/questions/questions/${question.id}/`)
+ }
+
+ static fetchWidgetTypes() {
+ return this.get('/api/v1/questions/widgettypes/')
+ }
+
+ static fetchValueTypes() {
+ return this.get('/api/v1/questions/valuetypes/')
+ }
+
+}
+
+export default QuestionsApi
diff --git a/rdmo/management/assets/js/api/TasksApi.js b/rdmo/management/assets/js/api/TasksApi.js
new file mode 100644
index 0000000000..b6a1d0f949
--- /dev/null
+++ b/rdmo/management/assets/js/api/TasksApi.js
@@ -0,0 +1,31 @@
+import isNil from 'lodash/isNil'
+
+import BaseApi from 'rdmo/core/assets/js/api/BaseApi'
+
+class TasksApi extends BaseApi {
+
+ static fetchTasks(action) {
+ let url = '/api/v1/tasks/tasks/'
+ if (action == 'index') url += 'index/'
+ return this.get(url)
+ }
+
+ static fetchTask(id) {
+ return this.get(`/api/v1/tasks/tasks/${id}/`)
+ }
+
+ static storeTask(task) {
+ if (isNil(task.id)) {
+ return this.post('/api/v1/tasks/tasks/', task)
+ } else {
+ return this.put(`/api/v1/tasks/tasks/${task.id}/`, task)
+ }
+ }
+
+ static deleteTask(task) {
+ return this.delete(`/api/v1/tasks/tasks/${task.id}/`)
+ }
+
+}
+
+export default TasksApi
diff --git a/rdmo/management/assets/js/api/ViewsApi.js b/rdmo/management/assets/js/api/ViewsApi.js
new file mode 100644
index 0000000000..02a11b7da3
--- /dev/null
+++ b/rdmo/management/assets/js/api/ViewsApi.js
@@ -0,0 +1,31 @@
+import isNil from 'lodash/isNil'
+
+import BaseApi from 'rdmo/core/assets/js/api/BaseApi'
+
+class ViewsApi extends BaseApi {
+
+ static fetchViews(action) {
+ let url = '/api/v1/views/views/'
+ if (action == 'index') url += 'index/'
+ return this.get(url)
+ }
+
+ static fetchView(id) {
+ return this.get(`/api/v1/views/views/${id}/`)
+ }
+
+ static storeView(view) {
+ if (isNil(view.id)) {
+ return this.post('/api/v1/views/views/', view)
+ } else {
+ return this.put(`/api/v1/views/views/${view.id}/`, view)
+ }
+ }
+
+ static deleteView(view) {
+ return this.delete(`/api/v1/views/views/${view.id}/`)
+ }
+
+}
+
+export default ViewsApi
diff --git a/rdmo/management/assets/js/components/common/Buttons.js b/rdmo/management/assets/js/components/common/Buttons.js
new file mode 100644
index 0000000000..9b8afb9530
--- /dev/null
+++ b/rdmo/management/assets/js/components/common/Buttons.js
@@ -0,0 +1,58 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+
+const BackButton = () => (
+ history.back()}>
+ {gettext('Back')}
+
+)
+
+const SaveButton = ({ elementAction, onClick, disabled=false, back=false }) => {
+ let text, className = 'element-button btn btn-xs'
+ if (elementAction == 'create') {
+ text = back ? gettext('Create') : gettext('Create and continue editing')
+ className += back ? ' btn-success' : ' btn-default'
+ } else if (elementAction == 'copy') {
+ text = back ? gettext('Copy') : gettext('Copy and continue editing')
+ className += back ? ' btn-info' : ' btn-default'
+ } else {
+ text = back ? gettext('Save') : gettext('Save and continue editing')
+ className += back ? ' btn-primary' : ' btn-default'
+ }
+
+ return (
+ onClick(back)} disabled={disabled}>
+ {text}
+
+ )
+}
+
+SaveButton.propTypes = {
+ elementAction: PropTypes.string,
+ onClick: PropTypes.func.isRequired,
+ disabled: PropTypes.bool,
+ back: PropTypes.bool
+}
+
+const NewButton = ({ onClick }) => (
+ onClick()}>
+ {gettext('New')}
+
+)
+
+NewButton.propTypes = {
+ onClick: PropTypes.func.isRequired
+}
+
+const DeleteButton = ({ onClick, disabled=false }) => (
+ onClick()} disabled={disabled}>
+ {gettext('Delete')}
+
+)
+
+DeleteButton.propTypes = {
+ onClick: PropTypes.func.isRequired,
+ disabled: PropTypes.bool
+}
+
+export { BackButton, SaveButton, NewButton, DeleteButton }
diff --git a/rdmo/management/assets/js/components/common/Checkboxes.js b/rdmo/management/assets/js/components/common/Checkboxes.js
new file mode 100644
index 0000000000..896209f076
--- /dev/null
+++ b/rdmo/management/assets/js/components/common/Checkboxes.js
@@ -0,0 +1,19 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+
+const Checkbox = ({ label, value, onChange }) => (
+
+
+ onChange(!value)} />
+ { label }
+
+
+)
+
+Checkbox.propTypes = {
+ label: PropTypes.oneOfType([PropTypes.object, PropTypes.string]).isRequired,
+ value: PropTypes.bool.isRequired,
+ onChange: PropTypes.func.isRequired
+}
+
+export { Checkbox }
diff --git a/rdmo/management/assets/js/components/common/DragAndDrop.js b/rdmo/management/assets/js/components/common/DragAndDrop.js
new file mode 100644
index 0000000000..5281290b46
--- /dev/null
+++ b/rdmo/management/assets/js/components/common/DragAndDrop.js
@@ -0,0 +1,80 @@
+import React, { useRef } from 'react'
+import { useDrag, useDrop } from 'react-dnd'
+import PropTypes from 'prop-types'
+import classNames from 'classnames'
+
+const Drag = ({ element, show=true }) => {
+ const dragRef = useRef(null)
+
+ const [{}, drag] = useDrag(() => ({
+ type: element.model,
+ item: element
+ }), [element])
+
+ drag(dragRef)
+
+ return show &&
+
+
+}
+
+Drag.propTypes = {
+ element: PropTypes.object.isRequired,
+ show: PropTypes.bool
+}
+
+const Drop = ({ element, elementActions, indent=0, mode='in', children=null }) => {
+ const dropRef = useRef(null)
+
+ let accept
+ if (mode == 'in') {
+ accept = {
+ 'questions.section': ['questions.page'],
+ 'questions.page': ['questions.questionset', 'questions.question'],
+ 'questions.questionset': ['questions.questionset', 'questions.question']
+ }[element.model]
+ } else {
+ accept = {
+ 'questions.section': ['questions.section'],
+ 'questions.page': ['questions.page'],
+ 'questions.questionset': ['questions.questionset', 'questions.question'],
+ 'questions.question': ['questions.questionset', 'questions.question']
+ }[element.model]
+ }
+
+ const [{ isDragging, isOver }, drop] = useDrop(() => ({
+ accept: accept,
+ collect: (monitor) => ({
+ isDragging: accept.includes(monitor.getItemType()),
+ isOver: monitor.isOver()
+ }),
+ drop: (item) => {
+ elementActions.dropElement(item, element, mode)
+ },
+ }), [element])
+
+ drop(dropRef)
+
+ const dropClassName = classNames({
+ 'drop-in': mode == 'in',
+ 'drop': mode != 'in',
+ 'show': isDragging,
+ 'over': isOver
+ })
+
+ if (mode == 'in') {
+ return {children}
+ } else {
+ return
+ }
+}
+
+Drop.propTypes = {
+ element: PropTypes.object.isRequired,
+ elementActions: PropTypes.object.isRequired,
+ mode: PropTypes.string,
+ indent: PropTypes.number,
+ children: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.node), PropTypes.node])
+}
+
+export { Drag, Drop }
diff --git a/rdmo/management/assets/js/components/common/Errors.js b/rdmo/management/assets/js/components/common/Errors.js
new file mode 100644
index 0000000000..0e63a37dfd
--- /dev/null
+++ b/rdmo/management/assets/js/components/common/Errors.js
@@ -0,0 +1,54 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import isString from 'lodash/isString'
+
+const MainErrors = ({ errors }) => {
+ return (
+
+
+
+
+ {gettext('One or more errors occurred:')}
+
+
+ { errors.map((error, index) => {error} ) }
+
+
+
+
+ )
+}
+
+MainErrors.propTypes = {
+ errors: PropTypes.array
+}
+
+const ElementErrors = ({ element }) => {
+ if (element.errors) {
+ const errorList = Object.values(element.errors).flat().reduce((acc, cur) => {
+ if (isString(cur)) {
+ acc.push(cur)
+ } else {
+ Object.values(cur).forEach(value => {
+ acc = acc.concat(value)
+ })
+ }
+
+ return acc
+ }, [])
+
+ return element.errors && (
+
+ {errorList.map((error, index) => {error} )}
+
+ )
+ } else {
+ return null
+ }
+}
+
+ElementErrors.propTypes = {
+ element: PropTypes.object.isRequired
+}
+
+export { MainErrors, ElementErrors }
diff --git a/rdmo/management/assets/js/components/common/Filter.js b/rdmo/management/assets/js/components/common/Filter.js
new file mode 100644
index 0000000000..48f10b29fa
--- /dev/null
+++ b/rdmo/management/assets/js/components/common/Filter.js
@@ -0,0 +1,65 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+
+const FilterString = ({ value, onChange, placeholder }) => {
+ return (
+
+
+ onChange(e.target.value)}>
+
+ onChange('')}>
+
+
+
+
+
+ )
+}
+
+FilterString.propTypes = {
+ value: PropTypes.string.isRequired,
+ onChange: PropTypes.func.isRequired,
+ placeholder: PropTypes.string.isRequired
+}
+
+const FilterUriPrefix = ({ value, options, onChange }) => {
+ return (
+
+ onChange(event.target.value)}>
+ {gettext('All URI prefixes')}
+ {
+ options.map((option, index) => {option} )
+ }
+
+
+ )
+}
+
+FilterUriPrefix.propTypes = {
+ value: PropTypes.string,
+ options: PropTypes.array,
+ onChange: PropTypes.func
+}
+
+const FilterSite = ({ value, options, onChange, allLabel = 'All sites' }) => {
+ return (
+
+ onChange(event.target.value)}>
+ {gettext(allLabel)}
+ {
+ options.map((option, index) => {option.name} )
+ }
+
+
+ )
+}
+
+FilterSite.propTypes = {
+ value: PropTypes.string,
+ options: PropTypes.array,
+ onChange: PropTypes.func,
+ allLabel: PropTypes.string
+}
+
+export { FilterString, FilterUriPrefix, FilterSite }
diff --git a/rdmo/management/assets/js/components/common/Forms.js b/rdmo/management/assets/js/components/common/Forms.js
new file mode 100644
index 0000000000..80a7c7a0d5
--- /dev/null
+++ b/rdmo/management/assets/js/components/common/Forms.js
@@ -0,0 +1,34 @@
+import React, { useState } from 'react'
+import PropTypes from 'prop-types'
+import isNil from 'lodash/isNil'
+
+const UploadForm = ({ onSubmit }) => {
+ const [file, setFile] = useState(null)
+
+ const handleSubmit = event => {
+ event.preventDefault()
+ onSubmit(file)
+ }
+
+ return (
+
+ )
+}
+
+UploadForm.propTypes = {
+ onSubmit: PropTypes.func.isRequired
+}
+
+export { UploadForm }
diff --git a/rdmo/management/assets/js/components/common/Icons.js b/rdmo/management/assets/js/components/common/Icons.js
new file mode 100644
index 0000000000..6456be6de4
--- /dev/null
+++ b/rdmo/management/assets/js/components/common/Icons.js
@@ -0,0 +1,15 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+
+const ReadOnlyIcon = ({ title, show }) => {
+ return show && (
+
+ )
+}
+
+ReadOnlyIcon.propTypes = {
+ title: PropTypes.string.isRequired,
+ show: PropTypes.bool
+}
+
+export { ReadOnlyIcon }
diff --git a/rdmo/management/assets/js/components/common/Links.js b/rdmo/management/assets/js/components/common/Links.js
new file mode 100644
index 0000000000..0c291ad8ed
--- /dev/null
+++ b/rdmo/management/assets/js/components/common/Links.js
@@ -0,0 +1,239 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import classNames from 'classnames'
+import isEmpty from 'lodash/isEmpty'
+import isUndefined from 'lodash/isUndefined'
+
+import Link from 'rdmo/core/assets/js/components/Link'
+import LinkButton from 'rdmo/core/assets/js/components/LinkButton'
+
+const NestedLink = ({ href, title, onClick, show=true }) => {
+ return show &&
+}
+
+NestedLink.propTypes = {
+ href: PropTypes.string.isRequired,
+ title: PropTypes.string.isRequired,
+ onClick: PropTypes.func.isRequired,
+ show: PropTypes.bool
+}
+
+const EditLink = ({ href, title, onClick }) => {
+ return
+}
+
+EditLink.propTypes = {
+ href: PropTypes.string.isRequired,
+ title: PropTypes.string.isRequired,
+ onClick: PropTypes.func.isRequired
+}
+
+const CopyLink = ({ href, title, onClick }) => {
+ return
+}
+
+CopyLink.propTypes = {
+ href: PropTypes.string.isRequired,
+ title: PropTypes.string.isRequired,
+ onClick: PropTypes.func.isRequired
+}
+
+const AddLink = ({ title, altTitle, onClick, onAltClick, disabled }) => {
+ if (isUndefined(onAltClick)) {
+ return
+ } else {
+ return (
+
+
+
+
+ {title}
+
+
+ {altTitle}
+
+
+
+ )
+ }
+}
+
+AddLink.propTypes = {
+ title: PropTypes.string.isRequired,
+ altTitle: PropTypes.string,
+ onClick: PropTypes.func.isRequired,
+ onAltClick: PropTypes.func,
+ disabled: PropTypes.bool
+}
+
+const AvailableLink = ({ available, locked, title, onClick, disabled }) => {
+ const className = classNames({
+ 'element-btn-link fa': true,
+ 'fa-toggle-on': available,
+ 'fa-toggle-off': !available
+ })
+
+ return
+}
+
+AvailableLink.propTypes = {
+ available: PropTypes.bool.isRequired,
+ locked: PropTypes.bool.isRequired,
+ title: PropTypes.string.isRequired,
+ onClick: PropTypes.func.isRequired,
+ disabled: PropTypes.bool
+}
+
+const LockedLink = ({ locked, title, onClick, disabled }) => {
+ const className = classNames({
+ 'element-btn-link fa': true,
+ 'fa-lock text-danger': locked,
+ 'fa-unlock-alt': !locked
+ })
+
+ return
+}
+
+LockedLink.propTypes = {
+ locked: PropTypes.bool.isRequired,
+ title: PropTypes.string.isRequired,
+ onClick: PropTypes.func.isRequired,
+ disabled: PropTypes.bool
+}
+
+const ShowElementsLink = ({ showElements, show, onClick }) => {
+ const className = classNames({
+ 'element-btn-link fa': true,
+ 'fa-chevron-down': showElements,
+ 'fa-chevron-up': !showElements
+ })
+
+ const title = showElements ? gettext('Hide elements') : gettext('Show elements')
+
+ return show &&
+}
+
+ShowElementsLink.propTypes = {
+ showElements: PropTypes.bool.isRequired,
+ show: PropTypes.bool.isRequired,
+ onClick: PropTypes.func.isRequired
+}
+
+const ExportLink = ({ exportUrl, title, exportFormats, csv=false, full=false }) => {
+ return (
+
+
+
+
+ )
+}
+
+ExportLink.propTypes = {
+ exportUrl: PropTypes.string.isRequired,
+ title: PropTypes.string.isRequired,
+ exportFormats: PropTypes.array,
+ csv: PropTypes.bool,
+ full: PropTypes.bool
+}
+
+const ExtendLink = ({ extend, onClick }) => {
+ const className = classNames({
+ 'element-link fa': true,
+ 'fa-chevron-up': extend,
+ 'fa-chevron-down': !extend
+ })
+
+ const title = extend ? gettext('Show less')
+ : gettext('Show more')
+
+ return
+}
+
+ExtendLink.propTypes = {
+ extend: PropTypes.bool.isRequired,
+ onClick: PropTypes.func.isRequired
+}
+
+const CodeLink = ({ className, uri, onClick }) => {
+ return (
+
+ {uri}
+
+ )
+}
+
+CodeLink.propTypes = {
+ className: PropTypes.string.isRequired,
+ uri: PropTypes.string.isRequired,
+ onClick: PropTypes.func.isRequired
+}
+
+const ErrorLink = ({ element, onClick }) => {
+ return (
+ !isEmpty(element.errors) &&
+
+ )
+}
+
+ErrorLink.propTypes = {
+ element: PropTypes.object.isRequired,
+ onClick: PropTypes.func.isRequired
+}
+
+
+const WarningLink = ({ element, onClick }) => {
+ return (
+ !isEmpty(element.warnings) &&
+
+ )
+}
+
+WarningLink.propTypes = {
+ element: PropTypes.object.isRequired,
+ onClick: PropTypes.func.isRequired
+}
+
+
+const ShowLink = ({ element, onClick }) => {
+ const title = element.show ? gettext('Hide') : gettext('Show')
+ const className = classNames({
+ 'element-link fa': true,
+ 'fa-eye-slash': element.show,
+ 'fa-eye': !element.show
+ })
+
+ return
+}
+
+ShowLink.propTypes = {
+ element: PropTypes.object.isRequired,
+ onClick: PropTypes.func.isRequired
+}
+
+export { EditLink, CopyLink, AddLink, AvailableLink, LockedLink, ShowElementsLink,
+ NestedLink, ExportLink, ExtendLink, CodeLink, ErrorLink, WarningLink, ShowLink }
diff --git a/rdmo/management/assets/js/components/common/Modals.js b/rdmo/management/assets/js/components/common/Modals.js
new file mode 100644
index 0000000000..2dfa3c72ed
--- /dev/null
+++ b/rdmo/management/assets/js/components/common/Modals.js
@@ -0,0 +1,34 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import { Modal } from 'react-bootstrap'
+
+const DeleteModal = ({ title, show, onClose, onDelete, children }) => {
+ return (
+
+
+ {title}
+
+
+ { children }
+
+
+
+ {gettext('Close')}
+
+
+ {gettext('Delete')}
+
+
+
+ )
+}
+
+DeleteModal.propTypes = {
+ title: PropTypes.string.isRequired,
+ show: PropTypes.bool.isRequired,
+ onClose: PropTypes.func.isRequired,
+ onDelete: PropTypes.func.isRequired,
+ children: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.node), PropTypes.node]).isRequired
+}
+
+export { DeleteModal }
diff --git a/rdmo/management/assets/js/components/edit/EditAttribute.js b/rdmo/management/assets/js/components/edit/EditAttribute.js
new file mode 100644
index 0000000000..8355caf91c
--- /dev/null
+++ b/rdmo/management/assets/js/components/edit/EditAttribute.js
@@ -0,0 +1,140 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import get from 'lodash/get'
+
+import Checkbox from './common/Checkbox'
+import Select from './common/Select'
+import Text from './common/Text'
+import Textarea from './common/Textarea'
+import UriPrefix from './common/UriPrefix'
+
+import { BackButton, SaveButton, DeleteButton } from '../common/Buttons'
+import { ReadOnlyIcon } from '../common/Icons'
+
+import AttributeInfo from '../info/AttributeInfo'
+import DeleteAttributeModal from '../modals/DeleteAttributeModal'
+
+import useDeleteModal from '../../hooks/useDeleteModal'
+
+const EditAttribute = ({ config, attribute, elements, elementActions }) => {
+
+ const { sites } = config
+ const { elementAction, parent, attributes } = elements
+
+ const editAttribute = (attribute) => elementActions.fetchElement('attributes', attribute)
+ const updateAttribute = (key, value) => elementActions.updateElement(attribute, {[key]: value})
+ const storeAttribute = (back) => elementActions.storeElement('attributes', attribute, back)
+ const deleteAttribute = () => elementActions.deleteElement('attributes', attribute)
+
+ const [showDeleteModal, openDeleteModal, closeDeleteModal] = useDeleteModal()
+
+ const info =
+
+ return (
+
+
+
+
+
+
+
+
+ {
+ attribute.id ? <>
+
{gettext('Attribute')}{': '}
+
{attribute.uri}
+ > :
{gettext('Create attribute')}
+ }
+
+
+ {
+ parent && parent.attribute &&
+
%s.'), [parent.attribute.uri])
+ }} />
+
+ }
+
+ {
+ parent && parent.page &&
+
%s.'), [parent.page.uri])
+ }} />
+
+ }
+ {
+ parent && parent.questionset &&
+
%s.'), [parent.questionset.uri])
+ }} />
+
+ }
+ {
+ parent && parent.question &&
+
%s.'), [parent.question.uri])
+ }} />
+
+ }
+ {
+ parent && parent.condition &&
+
%s.'), [parent.condition.uri])
+ }} />
+
+ }
+
+ {
+ attribute.id &&
+ { info }
+
+ }
+
+
+
+
+
+
+
+
+
+
+ {get(config, 'settings.multisite') &&
}
+
+
+
+
+
+
+
+
+ {attribute.id &&
}
+
+
+
+
+ )
+}
+
+EditAttribute.propTypes = {
+ config: PropTypes.object.isRequired,
+ attribute: PropTypes.object.isRequired,
+ elements: PropTypes.object.isRequired,
+ elementActions: PropTypes.object.isRequired
+}
+
+export default EditAttribute
diff --git a/rdmo/management/assets/js/components/edit/EditCatalog.js b/rdmo/management/assets/js/components/edit/EditCatalog.js
new file mode 100644
index 0000000000..e179456943
--- /dev/null
+++ b/rdmo/management/assets/js/components/edit/EditCatalog.js
@@ -0,0 +1,134 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import { Tabs, Tab } from 'react-bootstrap'
+import get from 'lodash/get'
+
+import Checkbox from './common/Checkbox'
+import Number from './common/Number'
+import OrderedMultiSelect from './common/OrderedMultiSelect'
+import Select from './common/Select'
+import Text from './common/Text'
+import Textarea from './common/Textarea'
+import UriPrefix from './common/UriPrefix'
+
+import { BackButton, SaveButton, DeleteButton } from '../common/Buttons'
+import { ReadOnlyIcon } from '../common/Icons'
+
+import CatalogInfo from '../info/CatalogInfo'
+import DeleteCatalogModal from '../modals/DeleteCatalogModal'
+
+import useDeleteModal from '../../hooks/useDeleteModal'
+
+const EditCatalog = ({ config, catalog, elements, elementActions }) => {
+
+ const { sites, groups } = config
+ const { elementAction, sections } = elements
+
+ const updateCatalog = (key, value) => elementActions.updateElement(catalog, {[key]: value})
+ const storeCatalog = (back) => elementActions.storeElement('catalogs', catalog, back)
+ const deleteCatalog = () => elementActions.deleteElement('catalogs', catalog)
+
+ const editSection = (value) => elementActions.fetchElement('sections', value.section)
+ const createSection = () => elementActions.createElement('sections', { catalog } )
+
+ const [showDeleteModal, openDeleteModal, closeDeleteModal] = useDeleteModal()
+
+ const info =
+
+ return (
+
+
+
+
+
+
+
+
+ {
+ catalog.id ? <>
+
{gettext('Catalog')}{': '}
+
{catalog.uri}
+ > :
{gettext('Create catalog')}
+ }
+
+
+ {
+ catalog.id &&
+ { info }
+
+ }
+
+
+
+
+
+
+
+
+
+ {
+ config.settings && config.settings.languages.map(([lang_code, lang], index) => (
+
+
+
+
+ ))
+ }
+
+
+
+
+ {get(config, 'settings.groups') &&
}
+
+ {get(config, 'settings.multisite') &&
}
+
+ {get(config, 'settings.multisite') &&
}
+
+
+
+
+
+
+
+
+ {catalog.id &&
}
+
+
+
+
+ )
+}
+
+EditCatalog.propTypes = {
+ config: PropTypes.object.isRequired,
+ catalog: PropTypes.object.isRequired,
+ elements: PropTypes.object.isRequired,
+ elementActions: PropTypes.object.isRequired
+}
+
+export default EditCatalog
diff --git a/rdmo/management/assets/js/components/edit/EditCondition.js b/rdmo/management/assets/js/components/edit/EditCondition.js
new file mode 100644
index 0000000000..b62de93bb8
--- /dev/null
+++ b/rdmo/management/assets/js/components/edit/EditCondition.js
@@ -0,0 +1,151 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import get from 'lodash/get'
+
+import Checkbox from './common/Checkbox'
+import Select from './common/Select'
+import Text from './common/Text'
+import Textarea from './common/Textarea'
+import UriPrefix from './common/UriPrefix'
+
+import { BackButton, SaveButton, DeleteButton } from '../common/Buttons'
+import { ReadOnlyIcon } from '../common/Icons'
+
+import ConditionInfo from '../info/ConditionInfo'
+import DeleteConditionModal from '../modals/DeleteConditionModal'
+
+import useDeleteModal from '../../hooks/useDeleteModal'
+
+const EditCondition = ({ config, condition, elements, elementActions }) => {
+
+ const { sites, relations } = config
+ const { elementAction, parent, attributes, options } = elements
+
+ const updateCondition = (key, value) => elementActions.updateElement(condition, {[key]: value})
+ const storeCondition = (back) => elementActions.storeElement('conditions', condition, back)
+ const deleteCondition = () => elementActions.deleteElement('conditions', condition)
+
+ const editAttribute = (attribute) => elementActions.fetchElement('attributes', attribute)
+ const createAttribute = () => elementActions.createElement('attributes', { condition })
+
+ const editOption = (option) => elementActions.fetchElement('options', option)
+
+ const [showDeleteModal, openDeleteModal, closeDeleteModal] = useDeleteModal()
+
+ const info =
+
+ return (
+
+
+
+
+
+
+
+
+ {
+ condition.id ? <>
+
{gettext('Condition')}{': '}
+
{condition.uri}
+ > :
{gettext('Create condition')}
+ }
+
+
+ {
+ parent && parent.optionset &&
+
%s.'), [parent.optionset.uri])
+ }} />
+
+ }
+ {
+ parent && parent.page &&
+
%s.'), [parent.page.uri])
+ }} />
+
+ }
+ {
+ parent && parent.questionset &&
+
%s.'), [parent.questionset.uri])
+ }} />
+
+ }
+ {
+ parent && parent.question &&
+
%s.'), [parent.question.uri])
+ }} />
+
+ }
+ {
+ parent && parent.task &&
+
%s.'), [parent.task.uri])
+ }} />
+
+ }
+
+ {
+ condition.id &&
+ { info }
+
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {get(config, 'settings.multisite') &&
}
+
+
+
+
+
+
+
+
+ {condition.id &&
}
+
+
+
+
+ )
+}
+
+EditCondition.propTypes = {
+ config: PropTypes.object.isRequired,
+ condition: PropTypes.object.isRequired,
+ elements: PropTypes.object.isRequired,
+ elementActions: PropTypes.object.isRequired
+}
+
+export default EditCondition
diff --git a/rdmo/management/assets/js/components/edit/EditOption.js b/rdmo/management/assets/js/components/edit/EditOption.js
new file mode 100644
index 0000000000..9f011d6fbc
--- /dev/null
+++ b/rdmo/management/assets/js/components/edit/EditOption.js
@@ -0,0 +1,131 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import { Tabs, Tab } from 'react-bootstrap'
+import get from 'lodash/get'
+
+import Checkbox from './common/Checkbox'
+import Select from './common/Select'
+import Text from './common/Text'
+import Textarea from './common/Textarea'
+import UriPrefix from './common/UriPrefix'
+
+import { BackButton, SaveButton, DeleteButton } from '../common/Buttons'
+import { ReadOnlyIcon } from '../common/Icons'
+
+import OptionInfo from '../info/OptionInfo'
+import DeleteOptionModal from '../modals/DeleteOptionModal'
+
+import useDeleteModal from '../../hooks/useDeleteModal'
+
+const EditOption = ({ config, option, elements, elementActions }) => {
+
+ const { sites } = config
+ const { elementAction, parent } = elements
+
+ const updateOption = (key, value) => elementActions.updateElement(option, {[key]: value})
+ const storeOption = (back) => elementActions.storeElement('options', option, back)
+ const deleteOption = () => elementActions.deleteElement('options', option)
+
+ const [showDeleteModal, openDeleteModal, closeDeleteModal] = useDeleteModal()
+
+ const info =
+
+ return (
+
+
+
+
+
+
+
+
+ {
+ option.id ? <>
+
{gettext('Option')}{': '}
+
{option.uri}
+ > :
{gettext('Create option')}
+ }
+
+
+ {
+ parent && parent.optionset &&
+
%s.'), [parent.optionset.uri])
+ }} />
+
+ }
+
+ {
+ option.id &&
+ { info }
+
+ }
+
+
+
+
+
+
+
+
+
+ {
+ config.settings && config.settings.languages.map(([lang_code, lang], index) => (
+
+
+
+ ))
+ }
+
+
+ {get(config, 'settings.multisite') &&
}
+
+
+
+
+
+
+
+
+ {option.id &&
}
+
+
+
+
+ )
+}
+
+EditOption.propTypes = {
+ config: PropTypes.object.isRequired,
+ option: PropTypes.object.isRequired,
+ elements: PropTypes.object.isRequired,
+ elementActions: PropTypes.object.isRequired
+}
+
+export default EditOption
diff --git a/rdmo/management/assets/js/components/edit/EditOptionSet.js b/rdmo/management/assets/js/components/edit/EditOptionSet.js
new file mode 100644
index 0000000000..4da6f00d1c
--- /dev/null
+++ b/rdmo/management/assets/js/components/edit/EditOptionSet.js
@@ -0,0 +1,135 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import get from 'lodash/get'
+
+import Checkbox from './common/Checkbox'
+import Number from './common/Number'
+import OrderedMultiSelect from './common/OrderedMultiSelect'
+import MultiSelect from './common/MultiSelect'
+import Select from './common/Select'
+import Text from './common/Text'
+import Textarea from './common/Textarea'
+import UriPrefix from './common/UriPrefix'
+
+import { BackButton, SaveButton, DeleteButton } from '../common/Buttons'
+import { ReadOnlyIcon } from '../common/Icons'
+
+import OptionSetInfo from '../info/OptionSetInfo'
+import DeleteOptionSetModal from '../modals/DeleteOptionSetModal'
+
+import useDeleteModal from '../../hooks/useDeleteModal'
+
+const EditOptionSet = ({ config, optionset, elements, elementActions }) => {
+
+ const { sites, providers } = config
+ const { elementAction, parent, conditions, options } = elements
+
+ const updateOptionSet = (key, value) => elementActions.updateElement(optionset, {[key]: value})
+ const storeOptionSet = (back) => elementActions.storeElement('optionsets', optionset, back)
+ const deleteOptionSet = () => elementActions.deleteElement('optionsets', optionset)
+
+ const editOption = (value) => elementActions.fetchElement('options', value.option)
+ const createOption = () => elementActions.createElement('options', { optionset })
+
+ const editCondition = (condition) => elementActions.fetchElement('conditions', condition)
+ const createCondition = () => elementActions.createElement('conditions', { optionset })
+
+ const [showDeleteModal, openDeleteModal, closeDeleteModal] = useDeleteModal()
+
+ const info =
+
+ return (
+
+
+
+
+
+
+
+
+ {
+ optionset.id ? <>
+
{gettext('Option set')}{': '}
+
{optionset.uri}
+ > :
{gettext('Create option set')}
+ }
+
+
+ {
+ parent && parent.question &&
+
%s.'), [parent.question.uri])
+ }} />
+
+ }
+
+ {
+ optionset.id &&
+ { info }
+
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {get(config, 'settings.multisite') &&
}
+
+
+
+
+
+
+
+
+ {optionset.id &&
}
+
+
+
+
+ )
+}
+
+EditOptionSet.propTypes = {
+ config: PropTypes.object.isRequired,
+ optionset: PropTypes.object.isRequired,
+ elements: PropTypes.object.isRequired,
+ elementActions: PropTypes.object.isRequired
+}
+
+export default EditOptionSet
diff --git a/rdmo/management/assets/js/components/edit/EditPage.js b/rdmo/management/assets/js/components/edit/EditPage.js
new file mode 100644
index 0000000000..3593d578b7
--- /dev/null
+++ b/rdmo/management/assets/js/components/edit/EditPage.js
@@ -0,0 +1,194 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import { Tabs, Tab } from 'react-bootstrap'
+import get from 'lodash/get'
+import isUndefined from 'lodash/isUndefined'
+import orderBy from 'lodash/orderBy'
+
+import Checkbox from './common/Checkbox'
+import MultiSelect from './common/MultiSelect'
+import OrderedMultiSelect from './common/OrderedMultiSelect'
+import Select from './common/Select'
+import Text from './common/Text'
+import Textarea from './common/Textarea'
+import UriPrefix from './common/UriPrefix'
+
+import { BackButton, SaveButton, DeleteButton } from '../common/Buttons'
+import { ReadOnlyIcon } from '../common/Icons'
+
+import PageInfo from '../info/PageInfo'
+import DeletePageModal from '../modals/DeletePageModal'
+
+import useDeleteModal from '../../hooks/useDeleteModal'
+
+const EditPage = ({ config, page, elements, elementActions }) => {
+
+ const { sites } = config
+ const { elementAction, parent, attributes, conditions } = elements
+
+ const elementValues = orderBy(page.questions.concat(page.questionsets), ['order', 'uri'])
+ const elementOptions = elements.questions.map(question => ({
+ value: 'question-' + question.id,
+ label: interpolate(gettext('Question: %s'), [question.uri])
+ })).concat(elements.questionsets.map(questionset => ({
+ value: 'questionset-' + questionset.id,
+ label: interpolate(gettext('Question set: %s'), [questionset.uri])
+ })))
+
+ const updatePage = (key, value) => {
+ if (key == 'elements') {
+ elementActions.updateElement(page, {
+ questions: value.filter(e => !isUndefined(e.question)),
+ questionsets: value.filter(e => !isUndefined(e.questionset))
+ })
+ } else {
+ elementActions.updateElement(page, { [key]: value })
+ }
+ }
+ const storePage = (back) => elementActions.storeElement('pages', page, back)
+ const deletePage = () => elementActions.deleteElement('pages', page)
+
+ const editElement = (value) => {
+ if (value.questionset) {
+ elementActions.fetchElement('questionsets', value.questionset)
+ } else if (value.question) {
+ elementActions.fetchElement('questions', value.question)
+ }
+ }
+ const createQuestionSet = () => elementActions.createElement('questionsets', { page })
+ const createQuestion = () => elementActions.createElement('questions', { page })
+
+ const editCondition = (condition) => elementActions.fetchElement('conditions', condition)
+ const createCondition = () => elementActions.createElement('conditions', { page })
+
+ const editAttribute = (attribute) => elementActions.fetchElement('attributes', attribute)
+ const createAttribute = () => elementActions.createElement('attributes', { page })
+
+ const [showDeleteModal, openDeleteModal, closeDeleteModal] = useDeleteModal()
+
+ const info =
+
+ return (
+
+
+
+
+
+
+
+
+ {
+ page.id ? <>
+
{gettext('Page')}{': '}
+
{page.uri}
+ > :
{gettext('Create page')}
+ }
+
+
+ {
+ parent && parent.section &&
+
%s.'), [parent.section.uri])
+ }} />
+
+ }
+
+ {
+ page.id &&
+ { info }
+
+ }
+
+
+
+
+
+
+
+
+
+ {
+ config.settings && config.settings.languages.map(([lang_code, lang], index) => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ))
+ }
+
+
+
+
+
+
+
+
+ {get(config, 'settings.multisite') &&
}
+
+
+
+
+
+
+
+
+ {page.id &&
}
+
+
+
+
+ )
+}
+
+EditPage.propTypes = {
+ config: PropTypes.object.isRequired,
+ page: PropTypes.object.isRequired,
+ elements: PropTypes.object.isRequired,
+ elementActions: PropTypes.object.isRequired
+}
+
+export default EditPage
diff --git a/rdmo/management/assets/js/components/edit/EditQuestion.js b/rdmo/management/assets/js/components/edit/EditQuestion.js
new file mode 100644
index 0000000000..70c8be6357
--- /dev/null
+++ b/rdmo/management/assets/js/components/edit/EditQuestion.js
@@ -0,0 +1,237 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import { Tabs, Tab } from 'react-bootstrap'
+import get from 'lodash/get'
+
+import Checkbox from './common/Checkbox'
+import MultiSelect from './common/MultiSelect'
+import Select from './common/Select'
+import Text from './common/Text'
+import Textarea from './common/Textarea'
+import UriPrefix from './common/UriPrefix'
+
+import { BackButton, SaveButton, DeleteButton } from '../common/Buttons'
+import { ReadOnlyIcon } from '../common/Icons'
+
+import QuestionInfo from '../info/QuestionInfo'
+import DeleteQuestionModal from '../modals/DeleteQuestionModal'
+
+import useDeleteModal from '../../hooks/useDeleteModal'
+
+const EditQuestion = ({ config, question, elements, elementActions}) => {
+
+ const { sites, widgetTypes, valueTypes } = config
+ const { elementAction, parent, attributes, optionsets, options, conditions } = elements
+
+ const updateQuestion = (key, value) => elementActions.updateElement(question, {[key]: value})
+ const storeQuestion = (back) => elementActions.storeElement('questions', question, back)
+ const deleteQuestion = () => elementActions.deleteElement('questions', question)
+
+ const editOptionSet = (optionset) => elementActions.fetchElement('optionsets', optionset)
+ const createOptionSet = () => elementActions.createElement('optionsets', { question })
+
+ const editCondition = (condition) => elementActions.fetchElement('conditions', condition)
+ const createCondition = () => elementActions.createElement('conditions', { question })
+
+ const editAttribute = (attribute) => elementActions.fetchElement('attributes', attribute)
+ const createAttribute = () => elementActions.createElement('attributes', { question })
+
+ const [showDeleteModal, openDeleteModal, closeDeleteModal] = useDeleteModal()
+
+ const info =
+
+ return (
+
+
+
+
+
+
+
+
+ {
+ question.id ? <>
+
{gettext('Question')}{': '}
+
{question.uri}
+ > :
{gettext('Create question')}
+ }
+
+
+ {
+ parent && parent.page &&
+
%s.'), [parent.page.uri])
+ }} />
+
+ }
+ {
+ parent && parent.questionset &&
+
%s.'), [parent.questionset.uri])
+ }} />
+
+ }
+
+ {
+ question.id &&
+ { info }
+
+ }
+
+
+
+
+
+
+
+
+
+ {
+ config.settings && config.settings.languages.map(([lang_code, lang], index) => {
+ return (
+
+
+
+
+
+
+
+ )
+ })
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ config.settings && config.settings.languages.map((language, index) => (
+
+ ))
+ }
+
+
+ {
+ get(config, 'settings.multisite') &&
+
+
+
+ }
+
+
+
+
+
+
+
+
+
+ {question.id &&
}
+
+
+
+
+ )
+}
+
+EditQuestion.propTypes = {
+ config: PropTypes.object.isRequired,
+ question: PropTypes.object.isRequired,
+ elements: PropTypes.object.isRequired,
+ elementActions: PropTypes.object.isRequired
+}
+
+export default EditQuestion
diff --git a/rdmo/management/assets/js/components/edit/EditQuestionSet.js b/rdmo/management/assets/js/components/edit/EditQuestionSet.js
new file mode 100644
index 0000000000..aabadc7285
--- /dev/null
+++ b/rdmo/management/assets/js/components/edit/EditQuestionSet.js
@@ -0,0 +1,203 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import { Tabs, Tab } from 'react-bootstrap'
+import get from 'lodash/get'
+import isUndefined from 'lodash/isUndefined'
+import orderBy from 'lodash/orderBy'
+
+import Checkbox from './common/Checkbox'
+import OrderedMultiSelect from './common/OrderedMultiSelect'
+import MultiSelect from './common/MultiSelect'
+import Select from './common/Select'
+import Text from './common/Text'
+import Textarea from './common/Textarea'
+import UriPrefix from './common/UriPrefix'
+
+import { BackButton, SaveButton, DeleteButton } from '../common/Buttons'
+import { ReadOnlyIcon } from '../common/Icons'
+
+import QuestionSetInfo from '../info/QuestionSetInfo'
+import DeleteQuestionSetModal from '../modals/DeleteQuestionSetModal'
+
+import useDeleteModal from '../../hooks/useDeleteModal'
+
+const EditQuestionSet = ({ config, questionset, elements, elementActions }) => {
+
+ const { sites } = config
+ const { elementAction, parent, attributes, conditions } = elements
+
+ const elementValues = orderBy(questionset.questions.concat(questionset.questionsets), ['order', 'uri'])
+ const elementOptions = elements.questions.map(question => ({
+ value: 'question-' + question.id,
+ label: interpolate(gettext('Question: %s'), [question.uri])
+ })).concat(elements.questionsets.map(questionset => ({
+ value: 'questionset-' + questionset.id,
+ label: interpolate(gettext('Question set: %s'), [questionset.uri])
+ })))
+
+ const updateQuestionSet = (key, value) => {
+ if (key == 'elements') {
+ elementActions.updateElement(questionset, {
+ questions: value.filter(e => !isUndefined(e.question)),
+ questionsets: value.filter(e => !isUndefined(e.questionset))
+ })
+ } else {
+ elementActions.updateElement(questionset, { [key]: value })
+ }
+ }
+ const storeQuestionSet = (back) => elementActions.storeElement('questionsets', questionset, back)
+ const deleteQuestionSet = () => elementActions.deleteElement('questionsets', questionset)
+
+ const editElement = (value) => {
+ if (value.questionset) {
+ elementActions.fetchElement('questionsets', value.questionset)
+ } else if (value.question) {
+ elementActions.fetchElement('questions', value.question)
+ }
+ }
+ const createQuestionSet = () => elementActions.createElement('questionsets', { questionset })
+ const createQuestion = () => elementActions.createElement('questions', { questionset })
+
+ const editCondition = (condition) => elementActions.fetchElement('conditions', condition)
+ const createCondition = () => elementActions.createElement('conditions', { questionset })
+
+ const editAttribute = (attribute) => elementActions.fetchElement('attributes', attribute)
+ const createAttribute = () => elementActions.createElement('attributes', { questionset })
+
+ const [showDeleteModal, openDeleteModal, closeDeleteModal] = useDeleteModal()
+
+ const info =
+
+ return (
+
+
+
+
+
+
+
+
+ {
+ questionset.id ? <>
+
{gettext('Question set')}{': '}
+
{questionset.uri}
+ > :
{gettext('Create question set')}
+ }
+
+
+ {
+ parent && parent.page &&
+
%s.'), [parent.page.uri])
+ }} />
+
+ }
+ {
+ parent && parent.questionset &&
+
%s.'), [parent.questionset.uri])
+ }} />
+
+ }
+
+ {
+ questionset.id &&
+ { info }
+
+ }
+
+
+
+
+
+
+
+
+
+ {
+ config.settings && config.settings.languages.map(([lang_code, lang], index) => {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+ })
+ }
+
+
+
+
+
+
+
+
+ {get(config, 'settings.multisite') && }
+
+
+
+
+
+
+
+
+ {questionset.id &&
}
+
+
+
+
+ )
+}
+
+EditQuestionSet.propTypes = {
+ config: PropTypes.object.isRequired,
+ questionset: PropTypes.object.isRequired,
+ elements: PropTypes.object.isRequired,
+ elementActions: PropTypes.object.isRequired
+}
+
+export default EditQuestionSet
diff --git a/rdmo/management/assets/js/components/edit/EditSection.js b/rdmo/management/assets/js/components/edit/EditSection.js
new file mode 100644
index 0000000000..0cedd4688a
--- /dev/null
+++ b/rdmo/management/assets/js/components/edit/EditSection.js
@@ -0,0 +1,127 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import { Tabs, Tab } from 'react-bootstrap'
+import get from 'lodash/get'
+
+import Checkbox from './common/Checkbox'
+import OrderedMultiSelect from './common/OrderedMultiSelect'
+import Select from './common/Select'
+import Text from './common/Text'
+import Textarea from './common/Textarea'
+import UriPrefix from './common/UriPrefix'
+
+import { BackButton, SaveButton, DeleteButton } from '../common/Buttons'
+import { ReadOnlyIcon } from '../common/Icons'
+
+import SectionInfo from '../info/SectionInfo'
+import DeleteSectionModal from '../modals/DeleteSectionModal'
+
+import useDeleteModal from '../../hooks/useDeleteModal'
+
+const EditSection = ({ config, section, elements, elementActions }) => {
+
+ const { sites } = config
+ const { elementAction, parent, pages } = elements
+
+ const updateSection = (key, value) => elementActions.updateElement(section, {[key]: value})
+ const storeSection = (back) => elementActions.storeElement('sections', section, back)
+ const deleteSection = () => elementActions.deleteElement('sections', section)
+
+ const editPage = (value) => elementActions.fetchElement('pages', value.page)
+ const createPage = () => elementActions.createElement('pages', { section })
+
+ const [showDeleteModal, openDeleteModal, closeDeleteModal] = useDeleteModal()
+
+ const info =
+
+ return (
+
+
+
+
+
+
+
+
+ {
+ section.id ? <>
+
{gettext('Section')}{': '}
+
{section.uri}
+ > :
{gettext('Create section')}
+ }
+
+
+ {
+ parent && parent.catalog &&
+
%s.'), [parent.catalog.uri])
+ }} />
+
+ }
+
+ {
+ section.id &&
+ { info }
+
+ }
+
+
+
+
+
+
+
+
+
+ {
+ config.settings && config.settings.languages.map(([lang_code, lang], index) => (
+
+
+
+ ))
+ }
+
+
+
+
+ {get(config, 'settings.multisite') &&
}
+
+
+
+
+
+
+
+
+ {section.id &&
}
+
+
+
+
+ )
+}
+
+EditSection.propTypes = {
+ config: PropTypes.object.isRequired,
+ section: PropTypes.object.isRequired,
+ elements: PropTypes.object.isRequired,
+ elementActions: PropTypes.object.isRequired
+}
+
+export default EditSection
diff --git a/rdmo/management/assets/js/components/edit/EditTask.js b/rdmo/management/assets/js/components/edit/EditTask.js
new file mode 100644
index 0000000000..2057562628
--- /dev/null
+++ b/rdmo/management/assets/js/components/edit/EditTask.js
@@ -0,0 +1,158 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import { Tabs, Tab } from 'react-bootstrap'
+import get from 'lodash/get'
+
+import Checkbox from './common/Checkbox'
+import MultiSelect from './common/MultiSelect'
+import Number from './common/Number'
+import Select from './common/Select'
+import Text from './common/Text'
+import Textarea from './common/Textarea'
+import UriPrefix from './common/UriPrefix'
+
+import { BackButton, SaveButton, DeleteButton } from '../common/Buttons'
+import { ReadOnlyIcon } from '../common/Icons'
+
+import TaskInfo from '../info/TaskInfo'
+import DeleteTaskModal from '../modals/DeleteTaskModal'
+
+import useDeleteModal from '../../hooks/useDeleteModal'
+
+const EditTask = ({ config, task, elements, elementActions}) => {
+
+ const { sites, groups } = config
+ const { elementAction, attributes, catalogs, conditions } = elements
+
+ const updateTask = (key, value) => elementActions.updateElement(task, {[key]: value})
+ const storeTask = (back) => elementActions.storeElement('tasks', task, back)
+ const deleteTask = () => elementActions.deleteElement('tasks', task)
+
+ const editCondition = (condition) => elementActions.fetchElement('conditions', condition)
+ const createCondition = () => elementActions.createElement('conditions', { task })
+
+ const editAttribute = (attribute) => elementActions.fetchElement('attributes', attribute)
+
+ const [showDeleteModal, openDeleteModal, closeDeleteModal] = useDeleteModal()
+
+ const info =
+
+ return (
+
+
+
+
+
+
+
+
+ {
+ task.id ? <>
+
{gettext('Task')}{': '}
+
{task.uri}
+ > :
{gettext('Create task')}
+ }
+
+
+ {
+ task.id &&
+ { info }
+
+ }
+
+
+
+
+
+
+
+
+
+ {
+ config.settings && config.settings.languages.map(([lang_code, lang], index) => (
+
+
+
+
+ ))
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+ {get(config, 'settings.groups') &&
}
+
+ {get(config, 'settings.multisite') &&
}
+
+ {get(config, 'settings.multisite') &&
}
+
+
+
+
+
+
+
+
+ {task.id &&
}
+
+
+
+
+ )
+}
+
+EditTask.propTypes = {
+ config: PropTypes.object.isRequired,
+ task: PropTypes.object.isRequired,
+ elements: PropTypes.object.isRequired,
+ elementActions: PropTypes.object.isRequired
+}
+
+export default EditTask
diff --git a/rdmo/management/assets/js/components/edit/EditView.js b/rdmo/management/assets/js/components/edit/EditView.js
new file mode 100644
index 0000000000..ab19a8b9c0
--- /dev/null
+++ b/rdmo/management/assets/js/components/edit/EditView.js
@@ -0,0 +1,134 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import { Tabs, Tab } from 'react-bootstrap'
+import get from 'lodash/get'
+
+import CodeMirror from './common/CodeMirror'
+import Checkbox from './common/Checkbox'
+import Select from './common/Select'
+import Text from './common/Text'
+import Textarea from './common/Textarea'
+import UriPrefix from './common/UriPrefix'
+
+import { BackButton, SaveButton, DeleteButton } from '../common/Buttons'
+import { ReadOnlyIcon } from '../common/Icons'
+
+import ViewInfo from '../info/ViewInfo'
+import DeleteViewModal from '../modals/DeleteViewModal'
+
+import useDeleteModal from '../../hooks/useDeleteModal'
+
+const EditView = ({ config, view, elements, elementActions }) => {
+
+ const { sites, groups } = config
+ const { elementAction, catalogs } = elements
+
+ const updateView = (key, value) => elementActions.updateElement(view, {[key]: value})
+ const storeView = (back) => elementActions.storeElement('views', view, back)
+ const deleteView = () => elementActions.deleteElement('views', view)
+
+ const [showDeleteModal, openDeleteModal, closeDeleteModal] = useDeleteModal()
+
+ const info =
+
+ return (
+
+
+
+
+
+
+
+
+ {
+ view.id ? <>
+
{gettext('View')}{': '}
+
{view.uri}
+ > :
{gettext('Create view')}
+ }
+
+
+ {
+ view.id &&
+ { info }
+
+ }
+
+
+
+
+
+
+
+
+
+ {
+ config.settings && config.settings.languages.map(([lang_code, lang], index) => (
+
+
+
+
+ ))
+ }
+
+
+
+
+ {get(config, 'settings.groups') &&
}
+
+ {get(config, 'settings.multisite') &&
}
+
+ {get(config, 'settings.multisite') &&
}
+
+
+
+
+
+
+
+
+
+
+ {view.id &&
}
+
+
+
+
+ )
+}
+
+EditView.propTypes = {
+ config: PropTypes.object.isRequired,
+ view: PropTypes.object.isRequired,
+ elements: PropTypes.object.isRequired,
+ elementActions: PropTypes.object.isRequired
+}
+
+export default EditView
diff --git a/rdmo/management/assets/js/components/edit/common/Checkbox.js b/rdmo/management/assets/js/components/edit/common/Checkbox.js
new file mode 100644
index 0000000000..7cb6fee960
--- /dev/null
+++ b/rdmo/management/assets/js/components/edit/common/Checkbox.js
@@ -0,0 +1,51 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import classNames from 'classnames'
+import isEmpty from 'lodash/isEmpty'
+import isNil from 'lodash/isNil'
+import get from 'lodash/get'
+
+import { getId, getLabel, getHelp } from 'rdmo/management/assets/js/utils/forms'
+
+const Checkbox = ({ config, element, field, onChange }) => {
+ const id = getId(element, field),
+ label = getLabel(config, element, field),
+ help = getHelp(config, element, field),
+ warnings = get(element, ['warnings', field]),
+ errors = get(element, ['errors', field])
+
+ const className = classNames({
+ 'form-group': true,
+ 'has-warning': !isEmpty(warnings),
+ 'has-error': !isEmpty(errors)
+ })
+
+ const checked = isNil(element[field]) ? '' : element[field]
+
+ return (
+
+
+
+ onChange(field, !checked)} />
+ {label}
+
+
+
+ {help &&
{help}
}
+
+ {errors &&
+ {errors.map((error, index) => {error} )}
+ }
+
+ )
+}
+
+Checkbox.propTypes = {
+ config: PropTypes.object,
+ element: PropTypes.object,
+ field: PropTypes.string,
+ onChange: PropTypes.func,
+}
+
+export default Checkbox
diff --git a/rdmo/management/assets/js/components/edit/common/CodeMirror.js b/rdmo/management/assets/js/components/edit/common/CodeMirror.js
new file mode 100644
index 0000000000..9c402b8b27
--- /dev/null
+++ b/rdmo/management/assets/js/components/edit/common/CodeMirror.js
@@ -0,0 +1,50 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import classNames from 'classnames'
+import isEmpty from 'lodash/isEmpty'
+import isNil from 'lodash/isNil'
+import get from 'lodash/get'
+import ReactCodeMirror from '@uiw/react-codemirror'
+import { html } from '@codemirror/lang-html'
+
+import { getId, getLabel, getHelp } from 'rdmo/management/assets/js/utils/forms'
+
+const CodeMirror = ({ config, element, field, onChange }) => {
+ const id = getId(element, field),
+ label = getLabel(config, element, field),
+ help = getHelp(config, element, field),
+ warnings = get(element, ['warnings', field]),
+ errors = get(element, ['errors', field])
+
+ const className = classNames({
+ 'form-group': true,
+ 'has-warning': !isEmpty(warnings),
+ 'has-error': !isEmpty(errors)
+ })
+
+ const value = isNil(element[field]) ? '' : element[field]
+
+ return (
+
+
{label}
+
+
onChange(field, value)} disabled={element.read_only} />
+
+ {help && {help}
}
+
+ {errors &&
+ {errors.map((error, index) => {error} )}
+ }
+
+ )
+}
+
+CodeMirror.propTypes = {
+ config: PropTypes.object,
+ element: PropTypes.object,
+ field: PropTypes.string,
+ onChange: PropTypes.func
+}
+
+export default CodeMirror
diff --git a/rdmo/management/assets/js/components/edit/common/MultiSelect.js b/rdmo/management/assets/js/components/edit/common/MultiSelect.js
new file mode 100644
index 0000000000..0feb6a2d38
--- /dev/null
+++ b/rdmo/management/assets/js/components/edit/common/MultiSelect.js
@@ -0,0 +1,110 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import ReactSelect from 'react-select'
+import classNames from 'classnames'
+import isEmpty from 'lodash/isEmpty'
+import isNil from 'lodash/isNil'
+import get from 'lodash/get'
+
+import Link from 'rdmo/core/assets/js/components/Link'
+
+import { getId, getLabel, getHelp } from 'rdmo/management/assets/js/utils/forms'
+
+const MultiSelect = ({ config, element, field, options, verboseName, onChange, onCreate, onEdit }) => {
+ const id = getId(element, field),
+ label = getLabel(config, element, field),
+ help = getHelp(config, element, field),
+ warnings = get(element, ['warnings', field]),
+ errors = get(element, ['errors', field])
+
+ const className = classNames({
+ 'form-group': true,
+ 'has-warning': !isEmpty(warnings),
+ 'has-error': !isEmpty(errors)
+ })
+
+ const values = isNil(element[field]) ? [] : element[field]
+
+ const selectOptions = options.map(option => ({
+ value: option.id,
+ label: option.uri || option.text || option.name
+ }))
+
+ const styles = onEdit ? {
+ container: provided => ({...provided, marginRight: 8 + 12 + 4 + 11})
+ } : {}
+
+ const handleAdd = () => {
+ values.push(selectOptions[0].value)
+ onChange(field, values)
+ }
+
+ const handleChange = (option, index) => {
+ values[index] = option.value
+ onChange(field, values)
+ }
+
+ const handleRemove = (index) => {
+ values.splice(index, 1)
+ onChange(field, values)
+ }
+
+ const handleEdit = (index) => {
+ onEdit(values[index])
+ }
+
+ return (
+
+
{label}
+
+
+ {
+ values.map((value, index) => {
+ const selectValue = selectOptions.find(option => (option.value == value))
+
+ return (
+
+ {
+ onEdit &&
+ handleEdit(index)} />
+ handleRemove(index)}
+ disabled={element.read_only} />
+
+ }
+
+
handleChange(option, index)} />
+
+ )
+ })
+ }
+
+
+
handleAdd()} disabled={element.read_only}>
+ {interpolate(gettext('Add %s'), [verboseName])}
+
+
+ {
+ onCreate &&
+ {interpolate(gettext('Create new %s'), [verboseName])}
+
+ }
+
+ {help &&
{help}
}
+
+ )
+}
+
+MultiSelect.propTypes = {
+ config: PropTypes.object,
+ element: PropTypes.object,
+ field: PropTypes.string,
+ options: PropTypes.array,
+ verboseName: PropTypes.string,
+ onChange: PropTypes.func,
+ onCreate: PropTypes.func,
+ onEdit: PropTypes.func
+}
+
+export default MultiSelect
diff --git a/rdmo/management/assets/js/components/edit/common/Number.js b/rdmo/management/assets/js/components/edit/common/Number.js
new file mode 100644
index 0000000000..10488d3ac4
--- /dev/null
+++ b/rdmo/management/assets/js/components/edit/common/Number.js
@@ -0,0 +1,48 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import classNames from 'classnames'
+import isEmpty from 'lodash/isEmpty'
+import isNil from 'lodash/isNil'
+import get from 'lodash/get'
+
+import { getId, getLabel, getHelp } from 'rdmo/management/assets/js/utils/forms'
+
+const Number = ({ config, element, field, onChange }) => {
+ const id = getId(element, field),
+ label = getLabel(config, element, field),
+ help = getHelp(config, element, field),
+ warnings = get(element, ['warnings', field]),
+ errors = get(element, ['errors', field])
+
+ const className = classNames({
+ 'form-group': true,
+ 'has-warning': !isEmpty(warnings),
+ 'has-error': !isEmpty(errors)
+ })
+
+ const value = isNil(element[field]) ? '' : element[field]
+
+ return (
+
+
{label}
+
+
onChange(field, event.target.value)} />
+
+ {help &&
{help}
}
+
+ {errors &&
+ {errors.map((error, index) => {error} )}
+ }
+
+ )
+}
+
+Number.propTypes = {
+ config: PropTypes.object,
+ element: PropTypes.object,
+ field: PropTypes.string,
+ onChange: PropTypes.func
+}
+
+export default Number
diff --git a/rdmo/management/assets/js/components/edit/common/OrderedMultiSelect.js b/rdmo/management/assets/js/components/edit/common/OrderedMultiSelect.js
new file mode 100644
index 0000000000..7609663927
--- /dev/null
+++ b/rdmo/management/assets/js/components/edit/common/OrderedMultiSelect.js
@@ -0,0 +1,292 @@
+import React, { Component, useRef } from 'react'
+import ReactSelect from 'react-select'
+import { useDrag, useDrop } from 'react-dnd'
+import PropTypes from 'prop-types'
+import classNames from 'classnames'
+import isEmpty from 'lodash/isEmpty'
+import isUndefined from 'lodash/isUndefined'
+import isNil from 'lodash/isNil'
+import isNumber from 'lodash/isNumber'
+import toNumber from 'lodash/toNumber'
+import get from 'lodash/get'
+import maxBy from 'lodash/maxBy'
+
+import Link from 'rdmo/core/assets/js/components/Link'
+
+import { getId, getLabel, getHelp } from 'rdmo/management/assets/js/utils/forms'
+
+const OrderedMultiSelectItem = ({ index, field, selectValue, selectOptions, errors, disabled,
+ handleChange, handleEdit, handleRemove, handleDrag }) => {
+ const dragRef = useRef(null)
+ const dropRef = useRef(null)
+
+ const [{}, drag] = useDrag(() => ({
+ type: field,
+ item: { index }
+ }))
+
+ const [{ isDragging, isOver }, drop] = useDrop(() => ({
+ accept: field,
+ collect: (monitor) => ({
+ isDragging: monitor.getItemType() == field,
+ isOver: monitor.isOver()
+ }),
+ drop: (item) => {
+ handleDrag(item.index, index)
+ },
+ }))
+
+ const dropClassName = classNames({
+ 'drop': true,
+ 'show': isDragging,
+ 'over': isOver
+ })
+
+ const dragClassName = classNames({
+ 'fa fa-arrows drag': true,
+ disabled: disabled
+ })
+
+ if (!disabled) {
+ drag(dragRef)
+ drop(dropRef)
+ }
+
+ const styles = {
+ container: provided => ({...provided, marginRight: 8 + 12 + 4 + 11 + 4 + 14})
+ }
+
+ return (
+ <>
+
+
+ handleEdit(index)} />
+ !disabled && handleRemove(index)} />
+
+
+
+ handleChange(option, index)}
+ menuPortalTarget={document.body} styles={styles} isDisabled={disabled} />
+
+ {
+ errors && errors[index] &&
+
+ {
+ Object.keys(errors[index]).map((key, i1) => {
+ return errors[index][key].map((error, i2) => {error} )
+ })
+ }
+
+ }
+
+
+ >
+ )
+}
+
+class OrderedMultiSelect extends Component {
+
+ constructor(props) {
+ super(props)
+
+ this.handleChange = this.handleChange.bind(this)
+ this.handleEdit = this.handleEdit.bind(this)
+ this.handleRemove = this.handleRemove.bind(this)
+ this.handleDrag = this.handleDrag.bind(this)
+ }
+
+ getValues() {
+ const { field, values, element } = this.props
+
+ if (isUndefined(values)) {
+ return isUndefined(element[field]) ? [] : [...element[field]]
+ } else {
+ return values
+ }
+ }
+
+ getSelectOptions() {
+ const { options } = this.props
+
+ return options.map(option => ({
+ value: option.value || option.id,
+ label: option.label || option.uri || option.text || option.name
+ }))
+ }
+
+ parseValue(option) {
+ const { field } = this.props
+
+ if (isNumber(option.value)) {
+ return [field.slice(0, -1), option.value]
+ } else {
+ const [valueField, id] = option.value.split('-')
+ return [valueField, toNumber(id)]
+ }
+ }
+
+ compareValue(option, value) {
+ const valueField = Object.keys(value).filter(k => k != 'order')[0]
+ if (isNumber(option.value)) {
+ return option.value == value[valueField]
+ } else {
+ return option.value == valueField + '-' + value[valueField]
+ }
+ }
+
+ handleAdd() {
+ const { field, onChange } = this.props
+ const values = this.getValues()
+
+ const maxValue = maxBy(values, 'order')
+ const [valueField, value] = this.parseValue(this.getSelectOptions()[0])
+ values.push({
+ [valueField]: value,
+ order: maxValue ? maxValue.order + 1 : 0
+ })
+
+ onChange(field, values)
+ }
+
+ handleChange(option, index) {
+ const { field, onChange } = this.props
+ const values = this.getValues()
+
+ if (isNil(option)) {
+ values[index] = null
+ } else {
+ const [valueField, value] = this.parseValue(option)
+ values[index] = {
+ [valueField]: value,
+ order: values[index].order
+ }
+ }
+
+ onChange(field, values)
+ }
+
+ handleEdit(index) {
+ const { onEdit } = this.props
+ const values = this.getValues()
+
+ onEdit(values[index])
+ }
+
+ handleRemove(index) {
+ const { field, onChange } = this.props
+ const values = this.getValues()
+
+ values.splice(index, 1)
+
+ onChange(field, values)
+ }
+
+ handleDrag(dragIndex, dropIndex) {
+ const { field, onChange } = this.props
+ const values = this.getValues()
+
+ const dragValue = values[dragIndex]
+ values.splice(dragIndex, 1)
+ values.splice(dropIndex, 0, dragValue)
+
+ // re-order the array
+ values.forEach((value, index) => {
+ value.order = index
+ })
+
+ onChange(field, values)
+ }
+
+ render() {
+ const { config, element, field, verboseName, verboseNameCreate,
+ verboseNameAltCreate, onCreate, onAltCreate } = this.props
+
+ const id = getId(element, field),
+ label = getLabel(config, element, field),
+ help = getHelp(config, element, field),
+ warnings = get(element, ['warnings', field]),
+ errors = get(element, ['errors', field])
+
+ const className = classNames({
+ 'form-group': true,
+ 'has-warning': !isEmpty(warnings),
+ 'has-error': !isEmpty(errors)
+ })
+
+ return (
+
+
{label}
+
+
+ {
+ this.getValues().map((value, index) => {
+ const selectOptions = this.getSelectOptions()
+ const selectValue = selectOptions.find(option => this.compareValue(option, value))
+ return (
+
+ )
+ })
+ }
+
+
+
this.handleAdd()}
+ disabled={element.read_only}>
+ {interpolate(gettext('Add existing %s'), [verboseName])}
+
+ {
+ onCreate &&
+ {interpolate(gettext('Create new %s'), [verboseNameCreate || verboseName])}
+
+ }
+ {
+ onAltCreate &&
+ {interpolate(gettext('Create new %s'), [verboseNameAltCreate || verboseName])}
+
+ }
+
+ {help &&
{help}
}
+
+ )
+ }
+}
+
+OrderedMultiSelectItem.propTypes = {
+ index: PropTypes.number,
+ field: PropTypes.string,
+ selectValue: PropTypes.object,
+ selectOptions: PropTypes.array,
+ errors: PropTypes.object,
+ disabled: PropTypes.bool,
+ handleChange: PropTypes.func,
+ handleEdit: PropTypes.func,
+ handleRemove: PropTypes.func,
+ handleDrag: PropTypes.func
+}
+
+OrderedMultiSelect.propTypes = {
+ config: PropTypes.object.isRequired,
+ element: PropTypes.object.isRequired,
+ field: PropTypes.string.isRequired,
+ fields: PropTypes.array,
+ options: PropTypes.array.isRequired,
+ values: PropTypes.array,
+ verboseName: PropTypes.string.isRequired,
+ verboseNameCreate: PropTypes.string,
+ verboseNameAltCreate: PropTypes.string,
+ onChange: PropTypes.func.isRequired,
+ onCreate: PropTypes.func,
+ onAltCreate: PropTypes.func,
+ onEdit: PropTypes.func.isRequired
+}
+
+export default OrderedMultiSelect
diff --git a/rdmo/management/assets/js/components/edit/common/Select.js b/rdmo/management/assets/js/components/edit/common/Select.js
new file mode 100644
index 0000000000..8998798047
--- /dev/null
+++ b/rdmo/management/assets/js/components/edit/common/Select.js
@@ -0,0 +1,93 @@
+import React from 'react'
+import ReactSelect from 'react-select'
+import PropTypes from 'prop-types'
+import classNames from 'classnames'
+import get from 'lodash/get'
+import isArray from 'lodash/isArray'
+import isEmpty from 'lodash/isEmpty'
+import isNil from 'lodash/isNil'
+
+import Link from 'rdmo/core/assets/js/components/Link'
+
+import { getId, getLabel, getHelp } from 'rdmo/management/assets/js/utils/forms'
+
+const Select = ({ config, element, field, options, verboseName, isMulti, onChange, onCreate, onEdit }) => {
+ const id = getId(element, field),
+ label = getLabel(config, element, field),
+ help = getHelp(config, element, field),
+ warnings = get(element, ['warnings', field]),
+ errors = get(element, ['errors', field])
+
+ const className = classNames({
+ 'form-group': true,
+ 'has-warning': !isEmpty(warnings),
+ 'has-error': !isEmpty(errors)
+ })
+
+ const selectOptions = options.map(option => ({
+ value: option.id,
+ label: option.uri || option.text || option.name
+ }))
+
+ const selectValue = isArray(element[field]) ? selectOptions.filter(option => (element[field].includes(option.value)))
+ : selectOptions.find(option => (option.value == element[field]))
+
+ const styles = onEdit && selectValue ? {
+ container: provided => ({...provided, marginRight: 8 + 12})
+ } : {}
+
+ const handleChange = (option) => {
+ if (isNil(option)) {
+ onChange(field, null)
+ } else if (isArray(option)) {
+ onChange(field, option.map(o => o.value))
+ } else {
+ onChange(field, option.value)
+ }
+ }
+
+ return (
+
+
{label}
+
+
+ {
+ onEdit && selectValue &&
+ onEdit(selectValue.value)} disabled={isNil(selectValue)} />
+
+ }
+
+
+
+
+ {
+ onCreate &&
+ {interpolate(gettext('Create new %s'), [verboseName])}
+
+ }
+
+ {help &&
{help}
}
+
+ {errors &&
+ {errors.map((error, index) => {error} )}
+ }
+
+ )
+}
+
+Select.propTypes = {
+ config: PropTypes.object,
+ element: PropTypes.object,
+ field: PropTypes.string,
+ options: PropTypes.array,
+ verboseName: PropTypes.string,
+ isMulti: PropTypes.bool,
+ onChange: PropTypes.func,
+ onCreate: PropTypes.func,
+ onEdit: PropTypes.func
+}
+
+export default Select
diff --git a/rdmo/management/assets/js/components/edit/common/Text.js b/rdmo/management/assets/js/components/edit/common/Text.js
new file mode 100644
index 0000000000..c249bd11fb
--- /dev/null
+++ b/rdmo/management/assets/js/components/edit/common/Text.js
@@ -0,0 +1,48 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import classNames from 'classnames'
+import isEmpty from 'lodash/isEmpty'
+import isNil from 'lodash/isNil'
+import get from 'lodash/get'
+
+import { getId, getLabel, getHelp } from 'rdmo/management/assets/js/utils/forms'
+
+const Text = ({ config, element, field, onChange }) => {
+ const id = getId(element, field),
+ label = getLabel(config, element, field),
+ help = getHelp(config, element, field),
+ warnings = get(element, ['warnings', field]),
+ errors = get(element, ['errors', field])
+
+ const className = classNames({
+ 'form-group': true,
+ 'has-warning': !isEmpty(warnings),
+ 'has-error': !isEmpty(errors)
+ })
+
+ const value = isNil(element[field]) ? '' : element[field]
+
+ return (
+
+
{label}
+
+
onChange(field, event.target.value)} />
+
+ {help &&
{help}
}
+
+ {errors &&
+ {errors.map((error, index) => {error} )}
+ }
+
+ )
+}
+
+Text.propTypes = {
+ config: PropTypes.object,
+ element: PropTypes.object,
+ field: PropTypes.string,
+ onChange: PropTypes.func
+}
+
+export default Text
diff --git a/rdmo/management/assets/js/components/edit/common/Textarea.js b/rdmo/management/assets/js/components/edit/common/Textarea.js
new file mode 100644
index 0000000000..b6530f2c74
--- /dev/null
+++ b/rdmo/management/assets/js/components/edit/common/Textarea.js
@@ -0,0 +1,49 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import classNames from 'classnames'
+import isEmpty from 'lodash/isEmpty'
+import isNil from 'lodash/isNil'
+import get from 'lodash/get'
+
+import { getId, getLabel, getHelp } from 'rdmo/management/assets/js/utils/forms'
+
+const Textarea = ({ config, element, field, rows, onChange }) => {
+ const id = getId(element, field),
+ label = getLabel(config, element, field),
+ help = getHelp(config, element, field),
+ warnings = get(element, ['warnings', field]),
+ errors = get(element, ['errors', field])
+
+ const className = classNames({
+ 'form-group': true,
+ 'has-warning': !isEmpty(warnings),
+ 'has-error': !isEmpty(errors)
+ })
+
+ const value = isNil(element[field]) ? '' : element[field]
+
+ return (
+
+ )
+}
+
+Textarea.propTypes = {
+ config: PropTypes.object,
+ element: PropTypes.object,
+ field: PropTypes.string,
+ rows: PropTypes.number,
+ onChange: PropTypes.func
+}
+
+export default Textarea
diff --git a/rdmo/management/assets/js/components/edit/common/UriPrefix.js b/rdmo/management/assets/js/components/edit/common/UriPrefix.js
new file mode 100644
index 0000000000..3f495df881
--- /dev/null
+++ b/rdmo/management/assets/js/components/edit/common/UriPrefix.js
@@ -0,0 +1,58 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import classNames from 'classnames'
+import isEmpty from 'lodash/isEmpty'
+import isNil from 'lodash/isNil'
+import get from 'lodash/get'
+
+import { getId, getLabel, getHelp } from 'rdmo/management/assets/js/utils/forms'
+
+const UriPrefix = ({ config, element, field, onChange }) => {
+ const id = getId(element, field),
+ label = getLabel(config, element, field),
+ help = getHelp(config, element, field),
+ warnings = get(element, ['warnings', field]),
+ errors = get(element, ['errors', field])
+
+ const className = classNames({
+ 'form-group': true,
+ 'has-warning': !isEmpty(warnings),
+ 'has-error': !isEmpty(errors)
+ })
+
+ const value = isNil(element[field]) ? '' : element[field]
+
+ return (
+
+
{label}
+
+
+ onChange(field, event.target.value)} />
+
+
+ onChange(field, config.settings.default_uri_prefix)}>
+
+
+
+
+
+ {help &&
{help}
}
+
+ {errors &&
+ {errors.map((error, index) => {error} )}
+ }
+
+ )
+}
+
+UriPrefix.propTypes = {
+ config: PropTypes.object,
+ element: PropTypes.object,
+ field: PropTypes.string,
+ onChange: PropTypes.func
+}
+
+export default UriPrefix
diff --git a/rdmo/management/assets/js/components/element/Attribute.js b/rdmo/management/assets/js/components/element/Attribute.js
new file mode 100644
index 0000000000..7c8d161d7f
--- /dev/null
+++ b/rdmo/management/assets/js/components/element/Attribute.js
@@ -0,0 +1,92 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+
+import { filterElement } from '../../utils/filter'
+import { buildPath } from '../../utils/location'
+
+import { ElementErrors } from '../common/Errors'
+import { EditLink, CopyLink, AddLink, LockedLink, NestedLink, ExportLink, CodeLink } from '../common/Links'
+import { ReadOnlyIcon } from '../common/Icons'
+
+const Attribute = ({ config, attribute, elementActions, display='list', indent=0,
+ filter=null, filterEditors=false }) => {
+
+ const showElement = filterElement(config, filter, false, filterEditors, attribute)
+
+ const editUrl = buildPath(config.baseUrl, 'attributes', attribute.id)
+ const copyUrl = buildPath(config.baseUrl, 'attributes', attribute.id, 'copy')
+ const nestedUrl = buildPath(config.baseUrl, 'attributes', attribute.id, 'nested')
+ const exportUrl = buildPath('/api/v1/', 'domain', 'attributes', attribute.id, 'export')
+
+ const fetchEdit = () => elementActions.fetchElement('attributes', attribute.id)
+ const fetchCopy = () => elementActions.fetchElement('attributes', attribute.id, 'copy')
+ const fetchNested = () => elementActions.fetchElement('attributes', attribute.id, 'nested')
+ const toggleLocked = () => elementActions.storeElement('attributes', {...attribute, locked: !attribute.locked })
+
+ const createAttribute = () => elementActions.createElement('attributes', { attribute })
+
+ const elementNode = (
+
+
+
+
+ {gettext('Attribute')}{': '}
+ fetchEdit()} />
+
+
+
+
+ )
+
+ switch (display) {
+ case 'list':
+ return showElement && (
+
+ { elementNode }
+
+ )
+ case 'nested':
+ return (
+ <>
+ {
+ showElement &&
+ }
+ {
+ attribute.elements.map((attribute, index) => (
+
+ ))
+ }
+ >
+ )
+ case 'plain':
+ return elementNode
+ }
+}
+
+Attribute.propTypes = {
+ config: PropTypes.object.isRequired,
+ attribute: PropTypes.object.isRequired,
+ elementActions: PropTypes.object.isRequired,
+ display: PropTypes.string,
+ indent: PropTypes.number,
+ filter: PropTypes.string,
+ filterEditors: PropTypes.bool
+}
+
+export default Attribute
diff --git a/rdmo/management/assets/js/components/element/Catalog.js b/rdmo/management/assets/js/components/element/Catalog.js
new file mode 100644
index 0000000000..fd934ae585
--- /dev/null
+++ b/rdmo/management/assets/js/components/element/Catalog.js
@@ -0,0 +1,84 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import get from 'lodash/get'
+
+import { filterElement } from '../../utils/filter'
+import { buildPath } from '../../utils/location'
+
+import { ElementErrors } from '../common/Errors'
+import { EditLink, CopyLink, AddLink, AvailableLink, LockedLink, NestedLink,
+ ExportLink, CodeLink } from '../common/Links'
+import { ReadOnlyIcon } from '../common/Icons'
+
+const Catalog = ({ config, catalog, elementActions, display='list',
+ filter=false, filterSites=false, filterEditors=false }) => {
+
+ const showElement = filterElement(config, filter, filterSites, filterEditors, catalog)
+
+ const editUrl = buildPath(config.baseUrl, 'catalogs', catalog.id)
+ const copyUrl = buildPath(config.baseUrl, 'catalogs', catalog.id, 'copy')
+ const nestedUrl = buildPath(config.baseUrl, 'catalogs', catalog.id, 'nested')
+ const exportUrl = buildPath('/api/v1/', 'questions', 'catalogs', catalog.id, 'export')
+
+ const fetchEdit = () => elementActions.fetchElement('catalogs', catalog.id)
+ const fetchCopy = () => elementActions.fetchElement('catalogs', catalog.id, 'copy')
+ const fetchNested = () => elementActions.fetchElement('catalogs', catalog.id, 'nested')
+
+ const toggleAvailable = () => elementActions.storeElement('catalogs', {...catalog, available: !catalog.available })
+ const toggleLocked = () => elementActions.storeElement('catalogs', {...catalog, locked: !catalog.locked })
+
+ const createSection = () => elementActions.createElement('sections', { catalog })
+
+ const elementNode = (
+
+
+
+
+ {gettext('Catalog')}{': '} {catalog.title}
+
+ {
+ get(config, 'display.uri.catalogs', true) &&
+
fetchEdit()} />
+ }
+
+
+
+ )
+
+ switch (display) {
+ case 'list':
+ return showElement && (
+
+ { elementNode }
+
+ )
+ case 'plain':
+ return elementNode
+ }
+}
+
+Catalog.propTypes = {
+ config: PropTypes.object.isRequired,
+ catalog: PropTypes.object.isRequired,
+ elementActions: PropTypes.object.isRequired,
+ display: PropTypes.string,
+ filter: PropTypes.string,
+ filterSites: PropTypes.bool,
+ filterEditors: PropTypes.bool
+}
+
+export default Catalog
diff --git a/rdmo/management/assets/js/components/element/Condition.js b/rdmo/management/assets/js/components/element/Condition.js
new file mode 100644
index 0000000000..816f77c65d
--- /dev/null
+++ b/rdmo/management/assets/js/components/element/Condition.js
@@ -0,0 +1,55 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+
+import { filterElement } from '../../utils/filter'
+import { buildPath } from '../../utils/location'
+
+import { ElementErrors } from '../common/Errors'
+import { EditLink, CopyLink, LockedLink, ExportLink, CodeLink } from '../common/Links'
+import { ReadOnlyIcon } from '../common/Icons'
+
+const Condition = ({ config, condition, elementActions, filter=false, filterEditors=false }) => {
+
+ const showElement = filterElement(config, filter, false, filterEditors, condition)
+
+ const editUrl = buildPath(config.baseUrl, 'conditions', condition.id)
+ const copyUrl = buildPath(config.baseUrl, 'conditions', condition.id, 'copy')
+ const exportUrl = buildPath('/api/v1/', 'conditions', 'conditions', condition.id, 'export')
+
+ const fetchEdit = () => elementActions.fetchElement('conditions', condition.id)
+ const fetchCopy = () => elementActions.fetchElement('conditions', condition.id, 'copy')
+ const toggleLocked = () => elementActions.storeElement('conditions', {...condition, locked: !condition.locked })
+
+ return showElement && (
+
+
+
+
+
+
+
+
+
+
+
+ {gettext('Condition')}{': '}
+ fetchEdit()} />
+
+
+
+
+
+ )
+}
+
+Condition.propTypes = {
+ config: PropTypes.object.isRequired,
+ condition: PropTypes.object.isRequired,
+ elementActions: PropTypes.object.isRequired,
+ filter: PropTypes.string,
+ filterEditors: PropTypes.bool
+}
+
+export default Condition
diff --git a/rdmo/management/assets/js/components/element/Option.js b/rdmo/management/assets/js/components/element/Option.js
new file mode 100644
index 0000000000..64aac49362
--- /dev/null
+++ b/rdmo/management/assets/js/components/element/Option.js
@@ -0,0 +1,76 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import get from 'lodash/get'
+
+import { filterElement } from '../../utils/filter'
+import { buildPath } from '../../utils/location'
+
+import { ElementErrors } from '../common/Errors'
+import { EditLink, CopyLink, LockedLink, ExportLink, CodeLink } from '../common/Links'
+import { ReadOnlyIcon } from '../common/Icons'
+
+const Option = ({ config, option, elementActions, display='list', indent=0, filter=false, filterEditors=false }) => {
+
+ const showElement = filterElement(config, filter, false, filterEditors, option)
+
+ const editUrl = buildPath(config.baseUrl, 'options', option.id)
+ const copyUrl = buildPath(config.baseUrl, 'options', option.id, 'copy')
+ const exportUrl = buildPath('/api/v1/', 'options', 'options', option.id, 'export')
+
+ const fetchEdit = () => elementActions.fetchElement('options', option.id)
+ const fetchCopy = () => elementActions.fetchElement('options', option.id, 'copy')
+ const toggleLocked = () => elementActions.storeElement('options', {...option, locked: !option.locked })
+
+ const elementNode = (
+
+
+
+
+
+
+
+
+
+
+ {gettext('Option')}{': '} {option.text}
+
+ {
+ get(config, 'display.uri.options', true) &&
+
fetchEdit()} />
+ }
+
+
+
+ )
+
+ switch (display) {
+ case 'list':
+ return showElement && (
+
+ { elementNode }
+
+ )
+ case 'nested':
+ return showElement && (
+
+ )
+ }
+}
+
+Option.propTypes = {
+ config: PropTypes.object.isRequired,
+ option: PropTypes.object.isRequired,
+ elementActions: PropTypes.object.isRequired,
+ display: PropTypes.string,
+ indent: PropTypes.number,
+ filter: PropTypes.string,
+ filterEditors: PropTypes.bool
+}
+
+export default Option
diff --git a/rdmo/management/assets/js/components/element/OptionSet.js b/rdmo/management/assets/js/components/element/OptionSet.js
new file mode 100644
index 0000000000..3628d8efa1
--- /dev/null
+++ b/rdmo/management/assets/js/components/element/OptionSet.js
@@ -0,0 +1,72 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+
+import { filterElement } from '../../utils/filter'
+import { buildPath } from '../../utils/location'
+
+import { ElementErrors } from '../common/Errors'
+import { EditLink, CopyLink, AddLink, LockedLink, NestedLink,
+ ExportLink, CodeLink } from '../common/Links'
+import { ReadOnlyIcon } from '../common/Icons'
+
+const OptionSet = ({ config, optionset, elementActions, display='list', filter=false, filterEditors=false }) => {
+
+ const showElement = filterElement(config, filter, false, filterEditors, optionset)
+
+ const editUrl = buildPath(config.baseUrl, 'optionsets', optionset.id)
+ const copyUrl = buildPath(config.baseUrl, 'optionsets', optionset.id, 'copy')
+ const nestedUrl = buildPath(config.baseUrl, 'optionsets', optionset.id, 'nested')
+ const exportUrl = buildPath('/api/v1/', 'options', 'optionsets', optionset.id, 'export')
+
+ const fetchEdit = () => elementActions.fetchElement('optionsets', optionset.id)
+ const fetchCopy = () => elementActions.fetchElement('optionsets', optionset.id, 'copy')
+ const fetchNested = () => elementActions.fetchElement('optionsets', optionset.id, 'nested')
+ const toggleLocked = () => elementActions.storeElement('optionsets', {...optionset, locked: !optionset.locked })
+
+ const createOption = () => elementActions.createElement('options', { optionset })
+
+ const elementNode = (
+
+
+
+
+ {gettext('Option set')}{': '}
+ fetchEdit()} />
+
+
+
+
+ )
+
+ switch (display) {
+ case 'list':
+ return showElement && (
+
+ { elementNode }
+
+ )
+ case 'plain':
+ return elementNode
+ }
+}
+
+OptionSet.propTypes = {
+ config: PropTypes.object.isRequired,
+ optionset: PropTypes.object.isRequired,
+ elementActions: PropTypes.object.isRequired,
+ display: PropTypes.string,
+ filter: PropTypes.string,
+ filterEditors: PropTypes.bool
+}
+
+export default OptionSet
diff --git a/rdmo/management/assets/js/components/element/Page.js b/rdmo/management/assets/js/components/element/Page.js
new file mode 100644
index 0000000000..af692967be
--- /dev/null
+++ b/rdmo/management/assets/js/components/element/Page.js
@@ -0,0 +1,134 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import get from 'lodash/get'
+
+import { filterElement } from '../../utils/filter'
+import { buildPath } from '../../utils/location'
+
+import QuestionSet from './QuestionSet'
+import Question from './Question'
+import { ElementErrors } from '../common/Errors'
+import { EditLink, CopyLink, AddLink, LockedLink, NestedLink,
+ ExportLink, CodeLink, ShowElementsLink } from '../common/Links'
+import { ReadOnlyIcon } from '../common/Icons'
+import { Drag, Drop } from '../common/DragAndDrop'
+
+const Page = ({ config, page, configActions, elementActions, display='list', indent=0,
+ filter=false, filterEditors=false }) => {
+
+ const showElement = filterElement(config, filter, false, filterEditors, page)
+ const showElements = get(config, `display.elements.pages.${page.id}`, true)
+
+ const editUrl = buildPath(config.baseUrl, 'pages', page.id)
+ const copyUrl = buildPath(config.baseUrl, 'pages', page.id, 'copy')
+ const nestedUrl = buildPath(config.baseUrl, 'pages', page.id, 'nested')
+ const exportUrl = buildPath('/api/v1/', 'questions', 'pages', page.id, 'export')
+
+ const fetchEdit = () => elementActions.fetchElement('pages', page.id)
+ const fetchCopy = () => elementActions.fetchElement('pages', page.id, 'copy')
+ const fetchNested = () => elementActions.fetchElement('pages', page.id, 'nested')
+ const toggleLocked = () => elementActions.storeElement('pages', {...page, locked: !page.locked })
+ const toggleElements = () => configActions.toggleElements(page)
+
+ const createQuestionSet = () => elementActions.createElement('questionsets', { page })
+ const createQuestion = () => elementActions.createElement('questions', { page })
+
+ const fetchAttribute = () => elementActions.fetchElement('attributes', page.attribute)
+ const fetchCondition = (index) => elementActions.fetchElement('conditions', page.conditions[index])
+
+ const elementNode = (
+
+
+
+
+ {gettext('Page')}{': '} {page.title}
+
+ {
+ get(config, 'display.uri.pages', true) &&
+ fetchEdit()} />
+
+ }
+ {
+ get(config, 'display.uri.attributes', true) && page.attribute_uri &&
+ fetchAttribute()} />
+
+ }
+ {
+ get(config, 'display.uri.conditions', true) && page.condition_uris.map((uri, index) => (
+
+ fetchCondition(index)} />
+
+ ))
+ }
+
+
+
+ )
+
+ switch (display) {
+ case 'list':
+ return showElement && (
+
+ { elementNode }
+
+ )
+ case 'nested':
+ return (
+ <>
+ {
+ showElement && (
+
+
+
+ )
+ }
+ {
+ showElements && page.elements.map((element, index) => {
+ if (element.model == 'questions.questionset') {
+ return
+ } else {
+ return
+ }
+ })
+ }
+
+ >
+ )
+ case 'plain':
+ return elementNode
+ }
+}
+
+Page.propTypes = {
+ config: PropTypes.object.isRequired,
+ page: PropTypes.object.isRequired,
+ configActions: PropTypes.object.isRequired,
+ elementActions: PropTypes.object.isRequired,
+ display: PropTypes.string,
+ indent: PropTypes.number,
+ filter: PropTypes.string,
+ filterEditors: PropTypes.bool
+}
+
+export default Page
diff --git a/rdmo/management/assets/js/components/element/Question.js b/rdmo/management/assets/js/components/element/Question.js
new file mode 100644
index 0000000000..ecf280573f
--- /dev/null
+++ b/rdmo/management/assets/js/components/element/Question.js
@@ -0,0 +1,112 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import get from 'lodash/get'
+
+import { filterElement } from '../../utils/filter'
+import { buildPath } from '../../utils/location'
+
+import { ElementErrors } from '../common/Errors'
+import { EditLink, CopyLink, LockedLink, ExportLink, CodeLink } from '../common/Links'
+import { ReadOnlyIcon } from '../common/Icons'
+import { Drag, Drop } from '../common/DragAndDrop'
+
+const Question = ({ config, question, elementActions, display='list', indent=0,
+ filter=false, filterEditors=false }) => {
+
+ const showElement = filterElement(config, filter, false, filterEditors, question)
+
+ const editUrl = buildPath(config.baseUrl, 'questions', question.id)
+ const copyUrl = buildPath(config.baseUrl, 'questions', question.id, 'copy')
+ const exportUrl = buildPath('/api/v1/', 'questions', 'questions', question.id, 'export')
+
+ const fetchEdit = () => elementActions.fetchElement('questions', question.id)
+ const fetchCopy = () => elementActions.fetchElement('questions', question.id, 'copy')
+ const toggleLocked = () => elementActions.storeElement('questions', {...question, locked: !question.locked })
+
+ const fetchAttribute = () => elementActions.fetchElement('attributes', question.attribute)
+ const fetchCondition = (index) => elementActions.fetchElement('conditions', question.conditions[index])
+ const fetchOptionSet = (index) => elementActions.fetchElement('optionsets', question.optionsets[index])
+
+ const elementNode = (
+
+
+
+
+
+
+
+
+
+
+
+ {gettext('Question')}{': '}
+ {question.text}
+
+ {
+ get(config, 'display.uri.questions', true) &&
+ fetchEdit()} />
+
+ }
+ {
+ get(config, 'display.uri.attributes', true) && question.attribute_uri &&
+ fetchAttribute()} />
+
+ }
+ {
+ get(config, 'display.uri.conditions', true) && question.condition_uris.map((uri, index) => (
+
+ fetchCondition(index)} />
+
+ ))
+ }
+ {
+ get(config, 'display.uri.optionsets', true) && question.optionset_uris.map((uri, index) => (
+
+ fetchOptionSet(index)} />
+
+ ))
+ }
+
+
+
+ )
+
+ switch (display) {
+ case 'list':
+ return showElement && (
+
+ { elementNode }
+
+ )
+ case 'nested':
+ return (
+ <>
+ {
+ showElement && (
+
+ )
+ }
+
+ >
+ )
+ }
+}
+
+Question.propTypes = {
+ config: PropTypes.object.isRequired,
+ question: PropTypes.object.isRequired,
+ configActions: PropTypes.object.isRequired,
+ elementActions: PropTypes.object.isRequired,
+ display: PropTypes.string,
+ indent: PropTypes.number,
+ filter: PropTypes.string,
+ filterEditors: PropTypes.bool
+}
+
+export default Question
diff --git a/rdmo/management/assets/js/components/element/QuestionSet.js b/rdmo/management/assets/js/components/element/QuestionSet.js
new file mode 100644
index 0000000000..024652536f
--- /dev/null
+++ b/rdmo/management/assets/js/components/element/QuestionSet.js
@@ -0,0 +1,133 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import get from 'lodash/get'
+
+import { filterElement } from '../../utils/filter'
+import { buildPath } from '../../utils/location'
+
+import Question from './Question'
+import { ElementErrors } from '../common/Errors'
+import { EditLink, CopyLink, AddLink, LockedLink,
+ NestedLink, ExportLink, CodeLink, ShowElementsLink } from '../common/Links'
+import { ReadOnlyIcon } from '../common/Icons'
+import { Drag, Drop } from '../common/DragAndDrop'
+
+const QuestionSet = ({ config, questionset, configActions, elementActions, display='list', indent=0,
+ filter=false, filterEditors=false }) => {
+
+ const showElement = filterElement(config, filter, false, filterEditors, questionset)
+ const showElements = get(config, `display.elements.questionsets.${questionset.id}`, true)
+
+ const editUrl = buildPath(config.baseUrl, 'questionsets', questionset.id)
+ const copyUrl = buildPath(config.baseUrl, 'questionsets', questionset.id, 'copy')
+ const nestedUrl = buildPath(config.baseUrl, 'questionsets', questionset.id, 'nested')
+ const exportUrl = buildPath('/api/v1/', 'questions', 'questionsets', questionset.id, 'export')
+
+ const fetchEdit = () => elementActions.fetchElement('questionsets', questionset.id)
+ const fetchCopy = () => elementActions.fetchElement('questionsets', questionset.id, 'copy')
+ const fetchNested = () => elementActions.fetchElement('questionsets', questionset.id, 'nested')
+ const toggleLocked = () => elementActions.storeElement('questionsets', {...questionset, locked: !questionset.locked })
+ const toggleElements = () => configActions.toggleElements(questionset)
+
+ const createQuestionSet = () => elementActions.createElement('questionsets', { questionset })
+ const createQuestion = () => elementActions.createElement('questions', { questionset })
+
+ const fetchAttribute = () => elementActions.fetchElement('attributes', questionset.attribute)
+ const fetchCondition = (index) => elementActions.fetchElement('conditions', questionset.conditions[index])
+
+ const elementNode = (
+
+
+
+
+ {gettext('Question set')}{': '} {questionset.title}
+
+ {
+ get(config, 'display.uri.questionsets', true) &&
+ fetchEdit()} />
+
+ }
+ {
+ get(config, 'display.uri.attributes', true) && questionset.attribute_uri &&
+ fetchAttribute()} />
+
+ }
+ {
+ get(config, 'display.uri.conditions', true) && questionset.condition_uris.map((uri, index) => (
+
+ fetchCondition(index)} />
+
+ ))
+ }
+
+
+
+ )
+
+ switch (display) {
+ case 'list':
+ return showElement && (
+
+ { elementNode }
+
+ )
+ case 'nested':
+ return (
+ <>
+ {
+ showElement && (
+
+
+
+ )
+ }
+ {
+ showElements && questionset.elements.map((element, index) => {
+ if (element.model == 'questions.questionset') {
+ return
+ } else {
+ return
+ }
+ })
+ }
+
+ >
+ )
+ case 'plain':
+ return elementNode
+ }
+}
+
+QuestionSet.propTypes = {
+ config: PropTypes.object.isRequired,
+ questionset: PropTypes.object.isRequired,
+ configActions: PropTypes.object.isRequired,
+ elementActions: PropTypes.object.isRequired,
+ display: PropTypes.string,
+ indent: PropTypes.number,
+ filter: PropTypes.string,
+ filterEditors: PropTypes.bool
+}
+
+export default QuestionSet
diff --git a/rdmo/management/assets/js/components/element/Section.js b/rdmo/management/assets/js/components/element/Section.js
new file mode 100644
index 0000000000..c675ee8103
--- /dev/null
+++ b/rdmo/management/assets/js/components/element/Section.js
@@ -0,0 +1,116 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import get from 'lodash/get'
+import isEmpty from 'lodash/isEmpty'
+
+import { filterElement } from '../../utils/filter'
+import { buildPath } from '../../utils/location'
+
+import Page from './Page'
+import { ElementErrors } from '../common/Errors'
+import { EditLink, CopyLink, AddLink, LockedLink, NestedLink, ExportLink,
+ CodeLink, ShowElementsLink } from '../common/Links'
+import { ReadOnlyIcon } from '../common/Icons'
+import { Drag, Drop } from '../common/DragAndDrop'
+
+
+const Section = ({ config, section, configActions, elementActions, display='list', indent=0,
+ filter=false, filterEditors=false }) => {
+
+ const showElement = filterElement(config, filter, false, filterEditors, section)
+ const showElements = get(config, `display.elements.sections.${section.id}`, true)
+
+ const editUrl = buildPath(config.baseUrl, 'sections', section.id)
+ const copyUrl = buildPath(config.baseUrl, 'sections', section.id, 'copy')
+ const nestedUrl = buildPath(config.baseUrl, 'sections', section.id, 'nested')
+ const exportUrl = buildPath('/api/v1/', 'questions', 'sections', section.id, 'export')
+
+ const fetchEdit = () => elementActions.fetchElement('sections', section.id)
+ const fetchCopy = () => elementActions.fetchElement('sections', section.id, 'copy')
+ const fetchNested = () => elementActions.fetchElement('sections', section.id, 'nested')
+ const toggleLocked = () => elementActions.storeElement('sections', {...section, locked: !section.locked })
+ const toggleElements = () => configActions.toggleElements(section)
+
+ const createPage = () => elementActions.createElement('pages', { section })
+
+ const elementNode = (
+
+
+
+
+ {gettext('Section')}{': '} {section.title}
+
+ {
+ get(config, 'display.uri.sections', true) &&
+
fetchEdit()} />
+ }
+
+
+
+ )
+
+ switch (display) {
+ case 'list':
+ return showElement && (
+
+ { elementNode }
+
+ )
+ case 'nested':
+ return (
+ <>
+ {
+ showElement && (
+
+
+
+ )
+ }
+ {
+ !isEmpty(section.elements) &&
+
+ }
+ {
+ showElements && section.elements.map((page, index) => (
+
+ ))
+ }
+
+ >
+ )
+ case 'plain':
+ return elementNode
+ }
+}
+
+Section.propTypes = {
+ config: PropTypes.object.isRequired,
+ section: PropTypes.object.isRequired,
+ configActions: PropTypes.object.isRequired,
+ elementActions: PropTypes.object.isRequired,
+ display: PropTypes.string,
+ indent: PropTypes.number,
+ filter: PropTypes.string,
+ filterEditors: PropTypes.bool
+}
+
+export default Section
diff --git a/rdmo/management/assets/js/components/element/Task.js b/rdmo/management/assets/js/components/element/Task.js
new file mode 100644
index 0000000000..c8a4f981bf
--- /dev/null
+++ b/rdmo/management/assets/js/components/element/Task.js
@@ -0,0 +1,62 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+
+import { filterElement } from '../../utils/filter'
+import { buildPath } from '../../utils/location'
+
+import { ElementErrors } from '../common/Errors'
+import { EditLink, CopyLink, AvailableLink, LockedLink, ExportLink, CodeLink } from '../common/Links'
+import { ReadOnlyIcon } from '../common/Icons'
+
+const Task = ({ config, task, elementActions, filter=false, filterSites=false, filterEditors=false }) => {
+
+ const showElement = filterElement(config, filter, filterSites, filterEditors, task)
+
+ const editUrl = buildPath(config.baseUrl, 'tasks', task.id)
+ const copyUrl = buildPath(config.baseUrl, 'tasks', task.id, 'copy')
+ const exportUrl = buildPath('/api/v1/', 'tasks', 'tasks', task.id, 'export')
+
+ const fetchEdit = () => elementActions.fetchElement('tasks', task.id)
+ const fetchCopy = () => elementActions.fetchElement('tasks', task.id, 'copy')
+ const toggleAvailable = () => elementActions.storeElement('tasks', {...task, available: !task.available })
+ const toggleLocked = () => elementActions.storeElement('tasks', {...task, locked: !task.locked })
+
+ return showElement && (
+
+
+
+
+
+ {gettext('Task')}{': '}
+ fetchEdit()} />
+
+
+
+
+
+ )
+}
+
+Task.propTypes = {
+ config: PropTypes.object.isRequired,
+ task: PropTypes.object.isRequired,
+ configActions: PropTypes.object.isRequired,
+ elementActions: PropTypes.object.isRequired,
+ filter: PropTypes.string,
+ filterSites: PropTypes.bool,
+ filterEditors: PropTypes.bool
+}
+
+export default Task
diff --git a/rdmo/management/assets/js/components/element/View.js b/rdmo/management/assets/js/components/element/View.js
new file mode 100644
index 0000000000..3c1655ae81
--- /dev/null
+++ b/rdmo/management/assets/js/components/element/View.js
@@ -0,0 +1,62 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+
+import { filterElement } from '../../utils/filter'
+import { buildPath } from '../../utils/location'
+
+import { ElementErrors } from '../common/Errors'
+import { EditLink, CopyLink, AvailableLink, LockedLink, ExportLink, CodeLink } from '../common/Links'
+import { ReadOnlyIcon } from '../common/Icons'
+
+const View = ({ config, view, elementActions, filter=false, filterSites=false, filterEditors=false }) => {
+
+ const showElement = filterElement(config, filter, filterSites, filterEditors, view)
+
+ const editUrl = buildPath(config.baseUrl, 'views', view.id)
+ const copyUrl = buildPath(config.baseUrl, 'views', view.id, 'copy')
+ const exportUrl = buildPath('/api/v1/', 'views', 'views', view.id, 'export')
+
+ const fetchEdit = () => elementActions.fetchElement('views', view.id)
+ const fetchCopy = () => elementActions.fetchElement('views', view.id, 'copy')
+ const toggleAvailable = () => elementActions.storeElement('views', {...view, available: !view.available })
+ const toggleLocked = () => elementActions.storeElement('views', {...view, locked: !view.locked })
+
+ return showElement && (
+
+
+
+
+
+ {gettext('View')}{': '}
+ fetchEdit()} />
+
+
+
+
+
+ )
+}
+
+View.propTypes = {
+ config: PropTypes.object.isRequired,
+ view: PropTypes.object.isRequired,
+ configActions: PropTypes.object.isRequired,
+ elementActions: PropTypes.object.isRequired,
+ filter: PropTypes.string,
+ filterSites: PropTypes.bool,
+ filterEditors: PropTypes.bool
+}
+
+export default View
diff --git a/rdmo/management/assets/js/components/elements/Attributes.js b/rdmo/management/assets/js/components/elements/Attributes.js
new file mode 100644
index 0000000000..ffb83c62d9
--- /dev/null
+++ b/rdmo/management/assets/js/components/elements/Attributes.js
@@ -0,0 +1,68 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import get from 'lodash/get'
+
+import { getUriPrefixes } from '../../utils/filter'
+
+import { FilterString, FilterUriPrefix, FilterSite } from '../common/Filter'
+import { BackButton, NewButton } from '../common/Buttons'
+
+import Attribute from '../element/Attribute'
+
+const Attributes = ({ config, attributes, configActions, elementActions }) => {
+
+ const updateFilterString = (value) => configActions.updateConfig('filter.attributes.search', value)
+ const updateFilterUriPrefix = (value) => configActions.updateConfig('filter.attributes.uri_prefix', value)
+ const updateFilterEditor = (value) => configActions.updateConfig('filter.editors', value)
+ const createAttribute = () => elementActions.createElement('attributes')
+
+ return (
+
+
+
+
+
+
+
{gettext('Attributes')}
+
+
+
+
+
+
+
+
+
+
+ {
+ config.settings.multisite &&
+
+
+ }
+
+
+
+
+ {
+ attributes.map((attribute, index) => (
+
+ ))
+ }
+
+
+ )
+}
+
+Attributes.propTypes = {
+ config: PropTypes.object.isRequired,
+ attributes: PropTypes.array.isRequired,
+ configActions: PropTypes.object.isRequired,
+ elementActions: PropTypes.object.isRequired
+}
+
+export default Attributes
diff --git a/rdmo/management/assets/js/components/elements/Catalogs.js b/rdmo/management/assets/js/components/elements/Catalogs.js
new file mode 100644
index 0000000000..a51d3c9f32
--- /dev/null
+++ b/rdmo/management/assets/js/components/elements/Catalogs.js
@@ -0,0 +1,83 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import get from 'lodash/get'
+
+import { getUriPrefixes } from '../../utils/filter'
+
+import { FilterString, FilterUriPrefix, FilterSite} from '../common/Filter'
+import { Checkbox } from '../common/Checkboxes'
+import { BackButton, NewButton } from '../common/Buttons'
+
+import Catalog from '../element/Catalog'
+
+const Catalogs = ({ config, catalogs, configActions, elementActions }) => {
+
+ const updateFilterString = (value) => configActions.updateConfig('filter.catalogs.search', value)
+ const updateFilterUriPrefix = (value) => configActions.updateConfig('filter.catalogs.uri_prefix', value)
+ const updateFilterSite = (value) => configActions.updateConfig('filter.sites', value)
+ const updateFilterEditor = (value) => configActions.updateConfig('filter.editors', value)
+ const updateDisplayCatalogURI = (value) => configActions.updateConfig('display.uri.catalogs', value)
+
+ const createCatalog = () => elementActions.createElement('catalogs')
+
+ return (
+
+
+
+
+
+
+
{gettext('Catalogs')}
+
+
+
+
+
+
+
+
+
+
+ {
+ config.settings.multisite && <>
+
+
+
+
+
+
+ >
+ }
+
+
+ {gettext('Show URIs:')}
+ {gettext('Catalogs')}}
+ value={get(config, 'display.uri.catalogs', true)} onChange={updateDisplayCatalogURI} />
+
+
+
+
+ {
+ catalogs.map((catalog, index) => (
+
+ ))
+ }
+
+
+ )
+}
+
+Catalogs.propTypes = {
+ config: PropTypes.object.isRequired,
+ catalogs: PropTypes.array.isRequired,
+ configActions: PropTypes.object.isRequired,
+ elementActions: PropTypes.object.isRequired
+}
+
+export default Catalogs
diff --git a/rdmo/management/assets/js/components/elements/Conditions.js b/rdmo/management/assets/js/components/elements/Conditions.js
new file mode 100644
index 0000000000..b46c6829d9
--- /dev/null
+++ b/rdmo/management/assets/js/components/elements/Conditions.js
@@ -0,0 +1,69 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import get from 'lodash/get'
+
+import { getUriPrefixes } from '../../utils/filter'
+
+import { FilterString, FilterUriPrefix, FilterSite} from '../common/Filter'
+import { BackButton, NewButton } from '../common/Buttons'
+
+import Condition from '../element/Condition'
+
+const Conditions = ({ config, conditions, configActions, elementActions}) => {
+
+ const updateFilterString = (value) => configActions.updateConfig('filter.conditions.search', value)
+ const updateFilterUriPrefix = (value) => configActions.updateConfig('filter.conditions.uri_prefix', value)
+ const updateFilterEditor = (value) => configActions.updateConfig('filter.editors', value)
+
+ const createCondition = () => elementActions.createElement('conditions')
+
+ return (
+
+
+
+
+
+
+
{gettext('Conditions')}
+
+
+
+
+
+
+
+
+
+
+ {
+ config.settings.multisite &&
+
+
+ }
+
+
+
+
+ {
+ conditions.map((condition, index) => (
+
+ ))
+ }
+
+
+ )
+}
+
+Conditions.propTypes = {
+ config: PropTypes.object.isRequired,
+ conditions: PropTypes.array.isRequired,
+ configActions: PropTypes.object.isRequired,
+ elementActions: PropTypes.object.isRequired
+}
+
+export default Conditions
diff --git a/rdmo/management/assets/js/components/elements/OptionSets.js b/rdmo/management/assets/js/components/elements/OptionSets.js
new file mode 100644
index 0000000000..33093bb17f
--- /dev/null
+++ b/rdmo/management/assets/js/components/elements/OptionSets.js
@@ -0,0 +1,69 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import get from 'lodash/get'
+
+import { getUriPrefixes } from '../../utils/filter'
+
+import { FilterString, FilterUriPrefix, FilterSite } from '../common/Filter'
+import { BackButton, NewButton } from '../common/Buttons'
+
+import OptionSet from '../element/OptionSet'
+
+const OptionSets = ({ config, optionsets, configActions, elementActions}) => {
+
+ const updateFilterString = (value) => configActions.updateConfig('filter.optionsets.search', value)
+ const updateFilterUriPrefix = (value) => configActions.updateConfig('filter.optionsets.uri_prefix', value)
+ const updateFilterEditor = (value) => configActions.updateConfig('filter.editors', value)
+
+ const createOptionSet = () => elementActions.createElement('optionsets')
+
+ return (
+
+
+
+
+
+
+
{gettext('Option sets')}
+
+
+
+
+
+
+
+
+
+
+ {
+ config.settings.multisite &&
+
+
+ }
+
+
+
+
+ {
+ optionsets.map((optionset, index) => (
+
+ ))
+ }
+
+
+ )
+}
+
+OptionSets.propTypes = {
+ config: PropTypes.object.isRequired,
+ optionsets: PropTypes.array.isRequired,
+ configActions: PropTypes.object.isRequired,
+ elementActions: PropTypes.object.isRequired
+}
+
+export default OptionSets
diff --git a/rdmo/management/assets/js/components/elements/Options.js b/rdmo/management/assets/js/components/elements/Options.js
new file mode 100644
index 0000000000..62b70a1a3e
--- /dev/null
+++ b/rdmo/management/assets/js/components/elements/Options.js
@@ -0,0 +1,77 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import get from 'lodash/get'
+
+import { getUriPrefixes } from '../../utils/filter'
+
+import { FilterString, FilterUriPrefix, FilterSite } from '../common/Filter'
+import { Checkbox } from '../common/Checkboxes'
+import { BackButton, NewButton } from '../common/Buttons'
+
+import Option from '../element/Option'
+
+const Options = ({ config, options, configActions, elementActions }) => {
+
+ const updateFilterString = (value) => configActions.updateConfig('filter.options.search', value)
+ const updateFilterUriPrefix = (value) => configActions.updateConfig('filter.options.uri_prefix', value)
+ const updateFilterEditor = (value) => configActions.updateConfig('filter.editors', value)
+
+ const updateDisplayURI = (value) => configActions.updateConfig('display.uri.options', value)
+
+ const createOption = () => elementActions.createElement('options')
+
+ return (
+
+
+
+
+
+
+
{gettext('Options')}
+
+
+
+
+
+
+
+
+
+
+ {
+ config.settings.multisite &&
+
+
+ }
+
+
+ {gettext('Show URIs:')}
+ {gettext('Options')}}
+ value={get(config, 'display.uri.options', true)} onChange={updateDisplayURI} />
+
+
+
+
+ {
+ options.map((option, index) => (
+
+ ))
+ }
+
+
+ )
+}
+
+Options.propTypes = {
+ config: PropTypes.object.isRequired,
+ options: PropTypes.array.isRequired,
+ configActions: PropTypes.object.isRequired,
+ elementActions: PropTypes.object.isRequired
+}
+
+export default Options
diff --git a/rdmo/management/assets/js/components/elements/Pages.js b/rdmo/management/assets/js/components/elements/Pages.js
new file mode 100644
index 0000000000..2fdce9f585
--- /dev/null
+++ b/rdmo/management/assets/js/components/elements/Pages.js
@@ -0,0 +1,83 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import get from 'lodash/get'
+
+import { getUriPrefixes } from '../../utils/filter'
+
+import { FilterString, FilterUriPrefix, FilterSite } from '../common/Filter'
+import { Checkbox } from '../common/Checkboxes'
+import { BackButton, NewButton } from '../common/Buttons'
+
+import Page from '../element/Page'
+
+const Pages = ({ config, pages, configActions, elementActions }) => {
+
+ const updateFilterString = (value) => configActions.updateConfig('filter.pages.search', value)
+ const updateFilterUriPrefix = (value) => configActions.updateConfig('filter.pages.uri_prefix', value)
+ const updateFilterEditor = (value) => configActions.updateConfig('filter.editors', value)
+
+ const updateDisplayPagesURI = (value) => configActions.updateConfig('display.uri.pages', value)
+ const updateDisplayAttributesURI = (value) => configActions.updateConfig('display.uri.attributes', value)
+ const updateDisplayConditionsURI = (value) => configActions.updateConfig('display.uri.conditions', value)
+
+ const createPage = () => elementActions.createElement('pages')
+
+ return (
+
+
+
+
+
+
+
{gettext('Pages')}
+
+
+
+
+
+
+
+
+
+
+ {
+ config.settings.multisite &&
+
+
+ }
+
+
+ {gettext('Show URIs:')}
+ {gettext('Pages')}}
+ value={get(config, 'display.uri.pages', true)} onChange={updateDisplayPagesURI} />
+ {gettext('Attributes')}}
+ value={get(config, 'display.uri.attributes', true)} onChange={updateDisplayAttributesURI} />
+ {gettext('Conditions')}}
+ value={get(config, 'display.uri.conditions', true)} onChange={updateDisplayConditionsURI} />
+
+
+
+
+ {
+ pages.map((page, index) => (
+
+ ))
+ }
+
+
+ )
+}
+
+Pages.propTypes = {
+ config: PropTypes.object.isRequired,
+ pages: PropTypes.array.isRequired,
+ configActions: PropTypes.object.isRequired,
+ elementActions: PropTypes.object.isRequired
+}
+
+export default Pages
diff --git a/rdmo/management/assets/js/components/elements/QuestionSets.js b/rdmo/management/assets/js/components/elements/QuestionSets.js
new file mode 100644
index 0000000000..8c1096ba89
--- /dev/null
+++ b/rdmo/management/assets/js/components/elements/QuestionSets.js
@@ -0,0 +1,83 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import get from 'lodash/get'
+
+import { getUriPrefixes } from '../../utils/filter'
+
+import { FilterString, FilterUriPrefix, FilterSite } from '../common/Filter'
+import { Checkbox } from '../common/Checkboxes'
+import { BackButton, NewButton } from '../common/Buttons'
+
+import QuestionSet from '../element/QuestionSet'
+
+const QuestionSets = ({ config, questionsets, configActions, elementActions }) => {
+
+ const updateFilterString = (value) => configActions.updateConfig('filter.questionsets.search', value)
+ const updateFilterUriPrefix = (value) => configActions.updateConfig('filter.questionsets.uri_prefix', value)
+ const updateFilterEditor = (value) => configActions.updateConfig('filter.editors', value)
+
+ const updateDisplayQuestionSetsURI = (value) => configActions.updateConfig('display.uri.questionsets', value)
+ const updateDisplayAttributesURI = (value) => configActions.updateConfig('display.uri.attributes', value)
+ const updateDisplayConditionsURI = (value) => configActions.updateConfig('display.uri.conditions', value)
+
+ const createQuestionSet = () => elementActions.createElement('questionsets')
+
+ return (
+
+
+
+
+
+
+
{gettext('Question sets')}
+
+
+
+
+
+
+
+
+
+
+ {
+ config.settings.multisite &&
+
+
+ }
+
+
+ {gettext('Show URIs:')}
+ {gettext('Question sets')}}
+ value={get(config, 'display.uri.questionsets', true)} onChange={updateDisplayQuestionSetsURI} />
+ {gettext('Attributes')}}
+ value={get(config, 'display.uri.attributes', true)} onChange={updateDisplayAttributesURI} />
+ {gettext('Conditions')}}
+ value={get(config, 'display.uri.conditions', true)} onChange={updateDisplayConditionsURI} />
+
+
+
+
+ {
+ questionsets.map((questionset, index) => (
+
+ ))
+ }
+
+
+ )
+}
+
+QuestionSets.propTypes = {
+ config: PropTypes.object.isRequired,
+ questionsets: PropTypes.array.isRequired,
+ configActions: PropTypes.object.isRequired,
+ elementActions: PropTypes.object.isRequired
+}
+
+export default QuestionSets
diff --git a/rdmo/management/assets/js/components/elements/Questions.js b/rdmo/management/assets/js/components/elements/Questions.js
new file mode 100644
index 0000000000..ac6cf6e4b4
--- /dev/null
+++ b/rdmo/management/assets/js/components/elements/Questions.js
@@ -0,0 +1,86 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import get from 'lodash/get'
+
+import { getUriPrefixes } from '../../utils/filter'
+
+import { FilterString, FilterUriPrefix, FilterSite } from '../common/Filter'
+import { Checkbox } from '../common/Checkboxes'
+import { BackButton, NewButton } from '../common/Buttons'
+
+import Question from '../element/Question'
+
+const Questions = ({ config, questions, configActions, elementActions }) => {
+
+ const updateFilterString = (value) => configActions.updateConfig('filter.questions.search', value)
+ const updateFilterUriPrefix = (value) => configActions.updateConfig('filter.questions.uri_prefix', value)
+ const updateFilterEditor = (value) => configActions.updateConfig('filter.editors', value)
+
+ const updateDisplayQuestionsURI = (value) => configActions.updateConfig('display.uri.questions', value)
+ const updateDisplayAttributesURI = (value) => configActions.updateConfig('display.uri.attributes', value)
+ const updateDisplayConditionsURI = (value) => configActions.updateConfig('display.uri.conditions', value)
+ const updateDisplayOptionSetURI = (value) => configActions.updateConfig('display.uri.optionsets', value)
+
+ const createQuestion = () => elementActions.createElement('questions')
+
+ return (
+
+
+
+
+
+
+
{gettext('Questions')}
+
+
+
+
+
+
+
+
+
+
+ {
+ config.settings.multisite &&
+
+
+ }
+
+
+ {gettext('Show URIs:')}
+ {gettext('Questions')}}
+ value={get(config, 'display.uri.questions', true)} onChange={updateDisplayQuestionsURI} />
+ {gettext('Attributes')}}
+ value={get(config, 'display.uri.attributes', true)} onChange={updateDisplayAttributesURI} />
+ {gettext('Conditions')}}
+ value={get(config, 'display.uri.conditions', true)} onChange={updateDisplayConditionsURI} />
+ {gettext('Option sets')}}
+ value={get(config, 'display.uri.optionsets', true)} onChange={updateDisplayOptionSetURI} />
+
+
+
+
+ {
+ questions.map((question, index) => (
+
+ ))
+ }
+
+
+ )
+}
+
+Questions.propTypes = {
+ config: PropTypes.object.isRequired,
+ questions: PropTypes.array.isRequired,
+ configActions: PropTypes.object.isRequired,
+ elementActions: PropTypes.object.isRequired
+}
+
+export default Questions
diff --git a/rdmo/management/assets/js/components/elements/Sections.js b/rdmo/management/assets/js/components/elements/Sections.js
new file mode 100644
index 0000000000..cea485df27
--- /dev/null
+++ b/rdmo/management/assets/js/components/elements/Sections.js
@@ -0,0 +1,77 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import get from 'lodash/get'
+
+import { getUriPrefixes } from '../../utils/filter'
+
+import { FilterString, FilterUriPrefix, FilterSite } from '../common/Filter'
+import { Checkbox } from '../common/Checkboxes'
+import { BackButton, NewButton } from '../common/Buttons'
+
+import Section from '../element/Section'
+
+const Sections = ({ config, sections, configActions, elementActions }) => {
+
+ const updateFilterString = (value) => configActions.updateConfig('filter.sections.search', value)
+ const updateFilterUriPrefix = (value) => configActions.updateConfig('filter.sections.uri_prefix', value)
+ const updateFilterEditor = (value) => configActions.updateConfig('filter.editors', value)
+
+ const updateDisplaySectionURI = (value) => configActions.updateConfig('display.uri.sections', value)
+
+ const createSection = () => elementActions.createElement('sections')
+
+ return (
+
+
+
+
+
+
+
{gettext('Sections')}
+
+
+
+
+
+
+
+
+
+
+ {
+ config.settings.multisite &&
+
+
+ }
+
+
+ {gettext('Show URIs:')}
+ {gettext('Sections')}}
+ value={get(config, 'display.uri.sections', true)} onChange={updateDisplaySectionURI} />
+
+
+
+
+ {
+ sections.map((section, index) => (
+
+ ))
+ }
+
+
+ )
+}
+
+Sections.propTypes = {
+ config: PropTypes.object.isRequired,
+ sections: PropTypes.array.isRequired,
+ configActions: PropTypes.object.isRequired,
+ elementActions: PropTypes.object.isRequired
+}
+
+export default Sections
diff --git a/rdmo/management/assets/js/components/elements/Tasks.js b/rdmo/management/assets/js/components/elements/Tasks.js
new file mode 100644
index 0000000000..693181538f
--- /dev/null
+++ b/rdmo/management/assets/js/components/elements/Tasks.js
@@ -0,0 +1,76 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import get from 'lodash/get'
+
+import { getUriPrefixes } from '../../utils/filter'
+
+import { FilterString, FilterUriPrefix, FilterSite} from '../common/Filter'
+import { BackButton, NewButton } from '../common/Buttons'
+
+import Task from '../element/Task'
+
+const Tasks = ({ config, tasks, configActions, elementActions }) => {
+
+ const updateFilterString = (value) => configActions.updateConfig('filter.tasks.search', value)
+ const updateFilterUriPrefix = (value) => configActions.updateConfig('filter.tasks.uri_prefix', value)
+ const updateFilterSite = (value) => configActions.updateConfig('filter.sites', value)
+ const updateFilterEditor = (value) => configActions.updateConfig('filter.editors', value)
+
+ const createTask = () => elementActions.createElement('tasks')
+
+ return (
+
+
+
+
+
+
+
{gettext('Tasks')}
+
+
+
+
+
+
+
+
+
+
+ {
+ config.settings.multisite && <>
+
+
+
+
+
+
+ >
+ }
+
+
+
+
+ {
+ tasks.map((task, index) => (
+
+ ))
+ }
+
+
+ )
+}
+
+Tasks.propTypes = {
+ config: PropTypes.object.isRequired,
+ tasks: PropTypes.array.isRequired,
+ configActions: PropTypes.object.isRequired,
+ elementActions: PropTypes.object.isRequired
+}
+
+export default Tasks
diff --git a/rdmo/management/assets/js/components/elements/Views.js b/rdmo/management/assets/js/components/elements/Views.js
new file mode 100644
index 0000000000..794f36d6ee
--- /dev/null
+++ b/rdmo/management/assets/js/components/elements/Views.js
@@ -0,0 +1,76 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import get from 'lodash/get'
+
+import { getUriPrefixes } from '../../utils/filter'
+
+import { FilterString, FilterUriPrefix, FilterSite} from '../common/Filter'
+import { BackButton, NewButton } from '../common/Buttons'
+
+import View from '../element/View'
+
+const Views = ({ config, views, configActions, elementActions }) => {
+
+ const updateFilterString = (value) => configActions.updateConfig('filter.views.search', value)
+ const updateFilterUriPrefix = (value) => configActions.updateConfig('filter.views.uri_prefix', value)
+ const updateFilterSite = (value) => configActions.updateConfig('filter.sites', value)
+ const updateFilterEditor = (value) => configActions.updateConfig('filter.editors', value)
+
+ const createView = () => elementActions.createElement('views')
+
+ return (
+
+
+
+
+
+
+
{gettext('Views')}
+
+
+
+
+
+
+
+
+
+
+ {
+ config.settings.multisite && <>
+
+
+
+
+
+
+ >
+ }
+
+
+
+
+ {
+ views.map((view, index) => (
+
+ ))
+ }
+
+
+ )
+}
+
+Views.propTypes = {
+ config: PropTypes.object.isRequired,
+ views: PropTypes.array.isRequired,
+ configActions: PropTypes.object.isRequired,
+ elementActions: PropTypes.object.isRequired
+}
+
+export default Views
diff --git a/rdmo/management/assets/js/components/import/ImportAttribute.js b/rdmo/management/assets/js/components/import/ImportAttribute.js
new file mode 100644
index 0000000000..a360f9c14f
--- /dev/null
+++ b/rdmo/management/assets/js/components/import/ImportAttribute.js
@@ -0,0 +1,50 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+
+import { CodeLink, WarningLink, ErrorLink, ShowLink } from '../common/Links'
+
+import Errors from './common/Errors'
+import Fields from './common/Fields'
+import Form from './common/Form'
+import Warnings from './common/Warnings'
+
+import { codeClass } from '../../constants/elements'
+
+const ImportAttribute = ({ config, attribute, importActions }) => {
+ const showFields = () => importActions.updateElement(attribute, {show: !attribute.show})
+ const toggleImport = () => importActions.updateElement(attribute, {import: !attribute.import})
+ const updateAttribute = (key, value) => importActions.updateElement(attribute, {[key]: value})
+
+ return (
+
+
+
+
+
+
+
+
+
+ {gettext('Attribute')}
+
+
+
+ {
+ attribute.show && <>
+
+
+
+
+ >
+ }
+
+ )
+}
+
+ImportAttribute.propTypes = {
+ config: PropTypes.object.isRequired,
+ attribute: PropTypes.object.isRequired,
+ importActions: PropTypes.object.isRequired
+}
+
+export default ImportAttribute
diff --git a/rdmo/management/assets/js/components/import/ImportCatalog.js b/rdmo/management/assets/js/components/import/ImportCatalog.js
new file mode 100644
index 0000000000..2e564e3b69
--- /dev/null
+++ b/rdmo/management/assets/js/components/import/ImportCatalog.js
@@ -0,0 +1,52 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+
+import { AvailableLink, CodeLink, WarningLink, ErrorLink, ShowLink } from '../common/Links'
+
+import Errors from './common/Errors'
+import Fields from './common/Fields'
+import Form from './common/Form'
+import Warnings from './common/Warnings'
+
+import { codeClass } from '../../constants/elements'
+
+const ImportCatalog = ({ config, catalog, importActions }) => {
+ const showFields = () => importActions.updateElement(catalog, {show: !catalog.show})
+ const toggleImport = () => importActions.updateElement(catalog, {import: !catalog.import})
+ const toggleAvailable = () => importActions.updateElement(catalog, {available: !catalog.available})
+ const updateCatalog = (key, value) => importActions.updateElement(catalog, {[key]: value})
+
+ return (
+
+
+
+
+
+ {gettext('Catalog')}
+
+
+
+ {
+ catalog.show && <>
+
+
+
+
+ >
+ }
+
+ )
+}
+
+ImportCatalog.propTypes = {
+ config: PropTypes.object.isRequired,
+ catalog: PropTypes.object.isRequired,
+ importActions: PropTypes.object.isRequired
+}
+
+export default ImportCatalog
diff --git a/rdmo/management/assets/js/components/import/ImportCondition.js b/rdmo/management/assets/js/components/import/ImportCondition.js
new file mode 100644
index 0000000000..58598f2429
--- /dev/null
+++ b/rdmo/management/assets/js/components/import/ImportCondition.js
@@ -0,0 +1,50 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+
+import { CodeLink, WarningLink, ErrorLink, ShowLink } from '../common/Links'
+
+import Errors from './common/Errors'
+import Fields from './common/Fields'
+import Form from './common/Form'
+import Warnings from './common/Warnings'
+
+import { codeClass } from '../../constants/elements'
+
+const ImportCondition = ({ config, condition, importActions }) => {
+ const showFields = () => importActions.updateElement(condition, {show: !condition.show})
+ const toggleImport = () => importActions.updateElement(condition, {import: !condition.import})
+ const updateCondition = (key, value) => importActions.updateElement(condition, {[key]: value})
+
+ return (
+
+
+
+
+
+
+
+
+
+ {gettext('Condition')}
+
+
+
+ {
+ condition.show && <>
+
+
+
+
+ >
+ }
+
+ )
+}
+
+ImportCondition.propTypes = {
+ config: PropTypes.object.isRequired,
+ condition: PropTypes.object.isRequired,
+ importActions: PropTypes.object.isRequired
+}
+
+export default ImportCondition
diff --git a/rdmo/management/assets/js/components/import/ImportOption.js b/rdmo/management/assets/js/components/import/ImportOption.js
new file mode 100644
index 0000000000..af8f0166ac
--- /dev/null
+++ b/rdmo/management/assets/js/components/import/ImportOption.js
@@ -0,0 +1,50 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+
+import { CodeLink, WarningLink, ErrorLink, ShowLink } from '../common/Links'
+
+import Errors from './common/Errors'
+import Fields from './common/Fields'
+import Form from './common/Form'
+import Warnings from './common/Warnings'
+
+import { codeClass } from '../../constants/elements'
+
+const ImportOption = ({ config, option, importActions }) => {
+ const showFields = () => importActions.updateElement(option, {show: !option.show})
+ const toggleImport = () => importActions.updateElement(option, {import: !option.import})
+ const updateOption = (key, value) => importActions.updateElement(option, {[key]: value})
+
+ return (
+
+
+
+
+
+
+
+
+
+ {gettext('Option')}
+
+
+
+ {
+ option.show && <>
+
+
+
+
+ >
+ }
+
+ )
+}
+
+ImportOption.propTypes = {
+ config: PropTypes.object.isRequired,
+ option: PropTypes.object.isRequired,
+ importActions: PropTypes.object.isRequired
+}
+
+export default ImportOption
diff --git a/rdmo/management/assets/js/components/import/ImportOptionSet.js b/rdmo/management/assets/js/components/import/ImportOptionSet.js
new file mode 100644
index 0000000000..e7798c051d
--- /dev/null
+++ b/rdmo/management/assets/js/components/import/ImportOptionSet.js
@@ -0,0 +1,50 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+
+import { CodeLink, WarningLink, ErrorLink, ShowLink } from '../common/Links'
+
+import Errors from './common/Errors'
+import Fields from './common/Fields'
+import Form from './common/Form'
+import Warnings from './common/Warnings'
+
+import { codeClass } from '../../constants/elements'
+
+const ImportOptionSet = ({ config, optionset, importActions }) => {
+ const showFields = () => importActions.updateElement(optionset, {show: !optionset.show})
+ const toggleImport = () => importActions.updateElement(optionset, {import: !optionset.import})
+ const updateOptionSet = (key, value) => importActions.updateElement(optionset, {[key]: value})
+
+ return (
+
+
+
+
+
+
+
+
+
+ {gettext('Option set')}
+
+
+
+ {
+ optionset.show && <>
+
+
+
+
+ >
+ }
+
+ )
+}
+
+ImportOptionSet.propTypes = {
+ config: PropTypes.object.isRequired,
+ optionset: PropTypes.object.isRequired,
+ importActions: PropTypes.object.isRequired
+}
+
+export default ImportOptionSet
diff --git a/rdmo/management/assets/js/components/import/ImportPage.js b/rdmo/management/assets/js/components/import/ImportPage.js
new file mode 100644
index 0000000000..2fb438f904
--- /dev/null
+++ b/rdmo/management/assets/js/components/import/ImportPage.js
@@ -0,0 +1,50 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+
+import { CodeLink, WarningLink, ErrorLink, ShowLink } from '../common/Links'
+
+import Errors from './common/Errors'
+import Fields from './common/Fields'
+import Form from './common/Form'
+import Warnings from './common/Warnings'
+
+import { codeClass } from '../../constants/elements'
+
+const ImportPage = ({ config, page, importActions }) => {
+ const showFields = () => importActions.updateElement(page, {show: !page.show})
+ const toggleImport = () => importActions.updateElement(page, {import: !page.import})
+ const updatePage = (key, value) => importActions.updateElement(page, {[key]: value})
+
+ return (
+
+
+
+
+
+
+
+
+
+ {gettext('Page')}
+
+
+
+ {
+ page.show && <>
+
+
+
+
+ >
+ }
+
+ )
+}
+
+ImportPage.propTypes = {
+ config: PropTypes.object.isRequired,
+ page: PropTypes.object.isRequired,
+ importActions: PropTypes.object.isRequired
+}
+
+export default ImportPage
diff --git a/rdmo/management/assets/js/components/import/ImportQuestion.js b/rdmo/management/assets/js/components/import/ImportQuestion.js
new file mode 100644
index 0000000000..12c757ee28
--- /dev/null
+++ b/rdmo/management/assets/js/components/import/ImportQuestion.js
@@ -0,0 +1,50 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+
+import { CodeLink, WarningLink, ErrorLink, ShowLink } from '../common/Links'
+
+import Errors from './common/Errors'
+import Fields from './common/Fields'
+import Form from './common/Form'
+import Warnings from './common/Warnings'
+
+import { codeClass } from '../../constants/elements'
+
+const ImportQuestion = ({ config, question, importActions }) => {
+ const showFields = () => importActions.updateElement(question, {show: !question.show})
+ const toggleImport = () => importActions.updateElement(question, {import: !question.import})
+ const updateQuestion = (key, value) => importActions.updateElement(question, {[key]: value})
+
+ return (
+
+
+
+
+
+
+
+
+
+ {gettext('Question')}
+
+
+
+ {
+ question.show && <>
+
+
+
+
+ >
+ }
+
+ )
+}
+
+ImportQuestion.propTypes = {
+ config: PropTypes.object.isRequired,
+ question: PropTypes.object.isRequired,
+ importActions: PropTypes.object.isRequired
+}
+
+export default ImportQuestion
diff --git a/rdmo/management/assets/js/components/import/ImportQuestionSet.js b/rdmo/management/assets/js/components/import/ImportQuestionSet.js
new file mode 100644
index 0000000000..8163015cbe
--- /dev/null
+++ b/rdmo/management/assets/js/components/import/ImportQuestionSet.js
@@ -0,0 +1,50 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+
+import { CodeLink, WarningLink, ErrorLink, ShowLink } from '../common/Links'
+
+import Errors from './common/Errors'
+import Fields from './common/Fields'
+import Form from './common/Form'
+import Warnings from './common/Warnings'
+
+import { codeClass } from '../../constants/elements'
+
+const ImportQuestionSet = ({ config, questionset, importActions }) => {
+ const showFields = () => importActions.updateElement(questionset, {show: !questionset.show})
+ const toggleImport = () => importActions.updateElement(questionset, {import: !questionset.import})
+ const updateQuestionSet = (key, value) => importActions.updateElement(questionset, {[key]: value})
+
+ return (
+
+
+
+
+
+
+
+
+
+ {gettext('Question set')}
+
+
+
+ {
+ questionset.show && <>
+
+
+
+
+ >
+ }
+
+ )
+}
+
+ImportQuestionSet.propTypes = {
+ config: PropTypes.object.isRequired,
+ questionset: PropTypes.object.isRequired,
+ importActions: PropTypes.object.isRequired
+}
+
+export default ImportQuestionSet
diff --git a/rdmo/management/assets/js/components/import/ImportSection.js b/rdmo/management/assets/js/components/import/ImportSection.js
new file mode 100644
index 0000000000..92203a8771
--- /dev/null
+++ b/rdmo/management/assets/js/components/import/ImportSection.js
@@ -0,0 +1,50 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+
+import { CodeLink, WarningLink, ErrorLink, ShowLink } from '../common/Links'
+
+import Errors from './common/Errors'
+import Fields from './common/Fields'
+import Form from './common/Form'
+import Warnings from './common/Warnings'
+
+import { codeClass } from '../../constants/elements'
+
+const ImportSection = ({ config, section, importActions }) => {
+ const showFields = () => importActions.updateElement(section, {show: !section.show})
+ const toggleImport = () => importActions.updateElement(section, {import: !section.import})
+ const updateSection = (key, value) => importActions.updateElement(section, {[key]: value})
+
+ return (
+
+
+
+
+
+
+
+
+
+ {gettext('Section')}
+
+
+
+ {
+ section.show && <>
+
+
+
+
+ >
+ }
+
+ )
+}
+
+ImportSection.propTypes = {
+ config: PropTypes.object.isRequired,
+ section: PropTypes.object.isRequired,
+ importActions: PropTypes.object.isRequired
+}
+
+export default ImportSection
diff --git a/rdmo/management/assets/js/components/import/ImportTask.js b/rdmo/management/assets/js/components/import/ImportTask.js
new file mode 100644
index 0000000000..72b2b73158
--- /dev/null
+++ b/rdmo/management/assets/js/components/import/ImportTask.js
@@ -0,0 +1,52 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+
+import { AvailableLink, CodeLink, WarningLink, ErrorLink, ShowLink } from '../common/Links'
+
+import Errors from './common/Errors'
+import Fields from './common/Fields'
+import Form from './common/Form'
+import Warnings from './common/Warnings'
+
+import { codeClass } from '../../constants/elements'
+
+const ImportTask = ({ config, task, importActions }) => {
+ const showFields = () => importActions.updateElement(task, {show: !task.show})
+ const toggleImport = () => importActions.updateElement(task, {import: !task.import})
+ const toggleAvailable = () => importActions.updateElement(task, {available: !task.available})
+ const updateTask = (key, value) => importActions.updateElement(task, {[key]: value})
+
+ return (
+
+
+
+
+
+ {gettext('Task')}
+
+
+
+ {
+ task.show && <>
+
+
+
+
+ >
+ }
+
+ )
+}
+
+ImportTask.propTypes = {
+ config: PropTypes.object.isRequired,
+ task: PropTypes.object.isRequired,
+ importActions: PropTypes.object.isRequired
+}
+
+export default ImportTask
diff --git a/rdmo/management/assets/js/components/import/ImportView.js b/rdmo/management/assets/js/components/import/ImportView.js
new file mode 100644
index 0000000000..e48196efb5
--- /dev/null
+++ b/rdmo/management/assets/js/components/import/ImportView.js
@@ -0,0 +1,52 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+
+import { AvailableLink, CodeLink, WarningLink, ErrorLink, ShowLink } from '../common/Links'
+
+import Errors from './common/Errors'
+import Fields from './common/Fields'
+import Form from './common/Form'
+import Warnings from './common/Warnings'
+
+import { codeClass } from '../../constants/elements'
+
+const ImportView = ({ config, view, importActions }) => {
+ const showFields = () => importActions.updateElement(view, {show: !view.show})
+ const toggleImport = () => importActions.updateElement(view, {import: !view.import})
+ const toggleAvailable = () => importActions.updateElement(view, {available: !view.available})
+ const updateView = (key, value) => importActions.updateElement(view, {[key]: value})
+
+ return (
+
+
+
+
+
+ {gettext('View')}
+
+
+
+ {
+ view.show && <>
+
+
+
+
+ >
+ }
+
+ )
+}
+
+ImportView.propTypes = {
+ config: PropTypes.object.isRequired,
+ view: PropTypes.object.isRequired,
+ importActions: PropTypes.object.isRequired
+}
+
+export default ImportView
diff --git a/rdmo/management/assets/js/components/import/common/Errors.js b/rdmo/management/assets/js/components/import/common/Errors.js
new file mode 100644
index 0000000000..47fcd60d50
--- /dev/null
+++ b/rdmo/management/assets/js/components/import/common/Errors.js
@@ -0,0 +1,25 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import isEmpty from 'lodash/isEmpty'
+import uniqueId from 'lodash/uniqueId'
+
+const Errors = ({ element }) => {
+ return !isEmpty(element.errors) &&
+
+ {gettext('Errors')}
+
+
+
+ {
+ element.errors.map(message => {message} )
+ }
+
+
+
+}
+
+Errors.propTypes = {
+ element: PropTypes.object.isRequired
+}
+
+export default Errors
diff --git a/rdmo/management/assets/js/components/import/common/Fields.js b/rdmo/management/assets/js/components/import/common/Fields.js
new file mode 100644
index 0000000000..da483eb9cb
--- /dev/null
+++ b/rdmo/management/assets/js/components/import/common/Fields.js
@@ -0,0 +1,66 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import isNil from 'lodash/isNil'
+import isString from 'lodash/isString'
+import isUndefined from 'lodash/isUndefined'
+import truncate from 'lodash/truncate'
+import uniqueId from 'lodash/uniqueId'
+
+import { codeClass } from '../../../constants/elements'
+
+const excludeKeys = [
+ 'created',
+ 'errors',
+ 'import',
+ 'key',
+ 'model',
+ 'show',
+ 'type',
+ 'updated',
+ 'uri',
+ 'uri_path',
+ 'uri_prefix',
+ 'valid',
+ 'warnings'
+]
+
+const Fields = ({ element }) => {
+ return (
+
+ {
+ Object.entries(element).sort().map(([key, value]) => {
+ if (!isNil(value) && !excludeKeys.includes(key)) {
+ return (
+
+
+ {key}
+
+
+ {
+ Array.isArray(value) &&
+ { value.map(el =>
+ {el.uri}
+ ) }
+
+ }
+ {
+ !isUndefined(value.uri) &&
{value.uri}
+ }
+ {
+ isString(value) &&
{truncate(value, {length: 512})}
+ }
+
+
+ )
+ }
+ })
+ }
+
+ )
+}
+
+Fields.propTypes = {
+ element: PropTypes.object.isRequired
+}
+
+export default Fields
diff --git a/rdmo/management/assets/js/components/import/common/Form.js b/rdmo/management/assets/js/components/import/common/Form.js
new file mode 100644
index 0000000000..a34892cc14
--- /dev/null
+++ b/rdmo/management/assets/js/components/import/common/Form.js
@@ -0,0 +1,32 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import isUndefined from 'lodash/isUndefined'
+
+import Key from './Key'
+import UriPath from './UriPath'
+import UriPrefix from './UriPrefix'
+
+const Form = ({ config, element, updateElement }) => {
+
+ return (
+
+
+
+
+
+ {
+ isUndefined(element.uri_path) ?
+ :
+ }
+
+
+ )
+}
+
+Form.propTypes = {
+ config: PropTypes.object.isRequired,
+ element: PropTypes.object.isRequired,
+ updateElement: PropTypes.func.isRequired
+}
+
+export default Form
diff --git a/rdmo/management/assets/js/components/import/common/Key.js b/rdmo/management/assets/js/components/import/common/Key.js
new file mode 100644
index 0000000000..e6396b0098
--- /dev/null
+++ b/rdmo/management/assets/js/components/import/common/Key.js
@@ -0,0 +1,26 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import uniqueId from 'lodash/uniqueId'
+
+const Key = ({ element, onChange }) => {
+ const id = uniqueId('key-'),
+ value = element.key
+
+ return (
+
+
+ {gettext('Key')}
+
+
+ onChange('key', event.target.value)} />
+
+ )
+}
+
+Key.propTypes = {
+ element: PropTypes.object.isRequired,
+ onChange: PropTypes.func.isRequired
+}
+
+export default Key
diff --git a/rdmo/management/assets/js/components/import/common/UriPath.js b/rdmo/management/assets/js/components/import/common/UriPath.js
new file mode 100644
index 0000000000..70ebe4ccaf
--- /dev/null
+++ b/rdmo/management/assets/js/components/import/common/UriPath.js
@@ -0,0 +1,26 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import uniqueId from 'lodash/uniqueId'
+
+const UriPrefix = ({ element, onChange }) => {
+ const id = uniqueId('uriPrefix-'),
+ value = element.uri_path
+
+ return (
+
+
+ {gettext('URI path')}
+
+
+ onChange('uri_path', event.target.value)} />
+
+ )
+}
+
+UriPrefix.propTypes = {
+ element: PropTypes.object.isRequired,
+ onChange: PropTypes.func.isRequired
+}
+
+export default UriPrefix
diff --git a/rdmo/management/assets/js/components/import/common/UriPrefix.js b/rdmo/management/assets/js/components/import/common/UriPrefix.js
new file mode 100644
index 0000000000..2b2ca000c6
--- /dev/null
+++ b/rdmo/management/assets/js/components/import/common/UriPrefix.js
@@ -0,0 +1,37 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import uniqueId from 'lodash/uniqueId'
+
+const UriPrefix = ({ config, element, onChange }) => {
+ const id = uniqueId('uriPrefix-'),
+ value = element.uri_prefix
+
+ return (
+
+
+ {gettext('URI prefix')}
+
+
+
+ onChange('uri_prefix', event.target.value)} />
+
+
+ onChange('uri_prefix', config.settings.default_uri_prefix)}>
+
+
+
+
+
+ )
+}
+
+UriPrefix.propTypes = {
+ config: PropTypes.object.isRequired,
+ element: PropTypes.object.isRequired,
+ onChange: PropTypes.func.isRequired
+}
+
+export default UriPrefix
diff --git a/rdmo/management/assets/js/components/import/common/Warnings.js b/rdmo/management/assets/js/components/import/common/Warnings.js
new file mode 100644
index 0000000000..c4c91bdb83
--- /dev/null
+++ b/rdmo/management/assets/js/components/import/common/Warnings.js
@@ -0,0 +1,25 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import isEmpty from 'lodash/isEmpty'
+import uniqueId from 'lodash/uniqueId'
+
+const Warnings = ({ element }) => {
+ return !isEmpty(element.warnings) &&
+
+ {gettext('Warnings')}
+
+
+
+ {
+ element.warnings.map(message => {message} )
+ }
+
+
+
+}
+
+Warnings.propTypes = {
+ element: PropTypes.object.isRequired
+}
+
+export default Warnings
diff --git a/rdmo/management/assets/js/components/info/AttributeInfo.js b/rdmo/management/assets/js/components/info/AttributeInfo.js
new file mode 100644
index 0000000000..9aac6441d2
--- /dev/null
+++ b/rdmo/management/assets/js/components/info/AttributeInfo.js
@@ -0,0 +1,141 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+
+import { ExtendLink, CodeLink } from '../common/Links'
+
+import useBool from '../../hooks/useBool'
+
+const AttributeInfo = ({ attribute, elements, elementActions }) => {
+
+ const [extendAttributes, toggleAttributes] = useBool(false)
+ const [extendConditions, toggleConditions] = useBool(false)
+ const [extendPages, togglePages] = useBool(false)
+ const [extendQuestionSets, toggleQuestionSets] = useBool(false)
+ const [extendQuestions, toggleQuestions] = useBool(false)
+ const [extendTasks, toggleTasks] = useBool(false)
+
+ const attributes = elements.attributes.filter(e => attribute.attributes.includes(e.id))
+ const conditions = elements.conditions.filter(e => attribute.conditions.includes(e.id))
+ const pages = elements.pages.filter(e => attribute.pages.includes(e.id))
+ const questionsets = elements.questionsets.filter(e => attribute.questionsets.includes(e.id))
+ const questions = elements.questions.filter(e => attribute.questions.includes(e.id))
+ const tasks = elements.tasks.filter(e => attribute.tasks.includes(e.id))
+
+ const fetchAttribute = (attribute) => elementActions.fetchElement('attributes', attribute.id)
+ const fetchCondition = (condition) => elementActions.fetchElement('conditions', condition.id)
+ const fetchPage = (page) => elementActions.fetchElement('pages', page.id)
+ const fetchQuestionSet = (questionset) => elementActions.fetchElement('questionsets', questionset.id)
+ const fetchQuestion = (question) => elementActions.fetchElement('questions', question.id)
+ const fetchTask = (task) => elementActions.fetchElement('tasks', task.id)
+
+ return (
+
+
+ %s values in one project .',
+ 'This attribute is used for %s values in %s projects .',
+ attribute.projects_count), [attribute.values_count, attribute.projects_count])}} />
+
+
+ one descendant.',
+ 'This attribute has %s descendants .',
+ attributes.length
+ ), [attributes.length])}} />
+ {attributes.length > 0 && }
+
+ {
+ extendAttributes && attributes.map((attribute, index) => (
+
+ fetchAttribute(attribute)} />
+
+ ))
+ }
+
+ one condition.',
+ 'This attribute is used in %s conditions .',
+ conditions.length), [conditions.length])}} />
+ {conditions.length > 0 && }
+
+ {
+ extendConditions && conditions.map((condition, index) => (
+
+ fetchCondition(condition)} />
+
+ ))
+ }
+
+ one page.',
+ 'This attribute is used in %s pages .',
+ pages.length), [pages.length])}} />
+ {pages.length > 0 && }
+
+ {
+ extendPages && pages.map((page, index) => (
+
+ fetchPage(page)} />
+
+ ))
+ }
+
+ one questionset.',
+ 'This attribute is used in %s questionsets .',
+ questionsets.length), [questionsets.length])}} />
+ {questionsets.length > 0 && }
+
+ {
+ extendQuestionSets && questionsets.map((questionset, index) => (
+
+ fetchQuestionSet(questionset)} />
+
+ ))
+ }
+
+ one question.',
+ 'This attribute is used in %s questions .',
+ questions.length), [questions.length])}} />
+ {questions.length > 0 && }
+
+ {
+ extendQuestions && questions.map((question, index) => (
+
+ fetchQuestion(question)} />
+
+ ))
+ }
+
+ one task.',
+ 'This attribute is used in %s tasks .',
+ tasks.length), [tasks.length])}} />
+ {tasks.length > 0 && }
+
+ {
+ extendTasks && tasks.map((task, index) => (
+
+ fetchTask(task)} />
+
+ ))
+ }
+
+ )
+}
+
+AttributeInfo.propTypes = {
+ attribute: PropTypes.object.isRequired,
+ elements: PropTypes.object.isRequired,
+ elementActions: PropTypes.object.isRequired
+}
+
+export default AttributeInfo
diff --git a/rdmo/management/assets/js/components/info/CatalogInfo.js b/rdmo/management/assets/js/components/info/CatalogInfo.js
new file mode 100644
index 0000000000..8de5d27edc
--- /dev/null
+++ b/rdmo/management/assets/js/components/info/CatalogInfo.js
@@ -0,0 +1,20 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+
+const CatalogInfo = ({ catalog }) => {
+ return (
+
+
one project.',
+ 'This catalog is used in %s projects .',
+ catalog.projects_count), [catalog.projects_count])}} />
+
+ )
+}
+
+CatalogInfo.propTypes = {
+ catalog: PropTypes.object.isRequired
+}
+
+export default CatalogInfo
diff --git a/rdmo/management/assets/js/components/info/ConditionInfo.js b/rdmo/management/assets/js/components/info/ConditionInfo.js
new file mode 100644
index 0000000000..1492ab06c8
--- /dev/null
+++ b/rdmo/management/assets/js/components/info/ConditionInfo.js
@@ -0,0 +1,115 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+
+import { ExtendLink, CodeLink } from '../common/Links'
+
+import useBool from '../../hooks/useBool'
+
+const ConditionInfo = ({ condition, elements, elementActions }) => {
+
+ const [extendOptionSets, toggleOptionSets] = useBool(false)
+ const [extendPages, togglePages] = useBool(false)
+ const [extendQuestionSets, toggleQuestionSets] = useBool(false)
+ const [extendQuestions, toggleQuestion] = useBool(false)
+ const [extendTasks, toggleTasks] = useBool(false)
+
+ const optionsets = elements.optionsets.filter(e => condition.optionsets.includes(e.id))
+ const pages = elements.pages.filter(e => condition.pages.includes(e.id))
+ const questionsets = elements.questionsets.filter(e => condition.questionsets.includes(e.id))
+ const questions = elements.questions.filter(e => condition.questions.includes(e.id))
+ const tasks = elements.tasks.filter(e => condition.tasks.includes(e.id))
+
+ const fetchOptionSet = (optionset) => elementActions.fetchElement('optionsets', optionset.id)
+ const fetchPage = (page) => elementActions.fetchElement('pages', page.id)
+ const fetchQuestionSet = (questionset) => elementActions.fetchElement('questionsets', questionset.id)
+ const fetchQuestion = (question) => elementActions.fetchElement('questions', question.id)
+ const fetchTask = (task) => elementActions.fetchElement('tasks', task.id)
+
+ return (
+
+
+ one optionset.',
+ 'This condition is used for %s optionsets .',
+ optionsets.length), [optionsets.length])}} />
+ {optionsets.length > 0 && }
+
+ {
+ extendOptionSets && optionsets.map((optionset, index) => (
+
+ fetchOptionSet(optionset)} />
+
+ ))
+ }
+
+ one page.',
+ 'This condition is used for %s pages .',
+ pages.length), [pages.length])}} />
+ {pages.length > 0 && }
+
+ {
+ extendPages && pages.map((page, index) => (
+
+ fetchPage(page)} />
+
+ ))
+ }
+
+ one questionset.',
+ 'This condition is used for %s questionsets .',
+ questionsets.length), [questionsets.length])}} />
+ {questionsets.length > 0 && }
+
+ {
+ extendQuestionSets && questionsets.map((questionset, index) => (
+
+ fetchQuestionSet(questionset)} />
+
+ ))
+ }
+
+ one question.',
+ 'This condition is used for %s questions .',
+ questions.length), [questions.length])}} />
+ {questions.length > 0 && }
+
+ {
+ extendQuestions && questions.map((question, index) => (
+
+ fetchQuestion(question)} />
+
+ ))
+ }
+
+ one task.',
+ 'This condition is used for %s tasks .',
+ tasks.length), [tasks.length])}} />
+ {tasks.length > 0 && }
+
+ {
+ extendTasks && tasks.map((task, index) => (
+
+ fetchTask(task)} />
+
+ ))
+ }
+
+ )
+}
+
+ConditionInfo.propTypes = {
+ condition: PropTypes.object.isRequired,
+ elements: PropTypes.object.isRequired,
+ elementActions: PropTypes.object.isRequired
+}
+
+export default ConditionInfo
diff --git a/rdmo/management/assets/js/components/info/OptionInfo.js b/rdmo/management/assets/js/components/info/OptionInfo.js
new file mode 100644
index 0000000000..597e50c55d
--- /dev/null
+++ b/rdmo/management/assets/js/components/info/OptionInfo.js
@@ -0,0 +1,49 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+
+import { ExtendLink, CodeLink } from '../common/Links'
+
+import useBool from '../../hooks/useBool'
+
+const OptionInfo = ({ option, elements, elementActions }) => {
+
+ const [extendConditions, toggleConditions] = useBool(false)
+
+ const conditions = elements.conditions.filter(e => option.conditions.includes(e.id))
+
+ const fetchCondition = (condition) => elementActions.fetchElement('conditions', condition.id)
+
+ return (
+
+
%s values in one project .',
+ 'This option is used for %s values in %s projects .',
+ option.projects_count), [option.values_count, option.projects_count])}} />
+
+ one condition.',
+ 'This option is used in %s conditions .',
+ conditions.length
+ ), [conditions.length])}} />
+ {conditions.length > 0 && }
+
+ {
+ extendConditions && conditions.map((condition, index) => (
+
+ fetchCondition(condition)} />
+
+ ))
+ }
+
+ )
+}
+
+OptionInfo.propTypes = {
+ option: PropTypes.object.isRequired,
+ elements: PropTypes.object.isRequired,
+ elementActions: PropTypes.object.isRequired
+}
+
+export default OptionInfo
diff --git a/rdmo/management/assets/js/components/info/OptionSetInfo.js b/rdmo/management/assets/js/components/info/OptionSetInfo.js
new file mode 100644
index 0000000000..7019e0c444
--- /dev/null
+++ b/rdmo/management/assets/js/components/info/OptionSetInfo.js
@@ -0,0 +1,43 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+
+import { ExtendLink, CodeLink } from '../common/Links'
+
+import useBool from '../../hooks/useBool'
+
+const OptionSetInfo = ({ optionset, elements, elementActions }) => {
+
+ const [extendQuestions, toggleQuestions] = useBool(false)
+
+ const questions = elements.questions.filter(e => optionset.questions.includes(e.id))
+
+ const fetchQuestion = (question) => elementActions.fetchElement('questions', question.id)
+
+ return (
+
+
+ one question.',
+ 'This option set is used in %s questions .',
+ questions.length), [questions.length])}} />
+ {questions.length > 0 && }
+
+ {
+ extendQuestions && questions.map((question, index) => (
+
+ fetchQuestion(question)} />
+
+ ))
+ }
+
+ )
+}
+
+OptionSetInfo.propTypes = {
+ optionset: PropTypes.object.isRequired,
+ elements: PropTypes.object.isRequired,
+ elementActions: PropTypes.object.isRequired
+}
+
+export default OptionSetInfo
diff --git a/rdmo/management/assets/js/components/info/PageInfo.js b/rdmo/management/assets/js/components/info/PageInfo.js
new file mode 100644
index 0000000000..0dfb75f9e8
--- /dev/null
+++ b/rdmo/management/assets/js/components/info/PageInfo.js
@@ -0,0 +1,43 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+
+import { ExtendLink, CodeLink } from '../common/Links'
+
+import useBool from '../../hooks/useBool'
+
+const PageInfo = ({ page, elements, elementActions }) => {
+
+ const [extendSections, toggleSections] = useBool(false)
+
+ const sections = elements.sections.filter(e => page.sections.includes(e.id))
+
+ const fetchSection = (section) => elementActions.fetchElement('sections', section.id)
+
+ return (
+
+
+ one section.',
+ 'This page is used in %s sections .',
+ sections.length), [sections.length])}} />
+ {sections.length > 0 && }
+
+ {
+ extendSections && sections.map((section, index) => (
+
+ fetchSection(section)} />
+
+ ))
+ }
+
+ )
+}
+
+PageInfo.propTypes = {
+ page: PropTypes.object.isRequired,
+ elements: PropTypes.object.isRequired,
+ elementActions: PropTypes.object.isRequired
+}
+
+export default PageInfo
diff --git a/rdmo/management/assets/js/components/info/QuestionInfo.js b/rdmo/management/assets/js/components/info/QuestionInfo.js
new file mode 100644
index 0000000000..74fd9dcc12
--- /dev/null
+++ b/rdmo/management/assets/js/components/info/QuestionInfo.js
@@ -0,0 +1,61 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+
+import { ExtendLink, CodeLink } from '../common/Links'
+
+import useBool from '../../hooks/useBool'
+
+const QuestionInfo = ({ question, elements, elementActions }) => {
+
+ const [extendPages, togglePages] = useBool(false)
+ const [extendQuestionSet, toggleQuestionSets] = useBool(false)
+
+ const pages = elements.pages.filter(e => question.pages.includes(e.id))
+ const questionsets = elements.questionsets.filter(e => question.questionsets.includes(e.id))
+
+ const fetchPage = (page) => elementActions.fetchElement('pages', page.id)
+ const fetchQuestionSet = (questionset) => elementActions.fetchElement('questionsets', questionset.id)
+
+ return (
+
+
+ one page.',
+ 'This question is used in %s pages .',
+ pages.length), [pages.length])}} />
+ {pages.length > 0 && }
+
+ {
+ extendPages && pages.map((page, index) => (
+
+ fetchPage(page)} />
+
+ ))
+ }
+
+ one question set.',
+ 'This question set is used in %s question sets .',
+ questionsets.length), [questionsets.length])}} />
+ {questionsets.length > 0 && }
+
+ {
+ extendQuestionSet && questionsets.map((questionset, index) => (
+
+ fetchQuestionSet(questionset)} />
+
+ ))
+ }
+
+ )
+}
+
+QuestionInfo.propTypes = {
+ question: PropTypes.object.isRequired,
+ elements: PropTypes.object.isRequired,
+ elementActions: PropTypes.object.isRequired
+}
+
+export default QuestionInfo
diff --git a/rdmo/management/assets/js/components/info/QuestionSetInfo.js b/rdmo/management/assets/js/components/info/QuestionSetInfo.js
new file mode 100644
index 0000000000..051ff6ab3f
--- /dev/null
+++ b/rdmo/management/assets/js/components/info/QuestionSetInfo.js
@@ -0,0 +1,61 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+
+import { ExtendLink, CodeLink } from '../common/Links'
+
+import useBool from '../../hooks/useBool'
+
+const QuestionSetInfo = ({ questionset, elements, elementActions }) => {
+
+ const [extendPages, togglePages] = useBool(false)
+ const [extendQuestionSet, toggleQuestionSets] = useBool(false)
+
+ const pages = elements.pages.filter(e => questionset.pages.includes(e.id))
+ const questionsets = elements.questionsets.filter(e => questionset.parents.includes(e.id))
+
+ const fetchPage = (page) => elementActions.fetchElement('pages', page.id)
+ const fetchQuestionSet = (questionset) => elementActions.fetchElement('questionsets', questionset.id)
+
+ return (
+
+
+ one page.',
+ 'This question set is used in %s pages .',
+ pages.length), [pages.length])}} />
+ {pages.length > 0 && }
+
+ {
+ extendPages && pages.map((page, index) => (
+
+ fetchPage(page)} />
+
+ ))
+ }
+
+ one question set.',
+ 'This question set is used in %s question sets .',
+ questionsets.length), [questionsets.length])}} />
+ {questionsets.length > 0 && }
+
+ {
+ extendQuestionSet && questionsets.map((questionset, index) => (
+
+ fetchQuestionSet(questionset)} />
+
+ ))
+ }
+
+ )
+}
+
+QuestionSetInfo.propTypes = {
+ questionset: PropTypes.object.isRequired,
+ elements: PropTypes.object.isRequired,
+ elementActions: PropTypes.object.isRequired
+}
+
+export default QuestionSetInfo
diff --git a/rdmo/management/assets/js/components/info/SectionInfo.js b/rdmo/management/assets/js/components/info/SectionInfo.js
new file mode 100644
index 0000000000..b5ac81f0fb
--- /dev/null
+++ b/rdmo/management/assets/js/components/info/SectionInfo.js
@@ -0,0 +1,43 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+
+import { ExtendLink, CodeLink } from '../common/Links'
+
+import useBool from '../../hooks/useBool'
+
+const SectionInfo = ({ section, elements, elementActions }) => {
+
+ const [showCatalogs, toggleCatalogs] = useBool(false)
+
+ const catalogs = elements.catalogs.filter(e => section.catalogs.includes(e.id))
+
+ const fetchCatalog = (catalog) => elementActions.fetchElement('catalogs', catalog.id)
+
+ return (
+
+
+ one catalog.',
+ 'This section is used in %s catalogs .',
+ catalogs.length), [catalogs.length])}} />
+ {catalogs.length > 0 && }
+
+ {
+ showCatalogs && catalogs.map((catalog, index) => (
+
+ fetchCatalog(catalog)} />
+
+ ))
+ }
+
+ )
+}
+
+SectionInfo.propTypes = {
+ section: PropTypes.object.isRequired,
+ elements: PropTypes.object.isRequired,
+ elementActions: PropTypes.object.isRequired
+}
+
+export default SectionInfo
diff --git a/rdmo/management/assets/js/components/info/TaskInfo.js b/rdmo/management/assets/js/components/info/TaskInfo.js
new file mode 100644
index 0000000000..16bc33890c
--- /dev/null
+++ b/rdmo/management/assets/js/components/info/TaskInfo.js
@@ -0,0 +1,21 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+
+const TaskInfo = ({ task }) => {
+ return (
+
+
one project.',
+ 'This task is used in %s projects .',
+ task.projects_count
+ ), [task.projects_count])}} />
+
+ )
+}
+
+TaskInfo.propTypes = {
+ task: PropTypes.object.isRequired
+}
+
+export default TaskInfo
diff --git a/rdmo/management/assets/js/components/info/ViewInfo.js b/rdmo/management/assets/js/components/info/ViewInfo.js
new file mode 100644
index 0000000000..d52ee545fb
--- /dev/null
+++ b/rdmo/management/assets/js/components/info/ViewInfo.js
@@ -0,0 +1,21 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+
+const ViewInfo = ({ view }) => {
+ return (
+
+
one project.',
+ 'This view is used in %s projects .',
+ view.projects_count
+ ), [view.projects_count])}} />
+
+ )
+}
+
+ViewInfo.propTypes = {
+ view: PropTypes.object.isRequired
+}
+
+export default ViewInfo
diff --git a/rdmo/management/assets/js/components/main/Edit.js b/rdmo/management/assets/js/components/main/Edit.js
new file mode 100644
index 0000000000..4e20597bee
--- /dev/null
+++ b/rdmo/management/assets/js/components/main/Edit.js
@@ -0,0 +1,55 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+
+import EditAttribute from '../edit/EditAttribute'
+import EditCatalog from '../edit/EditCatalog'
+import EditCondition from '../edit/EditCondition'
+import EditOption from '../edit/EditOption'
+import EditOptionSet from '../edit/EditOptionSet'
+import EditPage from '../edit/EditPage'
+import EditQuestion from '../edit/EditQuestion'
+import EditQuestionSet from '../edit/EditQuestionSet'
+import EditSection from '../edit/EditSection'
+import EditTask from '../edit/EditTask'
+import EditView from '../edit/EditView'
+
+import useScrollEffect from '../../hooks/useScrollEffect'
+
+const Edit = ({ config, elements, elementActions }) => {
+ const { element, elementType } = elements
+
+ useScrollEffect(elementType, element.id)
+
+ switch (elementType) {
+ case 'catalogs':
+ return
+ case 'sections':
+ return
+ case 'pages':
+ return
+ case 'questionsets':
+ return
+ case 'questions':
+ return
+ case 'attributes':
+ return
+ case 'optionsets':
+ return
+ case 'options':
+ return
+ case 'conditions':
+ return
+ case 'tasks':
+ return
+ case 'views':
+ return
+ }
+}
+
+Edit.propTypes = {
+ config: PropTypes.object.isRequired,
+ elements: PropTypes.object.isRequired,
+ elementActions: PropTypes.object.isRequired
+}
+
+export default Edit
diff --git a/rdmo/management/assets/js/components/main/Elements.js b/rdmo/management/assets/js/components/main/Elements.js
new file mode 100644
index 0000000000..24e3dd4810
--- /dev/null
+++ b/rdmo/management/assets/js/components/main/Elements.js
@@ -0,0 +1,67 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+
+import Attributes from '../elements/Attributes'
+import Catalogs from '../elements/Catalogs'
+import Conditions from '../elements/Conditions'
+import Options from '../elements/Options'
+import OptionSets from '../elements/OptionSets'
+import Pages from '../elements/Pages'
+import Questions from '../elements/Questions'
+import QuestionSets from '../elements/QuestionSets'
+import Sections from '../elements/Sections'
+import Tasks from '../elements/Tasks'
+import Views from '../elements/Views'
+
+import useScrollEffect from '../../hooks/useScrollEffect'
+
+const Elements = ({ config, elements, configActions, elementActions }) => {
+ const { elementType } = elements
+
+ useScrollEffect(elementType)
+
+ switch (elementType) {
+ case 'catalogs':
+ return
+ case 'sections':
+ return
+ case 'pages':
+ return
+ case 'questionsets':
+ return
+ case 'questions':
+ return
+ case 'attributes':
+ return
+ case 'optionsets':
+ return
+ case 'options':
+ return
+ case 'conditions':
+ return
+ case 'tasks':
+ return
+ case 'views':
+ return
+ }
+}
+
+Elements.propTypes = {
+ config: PropTypes.object.isRequired,
+ elements: PropTypes.object.isRequired,
+ configActions: PropTypes.object.isRequired,
+ elementActions: PropTypes.object.isRequired
+}
+
+export default Elements
diff --git a/rdmo/management/assets/js/components/main/Import.js b/rdmo/management/assets/js/components/main/Import.js
new file mode 100644
index 0000000000..6f93e80fdc
--- /dev/null
+++ b/rdmo/management/assets/js/components/main/Import.js
@@ -0,0 +1,95 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import uniqueId from 'lodash/uniqueId'
+import isEmpty from 'lodash/isEmpty'
+
+import ImportAttribute from '../import/ImportAttribute'
+import ImportCatalog from '../import/ImportCatalog'
+import ImportCondition from '../import/ImportCondition'
+import ImportOption from '../import/ImportOption'
+import ImportOptionSet from '../import/ImportOptionSet'
+import ImportPage from '../import/ImportPage'
+import ImportQuestion from '../import/ImportQuestion'
+import ImportQuestionSet from '../import/ImportQuestionSet'
+import ImportSection from '../import/ImportSection'
+import ImportTask from '../import/ImportTask'
+import ImportView from '../import/ImportView'
+
+import { codeClass, verboseNames } from '../../constants/elements'
+
+const Import = ({ config, imports, importActions }) => {
+ const { elements, success } = imports
+
+ return (
+
+
+ {gettext('Import')}
+
+
+
+ {
+ elements.map((element, index) => {
+ if (success) {
+ return (
+
+
+ {verboseNames[element.model]}{' '}
+ {element.uri}
+ {element.created && {' '}{gettext('created')} }
+ {element.updated && {' '}{gettext('updated')} }
+ {
+ !isEmpty(element.errors) && !(element.created || element.updated) &&
+ {' '}{gettext('could not be imported')}
+ }
+ {
+ !isEmpty(element.errors) && (element.created || element.updated) &&
+ <>{', '}{gettext('but could not be added to parent element')} >
+ }
+ {'.'}
+
+ {element.warnings.map(message => {message}
)}
+ {element.errors.map(message => {message}
)}
+
+ )
+ } else {
+ switch (element.model) {
+ case 'questions.catalog':
+ return
+ case 'questions.section':
+ return
+ case 'questions.page':
+ return
+ case 'questions.questionset':
+ return
+ case 'questions.question':
+ return
+ case 'domain.attribute':
+ return
+ case 'options.optionset':
+ return
+ case 'options.option':
+ return
+ case 'conditions.condition':
+ return
+ case 'tasks.task':
+ return
+ case 'views.view':
+ return
+ default:
+ return null
+ }
+ }
+ })
+ }
+
+
+ )
+}
+
+Import.propTypes = {
+ config: PropTypes.object.isRequired,
+ imports: PropTypes.object.isRequired,
+ importActions: PropTypes.object.isRequired
+}
+
+export default Import
diff --git a/rdmo/management/assets/js/components/main/Nested.js b/rdmo/management/assets/js/components/main/Nested.js
new file mode 100644
index 0000000000..8d3b9f25fd
--- /dev/null
+++ b/rdmo/management/assets/js/components/main/Nested.js
@@ -0,0 +1,47 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+
+import NestedAttribute from '../nested/NestedAttribute'
+import NestedCatalog from '../nested/NestedCatalog'
+import NestedOptionSet from '../nested/NestedOptionSet'
+import NestedPage from '../nested/NestedPage'
+import NestedQuestionSet from '../nested/NestedQuestionSet'
+import NestedSection from '../nested/NestedSection'
+
+import useScrollEffect from '../../hooks/useScrollEffect'
+
+const Nested = ({ config, elements, configActions, elementActions }) => {
+ const { element, elementType } = elements
+
+ useScrollEffect(elementType, element.id, 'nested')
+
+ switch (elementType) {
+ case 'catalogs':
+ return
+ case 'sections':
+ return
+ case 'pages':
+ return
+ case 'questionsets':
+ return
+ case 'attributes':
+ return
+ case 'optionsets':
+ return
+ }
+}
+
+Nested.propTypes = {
+ config: PropTypes.object.isRequired,
+ elements: PropTypes.object.isRequired,
+ configActions: PropTypes.object.isRequired,
+ elementActions: PropTypes.object.isRequired
+}
+
+export default Nested
diff --git a/rdmo/management/assets/js/components/modals/DeleteAttributeModal.js b/rdmo/management/assets/js/components/modals/DeleteAttributeModal.js
new file mode 100644
index 0000000000..98aa9d12b9
--- /dev/null
+++ b/rdmo/management/assets/js/components/modals/DeleteAttributeModal.js
@@ -0,0 +1,29 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+
+import { DeleteModal } from '../common/Modals'
+
+const DeleteAttributeModal = ({ attribute, info, show, onClose, onDelete }) => (
+
+
+ {gettext('You are about to permanently delete the attribute:')}
+
+
+ {attribute.uri}
+
+ { info }
+
+ {gettext('This action cannot be undone!')}
+
+
+)
+
+DeleteAttributeModal.propTypes = {
+ attribute: PropTypes.object.isRequired,
+ info: PropTypes.object.isRequired,
+ show: PropTypes.bool.isRequired,
+ onClose: PropTypes.func.isRequired,
+ onDelete: PropTypes.func.isRequired
+}
+
+export default DeleteAttributeModal
diff --git a/rdmo/management/assets/js/components/modals/DeleteCatalogModal.js b/rdmo/management/assets/js/components/modals/DeleteCatalogModal.js
new file mode 100644
index 0000000000..005ed3003d
--- /dev/null
+++ b/rdmo/management/assets/js/components/modals/DeleteCatalogModal.js
@@ -0,0 +1,29 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+
+import { DeleteModal } from '../common/Modals'
+
+const DeleteCatalogModal = ({ catalog, info, show, onClose, onDelete }) => (
+
+
+ {gettext('You are about to permanently delete the catalog:')}
+
+
+ {catalog.uri}
+
+ { info }
+
+ {gettext('Those projects will not be usable afterwards.')} {gettext('This action cannot be undone!')}
+
+
+)
+
+DeleteCatalogModal.propTypes = {
+ catalog: PropTypes.object.isRequired,
+ info: PropTypes.object.isRequired,
+ show: PropTypes.bool.isRequired,
+ onClose: PropTypes.func.isRequired,
+ onDelete: PropTypes.func.isRequired
+}
+
+export default DeleteCatalogModal
diff --git a/rdmo/management/assets/js/components/modals/DeleteConditionModal.js b/rdmo/management/assets/js/components/modals/DeleteConditionModal.js
new file mode 100644
index 0000000000..7072dc630a
--- /dev/null
+++ b/rdmo/management/assets/js/components/modals/DeleteConditionModal.js
@@ -0,0 +1,29 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+
+import { DeleteModal } from '../common/Modals'
+
+const DeleteConditionModal = ({ condition, info, show, onClose, onDelete }) => (
+
+
+ {gettext('You are about to permanently delete the condition:')}
+
+
+ {condition.uri}
+
+ { info }
+
+ {gettext('This action cannot be undone!')}
+
+
+)
+
+DeleteConditionModal.propTypes = {
+ condition: PropTypes.object.isRequired,
+ info: PropTypes.object.isRequired,
+ show: PropTypes.bool.isRequired,
+ onClose: PropTypes.func.isRequired,
+ onDelete: PropTypes.func.isRequired
+}
+
+export default DeleteConditionModal
diff --git a/rdmo/management/assets/js/components/modals/DeleteOptionModal.js b/rdmo/management/assets/js/components/modals/DeleteOptionModal.js
new file mode 100644
index 0000000000..dc52b2c807
--- /dev/null
+++ b/rdmo/management/assets/js/components/modals/DeleteOptionModal.js
@@ -0,0 +1,29 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+
+import { DeleteModal } from '../common/Modals'
+
+const DeleteOptionModal = ({ option, info, show, onClose, onDelete }) => (
+
+
+ {gettext('You are about to permanently delete the option:')}
+
+
+ {option.uri}
+
+ { info }
+
+ {gettext('This action cannot be undone!')}
+
+
+)
+
+DeleteOptionModal.propTypes = {
+ option: PropTypes.object.isRequired,
+ info: PropTypes.object.isRequired,
+ show: PropTypes.bool.isRequired,
+ onClose: PropTypes.func.isRequired,
+ onDelete: PropTypes.func.isRequired
+}
+
+export default DeleteOptionModal
diff --git a/rdmo/management/assets/js/components/modals/DeleteOptionSetModal.js b/rdmo/management/assets/js/components/modals/DeleteOptionSetModal.js
new file mode 100644
index 0000000000..f7c9b8e46d
--- /dev/null
+++ b/rdmo/management/assets/js/components/modals/DeleteOptionSetModal.js
@@ -0,0 +1,29 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+
+import { DeleteModal } from '../common/Modals'
+
+const DeleteOptionSetModal = ({ optionset, info, show, onClose, onDelete }) => (
+
+
+ {gettext('You are about to permanently delete the option set:')}
+
+
+ {optionset.uri}
+
+ { info }
+
+ {gettext('This action cannot be undone!')}
+
+
+)
+
+DeleteOptionSetModal.propTypes = {
+ optionset: PropTypes.object.isRequired,
+ info: PropTypes.object.isRequired,
+ show: PropTypes.bool.isRequired,
+ onClose: PropTypes.func.isRequired,
+ onDelete: PropTypes.func.isRequired
+}
+
+export default DeleteOptionSetModal
diff --git a/rdmo/management/assets/js/components/modals/DeletePageModal.js b/rdmo/management/assets/js/components/modals/DeletePageModal.js
new file mode 100644
index 0000000000..2694a69e04
--- /dev/null
+++ b/rdmo/management/assets/js/components/modals/DeletePageModal.js
@@ -0,0 +1,29 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+
+import { DeleteModal } from '../common/Modals'
+
+const DeletePageModal = ({ page, info, show, onClose, onDelete }) => (
+
+
+ {gettext('You are about to permanently delete the page:')}
+
+
+ {page.uri}
+
+ { info }
+
+ {gettext('This action cannot be undone!')}
+
+
+)
+
+DeletePageModal.propTypes = {
+ page: PropTypes.object.isRequired,
+ info: PropTypes.object.isRequired,
+ show: PropTypes.bool.isRequired,
+ onClose: PropTypes.func.isRequired,
+ onDelete: PropTypes.func.isRequired
+}
+
+export default DeletePageModal
diff --git a/rdmo/management/assets/js/components/modals/DeleteQuestionModal.js b/rdmo/management/assets/js/components/modals/DeleteQuestionModal.js
new file mode 100644
index 0000000000..fe4eb76a03
--- /dev/null
+++ b/rdmo/management/assets/js/components/modals/DeleteQuestionModal.js
@@ -0,0 +1,29 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+
+import { DeleteModal } from '../common/Modals'
+
+const DeleteQuestionModal = ({ question, info, show, onClose, onDelete }) => (
+
+
+ {gettext('You are about to permanently delete the question:')}
+
+
+ {question.uri}
+
+ { info }
+
+ {gettext('This action cannot be undone!')}
+
+
+)
+
+DeleteQuestionModal.propTypes = {
+ question: PropTypes.object.isRequired,
+ info: PropTypes.object.isRequired,
+ show: PropTypes.bool.isRequired,
+ onClose: PropTypes.func.isRequired,
+ onDelete: PropTypes.func.isRequired
+}
+
+export default DeleteQuestionModal
diff --git a/rdmo/management/assets/js/components/modals/DeleteQuestionSetModal.js b/rdmo/management/assets/js/components/modals/DeleteQuestionSetModal.js
new file mode 100644
index 0000000000..abc9ae1ec5
--- /dev/null
+++ b/rdmo/management/assets/js/components/modals/DeleteQuestionSetModal.js
@@ -0,0 +1,29 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+
+import { DeleteModal } from '../common/Modals'
+
+const DeleteQuestionSetModal = ({ questionset, info, show, onClose, onDelete }) => (
+
+
+ {gettext('You are about to permanently delete the question set:')}
+
+
+ {questionset.uri}
+
+ { info }
+
+ {gettext('This action cannot be undone!')}
+
+
+)
+
+DeleteQuestionSetModal.propTypes = {
+ questionset: PropTypes.object.isRequired,
+ info: PropTypes.object.isRequired,
+ show: PropTypes.bool.isRequired,
+ onClose: PropTypes.func.isRequired,
+ onDelete: PropTypes.func.isRequired
+}
+
+export default DeleteQuestionSetModal
diff --git a/rdmo/management/assets/js/components/modals/DeleteSectionModal.js b/rdmo/management/assets/js/components/modals/DeleteSectionModal.js
new file mode 100644
index 0000000000..7e35dc2d2a
--- /dev/null
+++ b/rdmo/management/assets/js/components/modals/DeleteSectionModal.js
@@ -0,0 +1,29 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+
+import { DeleteModal } from '../common/Modals'
+
+const DeleteSectionModal = ({ section, info, show, onClose, onDelete }) => (
+
+
+ {gettext('You are about to permanently delete the section:')}
+
+
+ {section.uri}
+
+ { info }
+
+ {gettext('This action cannot be undone!')}
+
+
+)
+
+DeleteSectionModal.propTypes = {
+ section: PropTypes.object.isRequired,
+ info: PropTypes.object.isRequired,
+ show: PropTypes.bool.isRequired,
+ onClose: PropTypes.func.isRequired,
+ onDelete: PropTypes.func.isRequired
+}
+
+export default DeleteSectionModal
diff --git a/rdmo/management/assets/js/components/modals/DeleteTaskModal.js b/rdmo/management/assets/js/components/modals/DeleteTaskModal.js
new file mode 100644
index 0000000000..105194733d
--- /dev/null
+++ b/rdmo/management/assets/js/components/modals/DeleteTaskModal.js
@@ -0,0 +1,29 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+
+import { DeleteModal } from '../common/Modals'
+
+const DeleteTaskModal = ({ task, info, show, onClose, onDelete }) => (
+
+
+ {gettext('You are about to permanently delete the task:')}
+
+
+ {task.uri}
+
+ { info }
+
+ {gettext('The task will be removed from these projects.')} {gettext('This action cannot be undone!')}
+
+
+)
+
+DeleteTaskModal.propTypes = {
+ task: PropTypes.object.isRequired,
+ info: PropTypes.object.isRequired,
+ show: PropTypes.bool.isRequired,
+ onClose: PropTypes.func.isRequired,
+ onDelete: PropTypes.func.isRequired
+}
+
+export default DeleteTaskModal
diff --git a/rdmo/management/assets/js/components/modals/DeleteViewModal.js b/rdmo/management/assets/js/components/modals/DeleteViewModal.js
new file mode 100644
index 0000000000..b98cef7d9f
--- /dev/null
+++ b/rdmo/management/assets/js/components/modals/DeleteViewModal.js
@@ -0,0 +1,29 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+
+import { DeleteModal } from '../common/Modals'
+
+const DeleteViewModal = ({ view, info, show, onClose, onDelete }) => (
+
+
+ {gettext('You are about to permanently delete the view:')}
+
+
+ {view.uri}
+
+ { info }
+
+ {gettext('The view will be removed from these projects.')} {gettext('This action cannot be undone!')}
+
+
+)
+
+DeleteViewModal.propTypes = {
+ view: PropTypes.object.isRequired,
+ info: PropTypes.object.isRequired,
+ show: PropTypes.bool.isRequired,
+ onClose: PropTypes.func.isRequired,
+ onDelete: PropTypes.func.isRequired
+}
+
+export default DeleteViewModal
diff --git a/rdmo/management/assets/js/components/nested/NestedAttribute.js b/rdmo/management/assets/js/components/nested/NestedAttribute.js
new file mode 100644
index 0000000000..c80a43c9d1
--- /dev/null
+++ b/rdmo/management/assets/js/components/nested/NestedAttribute.js
@@ -0,0 +1,60 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import get from 'lodash/get'
+
+import { getUriPrefixes } from '../../utils/filter'
+
+import { FilterString, FilterUriPrefix } from '../common/Filter'
+import { BackButton } from '../common/Buttons'
+
+import Attribute from '../element/Attribute'
+
+const NestedAttribute = ({ config, attribute, configActions, elementActions }) => {
+
+ const updateFilterString = (uri) => configActions.updateConfig('filter.attribute.search', uri)
+ const updateFilterUriPrefix = (uriPrefix) => configActions.updateConfig('filter.attribute.uri_prefix', uriPrefix)
+
+ return (
+ <>
+
+
+ {
+ attribute.elements.map((attribute, index) => (
+
+ ))
+ }
+ >
+ )
+}
+
+NestedAttribute.propTypes = {
+ config: PropTypes.object.isRequired,
+ attribute: PropTypes.object.isRequired,
+ configActions: PropTypes.object.isRequired,
+ elementActions: PropTypes.object.isRequired
+}
+
+export default NestedAttribute
diff --git a/rdmo/management/assets/js/components/nested/NestedCatalog.js b/rdmo/management/assets/js/components/nested/NestedCatalog.js
new file mode 100644
index 0000000000..3b2809f909
--- /dev/null
+++ b/rdmo/management/assets/js/components/nested/NestedCatalog.js
@@ -0,0 +1,106 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import get from 'lodash/get'
+import isEmpty from 'lodash/isEmpty'
+
+import Link from 'rdmo/core/assets/js/components/Link'
+
+import { getUriPrefixes } from '../../utils/filter'
+import { FilterString, FilterUriPrefix } from '../common/Filter'
+import { Checkbox } from '../common/Checkboxes'
+import { BackButton } from '../common/Buttons'
+import { Drop } from '../common/DragAndDrop'
+
+import Catalog from '../element/Catalog'
+import Section from '../element/Section'
+
+const NestedCatalog = ({ config, catalog, configActions, elementActions }) => {
+
+ const updateFilterString = (value) => configActions.updateConfig('filter.catalog.search', value)
+ const updateFilterUriPrefix = (value) => configActions.updateConfig('filter.catalog.uri_prefix', value)
+
+ const toggleSections = () => configActions.toggleDescandants(catalog, 'sections')
+ const togglePages = () => configActions.toggleDescandants(catalog, 'pages')
+ const toggleQuestionSets = () => configActions.toggleDescandants(catalog, 'questionsets')
+
+ const updateDisplayCatalogURI = (value) => configActions.updateConfig('display.uri.catalogs', value)
+ const updateDisplaySectionsURI = (value) => configActions.updateConfig('display.uri.sections', value)
+ const updateDisplayPagesURI = (value) => configActions.updateConfig('display.uri.pages', value)
+ const updateDisplayQuestionSetsURI = (value) => configActions.updateConfig('display.uri.questionsets', value)
+ const updateDisplayQuestionsURI = (value) => configActions.updateConfig('display.uri.questions', value)
+ const updateDisplayAttributesURI = (value) => configActions.updateConfig('display.uri.attributes', value)
+ const updateDisplayConditionsURI = (value) => configActions.updateConfig('display.uri.conditions', value)
+ const updateDisplayOptionSetURI = (value) => configActions.updateConfig('display.uri.optionsets', value)
+
+ return (
+ <>
+
+
+
+
+
+
+ {gettext('Toggle elements:')}
+ {gettext('Sections')}
+ {gettext('Pages')}
+ {gettext('Question sets')}
+
+
+ {gettext('Show URIs:')}
+ {gettext('Catalogs')}}
+ value={get(config, 'display.uri.catalogs', true)} onChange={updateDisplayCatalogURI} />
+ {gettext('Sections')}}
+ value={get(config, 'display.uri.sections', true)} onChange={updateDisplaySectionsURI} />
+ {gettext('Pages')}}
+ value={get(config, 'display.uri.pages', true)} onChange={updateDisplayPagesURI} />
+ {gettext('Question sets')}}
+ value={get(config, 'display.uri.questionsets', true)} onChange={updateDisplayQuestionSetsURI} />
+ {gettext('Questions')}}
+ value={get(config, 'display.uri.questions', true)} onChange={updateDisplayQuestionsURI} />
+ {gettext('Attributes')}}
+ value={get(config, 'display.uri.attributes', true)} onChange={updateDisplayAttributesURI} />
+ {gettext('Conditions')}}
+ value={get(config, 'display.uri.conditions', true)} onChange={updateDisplayConditionsURI} />
+ {gettext('Option sets')}}
+ value={get(config, 'display.uri.optionsets', true)} onChange={updateDisplayOptionSetURI} />
+
+
+
+ {
+ !isEmpty(catalog.elements) &&
+
+ }
+ {
+ catalog.elements.map((section, index) => (
+
+ ))
+ }
+ >
+ )
+}
+
+NestedCatalog.propTypes = {
+ config: PropTypes.object.isRequired,
+ catalog: PropTypes.object.isRequired,
+ configActions: PropTypes.object.isRequired,
+ elementActions: PropTypes.object.isRequired
+}
+
+export default NestedCatalog
diff --git a/rdmo/management/assets/js/components/nested/NestedOptionSet.js b/rdmo/management/assets/js/components/nested/NestedOptionSet.js
new file mode 100644
index 0000000000..5f4b3b9236
--- /dev/null
+++ b/rdmo/management/assets/js/components/nested/NestedOptionSet.js
@@ -0,0 +1,69 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import get from 'lodash/get'
+
+import { getUriPrefixes } from '../../utils/filter'
+
+import { FilterString, FilterUriPrefix } from '../common/Filter'
+import { Checkbox } from '../common/Checkboxes'
+import { BackButton } from '../common/Buttons'
+
+import Option from '../element/Option'
+import OptionSet from '../element/OptionSet'
+
+const NestedOptionSet = ({ config, optionset, configActions, elementActions }) => {
+
+ const updateFilterString = (uri) => configActions.updateConfig('filter.optionset.search', uri)
+ const updateFilterUriPrefix = (uriPrefix) => configActions.updateConfig('filter.optionset.uri_prefix', uriPrefix)
+
+ const updateDisplayURI = (value) => configActions.updateConfig('display.uri.options', value)
+
+ return (
+ <>
+
+
+
+
+
+
+ {gettext('Show URIs:')}
+ {gettext('Options')}}
+ value={get(config, 'display.uri.options', true)} onChange={updateDisplayURI} />
+
+
+
+
+ {
+ optionset.elements.map((option, index) => (
+
+ ))
+ }
+ >
+ )
+}
+
+NestedOptionSet.propTypes = {
+ config: PropTypes.object.isRequired,
+ optionset: PropTypes.object.isRequired,
+ configActions: PropTypes.object.isRequired,
+ elementActions: PropTypes.object.isRequired
+}
+
+export default NestedOptionSet
diff --git a/rdmo/management/assets/js/components/nested/NestedPage.js b/rdmo/management/assets/js/components/nested/NestedPage.js
new file mode 100644
index 0000000000..c19a212b8f
--- /dev/null
+++ b/rdmo/management/assets/js/components/nested/NestedPage.js
@@ -0,0 +1,104 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import get from 'lodash/get'
+import isEmpty from 'lodash/isEmpty'
+
+import Link from 'rdmo/core/assets/js/components/Link'
+
+import { getUriPrefixes } from '../../utils/filter'
+
+import { FilterString, FilterUriPrefix } from '../common/Filter'
+import { Checkbox } from '../common/Checkboxes'
+import { BackButton } from '../common/Buttons'
+import { Drop } from '../common/DragAndDrop'
+
+import Page from '../element/Page'
+import QuestionSet from '../element/QuestionSet'
+import Question from '../element/Question'
+
+const NestedPage = ({ config, page, configActions, elementActions }) => {
+
+ const updateFilterString = (uri) => configActions.updateConfig('filter.page.search', uri)
+ const updateFilterUriPrefix = (uriPrefix) => configActions.updateConfig('filter.page.uri_prefix', uriPrefix)
+
+ const toggleQuestionSets = () => configActions.toggleDescandants(page, 'questionsets')
+
+ const updateDisplayPagesURI = (value) => configActions.updateConfig('display.uri.pages', value)
+ const updateDisplayQuestionSetsURI = (value) => configActions.updateConfig('display.uri.questionsets', value)
+ const updateDisplayQuestionsURI = (value) => configActions.updateConfig('display.uri.questions', value)
+ const updateDisplayAttributesURI = (value) => configActions.updateConfig('display.uri.attributes', value)
+ const updateDisplayConditionsURI = (value) => configActions.updateConfig('display.uri.conditions', value)
+ const updateDisplayOptionSetURI = (value) => configActions.updateConfig('display.uri.optionsets', value)
+
+ return (
+ <>
+
+
+
+
+
+
+ {gettext('Show elements:')}
+ {gettext('Question sets')}
+
+
+ {gettext('Show URIs:')}
+ {gettext('Pages')}}
+ value={get(config, 'config.display.uri.pages', true)} onChange={updateDisplayPagesURI} />
+ {gettext('Question sets')}}
+ value={get(config, 'config.display.uri.questionsets', true)} onChange={updateDisplayQuestionSetsURI} />
+ {gettext('Questions')}}
+ value={get(config, 'config.display.uri.questions', true)} onChange={updateDisplayQuestionsURI} />
+ {gettext('Attributes')}}
+ value={get(config, 'config.display.uri.attributes', true)} onChange={updateDisplayAttributesURI} />
+ {gettext('Conditions')}}
+ value={get(config, 'config.display.uri.conditions', true)} onChange={updateDisplayConditionsURI} />
+ {gettext('Option sets')}}
+ value={get(config, 'config.display.uri.optionsets', true)} onChange={updateDisplayOptionSetURI} />
+
+
+
+ {
+ !isEmpty(page.elements) &&
+
+ }
+ {
+ page.elements.map((element, index) => {
+ if (element.model == 'questions.questionset') {
+ return
+ } else {
+ return
+ }
+ })
+ }
+ >
+ )
+}
+
+NestedPage.propTypes = {
+ config: PropTypes.object.isRequired,
+ page: PropTypes.object.isRequired,
+ configActions: PropTypes.object.isRequired,
+ elementActions: PropTypes.object.isRequired
+}
+
+export default NestedPage
diff --git a/rdmo/management/assets/js/components/nested/NestedQuestionSet.js b/rdmo/management/assets/js/components/nested/NestedQuestionSet.js
new file mode 100644
index 0000000000..42a42b7709
--- /dev/null
+++ b/rdmo/management/assets/js/components/nested/NestedQuestionSet.js
@@ -0,0 +1,100 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import get from 'lodash/get'
+import isEmpty from 'lodash/isEmpty'
+
+import Link from 'rdmo/core/assets/js/components/Link'
+
+import { getUriPrefixes } from '../../utils/filter'
+
+import { FilterString, FilterUriPrefix } from '../common/Filter'
+import { Checkbox } from '../common/Checkboxes'
+import { BackButton } from '../common/Buttons'
+import { Drop } from '../common/DragAndDrop'
+
+import QuestionSet from '../element/QuestionSet'
+import Question from '../element/Question'
+
+const NestedQuestionSet = ({ config, questionset, configActions, elementActions }) => {
+
+ const updateFilterString = (uri) => configActions.updateConfig('filter.questionset.search', uri)
+ const updateFilterUriPrefix = (uriPrefix) => configActions.updateConfig('filter.questionset.uri_prefix', uriPrefix)
+
+ const toggleQuestionSets = () => configActions.toggleDescandants(questionset, 'questionsets')
+
+ const updateDisplayQuestionSetsURI = (value) => configActions.updateConfig('display.uri.questionsets', value)
+ const updateDisplayQuestionsURI = (value) => configActions.updateConfig('display.uri.questions', value)
+ const updateDisplayAttributesURI = (value) => configActions.updateConfig('display.uri.attributes', value)
+ const updateDisplayConditionsURI = (value) => configActions.updateConfig('display.uri.conditions', value)
+ const updateDisplayOptionSetURI = (value) => configActions.updateConfig('display.uri.optionsets', value)
+
+ return (
+ <>
+
+
+
+
+
+
+ {gettext('Show elements:')}
+ {gettext('Question sets')}
+
+
+ {gettext('Show URIs:')}
+ {gettext('Question sets')}}
+ value={get(config, 'display.uri.questionsets', true)} onChange={updateDisplayQuestionSetsURI} />
+ {gettext('Questions')}}
+ value={get(config, 'display.uri.questions', true)} onChange={updateDisplayQuestionsURI} />
+ {gettext('Attributes')}}
+ value={get(config, 'display.uri.attributes', true)} onChange={updateDisplayAttributesURI} />
+ {gettext('Conditions')}}
+ value={get(config, 'display.uri.conditions', true)} onChange={updateDisplayConditionsURI} />
+ {gettext('Option sets')}}
+ value={get(config, 'display.uri.optionsets', true)} onChange={updateDisplayOptionSetURI} />
+
+
+
+ {
+ !isEmpty(questionset.elements) &&
+
+ }
+ {
+ questionset.elements.map((element, index) => {
+ if (element.model == 'questions.questionset') {
+ return
+ } else {
+ return
+ }
+ })
+ }
+ >
+ )
+}
+
+NestedQuestionSet.propTypes = {
+ config: PropTypes.object.isRequired,
+ questionset: PropTypes.object.isRequired,
+ configActions: PropTypes.object.isRequired,
+ elementActions: PropTypes.object.isRequired
+}
+
+export default NestedQuestionSet
diff --git a/rdmo/management/assets/js/components/nested/NestedSection.js b/rdmo/management/assets/js/components/nested/NestedSection.js
new file mode 100644
index 0000000000..3a852603db
--- /dev/null
+++ b/rdmo/management/assets/js/components/nested/NestedSection.js
@@ -0,0 +1,102 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import get from 'lodash/get'
+import isEmpty from 'lodash/isEmpty'
+
+import Link from 'rdmo/core/assets/js/components/Link'
+
+import { getUriPrefixes } from '../../utils/filter'
+
+import { FilterString, FilterUriPrefix } from '../common/Filter'
+import { Checkbox } from '../common/Checkboxes'
+import { BackButton } from '../common/Buttons'
+import { Drop } from '../common/DragAndDrop'
+
+import Section from '../element/Section'
+import Page from '../element/Page'
+
+const NestedCatalog = ({ config, section, configActions, elementActions }) => {
+
+ const updateFilterString = (uri) => configActions.updateConfig('filter.section.search', uri)
+ const updateFilterUriPrefix = (uriPrefix) => configActions.updateConfig('filter.section.uri_prefix', uriPrefix)
+
+ const togglePages = () => configActions.toggleDescandants(section, 'pages')
+ const toggleQuestionSets = () => configActions.toggleDescandants(section, 'questionsets')
+
+ const updateDisplaySectionURI = (value) => configActions.updateConfig('display.uri.sections', value)
+ const updateDisplayPagesURI = (value) => configActions.updateConfig('display.uri.pages', value)
+ const updateDisplayQuestionSetsURI = (value) => configActions.updateConfig('display.uri.questionsets', value)
+ const updateDisplayQuestionsURI = (value) => configActions.updateConfig('display.uri.questions', value)
+ const updateDisplayAttributesURI = (value) => configActions.updateConfig('display.uri.attributes', value)
+ const updateDisplayConditionsURI = (value) => configActions.updateConfig('display.uri.conditions', value)
+ const updateDisplayOptionSetURI = (value) => configActions.updateConfig('display.uri.optionsets', value)
+
+ return (
+ <>
+
+
+
+
+
+
+ {gettext('Show elements:')}
+ {gettext('Pages')}
+ {gettext('Question sets')}
+
+
+ {gettext('Show URIs:')}
+ {gettext('Sections')}}
+ value={get(config, 'config.display.uri.sections', true)} onChange={updateDisplaySectionURI} />
+ {gettext('Pages')}}
+ value={get(config, 'config.display.uri.pages', true)} onChange={updateDisplayPagesURI} />
+ {gettext('Question sets')}}
+ value={get(config, 'config.display.uri.questionsets', true)} onChange={updateDisplayQuestionSetsURI} />
+ {gettext('Questions')}}
+ value={get(config, 'config.display.uri.questions', true)} onChange={updateDisplayQuestionsURI} />
+ {gettext('Attributes')}}
+ value={get(config, 'config.display.uri.attributes', true)} onChange={updateDisplayAttributesURI} />
+ {gettext('Conditions')}}
+ value={get(config, 'config.display.uri.conditions', true)} onChange={updateDisplayConditionsURI} />
+ {gettext('Option sets')}}
+ value={get(config, 'config.display.uri.optionsets', true)} onChange={updateDisplayOptionSetURI} />
+
+
+
+ {
+ !isEmpty(section.elements) &&
+
+ }
+ {
+ section.elements.map((page, index) => (
+
+ ))
+ }
+ >
+ )
+}
+
+NestedCatalog.propTypes = {
+ config: PropTypes.object.isRequired,
+ section: PropTypes.object.isRequired,
+ configActions: PropTypes.object.isRequired,
+ elementActions: PropTypes.object.isRequired
+}
+
+export default NestedCatalog
diff --git a/rdmo/management/assets/js/components/sidebar/ElementsSidebar.js b/rdmo/management/assets/js/components/sidebar/ElementsSidebar.js
new file mode 100644
index 0000000000..b0b90eb123
--- /dev/null
+++ b/rdmo/management/assets/js/components/sidebar/ElementsSidebar.js
@@ -0,0 +1,118 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import isNil from 'lodash/isNil'
+import invert from 'lodash/invert'
+
+import { elementTypes, elementModules } from '../../constants/elements'
+
+import { buildPath } from '../../utils/location'
+import { getExportParams } from '../../utils/filter'
+
+import Link from 'rdmo/core/assets/js/components/Link'
+
+import { UploadForm } from '../common/Forms'
+
+const ElementsSidebar = ({ config, elements, elementActions, importActions }) => {
+ const { elementType, elementId } = elements
+
+ const model = invert(elementTypes)[elementType]
+ const exportUrl = isNil(elementId) ? `/api/v1/${elementModules[model]}/${elementType}/export/`
+ : `/api/v1/${elementModules[model]}/${elementType}/${elementId}/export/`
+ const exportParams = getExportParams(config.filter[elementType])
+
+ return (
+
+
Navigation
+
+
+
+ elementActions.fetchElements('catalogs')}>Catalogs
+
+
+ elementActions.fetchElements('sections')}>Sections
+
+
+ elementActions.fetchElements('pages')}>Pages
+
+
+ elementActions.fetchElements('questionsets')}>Question sets
+
+
+ elementActions.fetchElements('questions')}>Questions
+
+
+ elementActions.fetchElements('attributes')}>Attributes
+
+
+ elementActions.fetchElements('optionsets')}>Option sets
+
+
+ elementActions.fetchElements('options')}>Options
+
+
+ elementActions.fetchElements('conditions')}>Conditions
+
+
+ elementActions.fetchElements('tasks')}>Tasks
+
+
+ elementActions.fetchElements('views')}>Views
+
+
+
+
Export
+
+
+
+
Import
+
+
importActions.uploadFile(file)} />
+
+ )
+}
+
+ElementsSidebar.propTypes = {
+ config: PropTypes.object.isRequired,
+ elements: PropTypes.object.isRequired,
+ elementActions: PropTypes.object.isRequired,
+ importActions: PropTypes.object.isRequired
+}
+
+export default ElementsSidebar
diff --git a/rdmo/management/assets/js/components/sidebar/ImportSidebar.js b/rdmo/management/assets/js/components/sidebar/ImportSidebar.js
new file mode 100644
index 0000000000..9d27019d28
--- /dev/null
+++ b/rdmo/management/assets/js/components/sidebar/ImportSidebar.js
@@ -0,0 +1,93 @@
+import React, { useState } from 'react'
+import PropTypes from 'prop-types'
+import isEmpty from 'lodash/isEmpty'
+import isNil from 'lodash/isNil'
+
+import Link from 'rdmo/core/assets/js/components/Link'
+
+const ImportSidebar = ({ config, imports, importActions }) => {
+ const { elements, success } = imports
+ const count = elements.filter(e => e.import).length
+ const [uriPrefix, setUriPrefix] = useState('')
+ const disabled = isNil(uriPrefix) || isEmpty(uriPrefix)
+
+ const updateUriPrefix = () => {
+ if (!disabled) {
+ importActions.updateUriPrefix(uriPrefix)
+ }
+ }
+
+ if (success) {
+ return (
+
+
{gettext('Import successful')}
+
+
+ importActions.resetElements()}>
+ {gettext('Back')}
+
+
+
+ )
+ } else {
+ return (
+
+
{gettext('Import elements')}
+
+
+ importActions.importElements()}>
+ {interpolate(ngettext('Import one element', 'Import %s elements', count), [count])}
+
+ importActions.resetElements()}>
+ {gettext('Back')}
+
+
+
+
{gettext('Selection')}
+
+
+
+ importActions.selectElements(true)}>
+ {gettext('Select all')}
+
+
+
+ importActions.selectElements(false)}>
+ {gettext('Unselect all')}
+
+
+
+
+
{gettext('URI prefix')}
+
+
+
+ setUriPrefix(event.target.value)} />
+
+
+ setUriPrefix(config.settings.default_uri_prefix)}>
+
+
+
+
+
+
+
+
+
+ )
+ }
+}
+
+ImportSidebar.propTypes = {
+ config: PropTypes.object.isRequired,
+ imports: PropTypes.object.isRequired,
+ importActions: PropTypes.object.isRequired
+}
+
+export default ImportSidebar
diff --git a/rdmo/management/assets/js/constants/elements.js b/rdmo/management/assets/js/constants/elements.js
new file mode 100644
index 0000000000..9c32d008a1
--- /dev/null
+++ b/rdmo/management/assets/js/constants/elements.js
@@ -0,0 +1,57 @@
+const elementTypes = {
+ 'questions.catalog': 'catalogs',
+ 'questions.section': 'sections',
+ 'questions.page': 'pages',
+ 'questions.questionset': 'questionsets',
+ 'questions.question': 'questions',
+ 'domain.attribute':'attributes',
+ 'options.optionset': 'optionsets',
+ 'options.option': 'options',
+ 'conditions.condition': 'conditions',
+ 'tasks.task': 'tasks',
+ 'views.view': 'views'
+}
+
+const elementModules = {
+ 'questions.catalog': 'questions',
+ 'questions.section': 'questions',
+ 'questions.page': 'questions',
+ 'questions.questionset': 'questions',
+ 'questions.question': 'questions',
+ 'domain.attribute': 'domain',
+ 'options.optionset': 'options',
+ 'options.option': 'options',
+ 'conditions.condition': 'conditions',
+ 'tasks.task': 'tasks',
+ 'views.view': 'views'
+}
+
+const codeClass = {
+ 'questions.catalog': 'code-questions',
+ 'questions.section': 'code-questions',
+ 'questions.page': 'code-questions',
+ 'questions.questionset': 'code-questions',
+ 'questions.question': 'code-questions',
+ 'domain.attribute': 'code-domain',
+ 'options.optionset': 'code-options',
+ 'options.option': 'code-options',
+ 'conditions.condition': 'code-conditions',
+ 'tasks.task': 'code-tasks',
+ 'views.view': 'code-views'
+}
+
+const verboseNames = {
+ 'questions.catalog': gettext('Catalog'),
+ 'questions.section': gettext('Section'),
+ 'questions.page': gettext('Page'),
+ 'questions.questionset': gettext('Question set'),
+ 'questions.question': gettext('Question'),
+ 'domain.attribute': gettext('Attribute'),
+ 'options.optionset': gettext('Option set'),
+ 'options.option': gettext('Option'),
+ 'conditions.condition': gettext('Condition'),
+ 'tasks.task': gettext('Task'),
+ 'views.view': gettext('View')
+}
+
+export { elementTypes, elementModules, codeClass, verboseNames }
diff --git a/rdmo/management/assets/js/containers/Main.js b/rdmo/management/assets/js/containers/Main.js
new file mode 100644
index 0000000000..4cd6049d3f
--- /dev/null
+++ b/rdmo/management/assets/js/containers/Main.js
@@ -0,0 +1,88 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import { bindActionCreators } from 'redux'
+import { connect } from 'react-redux'
+import get from 'lodash/get'
+import isEmpty from 'lodash/isEmpty'
+import isNil from 'lodash/isNil'
+
+import * as configActions from '../actions/configActions'
+import * as elementActions from '../actions/elementActions'
+import * as importActions from '../actions/importActions'
+
+import { MainErrors } from '../components/common/Errors'
+
+import Edit from '../components/main/Edit'
+import Elements from '../components/main/Elements'
+import Import from '../components/main/Import'
+import Nested from '../components/main/Nested'
+
+const Main = ({ config, elements, imports, configActions, elementActions, importActions }) => {
+ const { element, elementType, elementId, elementAction } = elements
+
+ // check if anything was loaded yet
+ if (isNil(elementType)) {
+ return null
+ }
+
+ // check if an an error occurred
+ if (!isNil(elements.errors.api)) {
+ return
+ } else if (get(elements, 'element.errors.api')) {
+ return
+ } else if (!isNil(imports.errors.file)) {
+ return
+ }
+
+ if (!isEmpty(imports.elements)) {
+ return
+ }
+
+ // check if the nested components should be displayed
+ if (!isNil(element) && elementAction == 'nested') {
+ return
+ }
+
+ // check if the edit components should be displayed
+ if (!isNil(element)) {
+ return
+ }
+
+ // check if the list components should be displayed
+ if (isNil(elementId) && isNil(elementAction)) {
+ return
+ }
+
+ // fetching the data is not complete yet, or no action was invoked yet
+ return null
+}
+
+Main.propTypes = {
+ config: PropTypes.object.isRequired,
+ elements: PropTypes.object.isRequired,
+ imports: PropTypes.object.isRequired,
+ configActions: PropTypes.object.isRequired,
+ elementActions: PropTypes.object.isRequired,
+ importActions: PropTypes.object.isRequired
+}
+
+function mapStateToProps(state) {
+ return {
+ config: state.config,
+ elements: state.elements,
+ imports: state.imports
+ }
+}
+
+function mapDispatchToProps(dispatch) {
+ return {
+ configActions: bindActionCreators(configActions, dispatch),
+ elementActions: bindActionCreators(elementActions, dispatch),
+ importActions: bindActionCreators(importActions, dispatch)
+ }
+}
+
+export default connect(mapStateToProps, mapDispatchToProps)(Main)
diff --git a/rdmo/management/assets/js/containers/Pending.js b/rdmo/management/assets/js/containers/Pending.js
new file mode 100644
index 0000000000..7b421c49df
--- /dev/null
+++ b/rdmo/management/assets/js/containers/Pending.js
@@ -0,0 +1,23 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import { connect } from 'react-redux'
+
+const Pending = ({ config }) => {
+ if (config.pending) {
+ return
+ } else {
+ return null
+ }
+}
+
+Pending.propTypes = {
+ config: PropTypes.object.isRequired,
+}
+
+function mapStateToProps(state) {
+ return {
+ config: state.config,
+ }
+}
+
+export default connect(mapStateToProps)(Pending)
diff --git a/rdmo/management/assets/js/containers/Sidebar.js b/rdmo/management/assets/js/containers/Sidebar.js
new file mode 100644
index 0000000000..170c9f4deb
--- /dev/null
+++ b/rdmo/management/assets/js/containers/Sidebar.js
@@ -0,0 +1,45 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import { bindActionCreators } from 'redux'
+import { connect } from 'react-redux'
+import isEmpty from 'lodash/isEmpty'
+
+import * as elementActions from '../actions/elementActions'
+import * as importActions from '../actions/importActions'
+
+import ElementsSidebar from '../components/sidebar/ElementsSidebar'
+import ImportSidebar from '../components/sidebar/ImportSidebar'
+
+const Sidebar = ({ config, elements, imports, elementActions, importActions }) => {
+ if (isEmpty(imports.elements)) {
+ return
+ } else {
+ return
+ }
+}
+
+Sidebar.propTypes = {
+ config: PropTypes.object.isRequired,
+ elements: PropTypes.object.isRequired,
+ imports: PropTypes.object.isRequired,
+ elementActions: PropTypes.object.isRequired,
+ importActions: PropTypes.object.isRequired
+}
+
+function mapStateToProps(state) {
+ return {
+ config: state.config,
+ elements: state.elements,
+ imports: state.imports
+ }
+}
+
+function mapDispatchToProps(dispatch) {
+ return {
+ elementActions: bindActionCreators(elementActions, dispatch),
+ importActions: bindActionCreators(importActions, dispatch)
+ }
+}
+
+export default connect(mapStateToProps, mapDispatchToProps)(Sidebar)
diff --git a/rdmo/management/assets/js/factories/ConditionsFactory.js b/rdmo/management/assets/js/factories/ConditionsFactory.js
new file mode 100644
index 0000000000..28c8cbab48
--- /dev/null
+++ b/rdmo/management/assets/js/factories/ConditionsFactory.js
@@ -0,0 +1,18 @@
+class ConditionsFactory {
+
+ static createCondition(config, parent) {
+ return {
+ model: 'conditions.condition',
+ uri_prefix: config.settings.default_uri_prefix,
+ relation: 'eq',
+ optionsets: parent.optionset ? [parent.optionset.id] : [],
+ pages: parent.page ? [parent.page.id] : [],
+ questionsets: parent.questionset ? [parent.questionset.id] : [],
+ questions: parent.question ? [parent.question.id] : [],
+ tasks: parent.task ? [parent.task.id] : []
+ }
+ }
+
+}
+
+export default ConditionsFactory
diff --git a/rdmo/management/assets/js/factories/DomainFactory.js b/rdmo/management/assets/js/factories/DomainFactory.js
new file mode 100644
index 0000000000..914decb807
--- /dev/null
+++ b/rdmo/management/assets/js/factories/DomainFactory.js
@@ -0,0 +1,17 @@
+class QuestionsFactory {
+
+ static createAttribute(config, parent) {
+ return {
+ model: 'domain.attribute',
+ uri_prefix: config.settings.default_uri_prefix,
+ parent: parent.attribute ? parent.attribute.id : null,
+ conditions: parent.condition ? [parent.condition.id] : [],
+ pages: parent.page ? [parent.page.id] : [],
+ questionsets: parent.questionset ? [parent.questionset.id] : [],
+ questions: parent.question ? [parent.question.id] : []
+ }
+ }
+
+}
+
+export default QuestionsFactory
diff --git a/rdmo/management/assets/js/factories/OptionsFactory.js b/rdmo/management/assets/js/factories/OptionsFactory.js
new file mode 100644
index 0000000000..4cb4076017
--- /dev/null
+++ b/rdmo/management/assets/js/factories/OptionsFactory.js
@@ -0,0 +1,22 @@
+class OptionsFactory {
+
+ static createOptionSet(config, parent) {
+ return {
+ model: 'options.optionset',
+ uri_prefix: config.settings.default_uri_prefix,
+ questions: parent.question ? [parent.question.id] : []
+ }
+ }
+
+ static createOption(config, parent) {
+ return {
+ model: 'options.option',
+ uri_prefix: config.settings.default_uri_prefix,
+ optionsets: parent.optionset ? [parent.optionset.id] : [],
+ conditions: []
+ }
+ }
+
+}
+
+export default OptionsFactory
diff --git a/rdmo/management/assets/js/factories/QuestionsFactory.js b/rdmo/management/assets/js/factories/QuestionsFactory.js
new file mode 100644
index 0000000000..a9fe2dcd39
--- /dev/null
+++ b/rdmo/management/assets/js/factories/QuestionsFactory.js
@@ -0,0 +1,55 @@
+class QuestionsFactory {
+
+ static createCatalog(config) {
+ return {
+ model: 'questions.catalog',
+ uri_prefix: config.settings.default_uri_prefix,
+ available: true,
+ sections: []
+ }
+ }
+
+ static createSection(config, parent) {
+ return {
+ model: 'questions.section',
+ uri_prefix: config.settings.default_uri_prefix,
+ catalogs: parent.catalog ? [parent.catalog.id] : [],
+ pages: []
+ }
+ }
+
+ static createPage(config, parent) {
+ return {
+ model: 'questions.page',
+ uri_prefix: config.settings.default_uri_prefix,
+ sections: parent.section ? [parent.section.id] : [],
+ questionsets: [],
+ questions: []
+ }
+ }
+
+ static createQuestionSet(config, parent) {
+ return {
+ model: 'questions.questionset',
+ uri_prefix: config.settings.default_uri_prefix,
+ pages: parent.page ? [parent.page.id] : [],
+ parents: parent.questionset ? [parent.questionset.id] : [],
+ questionsets: [],
+ questions: []
+ }
+ }
+
+ static createQuestion(config, parent) {
+ return {
+ model: 'questions.question',
+ uri_prefix: config.settings.default_uri_prefix,
+ widget_type: 'text',
+ value_type: 'text',
+ pages: parent.page ? [parent.page.id] : [],
+ questionsets: parent.questionset ? [parent.questionset.id] : []
+ }
+ }
+
+}
+
+export default QuestionsFactory
diff --git a/rdmo/management/assets/js/factories/TasksFactory.js b/rdmo/management/assets/js/factories/TasksFactory.js
new file mode 100644
index 0000000000..b2496654cc
--- /dev/null
+++ b/rdmo/management/assets/js/factories/TasksFactory.js
@@ -0,0 +1,12 @@
+class TasksFactory {
+
+ static createTask(config) {
+ return {
+ model: 'tasks.task',
+ uri_prefix: config.settings.default_uri_prefix
+ }
+ }
+
+}
+
+export default TasksFactory
diff --git a/rdmo/management/assets/js/factories/ViewsFactory.js b/rdmo/management/assets/js/factories/ViewsFactory.js
new file mode 100644
index 0000000000..cf91d27a6f
--- /dev/null
+++ b/rdmo/management/assets/js/factories/ViewsFactory.js
@@ -0,0 +1,13 @@
+class ViewsFactory {
+
+ static createView(config) {
+ return {
+ model: 'views.view',
+ uri_prefix: config.settings.default_uri_prefix,
+ template: '{% load view_tags %}\n'
+ }
+ }
+
+}
+
+export default ViewsFactory
diff --git a/rdmo/management/assets/js/hooks/useBool.js b/rdmo/management/assets/js/hooks/useBool.js
new file mode 100644
index 0000000000..f934df53f7
--- /dev/null
+++ b/rdmo/management/assets/js/hooks/useBool.js
@@ -0,0 +1,10 @@
+import { useState } from 'react'
+
+const useBool = (initial) => {
+ const [value, setValue] = useState(initial)
+ const toggleValue = () => setValue(!value)
+
+ return [value, toggleValue]
+}
+
+export default useBool
diff --git a/rdmo/management/assets/js/hooks/useDeleteModal.js b/rdmo/management/assets/js/hooks/useDeleteModal.js
new file mode 100644
index 0000000000..1dcc715444
--- /dev/null
+++ b/rdmo/management/assets/js/hooks/useDeleteModal.js
@@ -0,0 +1,11 @@
+import { useState } from 'react'
+
+const useDeleteModal = () => {
+ const [show, setShow] = useState(false)
+ const open = () => setShow(true)
+ const close = () => setShow(false)
+
+ return [show, open, close]
+}
+
+export default useDeleteModal
diff --git a/rdmo/management/assets/js/hooks/useScrollEffect.js b/rdmo/management/assets/js/hooks/useScrollEffect.js
new file mode 100644
index 0000000000..87fad8e611
--- /dev/null
+++ b/rdmo/management/assets/js/hooks/useScrollEffect.js
@@ -0,0 +1,31 @@
+import { useEffect } from 'react'
+import isNil from 'lodash/isNil'
+
+const useScrollEffect = (elementType, elementId, elementAction) => {
+ useEffect(() => {
+ let lsKey = `rdmo.management.scroll.${elementType}`
+ if (!isNil(elementId)) {
+ lsKey += `.${elementId}`
+ }
+ if (!isNil(elementAction)) {
+ lsKey += `.${elementAction}`
+ }
+ const lsValue = localStorage.getItem(lsKey, 0)
+
+ // scroll to the right position
+ setTimeout(() => window.scrollTo(0, lsValue), 0)
+
+ // add a event handler to store the scroll position
+ const handleScroll = () => {
+ const lsValue = window.pageYOffset
+ localStorage.setItem(lsKey, lsValue)
+ }
+ window.addEventListener('scroll', handleScroll)
+ return () => {
+ window.removeEventListener('scroll', handleScroll)
+ }
+
+ }, [elementType, elementId, elementAction])
+}
+
+export default useScrollEffect
diff --git a/rdmo/management/assets/js/management.js b/rdmo/management/assets/js/management.js
new file mode 100644
index 0000000000..55b37879ed
--- /dev/null
+++ b/rdmo/management/assets/js/management.js
@@ -0,0 +1,34 @@
+import React from 'react'
+import { createRoot } from 'react-dom/client'
+import { Provider } from 'react-redux'
+
+import configureStore from './store/configureStore'
+
+import { DndProvider } from 'react-dnd'
+import { HTML5Backend } from 'react-dnd-html5-backend'
+
+import Main from './containers/Main'
+import Sidebar from './containers/Sidebar'
+import Pending from './containers/Pending'
+
+const store = configureStore()
+
+createRoot(document.getElementById('main')).render(
+
+
+
+
+
+)
+
+createRoot(document.getElementById('sidebar')).render(
+
+
+
+)
+
+createRoot(document.getElementById('pending')).render(
+
+
+
+)
diff --git a/rdmo/management/assets/js/reducers/configReducer.js b/rdmo/management/assets/js/reducers/configReducer.js
new file mode 100644
index 0000000000..12f8196864
--- /dev/null
+++ b/rdmo/management/assets/js/reducers/configReducer.js
@@ -0,0 +1,49 @@
+import set from 'lodash/set'
+
+const initialState = {
+ baseUrl: '/management/',
+ settings: {},
+ filter: {},
+ display: {}
+}
+
+export default function configReducer(state = initialState, action) {
+ let newState
+ switch(action.type) {
+ case 'config/updateConfig':
+ newState = {...state}
+
+ set(newState, action.path, action.value)
+ localStorage.setItem(`rdmo.management.config.${action.path}`, action.value)
+
+ return newState
+ case 'config/fetchConfigSuccess':
+ return {...state, ...action.config, currentSite: action.config.sites.find(site => site.current)}
+ case 'elements/fetchConfigInit':
+ case 'elements/fetchElementsInit':
+ case 'elements/fetchElementInit':
+ case 'elements/storeElementInit':
+ case 'elements/createElementInit':
+ case 'elements/deleteElementInit':
+ case 'import/uploadFileInit':
+ case 'import/importElementsInit':
+ return {...state, pending: true }
+ case 'elements/fetchElementsSuccess':
+ case 'elements/fetchElementsError':
+ case 'elements/fetchElementSuccess':
+ case 'elements/fetchElementError':
+ case 'elements/storeElementSuccess':
+ case 'elements/storeElementError':
+ case 'elements/createElementSuccess':
+ case 'elements/createElementError':
+ case 'elements/deleteElementSuccess':
+ case 'elements/deleteElementError':
+ case 'import/uploadFileSuccess':
+ case 'import/uploadFileError':
+ case 'import/importElementsSuccess':
+ case 'import/importElementsError':
+ return {...state, pending: false }
+ default:
+ return state
+ }
+}
diff --git a/rdmo/management/assets/js/reducers/elementsReducer.js b/rdmo/management/assets/js/reducers/elementsReducer.js
new file mode 100644
index 0000000000..02082a6f25
--- /dev/null
+++ b/rdmo/management/assets/js/reducers/elementsReducer.js
@@ -0,0 +1,116 @@
+import isNil from 'lodash/isNil'
+
+import { updateElement, resetElement } from '../utils/elements'
+
+const initialState = {
+ elementType: null,
+ elementId: null,
+ element: null,
+ errors: {},
+ conditions: [],
+ attributes: [],
+ optionsets: [],
+ options: [],
+ catalogs: [],
+ sections: [],
+ pages: [],
+ questionsets: [],
+ questions: [],
+ widgetTypes: [],
+ valueTypes: [],
+ tasks: [],
+ views: []
+}
+
+export default function elementsReducer(state = initialState, action) {
+ switch(action.type) {
+ // fetch elements
+ case 'elements/fetchElementsInit':
+ return {...state,
+ [action.elementType]: [],
+ elementType: action.elementType,
+ elementId: null,
+ elementAction: null,
+ element: null,
+ errors: {}
+ }
+ case 'elements/fetchElementsSuccess':
+ return {...state, ...action.elements}
+ case 'elements/fetchElementsError':
+ return {...state, errors: action.error.errors}
+
+ // fetch element
+ case 'elements/fetchElementInit':
+ return {...state,
+ elementType: action.elementType,
+ elementId: action.elementId,
+ elementAction: action.elementAction,
+ element: null,
+ errors: {}
+ }
+ case 'elements/fetchElementSuccess':
+ return {...state, ...action.elements}
+ case 'elements/fetchElementError':
+ return {...state, errors: action.error.errors}
+
+ // store element
+ case 'elements/storeElementInit':
+ if (isNil(state.element)) {
+ return state
+ } else {
+ // resetElements will apply the new order, when storing the element after drag and drop
+ return {...state, element: resetElement(state.element)}
+ }
+ case 'elements/storeElementError':
+ if (isNil(state.element) || state.elementAction == 'nested') {
+ // create a fake element with just the id and the model and the error for updateElement works,
+ // but the element won't get updated in the view
+ action.element = {id: action.element.id, model: action.element.model, errors: action.error.errors}
+ } else {
+ action.element.errors = action.error.errors
+ }
+ // there is not break here on purpose
+ case 'elements/storeElementSuccess': // eslint-disable-line no-fallthrough
+ if (isNil(state.element)) {
+ return {...state,
+ [state.elementType]: state[state.elementType].map(element => updateElement(element, action.element))
+ }
+ } else if (state.elementAction == 'nested') {
+ return {...state, element: updateElement(state.element, action.element)}
+ } else {
+ return {...state, element: action.element}
+ }
+
+ // create element
+ case 'elements/createElementInit':
+ return {
+ ...state,
+ elementType: action.elementType,
+ elementId: null,
+ elementAction: 'create',
+ element: null,
+ errors: {}
+ }
+ case 'elements/createElementSuccess':
+ return {...state, ...action.elements}
+ case 'elements/createElementError':
+ return {...state, errors: action.error.errors}
+
+ // delete element
+ case 'elements/deleteElementInit':
+ return state
+
+ case 'elements/deleteElementSuccess':
+ return state
+
+ case 'elements/deleteElementError':
+ return {...state, errors: action.error.errors}
+
+ // update element
+ case 'elements/updateElement':
+ return {...state, element: {...action.element, ...action.values}}
+
+ default:
+ return state
+ }
+}
diff --git a/rdmo/management/assets/js/reducers/importsReducer.js b/rdmo/management/assets/js/reducers/importsReducer.js
new file mode 100644
index 0000000000..5b6d115fa6
--- /dev/null
+++ b/rdmo/management/assets/js/reducers/importsReducer.js
@@ -0,0 +1,91 @@
+import isArray from 'lodash/isArray'
+import isNil from 'lodash/isNil'
+import isUndefined from 'lodash/isUndefined'
+
+import { buildUri } from '../utils/elements'
+
+
+const initialState = {
+ elements: [],
+ errors: [],
+ success: false
+}
+
+export default function importsReducer(state = initialState, action) {
+ let index, elements, elementsMap = {}
+
+ switch(action.type) {
+ // upload file
+ case 'import/uploadFileInit':
+ case 'elements/fetchElementsInit':
+ case 'elements/fetchElementInit':
+ return {...state, elements: [], errors: [], success: false}
+ case 'import/uploadFileSuccess':
+ return {...state, elements: action.elements.map(element => {
+ if (['questions.catalogs', 'tasks.task', 'views.view'].includes(element.model)) {
+ element.available = true
+ }
+ element.show = false
+ element.import = true
+ return element
+ })}
+ case 'import/uploadFileError':
+ return {...state, errors: action.error.errors}
+
+ // import elements
+ case 'import/importElementsSuccess':
+ return {...state, elements: action.elements, success: true}
+ case 'import/importElementsError':
+ return {...state, errors: action.error.errors}
+
+ // update element
+ case 'import/updateElement':
+ index = state.elements.findIndex(element => element == action.element)
+ if (index > -1) {
+ const elements = [...state.elements]
+ elements[index] = {...elements[index], ...action.values}
+ elements[index].uri = buildUri(elements[index])
+ return {...state, elements}
+ } else {
+ return state
+ }
+ case 'import/selectElements':
+ return {...state, elements: state.elements.map(element => {
+ return {...element, import: action.value}
+ })}
+ case 'import/updateUriPrefix':
+ elements = state.elements.map(element => {
+ element.uri_prefix = action.uriPrefix
+
+ // compute a new uri and store it in the elementMap
+ element.uri = elementsMap[element.uri] = buildUri(element)
+
+ return element
+ })
+
+ // loop over element fields and also update sub and sub-sub uris,
+ // which are in the map, i.e. are imported as well
+ elements.forEach(element => {
+ Object.keys(element).forEach(key => {
+ const subelement = element[key]
+ if (!isNil(subelement)) {
+ if (isArray(subelement)) {
+ subelement.forEach(subsubelement => {
+ if (!isUndefined(elementsMap[subsubelement.uri])) {
+ subsubelement.uri = elementsMap[subsubelement.uri]
+ }
+ })
+ } else if (!isUndefined(elementsMap[subelement.uri])) {
+ subelement.uri = elementsMap[subelement.uri]
+ }
+ }
+ })
+ })
+
+ return {...state, elements}
+ case 'import/resetElements':
+ return {...state, elements: []}
+ default:
+ return state
+ }
+}
diff --git a/rdmo/management/assets/js/reducers/rootReducer.js b/rdmo/management/assets/js/reducers/rootReducer.js
new file mode 100644
index 0000000000..46226671d8
--- /dev/null
+++ b/rdmo/management/assets/js/reducers/rootReducer.js
@@ -0,0 +1,13 @@
+import { combineReducers } from 'redux'
+
+import configReducer from './configReducer'
+import elementsReducer from './elementsReducer'
+import importsReducer from './importsReducer'
+
+const rootReducer = combineReducers({
+ config: configReducer,
+ elements: elementsReducer,
+ imports: importsReducer
+})
+
+export default rootReducer
diff --git a/rdmo/management/assets/js/store/configureStore.js b/rdmo/management/assets/js/store/configureStore.js
new file mode 100644
index 0000000000..e4720e7d4c
--- /dev/null
+++ b/rdmo/management/assets/js/store/configureStore.js
@@ -0,0 +1,81 @@
+import { applyMiddleware, createStore } from 'redux'
+import thunk from 'redux-thunk'
+import isNil from 'lodash/isNil'
+
+import { parseLocation } from '../utils/location'
+
+import rootReducer from '../reducers/rootReducer'
+
+import * as configActions from '../actions/configActions'
+import * as elementActions from '../actions/elementActions'
+
+export default function configureStore() {
+ const middlewares = [thunk]
+
+ if (process.env.NODE_ENV === 'development') {
+ const { logger } = require('redux-logger')
+ middlewares.push(logger)
+ }
+
+ const store = createStore(
+ rootReducer,
+ applyMiddleware(...middlewares)
+ )
+
+ // load: fetch some of the data which does not change
+ const fetchConfig = () => store.dispatch(configActions.fetchConfig())
+
+ // load: restore the config from the local storage
+ const updateConfigFromLocalStorage = () => {
+ const ls = {...localStorage}
+
+ Object.entries(ls).forEach(([lsPath, lsValue]) => {
+ const path = lsPath.replace('rdmo.management.config.', '')
+ let value
+ switch(lsValue) {
+ case 'true':
+ value = true
+ break
+ case 'false':
+ value = false
+ break
+ default:
+ value = lsValue
+ }
+ store.dispatch(configActions.updateConfig(path, value))
+ })
+ }
+
+ // load, popstate: fetch elements depending on the location
+ const fetchElementsFromLocation = () => {
+ const baseUrl = store.getState().config.baseUrl
+ const pathname = window.location.pathname
+ let { elementType, elementId, elementAction } = parseLocation(baseUrl, pathname)
+
+ if (isNil(elementType)) {
+ elementType = 'catalogs'
+ }
+ if (isNil(elementId)) {
+ if (isNil(elementAction)) {
+ return store.dispatch(elementActions.fetchElements(elementType))
+ } else {
+ return store.dispatch(elementActions.createElement(elementType))
+ }
+ } else {
+ return store.dispatch(elementActions.fetchElement(elementType, elementId, elementAction))
+ }
+ }
+
+ // this event is triggered when the page first loads
+ window.addEventListener('load', () => {
+ updateConfigFromLocalStorage()
+ fetchConfig().then(() => fetchElementsFromLocation())
+ })
+
+ // this event is triggered when when the forward/back buttons are used
+ window.addEventListener('popstate', () => {
+ fetchElementsFromLocation()
+ })
+
+ return store
+}
diff --git a/rdmo/management/assets/js/utils/elements.js b/rdmo/management/assets/js/utils/elements.js
new file mode 100644
index 0000000000..8c006c5c48
--- /dev/null
+++ b/rdmo/management/assets/js/utils/elements.js
@@ -0,0 +1,178 @@
+import isNil from 'lodash/isNil'
+import isUndefined from 'lodash/isUndefined'
+
+import { elementTypes, elementModules } from '../constants/elements'
+
+const compareElements = (element1, element2) => {
+ return element1.model == element2.model && element1.id == element2.id
+}
+
+const updateElement = (element, actionElement) => {
+ if (compareElements(element, actionElement)) {
+ return {...element, ...actionElement}
+ } else if (!isUndefined(element.elements)) {
+ return {...element, elements: element.elements.map(e => updateElement(e, actionElement))}
+ } else {
+ return element
+ }
+}
+
+const resetElement = (element) => {
+ delete element.errors
+
+ if (!isUndefined(element.elements)) {
+ element.elements.forEach(e => resetElement(e))
+ }
+
+ return element
+}
+
+function canMoveElement(dragElement, dropElement) {
+ if (compareElements(dragElement, dropElement)) {
+ // an element cannot be moved on itself
+ return false
+ } else if (isUndefined(dragElement.elements)) {
+ // if dragElement has no elements, the element can be moved
+ return true
+ } else {
+ // check recursively if one of the descendants of dragElement is dropElement
+ return dragElement.elements.reduce((acc, el) => {
+ return acc && canMoveElement(el, dropElement)
+ }, true)
+ }
+}
+
+function moveElement(element, dragElement, dropElement, mode) {
+ const dragParent = removeElement(element, dragElement)
+
+ let dropParent
+ switch (mode) {
+ case 'before':
+ dropParent = insertBeforeElement(element, dragElement, dropElement)
+ break
+ case 'after':
+ dropParent = insertAfterElement(element, dragElement, dropElement)
+ break
+ default:
+ dropParent = insertInElement(dragElement, dropElement)
+ break
+ }
+
+ updateElementElements(dragParent)
+ if (compareElements(dragParent, dropParent)) {
+ dropParent = null
+ } else {
+ updateElementElements(dropParent)
+ }
+
+ return { dragParent, dropParent }
+}
+
+function removeElement(element, dragElement) {
+ if (isUndefined(element.elements)) return null
+
+ const dragIndex = element.elements.findIndex(el => compareElements(el, dragElement))
+ if (dragIndex > -1) {
+ // remove the element
+ element.elements.splice(dragIndex, 1)
+ return element
+ } else {
+ // call the function recursively and return the first element which is not null
+ return element.elements.map(el => removeElement(el, dragElement))
+ .find(el => !isNil(el))
+ }
+}
+
+function insertBeforeElement(element, dragElement, dropElement) {
+ if (isUndefined(element.elements)) return null
+
+ const dropIndex = element.elements.findIndex(el => compareElements(el, dropElement))
+ if (dropIndex > -1) {
+ // insert the dragElement before the dropElement
+ element.elements.splice(dropIndex, 0, dragElement)
+ return element
+ } else {
+ // call the function recursively and return the first element which is not null
+ return element.elements.map(el => insertBeforeElement(el, dragElement, dropElement))
+ .find(el => !isNil(el))
+ }
+}
+
+function insertAfterElement(element, dragElement, dropElement) {
+ if (isUndefined(element.elements)) return null
+
+ const dropIndex = element.elements.findIndex(el => compareElements(el, dropElement))
+ if (dropIndex > -1) {
+ // insert the dragElement after the dropElement
+ element.elements.splice(dropIndex + 1, 0, dragElement)
+ return element
+ } else {
+ // call the function recursively and return the first element which is not null
+ return element.elements.map(el => insertAfterElement(el, dragElement, dropElement))
+ .find(el => !isNil(el))
+ }
+}
+
+function insertInElement(dragElement, dropElement) {
+ const dropParent = dropElement
+ dropParent.elements = [dragElement, ...dropParent.elements]
+ return dropParent
+}
+
+function updateElementElements(element) {
+ switch(element.model) {
+ case 'questions.catalog':
+ element.sections = element.elements.map((el, index) => ({ section: el.id, order: index }))
+ break
+ case 'questions.section':
+ element.pages = element.elements.map((el, index) => ({ page: el.id, order: index }))
+ break
+ case 'questions.page':
+ case 'questions.questionset':
+ element.questions = element.elements.reduce((questions, el, index) => {
+ if (el.model == 'questions.question') {
+ questions.push({ question: el.id, order: index })
+ }
+ return questions
+ }, [])
+ element.questionsets = element.elements.reduce((questionsets, el, index) => {
+ if (el.model == 'questions.questionset') {
+ questionsets.push({ questionset: el.id, order: index })
+ }
+ return questionsets
+ }, [])
+ break
+ }
+}
+
+function findDescendants(element, elementType) {
+ if (elementType == elementTypes[element.model]) {
+ return [element]
+ } else if (!isUndefined(element.elements)) {
+ return element.elements.reduce((agg, cur) => {
+ const descendants = findDescendants(cur, elementType)
+ if (!isNil(descendants)) {
+ agg = agg.concat(descendants)
+ }
+ return agg
+ }, [])
+ } else {
+ return null
+ }
+}
+
+const buildUri = (element) => {
+ let uri = element.uri_prefix + '/' + elementModules[element.model] + '/'
+
+ if (!isUndefined(element.uri_path)) {
+ uri += element.uri_path
+ } else if (!isUndefined(element.path)) {
+ uri += element.path
+ } else {
+ uri += element.key
+ }
+
+ return uri
+}
+
+export { compareElements, updateElement, resetElement, canMoveElement, moveElement, findDescendants, buildUri }
diff --git a/rdmo/management/assets/js/utils/filter.js b/rdmo/management/assets/js/utils/filter.js
new file mode 100644
index 0000000000..25ecf7d306
--- /dev/null
+++ b/rdmo/management/assets/js/utils/filter.js
@@ -0,0 +1,64 @@
+import isEmpty from 'lodash/isEmpty'
+import isUndefined from 'lodash/isUndefined'
+import get from 'lodash/get'
+import toNumber from 'lodash/toNumber'
+
+const filterElement = (config, filter, filterSites, filterEditors, element) => {
+ const strings = get(config, `filter.${filter}.search`, '').trim().split(' '),
+ uriPrefix = get(config, `filter.${filter}.uri_prefix`, ''),
+ site = get(config, 'filter.sites', ''),
+ editor = get(config, 'filter.editors', '')
+ return (
+ strings.some(search => filterSearch(search, element)) &&
+ filterUriPrefix(uriPrefix, element) &&
+ (!filterSites || filterSite(site, element)) &&
+ (!filterEditors || filterEditor(editor, element))
+ )
+}
+
+const filterSearch = (search, element) => {
+ return (
+ isEmpty(search) ||
+ element.uri.includes(search) ||
+ (!isUndefined(element.title) && element.title.includes(search)) ||
+ (!isUndefined(element.text) && element.text.includes(search))
+ )
+}
+
+const filterUriPrefix = (uriPrefix, element) => {
+ return isEmpty(uriPrefix) || element.uri.startsWith(uriPrefix)
+}
+
+const filterSite = (site, element) => {
+ return isEmpty(site) || element.sites.includes(toNumber(site))
+}
+
+const filterEditor = (editor, element) => {
+ return isEmpty(editor) || element.editors.includes(toNumber(editor))
+}
+
+const getUriPrefixes = (elements) => {
+ return elements.reduce((acc, cur) => {
+ if (!acc.includes(cur.uri_prefix)) {
+ acc.push(cur.uri_prefix)
+ }
+ return acc
+ }, [])
+}
+
+const getExportParams = (filter) => {
+ const exportParams = new URLSearchParams()
+
+ if (!isUndefined(filter)) {
+ for (const key in filter) {
+ const value = filter[key]
+ if (!isEmpty(value)) {
+ exportParams.append(key, value)
+ }
+ }
+ }
+
+ return exportParams.toString()
+}
+
+export { filterElement, getUriPrefixes, getExportParams }
diff --git a/rdmo/management/assets/js/utils/forms.js b/rdmo/management/assets/js/utils/forms.js
new file mode 100644
index 0000000000..a1670e6868
--- /dev/null
+++ b/rdmo/management/assets/js/utils/forms.js
@@ -0,0 +1,25 @@
+const getId = (element, field) => {
+ if (element.model) {
+ return `${element.model.replace('.', '-')}-${field}`
+ } else {
+ return field
+ }
+}
+
+const getLabel = (config, element, field) => {
+ if (config.meta && element.model && config.meta[element.model] && config.meta[element.model][field]) {
+ return config.meta[element.model][field].verbose_name
+ } else {
+ return ''
+ }
+}
+
+const getHelp = (config, element, field) => {
+ if (config.meta && element.model && config.meta[element.model] && config.meta[element.model][field]) {
+ return config.meta[element.model][field].help_text
+ } else {
+ return ''
+ }
+}
+
+export { getId, getLabel, getHelp }
diff --git a/rdmo/management/assets/js/utils/location.js b/rdmo/management/assets/js/utils/location.js
new file mode 100644
index 0000000000..5e6d013c0a
--- /dev/null
+++ b/rdmo/management/assets/js/utils/location.js
@@ -0,0 +1,54 @@
+import trim from 'lodash/trim'
+import isUndefined from 'lodash/isUndefined'
+import isNil from 'lodash/isNil'
+
+import { elementTypes } from '../constants/elements'
+
+const parseLocation = (basePath, pathname) => {
+ const path = pathname.replace(basePath, '')
+ const tokens = trim(path, '/').split('/')
+
+ let elementType = null,
+ elementId = null,
+ elementAction = null
+
+ if (!isUndefined(tokens[0]) && Object.values(elementTypes).includes(tokens[0])) {
+ elementType = tokens[0]
+
+ if (!isUndefined(tokens[1])) {
+ if (/^\d+$/.test(tokens[1])) {
+ elementId = tokens[1]
+
+ if (!isUndefined(tokens[2]) && /^[a-z]+$/.test(tokens[2])) {
+ elementAction = tokens[2]
+ }
+
+ } else if (/^[a-z]+$/.test(tokens[1])) {
+ elementAction = tokens[1]
+ }
+ }
+ }
+
+ return { elementType , elementId, elementAction }
+}
+
+const updateLocation = (basePath, elementType, elementId, elementAction) => {
+ const pathname = buildPath(basePath, elementType, elementId, elementAction)
+ if (pathname != window.location.pathname) {
+ history.pushState(null, null, pathname)
+ }
+}
+
+const buildPath = (basePath, ...args) => {
+ let path = basePath
+
+ args.forEach(arg => {
+ if (!isNil(arg)) {
+ path += arg + '/'
+ }
+ })
+
+ return path
+}
+
+export { parseLocation, updateLocation, buildPath }
diff --git a/rdmo/management/assets/scss/management.scss b/rdmo/management/assets/scss/management.scss
new file mode 100644
index 0000000000..be8787c4ea
--- /dev/null
+++ b/rdmo/management/assets/scss/management.scss
@@ -0,0 +1,302 @@
+$icon-font-path: "bootstrap-sass/assets/fonts/bootstrap/";
+@import '~bootstrap-sass';
+@import '~font-awesome/css/font-awesome.css';
+@import 'rdmo/core/assets/scss/variables';
+
+a.disabled {
+ cursor: not-allowed;
+}
+
+.flip {
+ transform: rotate(180deg) scaleX(-1);
+}
+
+.w-100 {
+ width: 100%;
+}
+.mt-0 {
+ margin-top: 0;
+}
+.mt-5 {
+ margin-top: 5px;
+}
+.mt-10 {
+ margin-top: 10px;
+}
+.mt-20 {
+ margin-top: 20px;
+}
+.mr-0 {
+ margin-right: 0;
+}
+.mr-5 {
+ margin-right: 5px;
+}
+.mr-10 {
+ margin-right: 10px;
+}
+.mr-20 {
+ margin-right: 20px;
+}
+.mb-0 {
+ margin-bottom: 0;
+}
+.mb-5 {
+ margin-bottom: 5px;
+}
+.mb-10 {
+ margin-bottom: 10px;
+}
+.mb-20 {
+ margin-bottom: 20px;
+}
+.ml-0 {
+ margin-left: 0;
+}
+.ml-5 {
+ margin-left: 5px;
+}
+.ml-10 {
+ margin-left: 10px;
+}
+.ml-20 {
+ margin-left: 20px;
+}
+
+.pt-0 {
+ padding-top: 0;
+}
+.pt-10 {
+ padding-top: 10px;
+}
+.pt-20 {
+ padding-top: 20px;
+}
+.pr-0 {
+ padding-right: 0;
+}
+.pr-10 {
+ padding-right: 10px;
+}
+.pr-20 {
+ padding-right: 20px;
+}
+.pb-0 {
+ padding-bottom: 0;
+}
+.pb-10 {
+ padding-bottom: 10px;
+}
+.pb-20 {
+ padding-bottom: 20px;
+}
+.pl-0 {
+ padding-left: 0;
+}
+.pl-10 {
+ padding-left: 10px;
+}
+.pl-20 {
+ padding-left: 20px;
+}
+
+.form-group {
+ .react-select__control {
+ min-height: 34px;
+ }
+ .react-select__value-container {
+ padding: 0 14px;
+ }
+ .react-select__input-container {
+ margin: 0;
+ padding: 0;
+ }
+ .react-select__indicator {
+ padding: 6px;
+ }
+ .react-select__option {
+ padding: 0 14px;
+ }
+}
+
+.management {
+ .panel {
+ p {
+ margin-bottom: 5px;
+ }
+ p:last-child {
+ margin-bottom: 0;
+ }
+
+
+ .panel-body {
+ > :last-child {
+ margin-bottom: 0;
+ }
+ }
+
+ .panel-border {
+ border-bottom: 1px solid #ddd;
+ }
+
+ &.panel-nested {
+ margin-bottom: 10px;
+ }
+
+ &.panel-edit {
+ .panel-heading,
+ .panel-footer {
+ min-height: 41px;
+ }
+ }
+
+ &.panel-import {
+ .checkbox {
+ position: static;
+ margin: 0;
+
+ label {
+ font-weight: normal;
+ }
+ }
+ }
+ }
+ .pull-right {
+ .element-link,
+ .element-btn-link,
+ .element-button {
+ margin-left: 4px;
+
+ &.disabled,
+ &:disabled {
+ color: #ccc !important;
+ cursor: not-allowed !important;
+ }
+ }
+ .element-btn-link {
+ padding: 0;
+ border: 0;
+ text-decoration: none;
+ }
+ .element-button {
+ &:first-child {
+ margin-left: 8px;
+ }
+ }
+ }
+ .checkboxes {
+ .checkbox {
+ display: inline-block;
+ margin-right: 20px;
+ margin-bottom: 0;
+ }
+ code {
+ user-select: none;
+ }
+ }
+ .nav-tabs {
+ border-bottom: none;
+ }
+ .tab-content {
+ margin-bottom: 15px;
+ }
+ .tab-pane {
+ padding: 15px 15px 0 15px;
+ border: 1px solid #ddd;
+ border-radius: 4px;
+
+ &:first-child {
+ border-top-left-radius: 0;
+ }
+
+ }
+
+ .drag {
+ color: $link-color;
+ cursor: grab;
+ }
+
+ .drop {
+ display: none;
+ margin-top: -10px;
+ padding: 4px 0;
+
+ &::after {
+ content: ' ';
+ height: 2px;
+ display: block;
+ background-color: transparent;
+ }
+
+ &.over {
+ &::after {
+ background-color: $link-color;
+ }
+ }
+ }
+ .drop-in {
+ &.over .panel {
+ border: 2px solid $link-color;
+
+ .panel-heading {
+ padding: 9px 14px;
+ }
+ }
+ }
+}
+
+.list-group-item {
+ .indent {
+ padding-left: 20px;
+ }
+}
+
+.codemirror.form-control {
+ height: auto;
+ padding: 0;
+
+ .cm-editor {
+ border-radius: 4px;
+ }
+ .cm-gutter {
+ background-color: white;
+ }
+ .cm-focused {
+ // copy the .form-control:focus from bootstrap
+ border-color: #66afe9;
+ outline: 0;
+ -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 8px rgba(102, 175, 233, 0.6);
+ box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 8px rgba(102, 175, 233, 0.6);
+ }
+}
+
+.select-item-options,
+.multi-select-item-options,
+.ordered-multi-select-item-options {
+ float: right;
+ display: flex;
+ gap: 4px;
+ padding: 10px 0;
+
+ .disabled {
+ color: #ccc !important;
+ cursor: not-allowed !important;
+ }
+}
+
+.ordered-multi-select-item {
+ margin-bottom: 10px;
+
+ .ordered-multi-select-item-select {
+ height: 34px;
+ }
+}
+
+.import-buttons {
+ display: flex;
+ gap: 5px;
+
+ .btn:first-child {
+ flex: 1;
+ }
+}
diff --git a/rdmo/management/imports.py b/rdmo/management/imports.py
index 6ae894a5ad..e47a3c7001 100644
--- a/rdmo/management/imports.py
+++ b/rdmo/management/imports.py
@@ -1,114 +1,66 @@
+from collections import defaultdict
+
from rdmo.conditions.imports import import_condition
-from rdmo.core.constants import PERMISSIONS
-from rdmo.domain.imports import fetch_attribute_parents, import_attribute
-from rdmo.options.imports import (fetch_option_parents, import_option,
- import_optionset)
-from rdmo.questions.imports import (fetch_question_parents,
- fetch_questionset_parents,
- fetch_section_parents, import_catalog,
- import_question, import_questionset,
- import_section)
+from rdmo.domain.imports import import_attribute
+from rdmo.options.imports import import_option, import_optionset
+from rdmo.questions.imports import import_catalog, import_page, import_question, import_questionset, import_section
from rdmo.tasks.imports import import_task
from rdmo.views.imports import import_view
-def check_permissions(elements, user):
- element_types = set([element.get('type') for element in elements])
-
- permissions = []
- for element_type in element_types:
- permissions += PERMISSIONS[element_type]
-
- return user.has_perms(permissions)
-
-
-def get_parent_uri(element_uri, parent_uri, parents, uris):
- if element_uri in parents and parents[element_uri]:
- # parent was explicitely selected
- return parents[element_uri]
- elif parent_uri in uris:
- # parent uri was changed during the import
- return uris[parent_uri]
- else:
- # needs to be False, to use parent uri in file
- return False
-
-
-def import_elements(elements, parents={}, save={}):
- instances = []
- uris = {}
-
+def import_elements(elements, save=True, user=None):
for element in elements:
- element_type = element.get('type')
- save_element = save.get(element.get('uri'))
-
- # step 1: get create model for elements
- if element_type == 'condition':
- instance = import_condition(element, save=save_element)
-
- elif element_type == 'attribute':
- parent_uri = get_parent_uri(element.get('uri'), element.get('parent'), parents, uris)
- instance = import_attribute(element, parent_uri=parent_uri, save=save_element)
-
- elif element_type == 'optionset':
- instance = import_optionset(element, save=save_element)
-
- elif element_type == 'option':
- optionset_uri = get_parent_uri(element.get('uri'), element.get('optionset'), parents, uris)
- instance = import_option(element, optionset_uri=optionset_uri, save=save_element)
+ model = element.get('model')
- elif element_type == 'catalog':
- instance = import_catalog(element, save=save_element)
+ element.update({
+ 'warnings': defaultdict(list),
+ 'errors': [],
+ 'created': False,
+ 'updated': False
+ })
- elif element_type == 'section':
- catalog_uri = get_parent_uri(element.get('uri'), element.get('catalog'), parents, uris)
- instance = import_section(element, catalog_uri=catalog_uri, save=save_element)
+ if model == 'conditions.condition':
+ import_condition(element, save, user)
- elif element_type == 'questionset':
- section_uri = get_parent_uri(element.get('uri'), element.get('section'), parents, uris)
- questionset_uri = get_parent_uri(element.get('uri'), element.get('questionset'), {}, uris)
- instance = import_questionset(element, section_uri=section_uri, questionset_uri=questionset_uri, save=save_element)
+ elif model == 'domain.attribute':
+ import_attribute(element, save, user)
- elif element_type == 'question':
- questionset_uri = get_parent_uri(element.get('uri'), element.get('questionset'), parents, uris)
- instance = import_question(element, questionset_uri=questionset_uri, save=save_element)
+ elif model == 'options.optionset':
+ import_optionset(element, save, user)
- elif element_type == 'task':
- instance = import_task(element, save=save_element)
+ elif model == 'options.option':
+ import_option(element, save, user)
- elif element_type == 'view':
- instance = import_view(element, save=save_element)
+ elif model == 'questions.catalog':
+ import_catalog(element, save, user)
- else:
- instance = None
+ elif model == 'questions.section':
+ import_section(element, save, user)
- # step 2: fetch available parents
- if instance:
- if not save:
- if element_type == 'attribute':
- instance.parents = fetch_attribute_parents(instances)
+ elif model == 'questions.page':
+ import_page(element, save, user)
- elif element.get('type') == 'option':
- instance.parents = fetch_option_parents(instances)
+ elif model == 'questions.questionset':
+ import_questionset(element, save, user)
- elif element.get('type') == 'section':
- instance.parents = fetch_section_parents(instances)
+ elif model == 'questions.question':
+ import_question(element, save, user)
- elif element.get('type') == 'questionset':
- instance.parents = fetch_questionset_parents(instances)
+ elif model == 'tasks.task':
+ import_task(element, save, user)
- elif element.get('type') == 'question':
- instance.parents = fetch_question_parents(instances)
+ elif model == 'views.view':
+ import_view(element, save, user)
- # check if a missing element was already imported
- for uri in instance.missing:
- if uri in [instance.uri for instance in instances]:
- instance.missing[uri]['in_file'] = True
+ element = filter_warnings(element, elements)
- # append the instance to the list of instances
- instances.append(instance)
- if element.get('uri') != instance.uri:
- uris[element.get('uri')] = instance.uri
+def filter_warnings(element, elements):
+ # remove warnings regarding elements which are in the elements list
+ warnings = []
+ for uri, messages in element['warnings'].items():
+ if not next(filter(lambda e: e['uri'] == uri, elements), None):
+ warnings += messages
- return instances
+ element['warnings'] = warnings
+ return element
diff --git a/rdmo/management/management/commands/import.py b/rdmo/management/management/commands/import.py
index 8a4aff4804..3438164220 100644
--- a/rdmo/management/management/commands/import.py
+++ b/rdmo/management/management/commands/import.py
@@ -3,7 +3,7 @@
from django.core.management.base import BaseCommand, CommandError
from django.utils.translation import gettext_lazy as _
-from rdmo.core.xml import flat_xml_to_elements, read_xml_file
+from rdmo.core.xml import convert_elements, flat_xml_to_elements, order_elements, read_xml_file
from rdmo.management.imports import import_elements
logger = logging.getLogger(__name__)
@@ -21,6 +21,10 @@ def handle(self, *args, **options):
elif root.tag != 'rdmo':
raise CommandError(_('This XML does not contain RDMO content.'))
else:
+ version = root.attrib.get('version')
elements = flat_xml_to_elements(root)
- save = {element.get('uri'): True for element in elements}
- import_elements(elements, save=save)
+ elements = convert_elements(elements, version)
+ elements = order_elements(elements)
+ elements = elements.values()
+
+ import_elements(elements)
diff --git a/rdmo/management/rules.py b/rdmo/management/rules.py
new file mode 100644
index 0000000000..3014306cfe
--- /dev/null
+++ b/rdmo/management/rules.py
@@ -0,0 +1,206 @@
+import logging
+
+import rules
+from rules.predicates import is_authenticated, is_superuser
+
+logger = logging.getLogger(__name__)
+
+
+@rules.predicate
+def is_editor(user) -> bool:
+ ''' Checks if any editor role exists for the user '''
+ return user.role.editor.exists()
+
+
+@rules.predicate
+def is_editor_for_current_site(user, site) -> bool:
+ ''' Checks if any editor role exists for the user '''
+ if not is_editor(user):
+ return False # if the user is not an editor, return False
+ return user.role.editor.filter(pk=site.pk).exists()
+
+
+@rules.predicate
+def is_element_editor(user, obj) -> bool:
+ ''' Checks if the user is an editor for the sites to which this element is editable '''
+
+ if obj.id is None: # for _add_object permissions
+ # if the element does not exist yet, it can be created by all users with an editor role
+ return is_editor(user)
+
+ if not obj.editors.exists():
+ # if the element has no editors, it is editable by all users with an editor role
+ return is_editor(user)
+
+ # else, return whether the user is an editor for the object
+ return user.role.editor.filter(id__in=obj.editors.all()).exists()
+
+
+@rules.predicate
+def is_reviewer(user) -> bool:
+ ''' Checks if any reviewer role exists for the user '''
+ return user.role.reviewer.exists()
+
+
+@rules.predicate
+def is_reviewer_for_current_site(user, site) -> bool:
+ ''' Checks if any reviewer role exists for the user '''
+ if not is_reviewer(user):
+ return False # if the user is not an reviewer, return False
+ return user.role.reviewer.filter(pk=site.pk).exists()
+
+
+@rules.predicate
+def is_element_reviewer(user, obj) -> bool:
+ ''' Checks if the user is an reviewer for the sites to which this element is editable '''
+
+ # if the element has no editors, it is reviewable by all reviewers
+ if not obj.editors.exists():
+ return is_reviewer(user)
+
+ # else, return whether the user is a reviewer for of the object
+ return user.role.reviewer.filter(id__in=obj.editors.all()).exists()
+
+
+@rules.predicate
+def is_legacy_reviewer(user) -> bool:
+ ''' Checks if the user has all the view permissions an editor or reviewer needs '''
+ return user.has_perms((
+ 'auth.view_group',
+ 'conditions.view_condition',
+ 'domain.view_attribute',
+ 'options.view_option',
+ 'options.view_optionset',
+ 'questions.view_catalog',
+ 'questions.view_page',
+ 'questions.view_question',
+ 'questions.view_questionset',
+ 'questions.view_section',
+ 'sites.view_site',
+ 'tasks.view_task',
+ 'views.view_view',
+ ))
+
+
+# Add rules
+rules.add_rule('management.can_view_management',
+ is_authenticated & (is_superuser |
+ is_editor_for_current_site |
+ is_reviewer_for_current_site |
+ is_legacy_reviewer))
+
+
+# Model Permissions for sites and group
+rules.add_perm('sites.view_site', is_editor | is_reviewer)
+rules.add_perm('auth.view_group', is_editor | is_reviewer)
+
+# Model Permissions for domain
+rules.add_perm('domain.view_attribute', is_editor | is_reviewer)
+rules.add_perm('domain.add_attribute', is_editor)
+
+
+# Object permissions domain attribute objects
+rules.add_perm('domain.view_attribute_object', is_element_editor | is_element_reviewer)
+rules.add_perm('domain.add_attribute_object', is_element_editor)
+rules.add_perm('domain.change_attribute_object', is_element_editor)
+rules.add_perm('domain.delete_attribute_object', is_element_editor)
+
+# Model Permissions for options
+rules.add_perm('options.view_option', is_editor | is_reviewer)
+rules.add_perm('options.add_option', is_editor)
+
+# Object Permissions for options option objects
+rules.add_perm('options.view_option_object', is_element_editor | is_element_reviewer)
+rules.add_perm('options.add_option_object', is_element_editor)
+rules.add_perm('options.change_option_object', is_element_editor)
+rules.add_perm('options.delete_option_object', is_element_editor)
+
+# Model Permissions for optionsets
+rules.add_perm('options.view_optionset', is_editor | is_reviewer)
+rules.add_perm('options.add_optionset', is_editor)
+
+# Object Permissions for options optionset objects
+rules.add_perm('options.view_optionset_object', is_element_editor | is_element_reviewer)
+rules.add_perm('options.add_optionset_object', is_element_editor)
+rules.add_perm('options.change_optionset_object', is_element_editor)
+rules.add_perm('options.delete_optionset_object', is_element_editor)
+
+# Model Permissions for conditions
+rules.add_perm('conditions.view_condition', is_editor | is_reviewer)
+rules.add_perm('conditions.add_condition', is_editor)
+
+# Object Permissions for conditions
+rules.add_perm('conditions.view_condition_object', is_element_editor | is_element_reviewer)
+rules.add_perm('conditions.add_condition_object', is_element_editor)
+rules.add_perm('conditions.change_condition_object', is_element_editor)
+rules.add_perm('conditions.delete_condition_object', is_element_editor)
+
+# Model Permissions for tasks
+rules.add_perm('tasks.view_task', is_editor | is_reviewer)
+rules.add_perm('tasks.add_task', is_editor)
+
+# Object Permissions for tasks
+rules.add_perm('tasks.view_task_object', is_element_editor | is_element_reviewer)
+rules.add_perm('tasks.add_task_object', is_element_editor)
+rules.add_perm('tasks.change_task_object', is_element_editor)
+rules.add_perm('tasks.delete_task_object', is_element_editor)
+
+# Model Permissions for views
+rules.add_perm('views.view_view', is_editor | is_reviewer)
+rules.add_perm('views.add_view', is_editor)
+
+# Object Permissions for views
+rules.add_perm('views.view_view_object', is_element_editor | is_element_reviewer)
+rules.add_perm('views.add_view_object', is_element_editor)
+rules.add_perm('views.change_view_object', is_element_editor)
+rules.add_perm('views.delete_view_object', is_element_editor)
+
+# Model permissions for catalogs
+rules.add_perm('questions.view_catalog', is_editor | is_reviewer)
+rules.add_perm('questions.add_catalog', is_editor)
+
+# Object permissions for catalogs
+rules.add_perm('questions.view_catalog_object', is_element_editor | is_element_reviewer)
+rules.add_perm('questions.add_catalog_object', is_element_editor)
+rules.add_perm('questions.change_catalog_object', is_element_editor)
+rules.add_perm('questions.delete_catalog_object', is_element_editor)
+
+# Model permissions for sections
+rules.add_perm('questions.view_section', is_editor | is_reviewer)
+rules.add_perm('questions.add_section', is_editor)
+
+# Object permissions for sections
+rules.add_perm('questions.view_section_object', is_element_editor | is_element_reviewer)
+rules.add_perm('questions.add_section_object', is_element_editor)
+rules.add_perm('questions.change_section_object', is_element_editor)
+rules.add_perm('questions.delete_section_object', is_element_editor)
+
+# Model permissions for pages
+rules.add_perm('questions.view_page', is_editor | is_reviewer)
+rules.add_perm('questions.add_page', is_editor)
+
+# Object permissions for pages
+rules.add_perm('questions.view_page_object', is_element_editor | is_element_reviewer)
+rules.add_perm('questions.add_page_object', is_element_editor)
+rules.add_perm('questions.change_page_object', is_element_editor)
+rules.add_perm('questions.delete_page_object', is_element_editor)
+
+# Model permissions for questionsets
+rules.add_perm('questions.view_questionset', is_editor | is_reviewer)
+rules.add_perm('questions.add_questionset', is_editor)
+
+# Object permissions for questionsets
+rules.add_perm('questions.view_questionset_object', is_element_editor | is_element_reviewer)
+rules.add_perm('questions.add_questionset_object', is_element_editor)
+rules.add_perm('questions.change_questionset_object', is_element_editor)
+rules.add_perm('questions.delete_questionset_object', is_element_editor)
+
+# Model permissions for questions
+rules.add_perm('questions.view_question', is_editor | is_reviewer)
+rules.add_perm('questions.add_question', is_editor)
+
+# Object permissions for questions
+rules.add_perm('questions.view_question_object', is_element_editor | is_element_reviewer)
+rules.add_perm('questions.add_question_object', is_element_editor)
+rules.add_perm('questions.change_question_object', is_element_editor)
+rules.add_perm('questions.delete_question_object', is_element_editor)
diff --git a/rdmo/management/templates/management/management.html b/rdmo/management/templates/management/management.html
new file mode 100644
index 0000000000..97818d89f8
--- /dev/null
+++ b/rdmo/management/templates/management/management.html
@@ -0,0 +1,34 @@
+{% extends 'core/page.html' %}
+{% load static %}
+{% load i18n %}
+{% load core_tags %}
+
+{% block vendor %}
+{% endblock %}
+
+{% block css %}
+
+ {{ block.super }}
+{% endblock %}
+
+{% block js %}
+
+
+
+
+
+{% endblock %}
+
+{% block sidebar %}
+
+
+
+{% endblock %}
+
+{% block page %}
+
+ {% trans 'Management' %}
+
+
+
+{% endblock %}
diff --git a/rdmo/management/tests/test_commands.py b/rdmo/management/tests/test_commands.py
index 227132dc07..b9b252d776 100644
--- a/rdmo/management/tests/test_commands.py
+++ b/rdmo/management/tests/test_commands.py
@@ -1,23 +1,14 @@
import io
-import os
+from pathlib import Path
import pytest
+
from django.core.management import call_command
from django.core.management.base import CommandError
-files = (
- 'conditions.xml',
- 'domain.xml',
- 'options.xml',
- 'questions.xml',
- 'tasks.xml',
- 'views.xml'
-)
-
-@pytest.mark.parametrize('file_name', files)
-def test_import(db, settings, file_name):
- xml_file = os.path.join(settings.BASE_DIR, 'xml', file_name)
+def test_import(db, settings):
+ xml_file = Path(settings.BASE_DIR) / 'xml' / 'elements' / 'catalogs.xml'
stdout, stderr = io.StringIO(), io.StringIO()
call_command('import', xml_file, stdout=stdout, stderr=stderr)
@@ -27,7 +18,7 @@ def test_import(db, settings, file_name):
def test_import_error(db, settings):
- xml_file = os.path.join(settings.BASE_DIR, 'xml', 'error.xml')
+ xml_file = Path(settings.BASE_DIR) / 'xml' / 'error.xml'
stdout, stderr = io.StringIO(), io.StringIO()
with pytest.raises(CommandError) as e:
@@ -37,7 +28,7 @@ def test_import_error(db, settings):
def test_import_error2(db, settings):
- xml_file = os.path.join(settings.BASE_DIR, 'xml', 'project.xml')
+ xml_file = Path(settings.BASE_DIR) / 'xml' / 'project.xml'
stdout, stderr = io.StringIO(), io.StringIO()
with pytest.raises(CommandError) as e:
diff --git a/rdmo/management/tests/test_import.py b/rdmo/management/tests/test_import.py
deleted file mode 100644
index ecae7794fd..0000000000
--- a/rdmo/management/tests/test_import.py
+++ /dev/null
@@ -1,60 +0,0 @@
-import os
-
-from rdmo.core.xml import flat_xml_to_elements, read_xml_file
-from rdmo.domain.models import Attribute
-from rdmo.management.imports import import_elements
-from rdmo.options.models import Option
-
-
-def test_non_unique_path(db, settings):
- count = Attribute.objects.count()
-
- xml_file = os.path.join(settings.BASE_DIR, 'xml', 'domain-non-unique-path.xml')
- root = read_xml_file(xml_file)
- elements = flat_xml_to_elements(root)
- checked = {element.get('uri'): True for element in elements}
- instances = import_elements(elements, parents={}, save=checked)
-
- # one instance has an error
- assert len([instance.errors for instance in instances if instance.errors]) == 1
-
- # two instances have no error
- assert len([instance.errors for instance in instances if not instance.errors]) == 2
-
- # only 2 attributes have been imported
- assert Attribute.objects.count() == count + 2
-
-
-def test_non_unique_key(db, settings):
- count = Option.objects.count()
-
- xml_file = os.path.join(settings.BASE_DIR, 'xml', 'options-non-unique-key.xml')
- root = read_xml_file(xml_file)
- elements = flat_xml_to_elements(root)
- checked = {element.get('uri'): True for element in elements}
- instances = import_elements(elements, parents={}, save=checked)
-
- # one instance has an error
- assert len([instance.errors for instance in instances if instance.errors]) == 1
-
- # one instance has no error
- assert len([instance.errors for instance in instances if not instance.errors]) == 1
-
- # no option has been imported
- assert Option.objects.count() == count
-
-
-def test_missing_parent(db, settings):
- count = Option.objects.count()
-
- xml_file = os.path.join(settings.BASE_DIR, 'xml', 'options-missing-parent.xml')
- root = read_xml_file(xml_file)
- elements = flat_xml_to_elements(root)
- checked = {element.get('uri'): True for element in elements}
- instances = import_elements(elements, parents={}, save=checked)
-
- # one instance has an error
- assert len([instance.errors for instance in instances if instance.errors]) == 1
-
- # no option has been imported
- assert Option.objects.count() == count
diff --git a/rdmo/management/tests/test_import_conditions.py b/rdmo/management/tests/test_import_conditions.py
new file mode 100644
index 0000000000..4c97ced3fd
--- /dev/null
+++ b/rdmo/management/tests/test_import_conditions.py
@@ -0,0 +1,73 @@
+from pathlib import Path
+
+from rdmo.conditions.models import Condition
+from rdmo.core.xml import convert_elements, flat_xml_to_elements, order_elements, read_xml_file
+from rdmo.management.imports import import_elements
+
+
+def test_create_conditions(db, settings):
+ Condition.objects.all().delete()
+
+ xml_file = Path(settings.BASE_DIR) / 'xml' / 'elements' / 'conditions.xml'
+
+ root = read_xml_file(xml_file)
+ version = root.attrib.get('version')
+ elements = flat_xml_to_elements(root)
+ elements = convert_elements(elements, version)
+ elements = order_elements(elements)
+ elements = elements.values()
+ import_elements(elements)
+
+ assert len(root) == len(elements) == Condition.objects.count() == 15
+ assert all(element['created'] is True for element in elements)
+ assert all(element['updated'] is False for element in elements)
+
+
+def test_update_conditions(db, settings):
+ xml_file = Path(settings.BASE_DIR) / 'xml' / 'elements' / 'conditions.xml'
+
+ root = read_xml_file(xml_file)
+ version = root.attrib.get('version')
+ elements = flat_xml_to_elements(root)
+ elements = convert_elements(elements, version)
+ elements = order_elements(elements)
+ elements = elements.values()
+ import_elements(elements)
+
+ assert len(root) == len(elements)
+ assert all(element['created'] is False for element in elements)
+ assert all(element['updated'] is True for element in elements)
+
+
+def test_create_legacy_conditions(db, settings):
+ Condition.objects.all().delete()
+
+ xml_file = Path(settings.BASE_DIR) / 'xml' / 'elements' / 'legacy' / 'conditions.xml'
+
+ root = read_xml_file(xml_file)
+ version = root.attrib.get('version')
+ elements = flat_xml_to_elements(root)
+ elements = convert_elements(elements, version)
+ elements = order_elements(elements)
+ elements = elements.values()
+ import_elements(elements)
+
+ assert len(root) == len(elements) == Condition.objects.count() == 15
+ assert all(element['created'] is True for element in elements)
+ assert all(element['updated'] is False for element in elements)
+
+
+def test_update_legacy_conditions(db, settings):
+ xml_file = Path(settings.BASE_DIR) / 'xml' / 'elements' / 'legacy' / 'conditions.xml'
+
+ root = read_xml_file(xml_file)
+ version = root.attrib.get('version')
+ elements = flat_xml_to_elements(root)
+ elements = convert_elements(elements, version)
+ elements = order_elements(elements)
+ elements = elements.values()
+ import_elements(elements)
+
+ assert len(root) == len(elements) == 15
+ assert all(element['created'] is False for element in elements)
+ assert all(element['updated'] is True for element in elements)
diff --git a/rdmo/management/tests/test_import_domain.py b/rdmo/management/tests/test_import_domain.py
new file mode 100644
index 0000000000..b9f032b92f
--- /dev/null
+++ b/rdmo/management/tests/test_import_domain.py
@@ -0,0 +1,74 @@
+from pathlib import Path
+
+from rdmo.core.xml import convert_elements, flat_xml_to_elements, order_elements, read_xml_file
+from rdmo.domain.models import Attribute
+from rdmo.management.imports import import_elements
+
+
+def test_create_domain(db, settings):
+ Attribute.objects.all().delete()
+
+ xml_file = Path(settings.BASE_DIR) / 'xml' / 'elements' / 'attributes.xml'
+
+ root = read_xml_file(xml_file)
+ version = root.attrib.get('version')
+ elements = flat_xml_to_elements(root)
+ elements = convert_elements(elements, version)
+ elements = order_elements(elements)
+ elements = elements.values()
+ import_elements(elements)
+
+ assert len(root) == len(elements) == Attribute.objects.count() == 86
+ assert all(element['created'] is True for element in elements)
+ assert all(element['updated'] is False for element in elements)
+
+
+def test_update_domain(db, settings):
+ xml_file = Path(settings.BASE_DIR) / 'xml' / 'elements' / 'attributes.xml'
+
+ root = read_xml_file(xml_file)
+ version = root.attrib.get('version')
+ elements = flat_xml_to_elements(root)
+ elements = convert_elements(elements, version)
+ elements = order_elements(elements)
+ elements = elements.values()
+ import_elements(elements)
+
+ assert len(root) == len(elements)
+ assert all(element['created'] is False for element in elements)
+ assert all(element['updated'] is True for element in elements)
+
+
+def test_create_legacy_domain(db, settings):
+ Attribute.objects.all().delete()
+
+ xml_file = Path(settings.BASE_DIR) / 'xml' / 'elements' / 'legacy' / 'domain.xml'
+
+ root = read_xml_file(xml_file)
+ version = root.attrib.get('version')
+ elements = flat_xml_to_elements(root)
+ elements = convert_elements(elements, version)
+ elements = order_elements(elements)
+ elements = elements.values()
+ import_elements(elements)
+
+ assert len(root) == len(elements) == 86
+ assert Attribute.objects.count() == 86
+ assert all(element['created'] is True for element in elements)
+ assert all(element['updated'] is False for element in elements)
+
+
+def test_update_legacy_domain(db, settings):
+ xml_file = Path(settings.BASE_DIR) / 'xml' / 'elements' / 'legacy' / 'domain.xml'
+
+ root = read_xml_file(xml_file)
+ version = root.attrib.get('version')
+ elements = flat_xml_to_elements(root)
+ elements = convert_elements(elements, version)
+ elements = order_elements(elements)
+ elements = elements.values()
+ import_elements(elements)
+
+ assert len(root) == len(elements) == 86
+ assert all(element['created'] is False for element in elements)
+ assert all(element['updated'] is True for element in elements)
diff --git a/rdmo/management/tests/test_import_options.py b/rdmo/management/tests/test_import_options.py
new file mode 100644
index 0000000000..8d98989901
--- /dev/null
+++ b/rdmo/management/tests/test_import_options.py
@@ -0,0 +1,109 @@
+from pathlib import Path
+
+from rdmo.core.xml import convert_elements, flat_xml_to_elements, order_elements, read_xml_file
+from rdmo.management.imports import import_elements
+from rdmo.options.models import Option, OptionSet
+
+
+def test_create_optionsets(db, settings):
+ OptionSet.objects.all().delete()
+ Option.objects.all().delete()
+
+ xml_file = Path(settings.BASE_DIR) / 'xml' / 'elements' / 'optionsets.xml'
+ root = read_xml_file(xml_file)
+ version = root.attrib.get('version')
+ elements = flat_xml_to_elements(root)
+ elements = convert_elements(elements, version)
+ elements = order_elements(elements)
+ elements = elements.values()
+ import_elements(elements)
+
+ assert len(root) == len(elements) == 12
+ assert OptionSet.objects.count() == 4
+ assert Option.objects.count() == 8
+ assert all(element['created'] is True for element in elements)
+ assert all(element['updated'] is False for element in elements)
+
+
+def test_update_optionsets(db, settings):
+ xml_file = Path(settings.BASE_DIR) / 'xml' / 'elements' / 'optionsets.xml'
+ root = read_xml_file(xml_file)
+ version = root.attrib.get('version')
+ elements = flat_xml_to_elements(root)
+ elements = convert_elements(elements, version)
+ elements = order_elements(elements)
+ elements = elements.values()
+ import_elements(elements)
+
+ assert len(root) == len(elements) == 12
+ assert all(element['created'] is False for element in elements)
+ assert all(element['updated'] is True for element in elements)
+
+
+def test_create_options(db, settings):
+ Option.objects.all().delete()
+
+ xml_file = Path(settings.BASE_DIR) / 'xml' / 'elements' / 'options.xml'
+ root = read_xml_file(xml_file)
+ version = root.attrib.get('version')
+ elements = flat_xml_to_elements(root)
+ elements = convert_elements(elements, version)
+ elements = order_elements(elements)
+ elements = elements.values()
+ import_elements(elements)
+
+ assert len(root) == len(elements) == Option.objects.count() == 8
+ assert all(element['created'] is True for element in elements)
+ assert all(element['updated'] is False for element in elements)
+
+
+def test_update_options(db, settings):
+ xml_file = Path(settings.BASE_DIR) / 'xml' / 'elements' / 'options.xml'
+ root = read_xml_file(xml_file)
+ version = root.attrib.get('version')
+ elements = flat_xml_to_elements(root)
+ elements = convert_elements(elements, version)
+ elements = order_elements(elements)
+ elements = elements.values()
+ import_elements(elements)
+
+ assert len(root) == len(elements) == 8
+ assert all(element['created'] is False for element in elements)
+ assert all(element['updated'] is True for element in elements)
+
+
+def test_create_legacy_options(db, settings):
+ OptionSet.objects.all().delete()
+ Option.objects.all().delete()
+
+ xml_file = Path(settings.BASE_DIR) / 'xml' / 'elements' / 'legacy' / 'options.xml'
+
+ root = read_xml_file(xml_file)
+ version = root.attrib.get('version')
+ elements = flat_xml_to_elements(root)
+ elements = convert_elements(elements, version)
+ elements = order_elements(elements)
+ elements = elements.values()
+ import_elements(elements)
+
+ assert len(root) == len(elements) == 12
+ assert OptionSet.objects.count() == 4
+ assert Option.objects.count() == 8
+ assert all(element['created'] is True for element in elements)
+ assert all(element['updated'] is False for element in elements)
+
+
+def test_update_legacy_options(db, settings):
+ xml_file = Path(settings.BASE_DIR) / 'xml' / 'elements' / 'legacy' / 'options.xml'
+
+ root = read_xml_file(xml_file)
+ version = root.attrib.get('version')
+ elements = flat_xml_to_elements(root)
+ elements = convert_elements(elements, version)
+ elements = order_elements(elements)
+ elements = elements.values()
+ import_elements(elements)
+
+ assert len(root) == len(elements) == 12
+ assert all(element['created'] is False for element in elements)
+ assert all(element['updated'] is True for element in elements)
diff --git a/rdmo/management/tests/test_import_questions.py b/rdmo/management/tests/test_import_questions.py
new file mode 100644
index 0000000000..7bb275fce2
--- /dev/null
+++ b/rdmo/management/tests/test_import_questions.py
@@ -0,0 +1,259 @@
+from pathlib import Path
+
+from rdmo.core.xml import convert_elements, flat_xml_to_elements, order_elements, read_xml_file
+from rdmo.management.imports import import_elements
+from rdmo.questions.models import Catalog, Page, Question, QuestionSet, Section
+
+
+def test_create_catalogs(db, settings):
+ Catalog.objects.all().delete()
+ Section.objects.all().delete()
+ Page.objects.all().delete()
+ QuestionSet.objects.all().delete()
+ Question.objects.all().delete()
+
+ xml_file = Path(settings.BASE_DIR) / 'xml' / 'elements' / 'catalogs.xml'
+
+ root = read_xml_file(xml_file)
+ version = root.attrib.get('version')
+ elements = flat_xml_to_elements(root)
+ elements = convert_elements(elements, version)
+ elements = order_elements(elements)
+ elements = elements.values()
+ import_elements(elements)
+
+ assert len(root) == len(elements) == 148
+ assert Catalog.objects.count() == 2
+ assert Section.objects.count() == 6
+ assert Page.objects.count() == 48
+ assert QuestionSet.objects.count() == 3
+ assert Question.objects.count() == 89
+ assert all(element['created'] is True for element in elements)
+ assert all(element['updated'] is False for element in elements)
+
+
+def test_update_catalogs(db, settings):
+ xml_file = Path(settings.BASE_DIR) / 'xml' / 'elements' / 'catalogs.xml'
+
+ root = read_xml_file(xml_file)
+ version = root.attrib.get('version')
+ elements = flat_xml_to_elements(root)
+ elements = convert_elements(elements, version)
+ elements = order_elements(elements)
+ elements = elements.values()
+ import_elements(elements)
+
+ assert len(root) == len(elements) == 148
+ assert all(element['created'] is False for element in elements)
+ assert all(element['updated'] is True for element in elements)
+
+
+def test_create_sections(db, settings):
+ Section.objects.all().delete()
+ Page.objects.all().delete()
+ QuestionSet.objects.all().delete()
+ Question.objects.all().delete()
+
+ xml_file = Path(settings.BASE_DIR) / 'xml' / 'elements' / 'sections.xml'
+
+ root = read_xml_file(xml_file)
+ version = root.attrib.get('version')
+ elements = flat_xml_to_elements(root)
+ elements = convert_elements(elements, version)
+ elements = order_elements(elements)
+ elements = elements.values()
+ import_elements(elements)
+
+ assert len(root) == len(elements) == 146
+ assert Section.objects.count() == 6
+ assert Page.objects.count() == 48
+ assert QuestionSet.objects.count() == 3
+ assert Question.objects.count() == 89
+ assert all(element['created'] is True for element in elements)
+ assert all(element['updated'] is False for element in elements)
+
+
+def test_update_sections(db, settings):
+ xml_file = Path(settings.BASE_DIR) / 'xml' / 'elements' / 'sections.xml'
+
+ root = read_xml_file(xml_file)
+ version = root.attrib.get('version')
+ elements = flat_xml_to_elements(root)
+ elements = convert_elements(elements, version)
+ elements = order_elements(elements)
+ elements = elements.values()
+ import_elements(elements)
+
+ assert len(root) == len(elements) == 146
+ assert all(element['created'] is False for element in elements)
+ assert all(element['updated'] is True for element in elements)
+
+
+def test_create_pages(db, settings):
+ Page.objects.all().delete()
+ QuestionSet.objects.all().delete()
+ Question.objects.all().delete()
+
+ xml_file = Path(settings.BASE_DIR) / 'xml' / 'elements' / 'pages.xml'
+
+ root = read_xml_file(xml_file)
+ version = root.attrib.get('version')
+ elements = flat_xml_to_elements(root)
+ elements = convert_elements(elements, version)
+ elements = order_elements(elements)
+ elements = elements.values()
+ import_elements(elements)
+
+ assert len(root) == len(elements) == 140
+ assert Page.objects.count() == 48
+ assert QuestionSet.objects.count() == 3
+ assert Question.objects.count() == 89
+ assert all(element['created'] is True for element in elements)
+ assert all(element['updated'] is False for element in elements)
+
+
+def test_update_pages(db, settings):
+ xml_file = Path(settings.BASE_DIR) / 'xml' / 'elements' / 'pages.xml'
+
+ root = read_xml_file(xml_file)
+ version = root.attrib.get('version')
+ elements = flat_xml_to_elements(root)
+ elements = convert_elements(elements, version)
+ elements = order_elements(elements)
+ elements = elements.values()
+ import_elements(elements)
+
+ assert len(root) == len(elements) == 140
+ assert all(element['created'] is False for element in elements)
+ assert all(element['updated'] is True for element in elements)
+
+
+def test_create_questionsets(db, settings):
+ Page.objects.all().delete()
+ QuestionSet.objects.all().delete()
+ Question.objects.all().delete()
+
+ xml_file = Path(settings.BASE_DIR) / 'xml' / 'elements' / 'questionsets.xml'
+
+ root = read_xml_file(xml_file)
+ version = root.attrib.get('version')
+ elements = flat_xml_to_elements(root)
+ elements = convert_elements(elements, version)
+ elements = order_elements(elements)
+ elements = elements.values()
+ import_elements(elements)
+
+ assert len(root) == 10 # two questionsets appear twice in the export file
+ assert len(elements) == 8
+ assert QuestionSet.objects.count() == 3
+ assert Question.objects.count() == 5
+ assert all(element['created'] is True for element in elements)
+ assert all(element['updated'] is False for element in elements)
+
+
+def test_update_questionsets(db, settings):
+ xml_file = Path(settings.BASE_DIR) / 'xml' / 'elements' / 'questionsets.xml'
+
+ root = read_xml_file(xml_file)
+ version = root.attrib.get('version')
+ elements = flat_xml_to_elements(root)
+ elements = convert_elements(elements, version)
+ elements = order_elements(elements)
+ elements = elements.values()
+ import_elements(elements)
+
+ assert len(root) == 10 # two questionsets appear twice in the export file
+ assert all(element['created'] is False for element in elements)
+ assert all(element['updated'] is True for element in elements)
+
+
+def test_create_questions(db, settings):
+ Page.objects.all().delete()
+ QuestionSet.objects.all().delete()
+ Question.objects.all().delete()
+
+ xml_file = Path(settings.BASE_DIR) / 'xml' / 'elements' / 'questions.xml'
+
+ root = read_xml_file(xml_file)
+ version = root.attrib.get('version')
+ elements = flat_xml_to_elements(root)
+ elements = convert_elements(elements, version)
+ elements = order_elements(elements)
+ elements = elements.values()
+ import_elements(elements)
+
+ assert len(root) == len(elements) == 89
+ assert Question.objects.count() == 89
+ assert all(element['created'] is True for element in elements)
+ assert all(element['updated'] is False for element in elements)
+
+
+def test_update_questions(db, settings):
+ xml_file = Path(settings.BASE_DIR) / 'xml' / 'elements' / 'questions.xml'
+
+ root = read_xml_file(xml_file)
+ version = root.attrib.get('version')
+ elements = flat_xml_to_elements(root)
+ elements = convert_elements(elements, version)
+ elements = order_elements(elements)
+ elements = elements.values()
+ import_elements(elements)
+
+ assert len(root) == len(elements) == 89
+ assert all(element['created'] is False for element in elements)
+ assert all(element['updated'] is True for element in elements)
+
+
+def test_create_legacy_questions(db, settings):
+ Catalog.objects.all().delete()
+ Section.objects.all().delete()
+ Page.objects.all().delete()
+ QuestionSet.objects.all().delete()
+ Question.objects.all().delete()
+
+ xml_file = Path(settings.BASE_DIR) / 'xml' / 'elements' / 'legacy' / 'questions.xml'
+
+ root = read_xml_file(xml_file)
+ version = root.attrib.get('version')
+ elements = flat_xml_to_elements(root)
+ elements = convert_elements(elements, version)
+ elements = order_elements(elements)
+ elements = elements.values()
+ import_elements(elements)
+
+ assert len(root) == len(elements) == 147
+ assert Catalog.objects.count() == 1
+ assert Section.objects.count() == 6
+ assert Page.objects.count() == 48
+ assert QuestionSet.objects.count() == 3
+ assert Question.objects.count() == 89
+ assert all(element['created'] is True for element in elements)
+ assert all(element['updated'] is False for element in elements)
+
+ # check that all elements ended up in the catalog
+ catalog = Catalog.objects.prefetch_elements().first()
+ descendant_uris = {element.uri for element in catalog.descendants}
+ element_uris = {element['uri'] for element in elements if element['uri'] != catalog.uri}
+ assert descendant_uris == element_uris
+
+
+def test_update_legacy_questions(db, settings):
+ xml_file = Path(settings.BASE_DIR) / 'xml' / 'elements' / 'legacy' / 'questions.xml'
+
+ root = read_xml_file(xml_file)
+ version = root.attrib.get('version')
+ elements = flat_xml_to_elements(root)
+ elements = convert_elements(elements, version)
+ elements = order_elements(elements)
+ elements = elements.values()
+ import_elements(elements)
+
+ assert len(root) == len(elements) == 147
+ assert all(element['created'] is False for element in elements)
+ assert all(element['updated'] is True for element in elements)
+
+ # check that all elements ended up in the catalog
+ catalog = Catalog.objects.prefetch_elements().first()
+ descendant_uris = {element.uri for element in catalog.descendants}
+ element_uris = {element['uri'] for element in elements if element['uri'] != catalog.uri}
+ assert descendant_uris == element_uris
diff --git a/rdmo/management/tests/test_import_tasks.py b/rdmo/management/tests/test_import_tasks.py
new file mode 100644
index 0000000000..08071990cf
--- /dev/null
+++ b/rdmo/management/tests/test_import_tasks.py
@@ -0,0 +1,73 @@
+from pathlib import Path
+
+from rdmo.core.xml import convert_elements, flat_xml_to_elements, order_elements, read_xml_file
+from rdmo.management.imports import import_elements
+from rdmo.tasks.models import Task
+
+
+def test_create_tasks(db, settings):
+ Task.objects.all().delete()
+
+ xml_file = Path(settings.BASE_DIR) / 'xml' / 'elements' / 'tasks.xml'
+
+ root = read_xml_file(xml_file)
+ version = root.attrib.get('version')
+ elements = flat_xml_to_elements(root)
+ elements = convert_elements(elements, version)
+ elements = order_elements(elements)
+ elements = elements.values()
+ import_elements(elements)
+
+ assert len(root) == len(elements) == Task.objects.count() == 2
+ assert all(element['created'] is True for element in elements)
+ assert all(element['updated'] is False for element in elements)
+
+
+def test_update_tasks(db, settings):
+ xml_file = Path(settings.BASE_DIR) / 'xml' / 'elements' / 'tasks.xml'
+
+ root = read_xml_file(xml_file)
+ version = root.attrib.get('version')
+ elements = flat_xml_to_elements(root)
+ elements = convert_elements(elements, version)
+ elements = order_elements(elements)
+ elements = elements.values()
+ import_elements(elements)
+
+ assert len(root) == len(elements) == 2
+ assert all(element['created'] is False for element in elements)
+ assert all(element['updated'] is True for element in elements)
+
+
+def test_create_legacy_tasks(db, settings):
+ Task.objects.all().delete()
+
+ xml_file = Path(settings.BASE_DIR) / 'xml' / 'elements' / 'legacy' / 'tasks.xml'
+
+ root = read_xml_file(xml_file)
+ version = root.attrib.get('version')
+ elements = flat_xml_to_elements(root)
+ elements = convert_elements(elements, version)
+ elements = order_elements(elements)
+ elements = elements.values()
+ import_elements(elements)
+
+ assert len(root) == len(elements) == Task.objects.count() == 2
+ assert all(element['created'] is True for element in elements)
+ assert all(element['updated'] is False for element in elements)
+
+
+def test_update_legacy_tasks(db, settings):
+ xml_file = Path(settings.BASE_DIR) / 'xml' / 'elements' / 'legacy' / 'tasks.xml'
+
+ root = read_xml_file(xml_file)
+ version = root.attrib.get('version')
+ elements = flat_xml_to_elements(root)
+ elements = convert_elements(elements, version)
+ elements = order_elements(elements)
+ elements = elements.values()
+ import_elements(elements)
+
+ assert len(root) == len(elements) == 2
+ assert all(element['created'] is False for element in elements)
+ assert all(element['updated'] is True for element in elements)
diff --git a/rdmo/management/tests/test_import_views.py b/rdmo/management/tests/test_import_views.py
new file mode 100644
index 0000000000..a6c7bc7d06
--- /dev/null
+++ b/rdmo/management/tests/test_import_views.py
@@ -0,0 +1,73 @@
+from pathlib import Path
+
+from rdmo.core.xml import convert_elements, flat_xml_to_elements, order_elements, read_xml_file
+from rdmo.management.imports import import_elements
+from rdmo.views.models import View
+
+
+def test_create_tasks(db, settings):
+ View.objects.all().delete()
+
+ xml_file = Path(settings.BASE_DIR) / 'xml' / 'elements' / 'views.xml'
+
+ root = read_xml_file(xml_file)
+ version = root.attrib.get('version')
+ elements = flat_xml_to_elements(root)
+ elements = convert_elements(elements, version)
+ elements = order_elements(elements)
+ elements = elements.values()
+ import_elements(elements)
+
+ assert len(root) == len(elements) == View.objects.count() == 3
+ assert all(element['created'] is True for element in elements)
+ assert all(element['updated'] is False for element in elements)
+
+
+def test_update_tasks(db, settings):
+ xml_file = Path(settings.BASE_DIR) / 'xml' / 'elements' / 'views.xml'
+
+ root = read_xml_file(xml_file)
+ version = root.attrib.get('version')
+ elements = flat_xml_to_elements(root)
+ elements = convert_elements(elements, version)
+ elements = order_elements(elements)
+ elements = elements.values()
+ import_elements(elements)
+
+ assert len(root) == len(elements) == 3
+ assert all(element['created'] is False for element in elements)
+ assert all(element['updated'] is True for element in elements)
+
+
+def test_create_legacy_tasks(db, settings):
+ View.objects.all().delete()
+
+ xml_file = Path(settings.BASE_DIR) / 'xml' / 'elements' / 'legacy' / 'views.xml'
+
+ root = read_xml_file(xml_file)
+ version = root.attrib.get('version')
+ elements = flat_xml_to_elements(root)
+ elements = convert_elements(elements, version)
+ elements = order_elements(elements)
+ elements = elements.values()
+ import_elements(elements)
+
+ assert len(root) == len(elements) == View.objects.count() == 3
+ assert all(element['created'] is True for element in elements)
+ assert all(element['updated'] is False for element in elements)
+
+
+def test_update_legacy_tasks(db, settings):
+ xml_file = Path(settings.BASE_DIR) / 'xml' / 'elements' / 'legacy' / 'views.xml'
+
+ root = read_xml_file(xml_file)
+ version = root.attrib.get('version')
+ elements = flat_xml_to_elements(root)
+ elements = convert_elements(elements, version)
+ elements = order_elements(elements)
+ elements = elements.values()
+ import_elements(elements)
+
+ assert len(root) == len(elements) == 3
+ assert all(element['created'] is False for element in elements)
+ assert all(element['updated'] is True for element in elements)
diff --git a/rdmo/management/tests/test_view.py b/rdmo/management/tests/test_view.py
new file mode 100644
index 0000000000..8e049f1602
--- /dev/null
+++ b/rdmo/management/tests/test_view.py
@@ -0,0 +1,27 @@
+import pytest
+
+from django.urls import reverse
+
+users = (
+ ('admin', 'admin'),
+ ('editor', 'editor'),
+ ('reviewer', 'reviewer'),
+ ('user', 'user'),
+ ('api', 'api'),
+ ('anonymous', None)
+)
+
+status_map = {
+ 'management': {
+ 'admin': 200, 'editor': 200, 'reviewer': 200, 'api': 200, 'user': 403, 'anonymous': 302
+ }
+}
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_list(db, client, username, password):
+ client.login(username=username, password=password)
+
+ url = reverse('management')
+ response = client.get(url)
+ assert response.status_code == status_map['management'][username]
diff --git a/rdmo/management/tests/test_views.py b/rdmo/management/tests/test_views.py
deleted file mode 100644
index 57365bc48e..0000000000
--- a/rdmo/management/tests/test_views.py
+++ /dev/null
@@ -1,219 +0,0 @@
-import os
-
-import pytest
-from django.urls import reverse
-
-from rdmo.core.xml import flat_xml_to_elements, read_xml_file
-
-users = (
- ('editor', 'editor'),
- ('reviewer', 'reviewer'),
- ('user', 'user'),
- ('api', 'api'),
- ('anonymous', None),
-)
-
-files = (
- 'conditions.xml',
- 'domain.xml',
- 'options.xml',
- 'questions.xml',
- 'tasks.xml',
- 'views.xml'
-)
-
-status_map = {
- 'upload_get': {
- 'editor': 302, 'reviewer': 302, 'api': 302, 'user': 302, 'anonymous': 302
- },
- 'upload_post': {
- 'editor': 200, 'reviewer': 403, 'api': 200, 'user': 403, 'anonymous': 302
- },
- 'upload_post_empty': {
- 'editor': 302, 'reviewer': 302, 'api': 302, 'user': 302, 'anonymous': 302
- },
- 'upload_post_error': {
- 'editor': 400, 'reviewer': 400, 'api': 400, 'user': 400, 'anonymous': 302
- },
- 'import_get': {
- 'editor': 302, 'reviewer': 302, 'api': 302, 'user': 302, 'anonymous': 302
- },
- 'import_post': {
- 'editor': 200, 'reviewer': 403, 'api': 200, 'user': 403, 'anonymous': 302
- },
- 'import_post_cancel': {
- 'editor': 302, 'reviewer': 302, 'api': 302, 'user': 302, 'anonymous': 302
- },
- 'import_post_empty': {
- 'editor': 302, 'reviewer': 403, 'api': 302, 'user': 403, 'anonymous': 302
- },
- 'import_post_error': {
- 'editor': 400, 'reviewer': 400, 'api': 400, 'user': 400, 'anonymous': 302
- }
-}
-
-
-@pytest.mark.parametrize('username,password', users)
-def test_upload_get(db, client, username, password):
- client.login(username=username, password=password)
-
- url = reverse('upload')
- response = client.get(url)
-
- assert response.status_code == status_map['upload_get'][username], response.content
-
- if not password:
- assert response.url.startswith('/account/login/'), response.content
-
-
-@pytest.mark.parametrize('file_name', files)
-@pytest.mark.parametrize('username,password', users)
-def test_upload_post_conditions(db, settings, client, username, password, file_name):
- client.login(username=username, password=password)
-
- xml_file = os.path.join(settings.BASE_DIR, 'xml', file_name)
-
- url = reverse('upload')
- with open(xml_file, encoding='utf8') as f:
- response = client.post(url, {'uploaded_file': f})
-
- assert response.status_code == status_map['upload_post'][username]
-
- if not password:
- assert response.url.startswith('/account/login/'), response.content
-
-
-@pytest.mark.parametrize('username,password', users)
-def test_upload_post_empty(db, client, username, password):
- client.login(username=username, password=password)
-
- url = reverse('upload')
- response = client.post(url)
-
- assert response.status_code == status_map['upload_post_empty'][username]
-
- if not password:
- assert response.url.startswith('/account/login/'), response.content
-
-
-@pytest.mark.parametrize('username,password', users)
-def test_upload_post_error(db, settings, client, username, password):
- client.login(username=username, password=password)
-
- xml_file = os.path.join(settings.BASE_DIR, 'xml', 'error.xml')
-
- url = reverse('upload')
- with open(xml_file, encoding='utf8') as f:
- response = client.post(url, {'uploaded_file': f})
-
- assert response.status_code == status_map['upload_post_error'][username]
-
- if not password:
- assert response.url.startswith('/account/login/'), response.content
-
-
-@pytest.mark.parametrize('username,password', users)
-def test_import_get(db, client, username, password):
- client.login(username=username, password=password)
-
- url = reverse('import')
- response = client.get(url)
-
- assert response.status_code == status_map['import_get'][username], response.content
-
- if not password:
- assert response.url.startswith('/account/login/'), response.content
-
-
-@pytest.mark.parametrize('file_name', files)
-@pytest.mark.parametrize('username,password', users)
-def test_import_post(db, settings, client, username, password, file_name):
- client.login(username=username, password=password)
-
- xml_file = os.path.join(settings.BASE_DIR, 'xml', file_name)
-
- session = client.session
- session['import_file_name'] = file_name
- session['import_tmpfile_name'] = xml_file
- session.save()
-
- root = read_xml_file(xml_file)
- elements = flat_xml_to_elements(root)
- checked = [element.get('uri') for element in elements]
- data = {uri: ['on'] for uri in checked}
-
- url = reverse('import')
- response = client.post(url, data)
-
- assert response.status_code == status_map['import_post'][username]
-
- if not password:
- assert response.url.startswith('/account/login/'), response.content
-
-
-@pytest.mark.parametrize('file_name', files)
-@pytest.mark.parametrize('username,password', users)
-def test_import_post_cancel(db, settings, client, username, password, file_name):
- client.login(username=username, password=password)
-
- xml_file = os.path.join(settings.BASE_DIR, 'xml', file_name)
-
- session = client.session
- session['import_file_name'] = file_name
- session['import_tmpfile_name'] = xml_file
- session.save()
-
- root = read_xml_file(xml_file)
- elements = flat_xml_to_elements(root)
- checked = [element.get('uri') for element in elements]
- data = {uri: ['on'] for uri in checked}
- data['cancel'] = 'Cancel'
-
- url = reverse('import')
- response = client.post(url, data)
-
- assert response.status_code == status_map['import_post_cancel'][username]
-
- if not password:
- assert response.url.startswith('/account/login/'), response.content
-
-
-@pytest.mark.parametrize('file_name', files)
-@pytest.mark.parametrize('username,password', users)
-def test_import_post_empty(db, settings, client, username, password, file_name):
- client.login(username=username, password=password)
-
- xml_file = os.path.join(settings.BASE_DIR, 'xml', file_name)
-
- session = client.session
- session['import_file_name'] = file_name
- session['import_tmpfile_name'] = xml_file
- session.save()
-
- url = reverse('import')
- response = client.post(url)
-
- assert response.status_code == status_map['import_post_empty'][username]
-
- if not password:
- assert response.url.startswith('/account/login/'), response.content
-
-
-@pytest.mark.parametrize('username,password', users)
-def test_import_post_error(db, settings, client, username, password):
- client.login(username=username, password=password)
-
- xml_file = os.path.join(settings.BASE_DIR, 'xml', 'error.xml')
-
- session = client.session
- session['import_file_name'] = 'error.xml'
- session['import_tmpfile_name'] = xml_file
- session.save()
-
- url = reverse('import')
- response = client.post(url)
-
- assert response.status_code == status_map['import_post_error'][username]
-
- if not password:
- assert response.url.startswith('/account/login/'), response.content
diff --git a/rdmo/management/tests/test_viewset_import.py b/rdmo/management/tests/test_viewset_import.py
new file mode 100644
index 0000000000..23cb6742d5
--- /dev/null
+++ b/rdmo/management/tests/test_viewset_import.py
@@ -0,0 +1,95 @@
+import pytest
+
+from django.urls import reverse
+
+from rdmo.questions.models import Catalog, Page, Question, QuestionSet, Section
+
+users = (
+ ('editor', 'editor'),
+ ('reviewer', 'reviewer'),
+ ('user', 'user'),
+ ('api', 'api'),
+ ('anonymous', None),
+)
+
+status_map = {
+ 'list': {
+ 'editor': 405, 'reviewer': 405, 'api': 405, 'user': 405, 'anonymous': 401
+ },
+ 'create': {
+ 'editor': 200, 'reviewer': 200, 'api': 200, 'user': 200, 'anonymous': 401
+ },
+ 'create_error': {
+ 'editor': 400, 'reviewer': 400, 'api': 400, 'user': 400, 'anonymous': 401
+ }
+}
+
+urlnames = {
+ 'list': 'v1-management:import-list'
+}
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_list(db, client, username, password):
+ client.login(username=username, password=password)
+
+ url = reverse(urlnames['list'])
+ response = client.get(url)
+ assert response.status_code == status_map['list'][username], response.json()
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_create_create(db, client, username, password, json_data):
+ Catalog.objects.all().delete()
+ Section.objects.all().delete()
+ Page.objects.all().delete()
+ QuestionSet.objects.all().delete()
+ Question.objects.all().delete()
+
+ client.login(username=username, password=password)
+
+ url = reverse(urlnames['list'])
+ response = client.post(url, json_data, content_type='application/json')
+
+ assert response.status_code == status_map['create'][username], response.json()
+ if response.status_code == 200:
+ for element in response.json():
+ if username in ['reviewer', 'user']:
+ assert element.get('created') is False
+ else:
+ assert element.get('created') is True
+ assert element.get('updated') is False
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_create_update(db, client, username, password, json_data):
+ client.login(username=username, password=password)
+
+ url = reverse(urlnames['list'])
+ response = client.post(url, json_data, content_type='application/json')
+
+ assert response.status_code == status_map['create'][username], response.json()
+ if response.status_code == 200:
+ for element in response.json():
+ assert element.get('created') is False
+ assert element.get('updated') is False if username in ['reviewer', 'user'] else True
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_create_empty(db, client, username, password):
+ client.login(username=username, password=password)
+
+ url = reverse(urlnames['list'])
+ response = client.post(url, {}, content_type='application/json')
+ assert response.status_code == status_map['create_error'][username], response.json()
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_create_error(db, client, username, password):
+ client.login(username=username, password=password)
+
+ json_data = {'foo': 'bar'}
+
+ url = reverse(urlnames['list'])
+ response = client.post(url, json_data, content_type='application/json')
+ assert response.status_code == status_map['create_error'][username], response.json()
diff --git a/rdmo/management/tests/test_viewset_import_multisite.py b/rdmo/management/tests/test_viewset_import_multisite.py
new file mode 100644
index 0000000000..5a0e2f3d35
--- /dev/null
+++ b/rdmo/management/tests/test_viewset_import_multisite.py
@@ -0,0 +1,128 @@
+import pytest
+
+from django.urls import reverse
+
+from rdmo.core.tests import get_obj_perms_status_code
+from rdmo.core.tests import multisite_users as users
+from rdmo.questions.models import Catalog, Page, Question, QuestionSet, Section
+
+status_map = {
+ 'list': {
+ 'default': 405, 'anonymous': 401
+ },
+ 'create': {
+ 'default': 200, 'anonymous': 401
+ },
+ 'create_error': {
+ 'default': 400, 'anonymous': 401
+ }
+}
+
+catalog_uri_paths = [
+ 'catalog',
+ 'catalog2',
+ 'foo-catalog',
+ 'bar-catalog',
+]
+
+urlnames = {
+ 'list': 'v1-management:import-list'
+}
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_list(db, client, username, password):
+ client.login(username=username, password=password)
+
+ url = reverse(urlnames['list'])
+ response = client.get(url)
+ assert response.status_code == status_map['list'].get(username, status_map['list']['default']), response.json()
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_create_create(db, client, username, password, json_data):
+ Catalog.objects.all().delete()
+ Section.objects.all().delete()
+ Page.objects.all().delete()
+ QuestionSet.objects.all().delete()
+ Question.objects.all().delete()
+
+ client.login(username=username, password=password)
+
+ url = reverse(urlnames['list'])
+ response = client.post(url, json_data, content_type='application/json')
+
+ assert response.status_code == status_map['create'].get(username, status_map['create']['default']), response.json()
+
+ if response.status_code == 200:
+ for element in response.json():
+ if any(i in username for i in ['reviewer', 'user']):
+ assert element.get('created') is False
+ else:
+ assert element.get('created') is True
+ assert element.get('updated') is False
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_create_update(db, client, username, password, json_data):
+ client.login(username=username, password=password)
+
+ url = reverse(urlnames['list'])
+ response = client.post(url, json_data, content_type='application/json')
+
+ assert response.status_code == status_map['create'].get(username, status_map['create']['default']), response.json()
+
+ if response.status_code == 200:
+ for element in response.json():
+ assert element.get('created') is False
+ obj_perm_status_code = get_obj_perms_status_code(element.get('uri_path'), username, 'update')
+ if obj_perm_status_code == 200:
+ assert element.get('updated') is True
+ else:
+ assert element.get('updated') is False
+
+
+@pytest.mark.parametrize('username,password', users)
+@pytest.mark.parametrize('catalog_uri_path', catalog_uri_paths)
+def test_create_update_certain_catalog(db, client, username, password, catalog_uri_path, json_data):
+ client.login(username=username, password=password)
+
+ instance_json = next(i for i in json_data['elements'] if i['uri_path'] == catalog_uri_path)
+ instance_json['title_en'] += ' (updated)'
+ instance_json['title_de'] += ' (updated)'
+ instance_data = {'elements': [instance_json]}
+
+ url = reverse(urlnames['list'])
+ response = client.post(url, instance_data, content_type='application/json')
+
+ assert response.status_code == status_map['create'].get(username, status_map['create']['default']), response.json()
+ if response.status_code == 200:
+ obj_perm_status_code = get_obj_perms_status_code(catalog_uri_path, username, 'update')
+ for element in response.json():
+ assert element.get('created') is False
+ if obj_perm_status_code == 200:
+ assert element.get('updated') is True
+ else:
+ assert element.get('updated') is False
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_create_empty(db, client, username, password):
+ client.login(username=username, password=password)
+
+ url = reverse(urlnames['list'])
+ response = client.post(url, {}, content_type='application/json')
+ assert response.status_code == status_map['create_error'].get(username, status_map['create_error']['default']), \
+ response.json()
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_create_error(db, client, username, password):
+ client.login(username=username, password=password)
+
+ json_data = {'foo': 'bar'}
+
+ url = reverse(urlnames['list'])
+ response = client.post(url, json_data, content_type='application/json')
+ assert response.status_code == status_map['create_error'].get(username, status_map['create_error']['default']), \
+ response.json()
diff --git a/rdmo/management/tests/test_viewset_upload.py b/rdmo/management/tests/test_viewset_upload.py
new file mode 100644
index 0000000000..3715a44e0f
--- /dev/null
+++ b/rdmo/management/tests/test_viewset_upload.py
@@ -0,0 +1,119 @@
+from pathlib import Path
+
+import pytest
+
+from django.conf import settings
+from django.urls import reverse
+
+from rdmo.questions.models import Catalog, Page, Question, QuestionSet, Section
+
+users = (
+ ('editor', 'editor'),
+ ('reviewer', 'reviewer'),
+ ('user', 'user'),
+ ('api', 'api'),
+ ('anonymous', None),
+)
+
+status_map = {
+ 'list': {
+ 'editor': 405, 'reviewer': 405, 'api': 405, 'user': 405, 'anonymous': 401
+ },
+ 'create': {
+ 'editor': 200, 'reviewer': 200, 'api': 200, 'user': 200, 'anonymous': 401
+ },
+ 'create_error': {
+ 'editor': 400, 'reviewer': 400, 'api': 400, 'user': 400, 'anonymous': 401
+ }
+}
+
+urlnames = {
+ 'list': 'v1-management:upload-list'
+}
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_list(db, client, username, password):
+ client.login(username=username, password=password)
+
+ url = reverse(urlnames['list'])
+ response = client.get(url)
+ assert response.status_code == status_map['list'][username], response.json()
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_create(db, client, username, password):
+ client.login(username=username, password=password)
+
+ xml_file = Path(settings.BASE_DIR) / 'xml' / 'elements' / 'catalogs.xml'
+
+ url = reverse(urlnames['list'])
+ with open(xml_file, encoding='utf8') as f:
+ response = client.post(url, {'file': f})
+
+ assert response.status_code == status_map['create'][username], response.json()
+ if response.status_code == 200:
+ for element in response.json():
+ assert element.get('updated') is False
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_create_import_create(db, client, username, password):
+ Catalog.objects.all().delete()
+ Section.objects.all().delete()
+ Page.objects.all().delete()
+ QuestionSet.objects.all().delete()
+ Question.objects.all().delete()
+
+ client.login(username=username, password=password)
+
+ xml_file = Path(settings.BASE_DIR) / 'xml' / 'elements' / 'catalogs.xml'
+
+ url = reverse(urlnames['list'])
+ with open(xml_file, encoding='utf8') as f:
+ response = client.post(url, {'file': f, 'import': 'true'})
+
+ assert response.status_code == status_map['create'][username], response.json()
+ if response.status_code == 200:
+ for element in response.json():
+ assert element.get('created') is False if username in ['reviewer', 'user'] else True
+ assert element.get('updated') is False
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_create_import_update(db, client, username, password):
+ client.login(username=username, password=password)
+
+ xml_file = Path(settings.BASE_DIR) / 'xml' / 'elements' / 'catalogs.xml'
+
+ url = reverse(urlnames['list'])
+ with open(xml_file, encoding='utf8') as f:
+ response = client.post(url, {'file': f, 'import': 'true'})
+
+ assert response.status_code == status_map['create'][username], response.json()
+ if response.status_code == 200:
+ for element in response.json():
+ assert element.get('created') is False
+ assert element.get('updated') is False if username in ['reviewer', 'user'] else True
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_create_empty(db, client, username, password):
+ client.login(username=username, password=password)
+
+ url = reverse(urlnames['list'])
+ response = client.post(url, {})
+ assert response.status_code == status_map['create_error'][username], response.json()
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_create_error(db, client, username, password):
+ client.login(username=username, password=password)
+
+ xml_file = Path(settings.BASE_DIR) / 'xml' / 'error.xml'
+
+ url = reverse(urlnames['list'])
+ with open(xml_file, encoding='utf8') as f:
+ response = client.post(url, {'file': f})
+
+ assert response.status_code == status_map['create_error'][username], response.json()
diff --git a/rdmo/management/urls/__init__.py b/rdmo/management/urls/__init__.py
index b26f195a30..cb5ea3f506 100644
--- a/rdmo/management/urls/__init__.py
+++ b/rdmo/management/urls/__init__.py
@@ -1,10 +1,7 @@
-from django.urls import path
-from django.views.generic.base import RedirectView
+from django.urls import re_path
-from ..views import ImportView, UploadView
+from ..views import ManagementView
urlpatterns = [
- path('', RedirectView.as_view(pattern_name='catalogs'), name='management'),
- path('upload/', UploadView.as_view(), name='upload'),
- path('import/', ImportView.as_view(), name='import')
+ re_path('', ManagementView.as_view(), name='management')
]
diff --git a/rdmo/management/urls/v1.py b/rdmo/management/urls/v1.py
new file mode 100644
index 0000000000..d0bee28c52
--- /dev/null
+++ b/rdmo/management/urls/v1.py
@@ -0,0 +1,16 @@
+from django.urls import include, path
+
+from rest_framework import routers
+
+from ..viewsets import ImportViewSet, MetaViewSet, UploadViewSet
+
+app_name = 'v1-management'
+
+router = routers.DefaultRouter()
+router.register(r'meta', MetaViewSet, basename='meta')
+router.register(r'upload', UploadViewSet, basename='upload')
+router.register(r'import', ImportViewSet, basename='import')
+
+urlpatterns = [
+ path('', include(router.urls)),
+]
diff --git a/rdmo/management/views.py b/rdmo/management/views.py
index 913db301b3..18ce86b5bf 100644
--- a/rdmo/management/views.py
+++ b/rdmo/management/views.py
@@ -1,126 +1,20 @@
import logging
from django.contrib.auth.mixins import LoginRequiredMixin
-from django.http import HttpResponseRedirect
-from django.shortcuts import render
-from django.urls import reverse
-from django.utils.translation import gettext_lazy as _
-from django.views.generic.base import View
-from rdmo.core.imports import handle_uploaded_file
-from rdmo.core.xml import flat_xml_to_elements, read_xml_file
+from django.views.generic import TemplateView
-from .imports import check_permissions, import_elements
+from rules import test_rule
+from rules.contrib.views import PermissionRequiredMixin as RulesPermissionRequiredMixin
-logger = logging.getLogger(__name__)
-
-
-class UploadView(LoginRequiredMixin, View):
-
- def get_success_url(self):
- return self.request.META.get('HTTP_REFERER') or reverse('management')
-
- def get(self, request):
- return HttpResponseRedirect(reverse('management'))
-
- def post(self, request):
- try:
- uploaded_file = request.FILES['uploaded_file']
- except KeyError:
- return HttpResponseRedirect(self.get_success_url())
- else:
- import_tmpfile_name = handle_uploaded_file(uploaded_file)
-
- root = read_xml_file(import_tmpfile_name)
- if root is None:
- logger.info('Xml parsing error. Import failed.')
- return render(request, 'core/error.html', {
- 'title': _('Import error'),
- 'errors': [_('The content of the xml file does not consist of well formed data or markup.')]
- }, status=400)
-
- else:
- try:
- elements = flat_xml_to_elements(root)
- except (KeyError, TypeError, AttributeError):
- return render(request, 'core/error.html', {
- 'title': _('Import error'),
- 'errors': [_('This is not a valid RDMO XML file.')]
- }, status=400)
-
- if check_permissions(elements, request.user):
- # store information in session for ProjectCreateImportView
- request.session['import_file_name'] = uploaded_file.name
- request.session['import_tmpfile_name'] = import_tmpfile_name
- request.session['import_success_url'] = self.get_success_url()
+from rdmo.core.views import CSRFViewMixin, PermissionRedirectMixin
- return render(request, 'management/upload.html', {
- 'file_name': uploaded_file.name,
- 'elements': import_elements(elements)
- })
- else:
- return render(request, 'core/error.html', {
- 'title': _('Import error'),
- 'errors': [_('Forbidden.')]
- }, status=403)
-
-
-class ImportView(LoginRequiredMixin, View):
-
- def get_success_url(self):
- return self.request.session.get('import_success_url') or reverse('management')
-
- def get(self, request):
- return HttpResponseRedirect(reverse('management'))
-
- def post(self, request):
- if 'cancel' in self.request.POST:
- return HttpResponseRedirect(self.get_success_url())
-
- import_file_name = request.session['import_file_name']
- import_tmpfile_name = request.session.get('import_tmpfile_name')
-
- # parse the form data, which is or
- parents = {}
- checked = {}
- for key, values in request.POST.lists():
- if key.startswith('http'):
- try:
- parents[key] = None if values[0] == 'null' else values[0]
- checked[key] = True if values[1] == 'on' else False
- except IndexError:
- parents[key] = False
- checked[key] = True if values[0] == 'on' else False
-
- root = read_xml_file(import_tmpfile_name)
- if root is None:
- logger.info('Xml parsing error. Import failed.')
- return render(request, 'core/error.html', {
- 'title': _('Import error'),
- 'errors': [_('The content of the xml file does not consist of well formed data or markup.')]
- }, status=400)
+logger = logging.getLogger(__name__)
- else:
- try:
- elements = flat_xml_to_elements(root)
- except (KeyError, TypeError):
- return render(request, 'core/error.html', {
- 'title': _('Import error'),
- 'errors': [_('This is not a RDMO XML file.')]
- }, status=400)
- if check_permissions(elements, request.user):
- if checked:
- return render(request, 'management/import.html', {
- 'file_name': import_file_name,
- 'elements': import_elements(elements, parents=parents, save=checked),
- 'success_url': self.get_success_url()
- })
- else:
- # if nothing was checked, just return to the success_url
- return HttpResponseRedirect(self.get_success_url())
+class ManagementView(LoginRequiredMixin, PermissionRedirectMixin, RulesPermissionRequiredMixin,
+ CSRFViewMixin, TemplateView):
+ template_name = 'management/management.html'
- else:
- return render(request, 'core/error.html', {
- 'title': _('Import error'),
- 'errors': [_('Forbidden.')]
- }, status=403)
+ def has_permission(self):
+ # Use test_rule from rules for permissions check
+ return test_rule('management.can_view_management', self.request.user, self.request.site)
diff --git a/rdmo/management/viewsets.py b/rdmo/management/viewsets.py
new file mode 100644
index 0000000000..36c625dc85
--- /dev/null
+++ b/rdmo/management/viewsets.py
@@ -0,0 +1,112 @@
+import logging
+
+from django.utils.translation import gettext_lazy as _
+
+from rest_framework import viewsets
+from rest_framework.permissions import IsAuthenticated
+from rest_framework.response import Response
+from rest_framework.serializers import ValidationError
+
+from rdmo.conditions.models import Condition
+from rdmo.core.imports import handle_uploaded_file
+from rdmo.core.utils import get_model_field_meta, is_truthy
+from rdmo.core.xml import convert_elements, flat_xml_to_elements, order_elements, read_xml_file
+from rdmo.domain.models import Attribute
+from rdmo.options.models import Option, OptionSet
+from rdmo.questions.models import Catalog, Page, Question, QuestionSet, Section
+from rdmo.tasks.models import Task
+from rdmo.views.models import View
+
+from .imports import import_elements
+
+logger = logging.getLogger(__name__)
+
+
+class MetaViewSet(viewsets.ViewSet):
+
+ permission_classes = (IsAuthenticated, )
+
+ def list(self, request, *args, **kwargs):
+ return Response({
+ 'conditions.condition': get_model_field_meta(Condition),
+ 'domain.attribute': get_model_field_meta(Attribute),
+ 'options.optionset': get_model_field_meta(OptionSet),
+ 'options.option': get_model_field_meta(Option),
+ 'questions.catalog': get_model_field_meta(Catalog),
+ 'questions.section': get_model_field_meta(Section),
+ 'questions.page': get_model_field_meta(Page),
+ 'questions.questionset': get_model_field_meta(QuestionSet),
+ 'questions.question': get_model_field_meta(Question),
+ 'tasks.task': get_model_field_meta(Task),
+ 'views.view': get_model_field_meta(View)
+ })
+
+
+class UploadViewSet(viewsets.ViewSet):
+
+ permission_classes = (IsAuthenticated, )
+
+ def create(self, request, *args, **kwargs):
+ # step 1: store xml file as tmp file
+ try:
+ uploaded_file = request.FILES['file']
+ except KeyError as e:
+ raise ValidationError({'file': [_('This field may not be blank.')]}) from e
+ else:
+ import_tmpfile_name = handle_uploaded_file(uploaded_file)
+
+ # step 2: parse xml
+ root = read_xml_file(import_tmpfile_name)
+ if root is None:
+ logger.info('XML parsing error. Import failed.')
+ raise ValidationError({'file': [
+ _('The content of the xml file does not consist of well formed data or markup.')
+ ]})
+
+ # step 3: create element dicts from xml
+ try:
+ elements = flat_xml_to_elements(root)
+ except KeyError as e:
+ logger.info('Import failed with KeyError (%s)' % e)
+ raise ValidationError({'file': [_('This is not a valid RDMO XML file.')]}) from e
+ except TypeError as e:
+ logger.info('Import failed with TypeError (%s)' % e)
+ raise ValidationError({'file': [_('This is not a valid RDMO XML file.')]}) from e
+ except AttributeError as e:
+ logger.info('Import failed with AttributeError (%s)' % e)
+ raise ValidationError({'file': [_('This is not a valid RDMO XML file.')]}) from e
+
+ # step 4: convert elements from previous versions
+ elements = convert_elements(elements, root.attrib.get('version'))
+
+ # step 5: order the elements and return
+ elements = order_elements(elements)
+
+ # step 6: convert elements to a list
+ elements = elements.values()
+
+ # step 8: import the elements if save=True is set
+ import_elements(elements, save=is_truthy(request.POST.get('import')), user=request.user)
+
+ # step 9: return the list of elements
+ return Response(elements)
+
+
+class ImportViewSet(viewsets.ViewSet):
+
+ permission_classes = (IsAuthenticated, )
+
+ def create(self, request, *args, **kwargs):
+ # step 1: store xml file as tmp file
+ try:
+ elements = request.data['elements']
+ except KeyError as e:
+ raise ValidationError({'elements': [_('This field may not be blank.')]}) from e
+ except TypeError as e:
+ raise ValidationError({'elements': [_('This is not a valid RDMO import JSON.')]}) from e
+
+ # step 3: import the elements
+ import_elements(elements, user=request.user)
+
+ # step 4: return the list of elements
+ return Response(elements)
diff --git a/rdmo/options/admin.py b/rdmo/options/admin.py
index 2f57bdb9db..bf5e21c65c 100644
--- a/rdmo/options/admin.py
+++ b/rdmo/options/admin.py
@@ -1,15 +1,18 @@
-from django import forms
from django.contrib import admin
+from rdmo.core.admin import ElementAdminForm
from rdmo.core.utils import get_language_fields
-from .models import Option, OptionSet
-from .validators import (OptionLockedValidator, OptionSetLockedValidator,
- OptionSetUniqueURIValidator, OptionUniqueURIValidator)
+from .models import Option, OptionSet, OptionSetOption
+from .validators import (
+ OptionLockedValidator,
+ OptionSetLockedValidator,
+ OptionSetUniqueURIValidator,
+ OptionUniqueURIValidator,
+)
-class OptionSetAdminForm(forms.ModelForm):
- key = forms.SlugField(required=True)
+class OptionSetAdminForm(ElementAdminForm):
class Meta:
model = OptionSet
@@ -20,8 +23,7 @@ def clean(self):
OptionSetLockedValidator(self.instance)(self.cleaned_data)
-class OptionAdminForm(forms.ModelForm):
- key = forms.SlugField(required=True)
+class OptionAdminForm(ElementAdminForm):
class Meta:
model = Option
@@ -32,21 +34,29 @@ def clean(self):
OptionLockedValidator(self.instance)(self.cleaned_data)
+class OptionSetOptionInline(admin.TabularInline):
+ model = OptionSetOption
+ extra = 0
+
+
class OptionSetAdmin(admin.ModelAdmin):
form = OptionSetAdminForm
+ inlines = (OptionSetOptionInline, )
search_fields = ('uri', )
list_display = ('uri', )
readonly_fields = ('uri', )
+ filter_horizontal = ('editors', 'conditions')
class OptionAdmin(admin.ModelAdmin):
form = OptionAdminForm
- search_fields = ['uri'] + get_language_fields('text')
+ search_fields = ['uri', *get_language_fields('text')]
list_display = ('uri', 'text', 'additional_input')
- readonly_fields = ('uri', 'path')
- list_filter = ('optionset', 'additional_input')
+ readonly_fields = ('uri', )
+ list_filter = ('editors', 'optionsets', 'additional_input')
+ filter_horizontal = ('editors', )
admin.site.register(OptionSet, OptionSetAdmin)
diff --git a/rdmo/options/imports.py b/rdmo/options/imports.py
index 6a5d1e6228..757fc34778 100644
--- a/rdmo/options/imports.py
+++ b/rdmo/options/imports.py
@@ -1,18 +1,29 @@
import logging
-from rdmo.conditions.models import Condition
-from rdmo.core.imports import (fetch_parents, get_foreign_field,
- get_m2m_instances, set_common_fields,
- set_lang_field, validate_instance)
+from django.contrib.sites.models import Site
+
+from rdmo.core.imports import (
+ check_permissions,
+ set_common_fields,
+ set_lang_field,
+ set_m2m_instances,
+ set_m2m_through_instances,
+ set_reverse_m2m_through_instance,
+ validate_instance,
+)
from .models import Option, OptionSet
-from .validators import (OptionLockedValidator, OptionSetLockedValidator,
- OptionSetUniqueURIValidator, OptionUniqueURIValidator)
+from .validators import (
+ OptionLockedValidator,
+ OptionSetLockedValidator,
+ OptionSetUniqueURIValidator,
+ OptionUniqueURIValidator,
+)
logger = logging.getLogger(__name__)
-def import_optionset(element, save=False):
+def import_optionset(element, save=False, user=None):
try:
optionset = OptionSet.objects.get(uri=element.get('uri'))
except OptionSet.DoesNotExist:
@@ -23,49 +34,52 @@ def import_optionset(element, save=False):
optionset.order = element.get('order') or 0
optionset.provider_key = element.get('provider_key') or ''
- conditions = get_m2m_instances(optionset, element.get('conditions'), Condition)
+ validate_instance(optionset, element, OptionSetLockedValidator, OptionSetUniqueURIValidator)
- if save and validate_instance(optionset, OptionSetLockedValidator, OptionSetUniqueURIValidator):
+ check_permissions(optionset, element, user)
+
+ if save and not element.get('errors'):
if optionset.id:
- logger.info('OptionSet created with uri %s.', element.get('uri'))
- else:
+ element['updated'] = True
logger.info('OptionSet %s updated.', element.get('uri'))
+ else:
+ element['created'] = True
+ logger.info('OptionSet created with uri %s.', element.get('uri'))
optionset.save()
- optionset.conditions.set(conditions)
- optionset.imported = True
+ set_m2m_instances(optionset, 'conditions', element)
+ set_m2m_through_instances(optionset, 'options', element, 'optionset', 'option', 'optionset_options')
+ optionset.editors.add(Site.objects.get_current())
return optionset
-def import_option(element, optionset_uri=False, save=False):
+def import_option(element, save=False, user=None):
try:
- if optionset_uri is False:
- option = Option.objects.get(uri=element.get('uri'))
- else:
- option = Option.objects.get(key=element.get('key'), optionset__uri=optionset_uri)
+ option = Option.objects.get(uri=element.get('uri'))
except Option.DoesNotExist:
option = Option()
set_common_fields(option, element)
- option.optionset = get_foreign_field(option, optionset_uri or element.get('optionset'), OptionSet)
- option.order = element.get('order') or 0
option.additional_input = element.get('additional_input') or False
set_lang_field(option, 'text', element)
- if save and validate_instance(option, OptionLockedValidator, OptionUniqueURIValidator):
+ validate_instance(option, element, OptionLockedValidator, OptionUniqueURIValidator)
+
+ check_permissions(option, element, user)
+
+ if save and not element.get('errors'):
if option.id:
- logger.info('Option created with uri %s.', element.get('uri'))
- else:
+ element['updated'] = True
logger.info('Option %s updated.', element.get('uri'))
+ else:
+ element['created'] = True
+ logger.info('Option created with uri %s.', element.get('uri'))
option.save()
- option.imported = True
+ set_reverse_m2m_through_instance(option, 'optionset', element, 'option', 'optionset', 'option_optionsets')
+ option.editors.add(Site.objects.get_current())
return option
-
-
-def fetch_option_parents(instances):
- return fetch_parents(OptionSet, instances)
diff --git a/rdmo/options/migrations/0028_optionset_uri_path.py b/rdmo/options/migrations/0028_optionset_uri_path.py
new file mode 100644
index 0000000000..0191ca7461
--- /dev/null
+++ b/rdmo/options/migrations/0028_optionset_uri_path.py
@@ -0,0 +1,32 @@
+# Generated by Django 3.2.14 on 2023-01-09 17:44
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('options', '0027_meta'),
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name='option',
+ options={'ordering': ('uri',), 'verbose_name': 'Option', 'verbose_name_plural': 'Options'},
+ ),
+ migrations.RenameField(
+ model_name='optionset',
+ old_name='key',
+ new_name='uri_path',
+ ),
+ migrations.AlterField(
+ model_name='optionset',
+ name='uri',
+ field=models.URLField(blank=True, help_text='The Uniform Resource Identifier of this option set (auto-generated).', max_length=800, verbose_name='URI'),
+ ),
+ migrations.AlterField(
+ model_name='optionset',
+ name='uri_path',
+ field=models.CharField(blank=True, help_text='The path for the URI of this option set.', max_length=512, verbose_name='URI Path'),
+ ),
+ ]
diff --git a/rdmo/options/migrations/0029_option_uri_path.py b/rdmo/options/migrations/0029_option_uri_path.py
new file mode 100644
index 0000000000..dd106f9ad9
--- /dev/null
+++ b/rdmo/options/migrations/0029_option_uri_path.py
@@ -0,0 +1,41 @@
+# Generated by Django 3.2.14 on 2023-01-10 11:08
+
+from django.db import migrations, models
+
+
+def run_data_migration(apps, schema_editor):
+ Option = apps.get_model('options', 'Option')
+
+ for option in Option.objects.all():
+ option.uri_path = '%s/%s' % (option.optionset.uri_path, option.key)
+ option.save()
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('options', '0028_optionset_uri_path'),
+ ]
+
+ operations = [
+ migrations.RenameField(
+ model_name='option',
+ old_name='path',
+ new_name='uri_path',
+ ),
+ migrations.AlterField(
+ model_name='option',
+ name='uri',
+ field=models.URLField(blank=True, help_text='The Uniform Resource Identifier of this option (auto-generated).', max_length=800, verbose_name='URI'),
+ ),
+ migrations.AlterField(
+ model_name='option',
+ name='uri_path',
+ field=models.CharField(blank=True, help_text='The path for the URI of this option.', max_length=512, verbose_name='URI Path'),
+ ),
+ migrations.RunPython(run_data_migration),
+ migrations.RemoveField(
+ model_name='option',
+ name='key',
+ ),
+ ]
diff --git a/rdmo/options/migrations/0030_optionset_options.py b/rdmo/options/migrations/0030_optionset_options.py
new file mode 100644
index 0000000000..2bb05b4a73
--- /dev/null
+++ b/rdmo/options/migrations/0030_optionset_options.py
@@ -0,0 +1,58 @@
+# Generated by Django 3.2.14 on 2023-01-10 14:05
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+def run_data_migration(apps, schema_editor):
+ Option = apps.get_model('options', 'Option')
+ OptionSetOption = apps.get_model('options', 'OptionSetOption')
+
+ for option in Option.objects.all():
+ OptionSetOption(
+ optionset=option.optionset,
+ option=option,
+ order=option.order,
+ ).save()
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('options', '0029_option_uri_path'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='OptionSetOption',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('order', models.IntegerField(default=0)),
+ ('optionset', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='optionset_options', to='options.optionset')),
+ ('option', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='option_optionsets', to='options.option')),
+ ],
+ options={
+ 'ordering': ('optionset', 'order'),
+ },
+ ),
+ # remove the related_name='options' from Option.optionset
+ migrations.AlterField(
+ model_name='option',
+ name='optionset',
+ field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='options.OptionSet'),
+ ),
+ migrations.AddField(
+ model_name='optionset',
+ name='options',
+ field=models.ManyToManyField(blank=True, help_text='The list of options for this option set.', related_name='optionsets', through='options.OptionSetOption', to='options.Option', verbose_name='Options'),
+ ),
+ migrations.RunPython(run_data_migration),
+ migrations.RemoveField(
+ model_name='option',
+ name='optionset',
+ ),
+ migrations.RemoveField(
+ model_name='option',
+ name='order',
+ )
+ ]
diff --git a/rdmo/options/migrations/0031_add_editors.py b/rdmo/options/migrations/0031_add_editors.py
new file mode 100644
index 0000000000..b245e02ad0
--- /dev/null
+++ b/rdmo/options/migrations/0031_add_editors.py
@@ -0,0 +1,24 @@
+# Generated by Django 3.2.16 on 2023-02-24 09:30
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('sites', '0002_alter_domain_unique'),
+ ('options', '0030_optionset_options'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='option',
+ name='editors',
+ field=models.ManyToManyField(blank=True, help_text='The sites that can edit this option (in a multi site setup).', related_name='options_as_editor', to='sites.Site', verbose_name='Editors'),
+ ),
+ migrations.AddField(
+ model_name='optionset',
+ name='editors',
+ field=models.ManyToManyField(blank=True, help_text='The sites that can edit this option set (in a multi site setup).', related_name='optionsets_as_editor', to='sites.Site', verbose_name='Editors'),
+ ),
+ ]
diff --git a/rdmo/options/models.py b/rdmo/options/models.py
index e4768af847..a450cf64ad 100644
--- a/rdmo/options/models.py
+++ b/rdmo/options/models.py
@@ -1,5 +1,7 @@
from django.conf import settings
+from django.contrib.sites.models import Site
from django.db import models
+from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from rdmo.conditions.models import Condition
@@ -11,7 +13,7 @@
class OptionSet(models.Model):
uri = models.URLField(
- max_length=640, blank=True,
+ max_length=800, blank=True,
verbose_name=_('URI'),
help_text=_('The Uniform Resource Identifier of this option set (auto-generated).')
)
@@ -20,10 +22,10 @@ class OptionSet(models.Model):
verbose_name=_('URI Prefix'),
help_text=_('The prefix for the URI of this option set.')
)
- key = models.SlugField(
- max_length=128, blank=True,
- verbose_name=_('Key'),
- help_text=_('The internal identifier of this option set.')
+ uri_path = models.CharField(
+ max_length=512, blank=True,
+ verbose_name=_('URI Path'),
+ help_text=_('The path for the URI of this option set.')
)
comment = models.TextField(
blank=True,
@@ -40,11 +42,21 @@ class OptionSet(models.Model):
verbose_name=_('Order'),
help_text=_('The position of this option set in lists.')
)
+ editors = models.ManyToManyField(
+ Site, related_name='optionsets_as_editor', blank=True,
+ verbose_name=_('Editors'),
+ help_text=_('The sites that can edit this option set (in a multi site setup).')
+ )
provider_key = models.SlugField(
max_length=128, blank=True,
verbose_name=_('Provider'),
help_text=_('The provider for this optionset. If set, it will create dynamic options for this optionset.')
)
+ options = models.ManyToManyField(
+ 'Option', through='OptionSetOption', blank=True, related_name='optionsets',
+ verbose_name=_('Options'),
+ help_text=_('The list of options for this option set.')
+ )
conditions = models.ManyToManyField(
Condition, blank=True, related_name='optionsets',
verbose_name=_('Conditions'),
@@ -57,24 +69,21 @@ class Meta:
verbose_name_plural = _('Option sets')
def __str__(self):
- return self.key
+ return self.uri
def save(self, *args, **kwargs):
- self.uri = self.build_uri(self.uri_prefix, self.key)
+ self.uri = self.build_uri(self.uri_prefix, self.uri_path)
super().save(*args, **kwargs)
- for option in self.options.all():
- option.save()
-
- def copy(self, uri_prefix, key):
- optionset = copy_model(self, uri_prefix=uri_prefix, key=key)
+ def copy(self, uri_prefix, uri_path):
+ optionset = copy_model(self, uri_prefix=uri_prefix, uri_path=uri_path)
- # copy m2m fields
+ # set m2m fields for copy
optionset.conditions.set(self.conditions.all())
- # copy children
+ # add copy to children
for option in self.options.all():
- option.copy(uri_prefix, option.key, optionset=optionset)
+ option.optionsets.add(optionset)
return optionset
@@ -106,16 +115,40 @@ def has_conditions(self):
def is_locked(self):
return self.locked
+ @cached_property
+ def elements(self):
+ return [element.option for element in sorted(self.optionset_options.all(), key=lambda e: e.order)]
+
@classmethod
- def build_uri(cls, uri_prefix, key):
- assert key
- return join_url(uri_prefix or settings.DEFAULT_URI_PREFIX, '/options/', key)
+ def build_uri(cls, uri_prefix, uri_path):
+ if not uri_path:
+ raise RuntimeError('uri_path is missing')
+ return join_url(uri_prefix or settings.DEFAULT_URI_PREFIX, '/options/', uri_path)
+
+
+class OptionSetOption(models.Model):
+
+ optionset = models.ForeignKey(
+ 'OptionSet', on_delete=models.CASCADE, related_name='optionset_options'
+ )
+ option = models.ForeignKey(
+ 'Option', on_delete=models.CASCADE, related_name='option_optionsets'
+ )
+ order = models.IntegerField(
+ default=0
+ )
+
+ class Meta:
+ ordering = ('optionset', 'order')
+
+ def __str__(self):
+ return f'{self.optionset} / {self.option} [{self.order}]'
class Option(models.Model, TranslationMixin):
uri = models.URLField(
- max_length=640, blank=True,
+ max_length=800, blank=True,
verbose_name=_('URI'),
help_text=_('The Uniform Resource Identifier of this option (auto-generated).')
)
@@ -124,15 +157,10 @@ class Option(models.Model, TranslationMixin):
verbose_name=_('URI Prefix'),
help_text=_('The prefix for the URI of this option.')
)
- key = models.SlugField(
- max_length=128, blank=True,
- verbose_name=_('Key'),
- help_text=_('The internal identifier of this option.')
- )
- path = models.SlugField(
+ uri_path = models.CharField(
max_length=512, blank=True,
- verbose_name=_('Path'),
- help_text=_('The path part of the URI for this option (auto-generated).')
+ verbose_name=_('URI Path'),
+ help_text=_('The path for the URI of this option.')
)
comment = models.TextField(
blank=True,
@@ -144,15 +172,10 @@ class Option(models.Model, TranslationMixin):
verbose_name=_('Locked'),
help_text=_('Designates whether this option can be changed.')
)
- optionset = models.ForeignKey(
- 'OptionSet', on_delete=models.CASCADE, related_name='options',
- verbose_name=_('Option set'),
- help_text=_('The option set this option belongs to.')
- )
- order = models.IntegerField(
- default=0,
- verbose_name=_('Order'),
- help_text=_('Position in lists.')
+ editors = models.ManyToManyField(
+ Site, related_name='options_as_editor', blank=True,
+ verbose_name=_('Editors'),
+ help_text=_('The sites that can edit this option (in a multi site setup).')
)
text_lang1 = models.CharField(
max_length=256, blank=True,
@@ -186,44 +209,31 @@ class Option(models.Model, TranslationMixin):
)
class Meta:
- ordering = ('optionset__order', 'optionset__key', 'order', 'key')
+ ordering = ('uri', )
verbose_name = _('Option')
verbose_name_plural = _('Options')
def __str__(self):
- return self.path
+ return self.uri
def save(self, *args, **kwargs):
- self.path = self.build_path(self.key, self.optionset)
- self.uri = self.build_uri(self.uri_prefix, self.path)
+ self.uri = self.build_uri(self.uri_prefix, self.uri_path)
super().save(*args, **kwargs)
- def copy(self, uri_prefix, key, optionset=None):
- return copy_model(self, uri_prefix=uri_prefix, key=key, optionset=optionset or self.optionset)
-
- @property
- def parent_fields(self):
- return ('optionset', )
-
@property
def text(self):
return self.trans('text')
@property
def label(self):
- return '%s ("%s")' % (self.uri, self.text)
+ return f'{self.uri} ("{self.text}")'
@property
def is_locked(self):
- return self.locked or self.optionset.locked
-
- @classmethod
- def build_path(cls, key, optionset):
- assert key
- assert optionset
- return '%s/%s' % (optionset.key, key) if (optionset and key) else None
+ return self.locked or self.optionsets.filter(locked=True).exists()
@classmethod
- def build_uri(cls, uri_prefix, path):
- assert path
- return join_url(uri_prefix or settings.DEFAULT_URI_PREFIX, '/options/', path)
+ def build_uri(cls, uri_prefix, uri_path):
+ if not uri_path:
+ raise RuntimeError('uri_path is missing')
+ return join_url(uri_prefix or settings.DEFAULT_URI_PREFIX, '/options/', uri_path)
diff --git a/rdmo/options/renderers.py b/rdmo/options/renderers.py
deleted file mode 100644
index 0ecc5954a6..0000000000
--- a/rdmo/options/renderers.py
+++ /dev/null
@@ -1,61 +0,0 @@
-from rdmo.core.renderers import BaseXMLRenderer
-from rdmo.core.utils import get_languages
-
-
-class OptionsRenderer(BaseXMLRenderer):
-
- def render_optionset(self, xml, optionset):
- xml.startElement('optionset', {'dc:uri': optionset['uri']})
- self.render_text_element(xml, 'uri_prefix', {}, optionset['uri_prefix'])
- self.render_text_element(xml, 'key', {}, optionset['key'])
- self.render_text_element(xml, 'dc:comment', {}, optionset['comment'])
- self.render_text_element(xml, 'order', {}, optionset['order'])
- self.render_text_element(xml, 'provider_key', {}, optionset['provider_key'])
- xml.startElement('conditions', {})
- for condition_uri in optionset['conditions']:
- self.render_text_element(xml, 'condition', {'dc:uri': condition_uri}, None)
- xml.endElement('conditions')
- xml.endElement('optionset')
-
- if 'options' in optionset and optionset['options']:
- for option in optionset['options']:
- self.render_option(xml, option)
-
- def render_option(self, xml, option):
- xml.startElement('option', {'dc:uri': option['uri']})
- self.render_text_element(xml, 'uri_prefix', {}, option['uri_prefix'])
- self.render_text_element(xml, 'key', {}, option['key'])
- self.render_text_element(xml, 'path', {}, option['path'])
- self.render_text_element(xml, 'dc:comment', {}, option['comment'])
- self.render_text_element(xml, 'optionset', {'dc:uri': option['optionset']}, None)
- self.render_text_element(xml, 'order', {}, option['order'])
-
- for lang_code, lang_string, lang_field in get_languages():
- self.render_text_element(xml, 'text', {'lang': lang_code}, option['text_%s' % lang_code])
-
- self.render_text_element(xml, 'additional_input', {}, option['additional_input'])
- xml.endElement('option')
-
-
-class OptionSetRenderer(OptionsRenderer):
-
- def render_document(self, xml, optionsets):
- xml.startElement('rdmo', {
- 'xmlns:dc': 'http://purl.org/dc/elements/1.1/',
- 'created': self.created
- })
- for optionset in optionsets:
- self.render_optionset(xml, optionset)
- xml.endElement('rdmo')
-
-
-class OptionRenderer(OptionsRenderer):
-
- def render_document(self, xml, options):
- xml.startElement('rdmo', {
- 'xmlns:dc': 'http://purl.org/dc/elements/1.1/',
- 'created': self.created
- })
- for option in options:
- self.render_option(xml, option)
- xml.endElement('rdmo')
diff --git a/rdmo/options/renderers/__init__.py b/rdmo/options/renderers/__init__.py
new file mode 100644
index 0000000000..41e18c2e63
--- /dev/null
+++ b/rdmo/options/renderers/__init__.py
@@ -0,0 +1,32 @@
+from rdmo.conditions.renderers.mixins import ConditionRendererMixin
+from rdmo.core.renderers import BaseXMLRenderer
+from rdmo.domain.renderers.mixins import AttributeRendererMixin
+
+from .mixins import OptionRendererMixin, OptionSetRendererMixin
+
+
+class OptionSetRenderer(OptionSetRendererMixin, OptionRendererMixin, ConditionRendererMixin,
+ AttributeRendererMixin, BaseXMLRenderer):
+
+ def render_document(self, xml, optionsets):
+ xml.startElement('rdmo', {
+ 'xmlns:dc': 'http://purl.org/dc/elements/1.1/',
+ 'version': self.version,
+ 'created': self.created
+ })
+ for optionset in optionsets:
+ self.render_optionset(xml, optionset)
+ xml.endElement('rdmo')
+
+
+class OptionRenderer(OptionRendererMixin, BaseXMLRenderer):
+
+ def render_document(self, xml, options):
+ xml.startElement('rdmo', {
+ 'xmlns:dc': 'http://purl.org/dc/elements/1.1/',
+ 'version': self.version,
+ 'created': self.created
+ })
+ for option in options:
+ self.render_option(xml, option)
+ xml.endElement('rdmo')
diff --git a/rdmo/options/renderers/mixins.py b/rdmo/options/renderers/mixins.py
new file mode 100644
index 0000000000..641c935fb0
--- /dev/null
+++ b/rdmo/options/renderers/mixins.py
@@ -0,0 +1,54 @@
+from rdmo.core.utils import get_languages
+
+
+class OptionSetRendererMixin:
+
+ def render_optionset(self, xml, optionset):
+ if optionset['uri'] not in self.uris:
+ self.uris.add(optionset['uri'])
+
+ xml.startElement('optionset', {'dc:uri': optionset['uri']})
+ self.render_text_element(xml, 'uri_prefix', {}, optionset['uri_prefix'])
+ self.render_text_element(xml, 'uri_path', {}, optionset['uri_path'])
+ self.render_text_element(xml, 'dc:comment', {}, optionset['comment'])
+ self.render_text_element(xml, 'provider_key', {}, optionset['provider_key'])
+
+ xml.startElement('options', {})
+ for optionset_option in optionset['optionset_options']:
+ self.render_text_element(xml, 'option', {
+ 'dc:uri': optionset_option['option']['uri'],
+ 'order': str(optionset_option['order'])
+ }, None)
+ xml.endElement('options')
+
+ xml.startElement('conditions', {})
+ for condition in optionset['conditions']:
+ self.render_text_element(xml, 'condition', {'dc:uri': condition['uri']}, None)
+ xml.endElement('conditions')
+
+ xml.endElement('optionset')
+
+ for optionset_option in optionset['optionset_options']:
+ self.render_option(xml, optionset_option.get('option'))
+
+ if self.context.get('conditions'):
+ for condition in optionset['conditions']:
+ self.render_condition(xml, condition)
+
+
+class OptionRendererMixin:
+
+ def render_option(self, xml, option):
+ if option['uri'] not in self.uris:
+ self.uris.add(option['uri'])
+
+ xml.startElement('option', {'dc:uri': option['uri']})
+ self.render_text_element(xml, 'uri_prefix', {}, option['uri_prefix'])
+ self.render_text_element(xml, 'uri_path', {}, option['uri_path'])
+ self.render_text_element(xml, 'dc:comment', {}, option['comment'])
+
+ for lang_code, lang_string, lang_field in get_languages():
+ self.render_text_element(xml, 'text', {'lang': lang_code}, option['text_%s' % lang_code])
+
+ self.render_text_element(xml, 'additional_input', {}, option['additional_input'])
+ xml.endElement('option')
diff --git a/rdmo/options/serializers/export.py b/rdmo/options/serializers/export.py
index fda48289c7..0911d5cc8e 100644
--- a/rdmo/options/serializers/export.py
+++ b/rdmo/options/serializers/export.py
@@ -1,47 +1,53 @@
-from rdmo.core.serializers import TranslationSerializerMixin
from rest_framework import serializers
-from ..models import Option, OptionSet
+from rdmo.conditions.serializers.export import ConditionExportSerializer
+from rdmo.core.serializers import TranslationSerializerMixin
+
+from ..models import Option, OptionSet, OptionSetOption
class OptionExportSerializer(TranslationSerializerMixin, serializers.ModelSerializer):
- optionset = serializers.CharField(source='optionset.uri', default=None, read_only=True)
-
class Meta:
model = Option
fields = (
'uri',
'uri_prefix',
- 'key',
- 'path',
+ 'uri_path',
'comment',
- 'order',
- 'additional_input',
- 'optionset'
+ 'additional_input'
)
trans_fields = (
'text',
)
+class OptionSetOptionExportSerializer(serializers.ModelSerializer):
+
+ option = OptionExportSerializer()
+
+ class Meta:
+ model = OptionSetOption
+ fields = (
+ 'option',
+ 'order'
+ )
+
+
class OptionSetExportSerializer(serializers.ModelSerializer):
- options = OptionExportSerializer(many=True)
- conditions = serializers.SerializerMethodField()
+ optionset_options = OptionSetOptionExportSerializer(many=True)
+ conditions = ConditionExportSerializer(many=True)
class Meta:
model = OptionSet
fields = (
'uri',
'uri_prefix',
- 'key',
+ 'uri_path',
'comment',
'order',
'provider_key',
- 'options',
+ 'optionset_options',
'conditions'
)
-
- def get_conditions(self, obj):
- return [condition.uri for condition in obj.conditions.all()]
diff --git a/rdmo/options/serializers/v1.py b/rdmo/options/serializers/v1.py
deleted file mode 100644
index 3c96708e19..0000000000
--- a/rdmo/options/serializers/v1.py
+++ /dev/null
@@ -1,203 +0,0 @@
-from rest_framework import serializers
-from rest_framework.reverse import reverse
-
-from rdmo.conditions.models import Condition
-from rdmo.core.serializers import TranslationSerializerMixin
-from rdmo.core.utils import get_language_warning
-from rdmo.questions.models import QuestionSet
-
-from ..models import Option, OptionSet
-from ..validators import (OptionLockedValidator, OptionSetLockedValidator,
- OptionSetUniqueURIValidator,
- OptionUniqueURIValidator)
-
-
-class QuestionSerializer(serializers.ModelSerializer):
-
- class Meta:
- model = QuestionSet
- fields = (
- 'id',
- 'uri'
- )
-
-
-class ConditionSerializer(serializers.ModelSerializer):
-
- class Meta:
- model = Condition
- fields = (
- 'id',
- 'uri'
- )
-
-
-class OptionSetSerializer(serializers.ModelSerializer):
-
- key = serializers.SlugField(required=True)
- questions = QuestionSerializer(many=True, read_only=True)
-
- class Meta:
- model = OptionSet
- fields = (
- 'id',
- 'uri',
- 'uri_prefix',
- 'key',
- 'comment',
- 'locked',
- 'order',
- 'provider_key',
- 'conditions',
- 'questions'
- )
- validators = (
- OptionSetUniqueURIValidator(),
- OptionSetLockedValidator()
- )
-
-
-class OptionSerializer(TranslationSerializerMixin, serializers.ModelSerializer):
-
- key = serializers.SlugField(required=True)
- optionset = serializers.PrimaryKeyRelatedField(queryset=OptionSet.objects.all(), required=True)
- conditions = ConditionSerializer(many=True, read_only=True)
- values_count = serializers.IntegerField(read_only=True)
- projects_count = serializers.IntegerField(read_only=True)
-
- class Meta:
- model = Option
- fields = (
- 'id',
- 'optionset',
- 'uri',
- 'uri_prefix',
- 'key',
- 'comment',
- 'locked',
- 'order',
- 'text',
- 'label',
- 'additional_input',
- 'conditions',
- 'values_count',
- 'projects_count'
- )
- trans_fields = (
- 'text',
- )
- validators = (
- OptionUniqueURIValidator(),
- OptionLockedValidator()
- )
-
-
-class OptionSetIndexOptionSerializer(serializers.ModelSerializer):
-
- class Meta:
- model = Option
- fields = (
- 'id',
- 'uri'
- )
-
-
-class OptionSetIndexSerializer(serializers.ModelSerializer):
-
- options = OptionSetIndexOptionSerializer(many=True)
-
- class Meta:
- model = OptionSet
- fields = (
- 'id',
- 'uri',
- 'options'
- )
-
-
-class OptionIndexSerializer(serializers.ModelSerializer):
-
- class Meta:
- model = Option
- fields = (
- 'id',
- 'optionset',
- 'uri',
- 'text'
- )
-
-
-class ConditionNestedSerializer(serializers.ModelSerializer):
-
- class Meta:
- model = Condition
- fields = (
- 'id',
- 'uri'
- )
-
-
-class ProviderNestedSerializer(serializers.Serializer):
-
- key = serializers.CharField()
- label = serializers.CharField()
- class_name = serializers.CharField()
-
- class Meta:
- fields = (
- 'key',
- 'label',
- 'class_name'
- )
-
-
-class OptionNestedSerializer(serializers.ModelSerializer):
-
- warning = serializers.SerializerMethodField()
- xml_url = serializers.SerializerMethodField()
-
- class Meta:
- model = Option
- fields = (
- 'id',
- 'uri',
- 'uri_prefix',
- 'path',
- 'locked',
- 'order',
- 'text',
- 'warning',
- 'xml_url'
- )
-
- def get_warning(self, obj):
- return get_language_warning(obj, 'text')
-
- def get_xml_url(self, obj):
- return reverse('v1-options:option-detail-export', args=[obj.pk])
-
-
-class OptionSetNestedSerializer(serializers.ModelSerializer):
-
- options = OptionNestedSerializer(many=True)
- conditions = ConditionNestedSerializer(many=True)
- provider = ProviderNestedSerializer()
- xml_url = serializers.SerializerMethodField()
-
- class Meta:
- model = OptionSet
- fields = (
- 'id',
- 'uri',
- 'uri_prefix',
- 'key',
- 'order',
- 'locked',
- 'provider',
- 'options',
- 'conditions',
- 'xml_url'
- )
-
- def get_xml_url(self, obj):
- return reverse('v1-options:optionset-detail-export', args=[obj.pk])
diff --git a/rdmo/options/serializers/v1/__init__.py b/rdmo/options/serializers/v1/__init__.py
new file mode 100644
index 0000000000..def6624fd8
--- /dev/null
+++ b/rdmo/options/serializers/v1/__init__.py
@@ -0,0 +1,2 @@
+from .option import OptionIndexSerializer, OptionSerializer
+from .optionset import OptionSetIndexSerializer, OptionSetNestedSerializer, OptionSetSerializer
diff --git a/rdmo/options/serializers/v1/option.py b/rdmo/options/serializers/v1/option.py
new file mode 100644
index 0000000000..b608dcdd5b
--- /dev/null
+++ b/rdmo/options/serializers/v1/option.py
@@ -0,0 +1,74 @@
+from rest_framework import serializers
+
+from rdmo.core.serializers import (
+ ElementModelSerializerMixin,
+ ElementWarningSerializerMixin,
+ ReadOnlyObjectPermissionSerializerMixin,
+ ThroughModelSerializerMixin,
+ TranslationSerializerMixin,
+)
+
+from ...models import Option, OptionSet
+from ...validators import OptionLockedValidator, OptionUniqueURIValidator
+
+
+class OptionSerializer(ThroughModelSerializerMixin, TranslationSerializerMixin,
+ ElementModelSerializerMixin, ElementWarningSerializerMixin,
+ ReadOnlyObjectPermissionSerializerMixin, serializers.ModelSerializer):
+
+ model = serializers.SerializerMethodField()
+ uri_path = serializers.CharField(required=True)
+
+ optionsets = serializers.PrimaryKeyRelatedField(queryset=OptionSet.objects.all(), required=False, many=True)
+ conditions = serializers.PrimaryKeyRelatedField(many=True, read_only=True)
+
+ warning = serializers.SerializerMethodField()
+ read_only = serializers.SerializerMethodField()
+
+ values_count = serializers.IntegerField(read_only=True)
+ projects_count = serializers.IntegerField(read_only=True)
+
+ class Meta:
+ model = Option
+ fields = (
+ 'id',
+ 'model',
+ 'uri',
+ 'uri_prefix',
+ 'uri_path',
+ 'comment',
+ 'locked',
+ 'text',
+ 'label',
+ 'additional_input',
+ 'optionsets',
+ 'conditions',
+ 'values_count',
+ 'projects_count',
+ 'editors',
+ 'warning',
+ 'read_only',
+ )
+ trans_fields = (
+ 'text',
+ )
+ parent_fields = (
+ ('optionsets', 'optionset', 'option', 'optionset_options'),
+ )
+ validators = (
+ OptionUniqueURIValidator(),
+ OptionLockedValidator()
+ )
+ warning_fields = (
+ 'text',
+ )
+
+
+class OptionIndexSerializer(serializers.ModelSerializer):
+
+ class Meta:
+ model = Option
+ fields = (
+ 'id',
+ 'uri'
+ )
diff --git a/rdmo/options/serializers/v1/optionset.py b/rdmo/options/serializers/v1/optionset.py
new file mode 100644
index 0000000000..264c2f7128
--- /dev/null
+++ b/rdmo/options/serializers/v1/optionset.py
@@ -0,0 +1,86 @@
+from rest_framework import serializers
+
+from rdmo.core.serializers import (
+ ElementModelSerializerMixin,
+ ReadOnlyObjectPermissionSerializerMixin,
+ ThroughModelSerializerMixin,
+)
+from rdmo.questions.models import Question
+
+from ...models import OptionSet, OptionSetOption
+from ...validators import OptionSetLockedValidator, OptionSetUniqueURIValidator
+from .option import OptionSerializer
+
+
+class OptionSetOptionSerializer(serializers.ModelSerializer):
+
+ class Meta:
+ model = OptionSetOption
+ fields = (
+ 'option',
+ 'order'
+ )
+
+
+class OptionSetSerializer(ThroughModelSerializerMixin, ElementModelSerializerMixin,
+ ReadOnlyObjectPermissionSerializerMixin, serializers.ModelSerializer):
+
+ model = serializers.SerializerMethodField()
+ uri_path = serializers.CharField(required=True)
+
+ options = OptionSetOptionSerializer(source='optionset_options', read_only=False, required=False, many=True)
+ questions = serializers.PrimaryKeyRelatedField(queryset=Question.objects.all(), required=False, many=True)
+
+ read_only = serializers.SerializerMethodField()
+
+ class Meta:
+ model = OptionSet
+ fields = (
+ 'id',
+ 'model',
+ 'uri',
+ 'uri_prefix',
+ 'uri_path',
+ 'comment',
+ 'locked',
+ 'read_only',
+ 'order',
+ 'provider_key',
+ 'options',
+ 'conditions',
+ 'questions',
+ 'editors',
+ 'read_only',
+ )
+ through_fields = (
+ ('options', 'optionset', 'option', 'optionset_options'),
+ )
+ validators = (
+ OptionSetUniqueURIValidator(),
+ OptionSetLockedValidator()
+ )
+
+
+class OptionSetNestedSerializer(OptionSetSerializer):
+
+ elements = serializers.SerializerMethodField()
+
+ class Meta(OptionSetSerializer.Meta):
+ fields = (
+ *OptionSetSerializer.Meta.fields,
+ 'elements'
+ )
+
+ def get_elements(self, obj):
+ for element in obj.elements:
+ yield OptionSerializer(element, context=self.context).data
+
+
+class OptionSetIndexSerializer(serializers.ModelSerializer):
+
+ class Meta:
+ model = OptionSet
+ fields = (
+ 'id',
+ 'uri'
+ )
diff --git a/rdmo/options/static/options/css/options.scss b/rdmo/options/static/options/css/options.scss
deleted file mode 100644
index e69de29bb2..0000000000
diff --git a/rdmo/options/static/options/js/options.js b/rdmo/options/static/options/js/options.js
deleted file mode 100644
index 51dc2051a9..0000000000
--- a/rdmo/options/static/options/js/options.js
+++ /dev/null
@@ -1,173 +0,0 @@
-angular.module('options', ['core'])
-
-.factory('OptionsService', ['$resource', '$timeout', '$window', '$q', 'utils', function($resource, $timeout, $window, $q, utils) {
-
- /* get the base url */
-
- var baseurl = angular.element('meta[name="baseurl"]').attr('content');
-
- /* configure resources */
-
- var resources = {
- optionsets: $resource(baseurl + 'api/v1/options/optionsets/:list_action/:id/:detail_action/'),
- options: $resource(baseurl + 'api/v1/options/options/:list_action/:id/:detail_action/'),
- conditions: $resource(baseurl + 'api/v1/conditions/conditions/:list_action/:id/'),
- providers: $resource(baseurl + 'api/v1/options/providers/:id/'),
- settings: $resource(baseurl + 'api/v1/core/settings/'),
- };
-
- /* configure factories */
-
- var factories = {
- optionsets: function() {
- return {
- order: 0,
- uri_prefix: service.settings.default_uri_prefix
- };
- },
- options: function(parent) {
- return {
- order: 0,
- optionset: (angular.isDefined(parent) && parent) ? parent.id : null,
- uri_prefix: (angular.isDefined(parent) && parent) ? parent.uri_prefix : service.settings.default_uri_prefix
- };
- }
- };
-
- /* create the options service */
-
- var service = {};
-
- service.init = function(options) {
- service.providers = resources.providers.query();
- service.settings = resources.settings.get();
- service.uri_prefixes = [];
- service.uri_prefix = '';
- service.filter = sessionStorage.getItem('options_filter') || '';
- service.showOptions = !(sessionStorage.getItem('options_showOptions') === 'false');
-
- service.initView().then(function () {
- var current_scroll_pos = sessionStorage.getItem('options_scroll_pos');
- if (current_scroll_pos) {
- $timeout(function() {
- $window.scrollTo(0, current_scroll_pos);
- });
- }
- });
-
- $window.addEventListener('beforeunload', function() {
- sessionStorage.setItem('options_scroll_pos', $window.scrollY);
- sessionStorage.setItem('options_filter', service.filter);
- sessionStorage.setItem('options_showOptions', service.showOptions);
- });
- };
-
- service.initView = function(options) {
- return resources.optionsets.query({list_action: 'nested'}, function(response) {
- service.optionsets = response;
-
- // construct list of uri_prefixes
- service.uri_prefixes = []
- service.optionsets.map(function(optionset) {
- if (service.uri_prefixes.indexOf(optionset.uri_prefix) < 0) {
- service.uri_prefixes.push(optionset.uri_prefix)
- }
- optionset.options.map(function(option) {
- if (service.uri_prefixes.indexOf(option.uri_prefix) < 0) {
- service.uri_prefixes.push(option.uri_prefix)
- }
- });
- });
- }).$promise;
- };
-
- service.openFormModal = function(resource, obj, create, copy) {
- var fetch_resource = (resource === 'conditions') ? 'optionsets': resource;
-
- service.errors = {};
- service.values = utils.fetchValues(resources[fetch_resource], factories[resource], obj, create, copy);
- service.conditions = resources.conditions.query({list_action: 'index'});
-
- $q.when([
- service.values.$promise,
- service.conditions.$promise
- ]).then(function() {
- $('#' + resource + '-form-modal').modal('show');
- $timeout(function() {
- $('formgroup[data-quicksearch="true"]').trigger('refresh');
- });
- });
- };
-
- service.submitFormModal = function(resource) {
- var submit_resource = (resource === 'conditions') ? 'optionsets': resource;
-
- utils.storeValues(resources[submit_resource], service.values).then(function() {
- $('#' + resource + '-form-modal').modal('hide');
- service.initView();
- }, function(result) {
- service.errors = result.data;
- });
- };
-
- service.openDeleteModal = function(resource, obj) {
- utils.fetchValues(resources[resource], factories[resource], obj).$promise.then(function(response) {
- service.values = response;
- service.values.options = obj.options;
- $('#' + resource + '-delete-modal').modal('show');
- });
- };
-
- service.submitDeleteModal = function(resource) {
- resources[resource].delete({id: service.values.id}, function() {
- $('#' + resource + '-delete-modal').modal('hide');
- service.initView();
- });
- };
-
- service.openShowModal = function(resource, obj) {
- service.values = utils.fetchValues(resources[resource], factories[resource], obj)
-
- $q.when(service.values.$promise).then(function() {
- $('#' + resource + '-show-modal').modal('show');
- });
- };
-
- service.hideOptionSet = function(item) {
- var hide = false;
-
- if (service.filter && item.key.indexOf(service.filter) < 0) {
- hide = true;
- }
- if (service.uri_prefix && item.uri_prefix != service.uri_prefix) {
- hide = true;
- }
-
- if (hide === true) {
- // hide only if all options of this optionsset are hidden
- return item.options.every(function(option) {
- return service.hideOption(option) === true;
- });
- }
- };
-
- service.hideOption = function(item) {
- if (service.filter && item.uri.indexOf(service.filter) < 0
- && item.text.indexOf(service.filter) < 0) {
- return true;
- }
- if (service.uri_prefix && item.uri_prefix != service.uri_prefix) {
- return true;
- }
- };
-
- return service;
-
-}])
-
-.controller('OptionsController', ['$scope', 'OptionsService', function($scope, OptionsService) {
-
- $scope.service = OptionsService;
- $scope.service.init();
-
-}]);
diff --git a/rdmo/options/templates/options/export/option.html b/rdmo/options/templates/options/export/option.html
new file mode 100644
index 0000000000..591f447770
--- /dev/null
+++ b/rdmo/options/templates/options/export/option.html
@@ -0,0 +1,13 @@
+{% load i18n %}
+
+
+
+ {% trans 'URI' %}: {{ option.uri }}
+
+
+ {% trans 'Text' %}: {{ option.text }}
+
+
+ {% trans 'Additional input' %}: {{ option.additional_input }}
+
+
diff --git a/rdmo/options/templates/options/export/options.html b/rdmo/options/templates/options/export/options.html
new file mode 100644
index 0000000000..b4c0f6dfa5
--- /dev/null
+++ b/rdmo/options/templates/options/export/options.html
@@ -0,0 +1,14 @@
+{% extends 'core/export.html' %}
+{% load i18n %}
+
+{% block body %}
+
+ {% trans 'Options' %}
+
+
+ {% for option in options %}
+ {% include 'options/export/option.html' with option=option %}
+ {% endfor %}
+
+
+{% endblock %}
diff --git a/rdmo/options/templates/options/export/optionset.html b/rdmo/options/templates/options/export/optionset.html
new file mode 100644
index 0000000000..b321464d22
--- /dev/null
+++ b/rdmo/options/templates/options/export/optionset.html
@@ -0,0 +1,41 @@
+{% load i18n %}
+
+
+
+ {% trans 'URI' %}: {{ optionset.uri }}
+
+
+
+ {% trans 'Options' %}:
+
+
+
+ {% for option in optionset.elements %}
+ {% include 'options/export/option.html' with option=option %}
+ {% endfor %}
+
+
+ {% if optionset.provider_key %}
+
+
+ {% trans 'Provider' %}: {{ optionset.provider.label }}
+
+
+ {% endif %}
+
+ {% if optionset.conditions.all %}
+
+
+ {% trans 'Conditions' %}:
+
+
+
+ {% for condition in optionset.conditions.all %}
+
+ {{ condition.source_label }} {{ condition.relation_label }} {{ condition.target_label }}
+
+ {% endfor %}
+
+
+ {% endif %}
+
diff --git a/rdmo/options/templates/options/export/optionsets.html b/rdmo/options/templates/options/export/optionsets.html
new file mode 100644
index 0000000000..69786ef1bd
--- /dev/null
+++ b/rdmo/options/templates/options/export/optionsets.html
@@ -0,0 +1,14 @@
+{% extends 'core/export.html' %}
+{% load i18n %}
+
+{% block body %}
+
+ {% trans 'Option sets' %}
+
+
+ {% for optionset in optionsets %}
+ {% include 'options/export/optionset.html' with optionset=optionset %}
+ {% endfor %}
+
+
+{% endblock %}
diff --git a/rdmo/options/templates/options/options.html b/rdmo/options/templates/options/options.html
deleted file mode 100644
index 6ec700cb95..0000000000
--- a/rdmo/options/templates/options/options.html
+++ /dev/null
@@ -1,220 +0,0 @@
-{% extends 'core/page.html' %}
-{% load static %}
-{% load compress %}
-{% load i18n %}
-{% load core_tags %}
-
-{% block head %}
- {% vendor 'angular' %}
- {% vendor 'select2' %}
- {% vendor 'select2-bootstrap-theme' %}
-
- {% compress css %}
-
- {% endcompress %}
-
- {% compress js %}
-
-
- {% endcompress %}
-{% endblock %}
-
-{% block bodyattr %} ng-app="options" ng-controller="OptionsController" {% endblock %}
-
-{% block sidebar %}
-
- {% trans 'Filter options' %}
-
-
-
-
-
- {% trans 'All URI prefixes' %}
-
- {$ uri_prefix $}
-
-
-
-
-
-
- {% trans 'Options' %}
-
-
-
- {% trans 'Export' %}
-
-
- {% for format, text in export_formats %}
-
-
- {{ text }}
-
-
- {% endfor %}
-
-
-
-
- {% trans 'Import' %}
-
-
- {% url 'upload' as upload_url %}
- {% include 'core/upload_form.html' with upload_url=upload_url %}
-
-
-
-{% endblock %}
-
-{% block page %}
-
- {% trans 'Options' %}
-
-
-
-
-
-
-
- {% trans 'Option set' %}
-
-
- {$ optionset.uri $}
- {$ optionset.order $}
-
-
- {$ condition.uri $}
-
-
-
-
-
- {% trans 'Provider' %}
- {$ optionset.provider.class_name $}
- {$ optionset.provider.label $}
-
-
-
-
-
-
-
-
- {% include 'options/options_modal_form_optionsets.html' %}
- {% include 'options/options_modal_form_options.html' %}
- {% include 'options/options_modal_form_conditions.html' %}
-
- {% include 'options/options_modal_show_optionsets.html' %}
- {% include 'options/options_modal_show_options.html' %}
-
- {% include 'options/options_modal_delete_optionsets.html' %}
- {% include 'options/options_modal_delete_options.html' %}
-
-{% endblock %}
diff --git a/rdmo/options/templates/options/options_export.html b/rdmo/options/templates/options/options_export.html
deleted file mode 100644
index fd6b3e88b4..0000000000
--- a/rdmo/options/templates/options/options_export.html
+++ /dev/null
@@ -1,80 +0,0 @@
-{% extends 'core/export.html' %}
-{% load i18n %}
-
-{% block body %}
-
- {% trans 'Option sets' %}
-
-
-
- {% for optionset in optionsets %}
-
-
-
-
- {% trans 'URI' %}: {{ optionset.uri }}
-
-
- {% if optionset.comment %}
-
-
- {% trans 'Comment' %}: {{ optionset.comment }}
-
-
- {% endif %}
-
- {% if optionset.options.all %}
-
-
- {% trans 'Options' %}:
-
-
-
- {% for option in optionset.options.all %}
-
-
- {% trans 'URI' %}: {{ option.uri }}
-
-
- {% trans 'Text' %}: {{ option.text }}
-
-
- {% trans 'Additional input' %}: {{ option.additional_input }}
-
-
- {% endfor %}
-
-
- {% endif %}
-
- {% if optionset.provider_key %}
-
-
- {% trans 'Provider' %}: {{ optionset.provider.label }}
-
-
- {% endif %}
-
- {% if optionset.conditions.all %}
-
-
- {% trans 'Conditions' %}:
-
-
-
- {% for condition in optionset.conditions.all %}
-
- {{ condition.source_label }} {{ condition.relation_label }} {{ condition.target_label }}
-
- {% endfor %}
-
-
- {% endif %}
-
-
-
- {% endfor %}
-
-
-
-{% endblock %}
diff --git a/rdmo/options/templates/options/options_modal_delete_options.html b/rdmo/options/templates/options/options_modal_delete_options.html
deleted file mode 100644
index 66d53e71dd..0000000000
--- a/rdmo/options/templates/options/options_modal_delete_options.html
+++ /dev/null
@@ -1,50 +0,0 @@
-{% load i18n %}
-
-
-
-
-
-
-
-
- {% blocktrans trimmed with object='{$ service.values.uri $}' %}
- You are about to permanently delete the option {{ object }}
.
- {% endblocktrans %}
-
-
-
- {% blocktrans trimmed with values_count='{$ service.values.values_count $}' projects_count='{$ service.values.projects_count $}' %}
- This option is used for {{ values_count }} values in {{ projects_count }} projects .
- {% endblocktrans %}
-
-
-
- {% blocktrans trimmed with conditions_count='{$ service.values.conditions.length $}' %}
- This option is also used in {{ conditions_count }} conditions .
- {% endblocktrans %}
-
-
-
- {% trans 'This action cannot be undone!' %}
-
-
-
-
-
-
-
diff --git a/rdmo/options/templates/options/options_modal_delete_optionsets.html b/rdmo/options/templates/options/options_modal_delete_optionsets.html
deleted file mode 100644
index 488cb0aa2a..0000000000
--- a/rdmo/options/templates/options/options_modal_delete_optionsets.html
+++ /dev/null
@@ -1,63 +0,0 @@
-{% load i18n %}
-
-
-
-
-
-
-
-
- {% blocktrans trimmed with object='{$ service.values.uri $}' %}
- You are about to permanently delete the option set {{ object }}
.
- {% endblocktrans %}
-
-
-
- {% blocktrans trimmed %}
- Important! This will also delete the following options:
- {% endblocktrans %}
-
-
-
-
- {% trans 'Option' %}
- {$ option.uri $}
-
-
-
-
-
- {% blocktrans trimmed with questions_count='{$ service.values.questions.length $}' %}
- This option set is also used in {{ questions_count }} questions .
- {% endblocktrans %}
-
-
-
- {% trans 'This does not include references from the options above. Please check carefully.' %}
-
-
-
-
- {% trans 'This action cannot be undone!' %}
-
-
-
-
-
-
-
diff --git a/rdmo/options/templates/options/options_modal_form_conditions.html b/rdmo/options/templates/options/options_modal_form_conditions.html
deleted file mode 100644
index d038b99790..0000000000
--- a/rdmo/options/templates/options/options_modal_form_conditions.html
+++ /dev/null
@@ -1,39 +0,0 @@
-{% load i18n %}
-
-
diff --git a/rdmo/options/templates/options/options_modal_form_options.html b/rdmo/options/templates/options/options_modal_form_options.html
deleted file mode 100644
index 909b063e52..0000000000
--- a/rdmo/options/templates/options/options_modal_form_options.html
+++ /dev/null
@@ -1,187 +0,0 @@
-{% load i18n %}
-{% get_available_languages as languages %}
-
-
diff --git a/rdmo/options/templates/options/options_modal_form_optionsets.html b/rdmo/options/templates/options/options_modal_form_optionsets.html
deleted file mode 100644
index 755cec34d5..0000000000
--- a/rdmo/options/templates/options/options_modal_form_optionsets.html
+++ /dev/null
@@ -1,123 +0,0 @@
-{% load i18n %}
-
-
diff --git a/rdmo/options/templates/options/options_modal_show_options.html b/rdmo/options/templates/options/options_modal_show_options.html
deleted file mode 100644
index 14597d1dea..0000000000
--- a/rdmo/options/templates/options/options_modal_show_options.html
+++ /dev/null
@@ -1,39 +0,0 @@
-{% load i18n %}
-
-
-
-
-
-
-
-
- {% blocktrans trimmed with values_count='{$ service.values.values_count $}' projects_count='{$ service.values.projects_count $}' %}
- This option is used for {{ values_count }} values in {{ projects_count }} projects .
- {% endblocktrans %}
- {% trans 'Furthermore, this attribute is used in the following elements:' %}
-
-
-
{% trans 'Conditions' %}
-
-
- {$ condition.uri $}
-
-
- {% trans 'none' %}
-
-
-
-
-
-
-
-
diff --git a/rdmo/options/templates/options/options_modal_show_optionsets.html b/rdmo/options/templates/options/options_modal_show_optionsets.html
deleted file mode 100644
index 58428b82e4..0000000000
--- a/rdmo/options/templates/options/options_modal_show_optionsets.html
+++ /dev/null
@@ -1,36 +0,0 @@
-{% load i18n %}
-
-
-
-
-
-
-
-
- {% trans 'This option set is used in the following elements:' %}
-
-
-
{% trans 'Questions' %}
-
-
- {$ question.uri $}
-
-
- {% trans 'none' %}
-
-
-
-
-
-
-
-
diff --git a/rdmo/options/tests/test_models.py b/rdmo/options/tests/test_models.py
index ffb7cd5021..f5f1cb083b 100644
--- a/rdmo/options/tests/test_models.py
+++ b/rdmo/options/tests/test_models.py
@@ -19,29 +19,7 @@ def test_options_str(db):
assert str(instance)
-def test_optionset_copy(db):
- instances = OptionSet.objects.all()
- for instance in instances:
- new_uri_prefix = instance.uri_prefix + '-'
- new_key = instance.key + '-'
- new_instance = instance.copy(new_uri_prefix, new_key)
- assert new_instance.uri_prefix == new_uri_prefix
- assert new_instance.key == new_key
- assert list(new_instance.conditions.values('id')) == list(new_instance.conditions.values('id'))
- assert new_instance.options.count() == instance.options.count()
-
-
def test_options_clean(db):
instances = Option.objects.all()
for instance in instances:
instance.clean()
-
-
-def test_options_copy(db):
- instances = Option.objects.all()
- for instance in instances:
- new_uri_prefix = instance.uri_prefix + '-'
- new_key = instance.key + '-'
- new_instance = instance.copy(new_uri_prefix, new_key)
- assert new_instance.uri_prefix == new_uri_prefix
- assert new_instance.key == new_key
diff --git a/rdmo/options/tests/test_validator_locked_options.py b/rdmo/options/tests/test_validator_locked_options.py
index 4947110497..4af2ecacba 100644
--- a/rdmo/options/tests/test_validator_locked_options.py
+++ b/rdmo/options/tests/test_validator_locked_options.py
@@ -1,7 +1,8 @@
import pytest
+
from django.core.exceptions import ValidationError
-from rest_framework.exceptions import \
- ValidationError as RestFameworkValidationError
+
+from rest_framework.exceptions import ValidationError as RestFameworkValidationError
from ..models import Option, OptionSet
from ..serializers.v1 import OptionSerializer
@@ -10,56 +11,50 @@
def test_create(db):
OptionLockedValidator()({
- 'optionset': OptionSet.objects.first(),
'locked': False
})
def test_create_locked(db):
OptionLockedValidator()({
- 'optionset': OptionSet.objects.first(),
'locked': True
})
-def test_update(db):
- option = Option.objects.first()
+def test_create_optionset(db):
+ optionset = OptionSet.objects.first()
- OptionLockedValidator(option)({
- 'optionset': option.optionset,
+ OptionLockedValidator()({
+ 'optionsets': [optionset],
'locked': False
})
-def test_update_error(db):
- option = Option.objects.first()
- option.locked = True
- option.save()
+def test_create_optionset_error(db):
+ optionset = OptionSet.objects.first()
+ optionset.locked = True
+ optionset.save()
with pytest.raises(ValidationError):
- OptionLockedValidator(option)({
- 'optionset': option.optionset,
- 'locked': True
+ OptionLockedValidator()({
+ 'optionsets': [optionset],
+ 'locked': False
})
-def test_update_parent_error(db):
+def test_update(db):
option = Option.objects.first()
- option.optionset.locked = True
- option.optionset.save()
- with pytest.raises(ValidationError):
- OptionLockedValidator(option)({
- 'optionset': option.optionset,
- 'locked': False
- })
+ OptionLockedValidator(option)({
+ 'locked': False
+ })
def test_update_lock(db):
option = Option.objects.first()
OptionLockedValidator(option)({
- 'optionset': option.optionset,
+ 'optionsets': option.optionsets.all(),
'locked': True
})
@@ -70,41 +65,72 @@ def test_update_unlock(db):
option.save()
OptionLockedValidator(option)({
- 'optionset': option.optionset,
+ 'optionsets': option.optionsets.all(),
+ 'locked': False
+ })
+
+
+def test_update_error(db):
+ option = Option.objects.first()
+ option.locked = True
+ option.save()
+
+ with pytest.raises(ValidationError):
+ OptionLockedValidator(option)({
+ 'locked': True
+ })
+
+
+def test_update_optionset(db):
+ optionset = OptionSet.objects.first()
+
+ option = Option.objects.exclude(optionsets=optionset).first()
+ OptionLockedValidator(option)({
+ 'optionsets': [optionset],
'locked': False
})
+def test_update_optionset_error(db):
+ optionset = OptionSet.objects.first()
+ optionset.locked = True
+ optionset.save()
+
+ option = Option.objects.exclude(optionsets=optionset).first()
+ with pytest.raises(ValidationError):
+ OptionLockedValidator(option)({
+ 'optionsets': [optionset],
+ 'locked': False
+ })
+
+
def test_serializer_create(db):
validator = OptionLockedValidator()
- validator.set_context(OptionSerializer())
+ serializer = OptionSerializer()
validator({
- 'optionset': OptionSet.objects.first(),
'locked': False
- })
+ }, serializer)
def test_serializer_create_locked(db):
validator = OptionLockedValidator()
- validator.set_context(OptionSerializer())
+ serializer = OptionSerializer()
validator({
- 'optionset': OptionSet.objects.first(),
'locked': True
- })
+ }, serializer)
def test_serializer_update(db):
option = Option.objects.first()
validator = OptionLockedValidator()
- validator.set_context(OptionSerializer(instance=option))
+ serializer = OptionSerializer(instance=option)
validator({
- 'optionset': option.optionset,
'locked': False
- })
+ }, serializer)
def test_serializer_update_error(db):
@@ -113,51 +139,9 @@ def test_serializer_update_error(db):
option.save()
validator = OptionLockedValidator()
- validator.set_context(OptionSerializer(instance=option))
-
- with pytest.raises(RestFameworkValidationError):
- validator({
- 'optionset': option.optionset,
- 'locked': True
- })
-
-
-def test_serializer_update_parent_error(db):
- option = Option.objects.first()
- option.optionset.locked = True
- option.optionset.save()
-
- validator = OptionLockedValidator()
- validator.set_context(OptionSerializer(instance=option))
+ serializer = OptionSerializer(instance=option)
with pytest.raises(RestFameworkValidationError):
validator({
- 'optionset': option.optionset,
'locked': True
- })
-
-
-def test_serializer_update_lock(db):
- option = Option.objects.first()
-
- validator = OptionLockedValidator()
- validator.set_context(OptionSerializer(instance=option))
-
- validator({
- 'optionset': option.optionset,
- 'locked': True
- })
-
-
-def test_serializer_update_unlock(db):
- option = Option.objects.first()
- option.locked = True
- option.save()
-
- validator = OptionLockedValidator()
- validator.set_context(OptionSerializer(instance=option))
-
- validator({
- 'optionset': option.optionset,
- 'locked': False
- })
+ }, serializer)
diff --git a/rdmo/options/tests/test_validator_locked_optionsets.py b/rdmo/options/tests/test_validator_locked_optionsets.py
index ffdf080667..511b2b733e 100644
--- a/rdmo/options/tests/test_validator_locked_optionsets.py
+++ b/rdmo/options/tests/test_validator_locked_optionsets.py
@@ -1,7 +1,8 @@
import pytest
+
from django.core.exceptions import ValidationError
-from rest_framework.exceptions import \
- ValidationError as RestFameworkValidationError
+
+from rest_framework.exceptions import ValidationError as RestFameworkValidationError
from ..models import OptionSet
from ..serializers.v1 import OptionSetSerializer
@@ -59,29 +60,31 @@ def test_update_unlock(db):
def test_serializer_create(db):
validator = OptionSetLockedValidator()
- validator.set_context(OptionSetSerializer())
+ serializer = OptionSetSerializer()
validator({
'locked': False
- })
+ }, serializer)
def test_serializer_create_locked(db):
validator = OptionSetLockedValidator()
- validator.set_context(OptionSetSerializer())
+ serializer = OptionSetSerializer()
validator({
'locked': True
- })
+ }, serializer)
def test_serializer_update(db):
optionset = OptionSet.objects.first()
validator = OptionSetLockedValidator()
- validator.set_context(OptionSetSerializer(instance=optionset))
+ serializer = OptionSetSerializer(instance=optionset)
- validator({})
+ validator({
+ 'locked': False
+ }, serializer)
def test_serializer_update_error(db):
@@ -90,33 +93,9 @@ def test_serializer_update_error(db):
optionset.save()
validator = OptionSetLockedValidator()
- validator.set_context(OptionSetSerializer(instance=optionset))
+ serializer = OptionSetSerializer(instance=optionset)
with pytest.raises(RestFameworkValidationError):
validator({
'locked': True
- })
-
-
-def test_serializer_update_lock(db):
- optionset = OptionSet.objects.first()
-
- validator = OptionSetLockedValidator()
- validator.set_context(OptionSetSerializer(instance=optionset))
-
- validator({
- 'locked': True
- })
-
-
-def test_serializer_update_unlock(db):
- optionset = OptionSet.objects.first()
- optionset.locked = True
- optionset.save()
-
- validator = OptionSetLockedValidator()
- validator.set_context(OptionSetSerializer(instance=optionset))
-
- validator({
- 'locked': False
- })
+ }, serializer)
diff --git a/rdmo/options/tests/test_validator_unique_uri_options.py b/rdmo/options/tests/test_validator_unique_uri_options.py
index 6df00bcce9..b29ec193c3 100644
--- a/rdmo/options/tests/test_validator_unique_uri_options.py
+++ b/rdmo/options/tests/test_validator_unique_uri_options.py
@@ -1,8 +1,9 @@
import pytest
+
from django.conf import settings
from django.core.exceptions import ValidationError
-from rest_framework.exceptions import \
- ValidationError as RestFameworkValidationError
+
+from rest_framework.exceptions import ValidationError as RestFameworkValidationError
from ..models import Option, OptionSet
from ..serializers.v1 import OptionSerializer
@@ -12,90 +13,96 @@
def test_unique_uri_validator_create(db):
OptionUniqueURIValidator()({
'uri_prefix': settings.DEFAULT_URI_PREFIX,
- 'key': 'test',
- 'optionset': OptionSet.objects.first()
+ 'uri_path': 'test'
})
-def test_unique_uri_validator_create_error(db):
- optionset = OptionSet.objects.first()
+def test_unique_uri_validator_create_error_option(db):
+ with pytest.raises(ValidationError):
+ OptionUniqueURIValidator()({
+ 'uri_prefix': settings.DEFAULT_URI_PREFIX,
+ 'uri_path': Option.objects.first().uri_path
+ })
+
+def test_unique_uri_validator_create_error_optionset(db):
with pytest.raises(ValidationError):
OptionUniqueURIValidator()({
'uri_prefix': settings.DEFAULT_URI_PREFIX,
- 'key': optionset.options.first().key,
- 'optionset': optionset
+ 'uri_path': OptionSet.objects.first().uri_path
})
def test_unique_uri_validator_update(db):
- option = Option.objects.first()
+ instance = Option.objects.first()
- OptionUniqueURIValidator(option)({
- 'uri_prefix': option.uri_prefix,
- 'key': option.key,
- 'optionset': option.optionset
+ OptionUniqueURIValidator(instance)({
+ 'uri_prefix': instance.uri_prefix,
+ 'uri_path': instance.uri_path
})
-def test_unique_uri_validator_update_error(db):
- option = Option.objects.first()
+def test_unique_uri_validator_update_error_option(db):
+ instance = Option.objects.first()
with pytest.raises(ValidationError):
- OptionUniqueURIValidator(option)({
- 'uri_prefix': option.uri_prefix,
- 'key': option.optionset.options.last().key,
- 'optionset': option.optionset
+ OptionUniqueURIValidator(instance)({
+ 'uri_prefix': instance.uri_prefix,
+ 'uri_path': Option.objects.exclude(id=instance.id).first().uri_path
+ })
+
+
+def test_unique_uri_validator_update_error_optionset(db):
+ instance = Option.objects.first()
+
+ with pytest.raises(ValidationError):
+ OptionUniqueURIValidator(instance)({
+ 'uri_prefix': instance.uri_prefix,
+ 'uri_path': OptionSet.objects.first().uri_path
})
def test_unique_uri_validator_serializer_create(db):
validator = OptionUniqueURIValidator()
- validator.set_context(OptionSerializer())
+ serializer = OptionSerializer()
validator({
'uri_prefix': settings.DEFAULT_URI_PREFIX,
- 'key': 'test',
- 'optionset': OptionSet.objects.first()
- })
+ 'uri_path': 'test'
+ }, serializer)
def test_unique_uri_validator_serializer_create_error(db):
- optionset = OptionSet.objects.first()
-
validator = OptionUniqueURIValidator()
- validator.set_context(OptionSerializer())
+ serializer = OptionSerializer()
with pytest.raises(RestFameworkValidationError):
validator({
'uri_prefix': settings.DEFAULT_URI_PREFIX,
- 'key': optionset.options.last().key,
- 'optionset': optionset
- })
+ 'uri_path': Option.objects.first().uri_path
+ }, serializer)
def test_unique_uri_validator_serializer_update(db):
- option = Option.objects.first()
+ instance = Option.objects.first()
validator = OptionUniqueURIValidator()
- validator.set_context(OptionSerializer(instance=option))
+ serializer = OptionSerializer(instance=instance)
validator({
- 'uri_prefix': option.uri_prefix,
- 'key': option.key,
- 'optionset': option.optionset
- })
+ 'uri_prefix': instance.uri_prefix,
+ 'uri_path': instance.uri_path
+ }, serializer)
def test_unique_uri_validator_serializer_update_error(db):
- option = Option.objects.first()
+ instance = Option.objects.first()
validator = OptionUniqueURIValidator()
- validator.set_context(OptionSerializer(instance=option))
+ serializer = OptionSerializer(instance=instance)
with pytest.raises(RestFameworkValidationError):
validator({
- 'uri_prefix': option.uri_prefix,
- 'key': option.optionset.options.last().key,
- 'optionset': option.optionset
- })
+ 'uri_prefix': instance.uri_prefix,
+ 'uri_path': Option.objects.exclude(id=instance.id).first().uri_path
+ }, serializer)
diff --git a/rdmo/options/tests/test_validator_unique_uri_optionsets.py b/rdmo/options/tests/test_validator_unique_uri_optionsets.py
index ae828e4fb5..bf77068096 100644
--- a/rdmo/options/tests/test_validator_unique_uri_optionsets.py
+++ b/rdmo/options/tests/test_validator_unique_uri_optionsets.py
@@ -1,10 +1,11 @@
import pytest
+
from django.conf import settings
from django.core.exceptions import ValidationError
-from rest_framework.exceptions import \
- ValidationError as RestFameworkValidationError
-from ..models import OptionSet
+from rest_framework.exceptions import ValidationError as RestFameworkValidationError
+
+from ..models import Option, OptionSet
from ..serializers.v1 import OptionSetSerializer
from ..validators import OptionSetUniqueURIValidator
@@ -12,78 +13,96 @@
def test_unique_uri_validator_create(db):
OptionSetUniqueURIValidator()({
'uri_prefix': settings.DEFAULT_URI_PREFIX,
- 'key': 'test'
+ 'uri_path': 'test'
})
-def test_unique_uri_validator_create_error(db):
+def test_unique_uri_validator_create_error_option(db):
with pytest.raises(ValidationError):
OptionSetUniqueURIValidator()({
'uri_prefix': settings.DEFAULT_URI_PREFIX,
- 'key': OptionSet.objects.last().key
+ 'uri_path': Option.objects.first().uri_path
+ })
+
+
+def test_unique_uri_validator_create_error_optionset(db):
+ with pytest.raises(ValidationError):
+ OptionSetUniqueURIValidator()({
+ 'uri_prefix': settings.DEFAULT_URI_PREFIX,
+ 'uri_path': OptionSet.objects.first().uri_path
})
def test_unique_uri_validator_update(db):
- optionset = OptionSet.objects.first()
+ instance = OptionSet.objects.first()
- OptionSetUniqueURIValidator(optionset)({
- 'uri_prefix': optionset.uri_prefix,
- 'key': optionset.key
+ OptionSetUniqueURIValidator(instance)({
+ 'uri_prefix': instance.uri_prefix,
+ 'uri_path': instance.uri_path
})
-def test_unique_uri_validator_update_error(db):
- optionset = OptionSet.objects.first()
+def test_unique_uri_validator_update_error_option(db):
+ instance = OptionSet.objects.first()
with pytest.raises(ValidationError):
- OptionSetUniqueURIValidator(optionset)({
- 'uri_prefix': optionset.uri_prefix,
- 'key': OptionSet.objects.last().key
+ OptionSetUniqueURIValidator(instance)({
+ 'uri_prefix': instance.uri_prefix,
+ 'uri_path': Option.objects.first().uri_path
+ })
+
+
+def test_unique_uri_validator_update_error_optionset(db):
+ instance = OptionSet.objects.first()
+
+ with pytest.raises(ValidationError):
+ OptionSetUniqueURIValidator(instance)({
+ 'uri_prefix': instance.uri_prefix,
+ 'uri_path': OptionSet.objects.exclude(id=instance.id).first().uri_path
})
def test_unique_uri_validator_serializer_create(db):
validator = OptionSetUniqueURIValidator()
- validator.set_context(OptionSetSerializer())
+ serializer = OptionSetSerializer()
validator({
'uri_prefix': settings.DEFAULT_URI_PREFIX,
- 'key': 'test'
- })
+ 'uri_path': 'test'
+ }, serializer)
def test_unique_uri_validator_serializer_create_error(db):
validator = OptionSetUniqueURIValidator()
- validator.set_context(OptionSetSerializer())
+ serializer = OptionSetSerializer()
with pytest.raises(RestFameworkValidationError):
validator({
'uri_prefix': settings.DEFAULT_URI_PREFIX,
- 'key': OptionSet.objects.last().key
- })
+ 'uri_path': OptionSet.objects.first().uri_path
+ }, serializer)
def test_unique_uri_validator_serializer_update(db):
- optionset = OptionSet.objects.first()
+ instance = OptionSet.objects.first()
validator = OptionSetUniqueURIValidator()
- validator.set_context(OptionSetSerializer(instance=optionset))
+ serializer = OptionSetSerializer(instance=instance)
validator({
- 'uri_prefix': optionset.uri_prefix,
- 'key': optionset.key
- })
+ 'uri_prefix': instance.uri_prefix,
+ 'uri_path': instance.uri_path
+ }, serializer)
def test_unique_uri_validator_serializer_update_error(db):
- optionset = OptionSet.objects.first()
+ instance = OptionSet.objects.first()
validator = OptionSetUniqueURIValidator()
- validator.set_context(OptionSetSerializer(instance=optionset))
+ serializer = OptionSetSerializer(instance=instance)
with pytest.raises(RestFameworkValidationError):
validator({
- 'uri_prefix': optionset.uri_prefix,
- 'key': OptionSet.objects.last().key
- })
+ 'uri_prefix': instance.uri_prefix,
+ 'uri_path': OptionSet.objects.exclude(id=instance.id).first().uri_path
+ }, serializer)
diff --git a/rdmo/options/tests/test_views.py b/rdmo/options/tests/test_views.py
deleted file mode 100644
index 12f57b6370..0000000000
--- a/rdmo/options/tests/test_views.py
+++ /dev/null
@@ -1,49 +0,0 @@
-import xml.etree.ElementTree as et
-
-import pytest
-from django.urls import reverse
-
-users = (
- ('editor', 'editor'),
- ('reviewer', 'reviewer'),
- ('user', 'user'),
- ('api', 'api'),
- ('anonymous', None),
-)
-
-status_map = {
- 'options': {
- 'editor': 200, 'reviewer': 200, 'api': 200, 'user': 403, 'anonymous': 302
- },
- 'options_export': {
- 'editor': 200, 'reviewer': 200, 'api': 200, 'user': 403, 'anonymous': 302
- }
-}
-
-export_formats = ('xml', 'rtf', 'odt', 'docx', 'html', 'markdown', 'tex', 'pdf')
-
-
-@pytest.mark.parametrize('username,password', users)
-def test_options(db, client, username, password):
- client.login(username=username, password=password)
-
- url = reverse('options')
- response = client.get(url)
- assert response.status_code == status_map['options'][username]
-
-
-@pytest.mark.parametrize('username,password', users)
-@pytest.mark.parametrize('export_format', export_formats)
-def test_options_export(db, client, username, password, export_format):
- client.login(username=username, password=password)
-
- url = reverse('options_export', args=[export_format])
- response = client.get(url)
- assert response.status_code == status_map['options_export'][username]
-
- if response.status_code == 200:
- if export_format == 'xml':
- root = et.fromstring(response.content)
- assert root.tag == 'rdmo'
- for child in root:
- assert child.tag in ['optionset', 'option']
diff --git a/rdmo/options/tests/test_viewset_options.py b/rdmo/options/tests/test_viewset_options.py
index a94199d58a..47542460cd 100644
--- a/rdmo/options/tests/test_viewset_options.py
+++ b/rdmo/options/tests/test_viewset_options.py
@@ -1,6 +1,8 @@
import xml.etree.ElementTree as et
import pytest
+
+from django.db.models import Max
from django.urls import reverse
from ..models import Option
@@ -18,16 +20,16 @@
'editor': 200, 'reviewer': 200, 'api': 200, 'user': 403, 'anonymous': 401
},
'detail': {
- 'editor': 200, 'reviewer': 200, 'api': 200, 'user': 403, 'anonymous': 401
+ 'editor': 200, 'reviewer': 200, 'api': 200, 'user': 404, 'anonymous': 401
},
'create': {
'editor': 201, 'reviewer': 403, 'api': 201, 'user': 403, 'anonymous': 401
},
'update': {
- 'editor': 200, 'reviewer': 403, 'api': 200, 'user': 403, 'anonymous': 401
+ 'editor': 200, 'reviewer': 403, 'api': 200, 'user': 404, 'anonymous': 401
},
'delete': {
- 'editor': 204, 'reviewer': 403, 'api': 204, 'user': 403, 'anonymous': 401
+ 'editor': 204, 'reviewer': 403, 'api': 204, 'user': 404, 'anonymous': 401
}
}
@@ -40,6 +42,8 @@
'copy': 'v1-options:option-copy'
}
+export_formats = ('xml', 'rtf', 'odt', 'docx', 'html', 'markdown', 'tex', 'pdf')
+
@pytest.mark.parametrize('username,password', users)
def test_list(db, client, username, password):
@@ -60,14 +64,15 @@ def test_index(db, client, username, password):
@pytest.mark.parametrize('username,password', users)
-def test_export(db, client, username, password):
+@pytest.mark.parametrize('export_format', export_formats)
+def test_export(db, client, username, password, export_format):
client.login(username=username, password=password)
- url = reverse(urlnames['export'])
+ url = reverse(urlnames['export']) + export_format + '/'
response = client.get(url)
assert response.status_code == status_map['list'][username], response.content
- if response.status_code == 200:
+ if response.status_code == 200 and export_format == 'xml':
root = et.fromstring(response.content)
assert root.tag == 'rdmo'
for child in root:
@@ -94,36 +99,67 @@ def test_create(db, client, username, password):
url = reverse(urlnames['list'])
data = {
'uri_prefix': instance.uri_prefix,
- 'key': '%s_new_%s' % (instance.key, username),
+ 'uri_path': f'{instance.uri_path}_new_{username}',
'comment': instance.comment,
- 'optionset': instance.optionset.pk,
- 'order': instance.order,
'text_en': instance.text_lang1,
'text_de': instance.text_lang2
}
- response = client.post(url, data)
+ response = client.post(url, data, content_type='application/json')
assert response.status_code == status_map['create'][username], response.json()
+@pytest.mark.parametrize('username,password', users)
+def test_create_optionset(db, client, username, password):
+ client.login(username=username, password=password)
+ instances = Option.objects.all()
+
+ for instance in instances:
+ optionset = instance.optionsets.first()
+ if optionset is not None:
+ catalog_sections = list(optionset.optionset_options.values_list('option', 'order'))
+ order = optionset.optionset_options.aggregate(order=Max('order')).get('order') + 1
+
+ url = reverse(urlnames['list'])
+ data = {
+ 'uri_prefix': instance.uri_prefix,
+ 'uri_path': f'{instance.uri_path}_new_{username}',
+ 'comment': instance.comment,
+ 'text_en': instance.text_lang1,
+ 'text_de': instance.text_lang2,
+ 'optionsets': [optionset.id]
+ }
+ response = client.post(url, data, content_type='application/json')
+ assert response.status_code == status_map['create'][username], response.json()
+
+ if response.status_code == 201:
+ new_instance = Option.objects.get(id=response.json().get('id'))
+ optionset.refresh_from_db()
+ assert [*catalog_sections, (new_instance.id, order)] == \
+ list(optionset.optionset_options.values_list('option', 'order'))
+
+
@pytest.mark.parametrize('username,password', users)
def test_update(db, client, username, password):
client.login(username=username, password=password)
instances = Option.objects.all()
for instance in instances:
+ optionsets = [optionset.id for optionset in instance.optionsets.all()]
+
url = reverse(urlnames['detail'], args=[instance.pk])
data = {
'uri_prefix': instance.uri_prefix,
- 'key': instance.key,
+ 'uri_path': instance.uri_path,
'comment': instance.comment,
- 'optionset': instance.optionset.pk,
- 'order': instance.order,
'text_en': instance.text_lang1,
'text_de': instance.text_lang2
}
response = client.put(url, data, content_type='application/json')
assert response.status_code == status_map['update'][username], response.json()
+ instance.refresh_from_db()
+ assert optionsets == [optionset.id for optionset in instance.optionsets.all()]
+
@pytest.mark.parametrize('username,password', users)
def test_delete(db, client, username, password):
@@ -137,51 +173,17 @@ def test_delete(db, client, username, password):
@pytest.mark.parametrize('username,password', users)
-def test_detail_export(db, client, username, password):
- client.login(username=username, password=password)
- instances = Option.objects.all()
-
- for instance in instances:
- url = reverse(urlnames['detail_export'], args=[instance.pk])
- response = client.get(url)
- assert response.status_code == status_map['list'][username], response.content
-
- if response.status_code == 200:
- root = et.fromstring(response.content)
- assert root.tag == 'rdmo'
- for child in root:
- assert child.tag in ['option']
-
-
-@pytest.mark.parametrize('username,password', users)
-def test_copy(db, client, username, password):
- client.login(username=username, password=password)
- instances = Option.objects.all()
-
- for instance in instances:
- url = reverse(urlnames['copy'], args=[instance.pk])
- data = {
- 'uri_prefix': instance.uri_prefix + '-',
- 'key': instance.key + '-',
- 'optionset': instance.optionset.id
- }
- response = client.put(url, data, content_type='application/json')
- assert response.status_code == status_map['create'][username], response.json()
-
-
-@pytest.mark.parametrize('username,password', users)
-def test_copy_wrong(db, client, username, password):
+@pytest.mark.parametrize('export_format', export_formats)
+def test_detail_export(db, client, username, password, export_format):
client.login(username=username, password=password)
instance = Option.objects.first()
- url = reverse(urlnames['copy'], args=[instance.pk])
- data = {
- 'uri_prefix': instance.uri_prefix,
- 'key': instance.key
- }
- response = client.put(url, data, content_type='application/json')
+ url = reverse(urlnames['detail_export'], args=[instance.pk]) + export_format + '/'
+ response = client.get(url)
+ assert response.status_code == status_map['detail'][username], response.content
- if status_map['create'][username] == 201:
- assert response.status_code == 400, response.json()
- else:
- assert response.status_code == status_map['create'][username], response.json()
+ if response.status_code == 200 and export_format == 'xml':
+ root = et.fromstring(response.content)
+ assert root.tag == 'rdmo'
+ for child in root:
+ assert child.tag in ['option']
diff --git a/rdmo/options/tests/test_viewset_options_multisite.py b/rdmo/options/tests/test_viewset_options_multisite.py
new file mode 100644
index 0000000000..9062de7f74
--- /dev/null
+++ b/rdmo/options/tests/test_viewset_options_multisite.py
@@ -0,0 +1,107 @@
+import xml.etree.ElementTree as et
+
+import pytest
+
+from django.urls import reverse
+
+from ...core.tests import get_obj_perms_status_code
+from ...core.tests import multisite_status_map as status_map
+from ...core.tests import multisite_users as users
+from ..models import Option
+from .test_viewset_options import urlnames
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_list(db, client, username, password):
+ client.login(username=username, password=password)
+
+ url = reverse(urlnames['list'])
+ response = client.get(url)
+ assert response.status_code == status_map['list'][username], response.json()
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_index(db, client, username, password):
+ client.login(username=username, password=password)
+
+ url = reverse(urlnames['index'])
+ response = client.get(url)
+ assert response.status_code == status_map['list'][username], response.json()
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_export(db, client, username, password):
+ client.login(username=username, password=password)
+
+ url = reverse(urlnames['export'])
+ response = client.get(url)
+ assert response.status_code == status_map['list'][username], response.content
+
+ if response.status_code == 200:
+ root = et.fromstring(response.content)
+ assert root.tag == 'rdmo'
+ for child in root:
+ assert child.tag in ['option']
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_detail(db, client, username, password):
+ client.login(username=username, password=password)
+ instances = Option.objects.all()
+
+ for instance in instances:
+ url = reverse(urlnames['detail'], args=[instance.pk])
+ response = client.get(url)
+ assert response.status_code == status_map['detail'][username], response.json()
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_create(db, client, username, password):
+ client.login(username=username, password=password)
+ instances = Option.objects.all()
+
+ for instance in instances:
+ url = reverse(urlnames['list'])
+ data = {
+ 'uri_prefix': instance.uri_prefix,
+ 'uri_path': f'{instance.uri_path}_new_{username}',
+ 'comment': instance.comment,
+ 'text_en': instance.text_lang1,
+ 'text_de': instance.text_lang2
+ }
+ response = client.post(url, data, content_type='application/json')
+ assert response.status_code == status_map['create'][username], response.json()
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_update_multisite(db, client, username, password):
+ client.login(username=username, password=password)
+ instances = Option.objects.all()
+
+ for instance in instances:
+ optionsets = [optionset.id for optionset in instance.optionsets.all()]
+
+ url = reverse(urlnames['detail'], args=[instance.pk])
+ data = {
+ 'uri_prefix': instance.uri_prefix,
+ 'uri_path': instance.uri_path,
+ 'comment': instance.comment,
+ 'text_en': instance.text_lang1,
+ 'text_de': instance.text_lang2
+ }
+ response = client.put(url, data, content_type='application/json')
+ assert response.status_code == get_obj_perms_status_code(instance, username, 'update'), response.json()
+
+ instance.refresh_from_db()
+ assert optionsets == [optionset.id for optionset in instance.optionsets.all()]
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_delete_multisite(db, client, username, password):
+ client.login(username=username, password=password)
+ instances = Option.objects.all()
+
+ for instance in instances:
+ url = reverse(urlnames['detail'], args=[instance.pk])
+ response = client.delete(url)
+ assert response.status_code == get_obj_perms_status_code(instance, username, 'delete')
diff --git a/rdmo/options/tests/test_viewset_optionsets.py b/rdmo/options/tests/test_viewset_optionsets.py
index 4a81d6cfb1..8ad51c009b 100644
--- a/rdmo/options/tests/test_viewset_optionsets.py
+++ b/rdmo/options/tests/test_viewset_optionsets.py
@@ -1,6 +1,7 @@
import xml.etree.ElementTree as et
import pytest
+
from django.urls import reverse
from ..models import OptionSet
@@ -18,16 +19,16 @@
'editor': 200, 'reviewer': 200, 'api': 200, 'user': 403, 'anonymous': 401
},
'detail': {
- 'editor': 200, 'reviewer': 200, 'api': 200, 'user': 403, 'anonymous': 401
+ 'editor': 200, 'reviewer': 200, 'api': 200, 'user': 404, 'anonymous': 401
},
'create': {
'editor': 201, 'reviewer': 403, 'api': 201, 'user': 403, 'anonymous': 401
},
'update': {
- 'editor': 200, 'reviewer': 403, 'api': 200, 'user': 403, 'anonymous': 401
+ 'editor': 200, 'reviewer': 403, 'api': 200, 'user': 404, 'anonymous': 401
},
'delete': {
- 'editor': 204, 'reviewer': 403, 'api': 204, 'user': 403, 'anonymous': 401
+ 'editor': 204, 'reviewer': 403, 'api': 204, 'user': 404, 'anonymous': 401
}
}
@@ -41,6 +42,8 @@
'copy': 'v1-options:optionset-copy'
}
+export_formats = ('xml', 'rtf', 'odt', 'docx', 'html', 'markdown', 'tex', 'pdf')
+
@pytest.mark.parametrize('username,password', users)
def test_list(db, client, username, password):
@@ -51,15 +54,6 @@ def test_list(db, client, username, password):
assert response.status_code == status_map['list'][username], response.json()
-@pytest.mark.parametrize('username,password', users)
-def test_nested(db, client, username, password):
- client.login(username=username, password=password)
-
- url = reverse(urlnames['nested'])
- response = client.get(url)
- assert response.status_code == status_map['list'][username], response.json()
-
-
@pytest.mark.parametrize('username,password', users)
def test_index(db, client, username, password):
client.login(username=username, password=password)
@@ -70,14 +64,15 @@ def test_index(db, client, username, password):
@pytest.mark.parametrize('username,password', users)
-def test_export(db, client, username, password):
+@pytest.mark.parametrize('export_format', export_formats)
+def test_export(db, client, username, password, export_format):
client.login(username=username, password=password)
- url = reverse(urlnames['export'])
+ url = reverse(urlnames['export']) + export_format + '/'
response = client.get(url)
assert response.status_code == status_map['list'][username], response.content
- if response.status_code == 200:
+ if response.status_code == 200 and export_format == 'xml':
root = et.fromstring(response.content)
assert root.tag == 'rdmo'
for child in root:
@@ -95,6 +90,17 @@ def test_detail(db, client, username, password):
assert response.status_code == status_map['detail'][username], response.json()
+@pytest.mark.parametrize('username,password', users)
+def test_nested(db, client, username, password):
+ client.login(username=username, password=password)
+ instances = OptionSet.objects.all()
+
+ for instance in instances:
+ url = reverse(urlnames['nested'], args=[instance.pk])
+ response = client.get(url)
+ assert response.status_code == status_map['detail'][username], response.json()
+
+
@pytest.mark.parametrize('username,password', users)
def test_create(db, client, username, password):
client.login(username=username, password=password)
@@ -104,14 +110,70 @@ def test_create(db, client, username, password):
url = reverse(urlnames['list'])
data = {
'uri_prefix': instance.uri_prefix,
- 'key': '%s_new_%s' % (instance.key, username),
+ 'uri_path': f'{instance.uri_path}_new_{username}',
+ 'comment': instance.comment,
+ 'order': instance.order
+ }
+ response = client.post(url, data, content_type='application/json')
+ assert response.status_code == status_map['create'][username], response.json()
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_create_question(db, client, username, password):
+ client.login(username=username, password=password)
+ instances = OptionSet.objects.all()
+
+ for instance in instances:
+ question = instance.questions.first()
+ if question:
+ url = reverse(urlnames['list'])
+ data = {
+ 'uri_prefix': instance.uri_prefix,
+ 'uri_path': f'{instance.uri_path}_new_{username}',
+ 'comment': instance.comment,
+ 'order': instance.order,
+ 'questions': [question.id]
+ }
+ response = client.post(url, data, content_type='application/json')
+ assert response.status_code == status_map['create'][username], response.json()
+
+ if response.status_code == 201:
+ new_instance = OptionSet.objects.get(id=response.json().get('id'))
+ assert [question.id] == [question.id for question in new_instance.questions.all()]
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_create_m2m(db, client, username, password):
+ client.login(username=username, password=password)
+ instances = OptionSet.objects.all()
+
+ for instance in instances:
+ optionset_options = [{
+ 'option': optionset_option.option.id,
+ 'order': optionset_option.order
+ } for optionset_option in instance.optionset_options.all()[:1]]
+ conditions = [condition.pk for condition in instance.conditions.all()[:1]]
+
+ url = reverse(urlnames['list'])
+ data = {
+ 'uri_prefix': instance.uri_prefix,
+ 'uri_path': f'{instance.uri_path}_new_{username}',
'comment': instance.comment,
'order': instance.order,
- 'conditions': [condition.pk for condition in instance.conditions.all()],
+ 'options': optionset_options,
+ 'conditions': conditions,
}
- response = client.post(url, data)
+ response = client.post(url, data, content_type='application/json')
assert response.status_code == status_map['create'][username], response.json()
+ if response.status_code == 201:
+ new_instance = OptionSet.objects.get(id=response.json().get('id'))
+ assert optionset_options == [{
+ 'option': optionset_option.option.id,
+ 'order': optionset_option.order
+ } for optionset_option in new_instance.optionset_options.all()]
+ assert conditions == [condition.pk for condition in new_instance.conditions.all()]
+
@pytest.mark.parametrize('username,password', users)
def test_update(db, client, username, password):
@@ -119,74 +181,116 @@ def test_update(db, client, username, password):
instances = OptionSet.objects.all()
for instance in instances:
+ optionset_options = [{
+ 'option': optionset_option.option.id,
+ 'order': optionset_option.order
+ } for optionset_option in instance.optionset_options.all()]
+ conditions = [condition.pk for condition in instance.conditions.all()]
+
url = reverse(urlnames['detail'], args=[instance.pk])
data = {
'uri_prefix': instance.uri_prefix,
- 'key': instance.key,
+ 'uri_path': instance.uri_path,
'comment': instance.comment,
'order': instance.order,
- 'conditions': [condition.pk for condition in instance.conditions.all()],
}
response = client.put(url, data, content_type='application/json')
assert response.status_code == status_map['update'][username], response.json()
+ instance.refresh_from_db()
+ assert optionset_options == [{
+ 'option': optionset_option.option.id,
+ 'order': optionset_option.order
+ } for optionset_option in instance.optionset_options.all()]
+ assert conditions == [condition.pk for condition in instance.conditions.all()]
+
@pytest.mark.parametrize('username,password', users)
-def test_delete(db, client, username, password):
+def test_update_m2m(db, client, username, password):
client.login(username=username, password=password)
instances = OptionSet.objects.all()
for instance in instances:
+ optionset_options = [{
+ 'option': optionset_option.option.id,
+ 'order': optionset_option.order
+ } for optionset_option in instance.optionset_options.all()[:1]]
+ conditions = [condition.pk for condition in instance.conditions.all()[:1]]
+
url = reverse(urlnames['detail'], args=[instance.pk])
- response = client.delete(url)
- assert response.status_code == status_map['delete'][username], response.json()
+ data = {
+ 'uri_prefix': instance.uri_prefix,
+ 'uri_path': instance.uri_path,
+ 'comment': instance.comment,
+ 'order': instance.order,
+ 'options': optionset_options,
+ 'conditions': conditions,
+ }
+ response = client.put(url, data, content_type='application/json')
+ assert response.status_code == status_map['update'][username], response.json()
+
+ if response.status_code == 200:
+ instance.refresh_from_db()
+ assert optionset_options == [{
+ 'option': optionset_option.option.id,
+ 'order': optionset_option.order
+ } for optionset_option in instance.optionset_options.all()]
+ assert conditions == [condition.pk for condition in instance.conditions.all()]
@pytest.mark.parametrize('username,password', users)
-def test_detail_export(db, client, username, password):
+def test_update_m2m_order(db, client, username, password):
client.login(username=username, password=password)
instances = OptionSet.objects.all()
for instance in instances:
- url = reverse(urlnames['detail_export'], args=[instance.pk])
- response = client.get(url)
- assert response.status_code == status_map['list'][username], response.content
+ optionset_options = [{
+ 'option': optionset_option.option.id,
+ 'order': optionset_option.order
+ } for optionset_option in instance.optionset_options.all().order_by('-order')]
+
+ url = reverse(urlnames['detail'], args=[instance.pk])
+ data = {
+ 'uri_prefix': instance.uri_prefix,
+ 'uri_path': instance.uri_path,
+ 'comment': instance.comment,
+ 'order': instance.order,
+ 'options': optionset_options
+ }
+ response = client.put(url, data, content_type='application/json')
+ assert response.status_code == status_map['update'][username], response.json()
if response.status_code == 200:
- root = et.fromstring(response.content)
- assert root.tag == 'rdmo'
- for child in root:
- assert child.tag in ['optionset', 'option']
+ instance.refresh_from_db()
+ assert optionset_options == [{
+ 'option': optionset_option.option.id,
+ 'order': optionset_option.order
+ } for optionset_option in instance.optionset_options.all().order_by('-order')]
@pytest.mark.parametrize('username,password', users)
-def test_copy(db, client, username, password):
+def test_delete(db, client, username, password):
client.login(username=username, password=password)
instances = OptionSet.objects.all()
for instance in instances:
- url = reverse(urlnames['copy'], args=[instance.pk])
- data = {
- 'uri_prefix': instance.uri_prefix + '-',
- 'key': instance.key + '-'
- }
- response = client.put(url, data, content_type='application/json')
- assert response.status_code == status_map['create'][username], response.json()
+ url = reverse(urlnames['detail'], args=[instance.pk])
+ response = client.delete(url)
+ assert response.status_code == status_map['delete'][username], response.json()
@pytest.mark.parametrize('username,password', users)
-def test_copy_wrong(db, client, username, password):
+@pytest.mark.parametrize('export_format', export_formats)
+def test_detail_export(db, client, username, password, export_format):
client.login(username=username, password=password)
instance = OptionSet.objects.first()
- url = reverse(urlnames['copy'], args=[instance.pk])
- data = {
- 'uri_prefix': instance.uri_prefix,
- 'key': instance.key
- }
- response = client.put(url, data, content_type='application/json')
+ url = reverse(urlnames['detail_export'], args=[instance.pk]) + export_format + '/'
+ response = client.get(url)
+ assert response.status_code == status_map['detail'][username], response.content
- if status_map['create'][username] == 201:
- assert response.status_code == 400, response.json()
- else:
- assert response.status_code == status_map['create'][username], response.json()
+ if response.status_code == 200 and export_format == 'xml':
+ root = et.fromstring(response.content)
+ assert root.tag == 'rdmo'
+ for child in root:
+ assert child.tag in ['optionset', 'option']
diff --git a/rdmo/options/tests/test_viewset_optionsets_multisite.py b/rdmo/options/tests/test_viewset_optionsets_multisite.py
new file mode 100644
index 0000000000..2c74bd8e79
--- /dev/null
+++ b/rdmo/options/tests/test_viewset_optionsets_multisite.py
@@ -0,0 +1,145 @@
+import xml.etree.ElementTree as et
+
+import pytest
+
+from django.urls import reverse
+
+from ...core.tests import get_obj_perms_status_code
+from ...core.tests import multisite_status_map as status_map
+from ...core.tests import multisite_users as users
+from ..models import OptionSet
+from .test_viewset_optionsets import urlnames
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_list(db, client, username, password):
+ client.login(username=username, password=password)
+
+ url = reverse(urlnames['list'])
+ response = client.get(url)
+ assert response.status_code == status_map['list'][username], response.json()
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_detail(db, client, username, password):
+ client.login(username=username, password=password)
+ instances = OptionSet.objects.all()
+
+ for instance in instances:
+ url = reverse(urlnames['detail'], args=[instance.pk])
+ response = client.get(url)
+ assert response.status_code == status_map['detail'][username], response.json()
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_nested(db, client, username, password):
+ client.login(username=username, password=password)
+ instances = OptionSet.objects.all()
+
+ for instance in instances:
+ url = reverse(urlnames['nested'], args=[instance.pk])
+ response = client.get(url)
+ assert response.status_code == status_map['detail'][username], response.json()
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_index(db, client, username, password):
+ client.login(username=username, password=password)
+
+ url = reverse(urlnames['index'])
+ response = client.get(url)
+ assert response.status_code == status_map['list'][username], response.json()
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_export(db, client, username, password):
+ client.login(username=username, password=password)
+
+ url = reverse(urlnames['export'])
+ response = client.get(url)
+ assert response.status_code == status_map['list'][username], response.content
+
+ if response.status_code == 200:
+ root = et.fromstring(response.content)
+ assert root.tag == 'rdmo'
+ for child in root:
+ assert child.tag in ['optionset', 'option']
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_create(db, client, username, password):
+ client.login(username=username, password=password)
+ instances = OptionSet.objects.all()
+
+ for instance in instances:
+ url = reverse(urlnames['list'])
+ data = {
+ 'uri_prefix': instance.uri_prefix,
+ 'uri_path': f'{instance.uri_path}_new_{username}',
+ 'comment': instance.comment,
+ 'order': instance.order,
+ 'conditions': [condition.pk for condition in instance.conditions.all()],
+ }
+ response = client.post(url, data)
+ assert response.status_code == status_map['create'][username], response.json()
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_update_m2m_multisite(db, client, username, password):
+ client.login(username=username, password=password)
+ instances = OptionSet.objects.all()
+
+ for instance in instances:
+ optionset_options = [{
+ 'option': optionset_option.option.id,
+ 'order': optionset_option.order
+ } for optionset_option in instance.optionset_options.all()[:1]]
+ conditions = [condition.pk for condition in instance.conditions.all()[:1]]
+
+ url = reverse(urlnames['detail'], args=[instance.pk])
+ data = {
+ 'uri_prefix': instance.uri_prefix,
+ 'uri_path': instance.uri_path,
+ 'comment': instance.comment,
+ 'order': instance.order,
+ 'options': optionset_options,
+ 'conditions': conditions,
+ }
+ response = client.put(url, data, content_type='application/json')
+ assert response.status_code == get_obj_perms_status_code(instance, username, 'update'), response.json()
+
+ if response.status_code == 200:
+ instance.refresh_from_db()
+ assert optionset_options == [{
+ 'option': optionset_option.option.id,
+ 'order': optionset_option.order
+ } for optionset_option in instance.optionset_options.all()]
+ assert conditions == [condition.pk for condition in instance.conditions.all()]
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_delete_multisite(db, client, username, password):
+ client.login(username=username, password=password)
+ instances = OptionSet.objects.all()
+
+ for instance in instances:
+ url = reverse(urlnames['detail'], args=[instance.pk])
+ response = client.delete(url)
+ assert response.status_code == get_obj_perms_status_code(instance, username, 'delete'), response.json()
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_detail_export(db, client, username, password):
+ client.login(username=username, password=password)
+ instances = OptionSet.objects.all()
+
+ for instance in instances:
+ url = reverse(urlnames['detail_export'], args=[instance.pk])
+ response = client.get(url)
+ assert response.status_code == status_map['detail'][username], response.content
+
+ if response.status_code == 200:
+ root = et.fromstring(response.content)
+ assert root.tag == 'rdmo'
+ for child in root:
+ assert child.tag in ['optionset', 'option']
diff --git a/rdmo/options/urls/__init__.py b/rdmo/options/urls/__init__.py
index 791b137203..e69de29bb2 100644
--- a/rdmo/options/urls/__init__.py
+++ b/rdmo/options/urls/__init__.py
@@ -1,8 +0,0 @@
-from django.urls import re_path
-
-from ..views import OptionsExportView, OptionsView
-
-urlpatterns = [
- re_path(r'^$', OptionsView.as_view(), name='options'),
- re_path(r'^export/(?P[a-z]+)/$', OptionsExportView.as_view(), name='options_export'),
-]
diff --git a/rdmo/options/urls/v1.py b/rdmo/options/urls/v1.py
index 5a5eb1ce11..88e879bb23 100644
--- a/rdmo/options/urls/v1.py
+++ b/rdmo/options/urls/v1.py
@@ -1,4 +1,5 @@
from django.urls import include, path
+
from rest_framework import routers
from ..viewsets import OptionSetViewSet, OptionViewSet, ProviderViewSet
diff --git a/rdmo/options/validators.py b/rdmo/options/validators.py
index 545d964284..33d878b5ef 100644
--- a/rdmo/options/validators.py
+++ b/rdmo/options/validators.py
@@ -1,5 +1,3 @@
-from django.utils.translation import gettext_lazy as _
-
from rdmo.core.validators import LockedValidator, UniqueURIValidator
from .models import Option, OptionSet
@@ -8,28 +6,13 @@
class OptionSetUniqueURIValidator(UniqueURIValidator):
model = OptionSet
-
- def get_uri(self, data):
- if not data.get('key'):
- self.raise_validation_error({'key': _('This field is required.')})
- else:
- uri = self.model.build_uri(data.get('uri_prefix'), data.get('key'))
- return uri
+ models = (Option, OptionSet)
class OptionUniqueURIValidator(UniqueURIValidator):
model = Option
-
- def get_uri(self, data):
- if not data.get('key'):
- self.raise_validation_error({'key': _('This field is required.')})
- elif not data.get('optionset'):
- self.raise_validation_error({'optionset': _('This field may not be null.')})
- else:
- path = self.model.build_path(data.get('key'), data.get('optionset'))
- uri = self.model.build_uri(data.get('uri_prefix'), path)
- return uri
+ models = (Option, OptionSet)
class OptionSetLockedValidator(LockedValidator):
@@ -39,4 +22,4 @@ class OptionSetLockedValidator(LockedValidator):
class OptionLockedValidator(LockedValidator):
- parent_field = 'optionset'
+ parent_fields = ('optionsets', )
diff --git a/rdmo/options/views.py b/rdmo/options/views.py
deleted file mode 100644
index 8fe0b237fb..0000000000
--- a/rdmo/options/views.py
+++ /dev/null
@@ -1,44 +0,0 @@
-import logging
-
-from django.conf import settings
-from django.utils.translation import gettext_lazy as _
-from django.views.generic import ListView, TemplateView
-
-from rdmo.core.exports import XMLResponse
-from rdmo.core.utils import get_model_field_meta, render_to_format
-from rdmo.core.views import CSRFViewMixin, ModelPermissionMixin
-
-from .models import Option, OptionSet
-from .renderers import OptionSetRenderer
-from .serializers.export import OptionSetExportSerializer
-
-log = logging.getLogger(__name__)
-
-
-class OptionsView(ModelPermissionMixin, CSRFViewMixin, TemplateView):
- template_name = 'options/options.html'
- permission_required = 'options.view_option'
-
- def get_context_data(self, **kwargs):
- context = super(OptionsView, self).get_context_data(**kwargs)
- context['export_formats'] = settings.EXPORT_FORMATS
- context['meta'] = {
- 'OptionSet': get_model_field_meta(OptionSet),
- 'Option': get_model_field_meta(Option)
- }
- return context
-
-
-class OptionsExportView(ModelPermissionMixin, ListView):
- model = OptionSet
- context_object_name = 'optionsets'
- permission_required = 'options.view_optionset'
-
- def render_to_response(self, context, **response_kwargs):
- format = self.kwargs.get('format')
- if format == 'xml':
- serializer = OptionSetExportSerializer(context['optionsets'], many=True)
- xml = OptionSetRenderer().render(serializer.data)
- return XMLResponse(xml, name='options')
- else:
- return render_to_format(self.request, format, _('Options'), 'options/options_export.html', context)
diff --git a/rdmo/options/viewsets.py b/rdmo/options/viewsets.py
index 6b69fc853e..804e003fcc 100644
--- a/rdmo/options/viewsets.py
+++ b/rdmo/options/viewsets.py
@@ -1,101 +1,144 @@
from django.conf import settings
from django.db import models
-from django_filters.rest_framework import DjangoFilterBackend
-
from rest_framework.decorators import action
+from rest_framework.filters import SearchFilter
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.viewsets import ModelViewSet
+from django_filters.rest_framework import DjangoFilterBackend
+
from rdmo.core.exports import XMLResponse
-from rdmo.core.permissions import HasModelPermission
+from rdmo.core.permissions import HasModelPermission, HasObjectPermission
+from rdmo.core.utils import is_truthy, render_to_format
from rdmo.core.views import ChoicesViewSet
-from rdmo.core.viewsets import CopyModelMixin
from .models import Option, OptionSet
from .renderers import OptionRenderer, OptionSetRenderer
-from .serializers.export import (OptionExportSerializer,
- OptionSetExportSerializer)
-from .serializers.v1 import (OptionIndexSerializer, OptionSerializer,
- OptionSetIndexSerializer,
- OptionSetNestedSerializer, OptionSetSerializer)
-
-
-class OptionSetViewSet(CopyModelMixin, ModelViewSet):
- permission_classes = (HasModelPermission, )
- queryset = OptionSet.objects.order_by('order').prefetch_related(
+from .serializers.export import OptionExportSerializer, OptionSetExportSerializer
+from .serializers.v1 import (
+ OptionIndexSerializer,
+ OptionSerializer,
+ OptionSetIndexSerializer,
+ OptionSetNestedSerializer,
+ OptionSetSerializer,
+)
+
+
+class OptionSetViewSet(ModelViewSet):
+ permission_classes = (HasModelPermission | HasObjectPermission, )
+ serializer_class = OptionSetSerializer
+ queryset = OptionSet.objects.prefetch_related(
+ 'optionset_options__option',
'conditions',
'questions',
- 'options'
+ 'editors'
)
- serializer_class = OptionSetSerializer
- filter_backends = (DjangoFilterBackend,)
+ filter_backends = (SearchFilter, DjangoFilterBackend)
+ search_fields = ('uri', )
filterset_fields = (
'uri',
- 'key',
+ 'uri_prefix',
+ 'uri_path',
'comment'
)
- @action(detail=False)
- def nested(self, request):
- serializer = OptionSetNestedSerializer(self.get_queryset(), many=True)
- return Response(serializer.data)
-
@action(detail=False)
def index(self, request):
- queryset = OptionSet.objects.order_by('order').prefetch_related('options')
+ queryset = self.filter_queryset(self.get_queryset())
serializer = OptionSetIndexSerializer(queryset, many=True)
return Response(serializer.data)
- @action(detail=False, permission_classes=[HasModelPermission])
- def export(self, request):
- serializer = OptionSetExportSerializer(self.get_queryset(), many=True)
- xml = OptionSetRenderer().render(serializer.data)
- return XMLResponse(xml, name='optionsets')
-
- @action(detail=True, url_path='export', permission_classes=[HasModelPermission])
- def detail_export(self, request, pk=None):
- serializer = OptionSetExportSerializer(self.get_object())
- xml = OptionSetRenderer().render([serializer.data])
- return XMLResponse(xml, name=self.get_object().key)
-
+ @action(detail=True)
+ def nested(self, request, pk):
+ serializer = OptionSetNestedSerializer(self.get_object(), context={'request': request})
+ return Response(serializer.data)
-class OptionViewSet(CopyModelMixin, ModelViewSet):
- permission_classes = (HasModelPermission, )
- queryset = Option.objects.order_by('optionset__order', 'order') \
- .annotate(values_count=models.Count('values')) \
- .annotate(projects_count=models.Count('values__project', distinct=True)) \
- .select_related('optionset') \
- .prefetch_related('conditions')
+ @action(detail=False, url_path='export(/(?P[a-z]+))?')
+ def export(self, request, export_format='xml'):
+ queryset = self.filter_queryset(self.get_queryset())
+ if export_format == 'xml':
+ serializer = OptionSetExportSerializer(queryset, many=True)
+ xml = OptionSetRenderer().render(serializer.data, context=self.get_export_renderer_context(request))
+ return XMLResponse(xml, name='optionsets')
+ else:
+ return render_to_format(self.request, export_format, 'optionsets', 'options/export/optionsets.html', {
+ 'optionsets': queryset
+ })
+
+ @action(detail=True, url_path='export(/(?P[a-z]+))?')
+ def detail_export(self, request, pk=None, export_format='xml'):
+ if export_format == 'xml':
+ serializer = OptionSetExportSerializer(self.get_object())
+ xml = OptionSetRenderer().render([serializer.data], context=self.get_export_renderer_context(request))
+ return XMLResponse(xml, name=self.get_object().uri_path)
+ else:
+ return render_to_format(
+ self.request, export_format, self.get_object().uri_path, 'options/export/optionsets.html', {
+ 'optionsets': [self.get_object()]
+ }
+ )
+
+ def get_export_renderer_context(self, request):
+ full = is_truthy(request.GET.get('full'))
+ return {
+ 'attributes': full or is_truthy(request.GET.get('attributes')),
+ 'conditions': full or is_truthy(request.GET.get('conditions')),
+ 'options': full or is_truthy(request.GET.get('options'))
+ }
+
+
+class OptionViewSet(ModelViewSet):
+ permission_classes = (HasModelPermission | HasObjectPermission, )
serializer_class = OptionSerializer
+ queryset = Option.objects.annotate(values_count=models.Count('values')) \
+ .annotate(projects_count=models.Count('values__project', distinct=True)) \
+ .prefetch_related('optionsets', 'conditions', 'editors')
- filter_backends = (DjangoFilterBackend,)
+ filter_backends = (SearchFilter, DjangoFilterBackend)
+ search_fields = ('uri', 'text')
filterset_fields = (
'uri',
- 'key',
- 'optionset',
+ 'uri_prefix',
+ 'uri_path',
+ 'optionsets',
+ 'optionsets__uri',
+ 'optionsets__uri_path',
'comment'
)
@action(detail=False)
def index(self, request):
- queryset = Option.objects.order_by('optionset__order', 'order')
+ queryset = self.filter_queryset(self.get_queryset())
serializer = OptionIndexSerializer(queryset, many=True)
return Response(serializer.data)
- @action(detail=False, permission_classes=[HasModelPermission])
- def export(self, request):
- serializer = OptionExportSerializer(self.get_queryset(), many=True)
- xml = OptionRenderer().render(serializer.data)
- return XMLResponse(xml, name='options')
-
- @action(detail=True, url_path='export', permission_classes=[HasModelPermission])
- def detail_export(self, request, pk=None):
- serializer = OptionExportSerializer(self.get_object())
- xml = OptionRenderer().render([serializer.data])
- return XMLResponse(xml, name=self.get_object().path)
+ @action(detail=False, url_path='export(/(?P[a-z]+))?')
+ def export(self, request, export_format='xml'):
+ queryset = self.filter_queryset(self.get_queryset())
+ if export_format == 'xml':
+ serializer = OptionExportSerializer(queryset, many=True)
+ xml = OptionRenderer().render(serializer.data)
+ return XMLResponse(xml, name='options')
+ else:
+ return render_to_format(self.request, export_format, 'options', 'options/export/options.html', {
+ 'options': queryset
+ })
+
+ @action(detail=True, url_path='export(/(?P[a-z]+))?')
+ def detail_export(self, request, pk=None, export_format='xml'):
+ if export_format == 'xml':
+ serializer = OptionExportSerializer(self.get_object())
+ xml = OptionRenderer().render([serializer.data])
+ return XMLResponse(xml, name=self.get_object().uri_path)
+ else:
+ return render_to_format(
+ self.request, export_format, self.get_object().uri_path, 'options/export/options.html', {
+ 'options': [self.get_object()]
+ }
+ )
class ProviderViewSet(ChoicesViewSet):
diff --git a/rdmo/overlays/models.py b/rdmo/overlays/models.py
index 86371ccfb4..16703a497b 100644
--- a/rdmo/overlays/models.py
+++ b/rdmo/overlays/models.py
@@ -33,4 +33,4 @@ class Meta:
verbose_name_plural = _('Overlays')
def __str__(self):
- return '{} / {}'.format(self.user, self.url_name)
+ return f'{self.user} / {self.url_name}'
diff --git a/rdmo/overlays/tests/test_views.py b/rdmo/overlays/tests/test_views.py
index 8d3a26a7ae..8f6fa8aa0e 100644
--- a/rdmo/overlays/tests/test_views.py
+++ b/rdmo/overlays/tests/test_views.py
@@ -1,4 +1,5 @@
import pytest
+
from django.urls import reverse
from ..models import Overlay
diff --git a/rdmo/overlays/tests/test_viewsets.py b/rdmo/overlays/tests/test_viewsets.py
index f875a66d6d..931a71944a 100644
--- a/rdmo/overlays/tests/test_viewsets.py
+++ b/rdmo/overlays/tests/test_viewsets.py
@@ -1,4 +1,5 @@
import pytest
+
from django.urls import reverse
from ..models import Overlay
diff --git a/rdmo/overlays/urls/v1.py b/rdmo/overlays/urls/v1.py
index b560efb73b..9054f26c1d 100644
--- a/rdmo/overlays/urls/v1.py
+++ b/rdmo/overlays/urls/v1.py
@@ -1,4 +1,5 @@
from django.urls import include, path
+
from rest_framework import routers
from ..viewsets import OverlayViewSet
diff --git a/rdmo/overlays/viewsets.py b/rdmo/overlays/viewsets.py
index 120c8692b2..20a1aa3895 100644
--- a/rdmo/overlays/viewsets.py
+++ b/rdmo/overlays/viewsets.py
@@ -1,5 +1,6 @@
from django.conf import settings
from django.contrib.sites.models import Site
+
from rest_framework.decorators import action
from rest_framework.exceptions import NotFound
from rest_framework.permissions import IsAuthenticated
@@ -11,7 +12,8 @@
class OverlayViewSet(ViewSet):
- @action(detail=False, methods=['post'], url_path='(?P[-\\w]+)/current', permission_classes=[IsAuthenticated])
+ @action(detail=False, methods=['post'], url_path='(?P[-\\w]+)/current',
+ permission_classes=[IsAuthenticated])
def current(self, request, url_name=None):
site = Site.objects.get_current()
overlays = settings.OVERLAYS.get(url_name)[:]
@@ -29,7 +31,8 @@ def current(self, request, url_name=None):
'last': overlay.current == overlays[-1]
})
- @action(detail=False, methods=['post'], url_path='(?P[-\\w]+)/next', permission_classes=[IsAuthenticated])
+ @action(detail=False, methods=['post'], url_path='(?P[-\\w]+)/next',
+ permission_classes=[IsAuthenticated])
def next(self, request, url_name=None):
site = Site.objects.get_current()
overlays = settings.OVERLAYS.get(url_name)[:]
@@ -51,7 +54,8 @@ def next(self, request, url_name=None):
'last': overlay.current == overlays[-1]
})
- @action(detail=False, methods=['post'], url_path='(?P[-\\w]+)/dismiss', permission_classes=[IsAuthenticated])
+ @action(detail=False, methods=['post'], url_path='(?P[-\\w]+)/dismiss',
+ permission_classes=[IsAuthenticated])
def dismiss(self, request, url_name=None):
site = Site.objects.get_current()
overlays = settings.OVERLAYS.get(url_name)[:]
diff --git a/rdmo/projects/admin.py b/rdmo/projects/admin.py
index aff75c3c69..e8890cf19d 100644
--- a/rdmo/projects/admin.py
+++ b/rdmo/projects/admin.py
@@ -1,9 +1,18 @@
from django.contrib import admin
from django.db.models import Prefetch
-from .models import (Continuation, Integration, IntegrationOption, Invite,
- Issue, IssueResource, Membership, Project, Snapshot,
- Value)
+from .models import (
+ Continuation,
+ Integration,
+ IntegrationOption,
+ Invite,
+ Issue,
+ IssueResource,
+ Membership,
+ Project,
+ Snapshot,
+ Value,
+)
class ProjectAdmin(admin.ModelAdmin):
@@ -30,7 +39,7 @@ class MembershipAdmin(admin.ModelAdmin):
class ContinuationAdmin(admin.ModelAdmin):
search_fields = ('project__title', 'user__username')
- list_display = ('project', 'user', 'questionset')
+ list_display = ('project', 'user', 'page')
class IntegrationAdmin(admin.ModelAdmin):
diff --git a/rdmo/projects/apps.py b/rdmo/projects/apps.py
index 4664adcbad..178bb4ccd3 100644
--- a/rdmo/projects/apps.py
+++ b/rdmo/projects/apps.py
@@ -8,7 +8,7 @@ class ProjectsConfig(AppConfig):
verbose_name = _('Projects')
def ready(self):
- from . import rules
+ from . import rules # noqa: F401
if settings.PROJECT_REMOVE_VIEWS:
- from . import handlers
+ from . import handlers # noqa: F401
diff --git a/rdmo/projects/exports.py b/rdmo/projects/exports.py
index 16cbfcbad1..6be2125f7c 100644
--- a/rdmo/projects/exports.py
+++ b/rdmo/projects/exports.py
@@ -4,8 +4,9 @@
from rdmo.core.exports import prettify_xml
from rdmo.core.plugins import Plugin
-from rdmo.core.utils import render_to_csv
-from rdmo.questions.models import Question
+from rdmo.core.utils import render_to_csv, render_to_json
+from rdmo.views.templatetags import view_tags
+from rdmo.views.utils import ProjectWrapper
from .renderers import XMLRenderer
from .serializers.export import ProjectSerializer as ExportSerializer
@@ -30,7 +31,8 @@ def get_set(self, path, set_prefix=''):
.order_by('set_index', 'collection_index')
def get_values(self, path, set_prefix='', set_index=0):
- return self.project.values.filter(snapshot=self.snapshot, attribute__path=path, set_prefix=set_prefix, set_index=set_index) \
+ return self.project.values.filter(snapshot=self.snapshot, attribute__path=path,
+ set_prefix=set_prefix, set_index=set_index) \
.order_by('collection_index')
def get_value(self, path, set_prefix='', set_index=0, collection_index=0):
@@ -77,35 +79,45 @@ def get_option(self, options, path, set_prefix='', set_index=0, collection_index
return default
-class CSVExport(Export):
+class AnswersExportMixin:
- delimiter = ','
-
- def render(self):
- queryset = self.project.values.filter(snapshot=None)
- data = []
-
- for question in Question.objects.order_by_catalog(self.project.catalog):
- if question.questionset.is_collection and question.questionset.attribute:
- if question.questionset.attribute.uri.endswith('/id'):
- set_attribute_uri = question.questionset.attribute.uri
- else:
- set_attribute_uri = question.questionset.attribute.uri.rstrip('/') + '/id'
+ def get_data(self):
+ # prefetch most elements of the catalog
+ self.project.catalog.prefetch_elements()
- for value_set in queryset.filter(attribute__uri=set_attribute_uri):
- values = queryset.filter(attribute=question.attribute, set_index=value_set.set_index) \
- .order_by('set_prefix', 'set_index', 'collection_index')
- data.append((self.stringify(question.text), self.stringify(value_set.value), self.stringify_values(values)))
- else:
- values = queryset.filter(attribute=question.attribute).order_by('set_prefix', 'set_index', 'collection_index')
+ # create project wrapper as for the views
+ project_wrapper = ProjectWrapper(self.project, self.snapshot)
- data.append((self.stringify(question.text), '', self.stringify_values(values)))
-
- return render_to_csv(self.project.title, data, self.delimiter)
+ data = []
+ for question in project_wrapper.questions:
+ # use the same template tags as in project_answers_element.html
+ # get labels and to correctly attribute for conditions
+ set_prefixes = view_tags.get_set_prefixes({}, question['attribute'], project=project_wrapper)
+ for set_prefix in set_prefixes:
+ set_indexes = view_tags.get_set_indexes({}, question['attribute'], set_prefix=set_prefix,
+ project=project_wrapper)
+ for set_index in set_indexes:
+ values = view_tags.get_values(
+ {}, question['attribute'], set_prefix=set_prefix, set_index=set_index, project=project_wrapper
+ )
+ labels = view_tags.get_labels(
+ {}, question, set_prefix=set_prefix, set_index=set_index, project=project_wrapper
+ )
+ result = view_tags.check_element(
+ {}, question, set_prefix=set_prefix, set_index=set_index, project=project_wrapper
+ )
+ if result:
+ data.append({
+ 'question': self.stringify(question['text']),
+ 'set': ' '.join(labels),
+ 'values': self.stringify_values(values)
+ })
+
+ return data
def stringify_values(self, values):
if values is not None:
- return '; '.join([self.stringify(value.value_and_unit) for value in values])
+ return '; '.join([self.stringify(value['value_and_unit']) for value in values])
else:
return ''
@@ -116,6 +128,15 @@ def stringify(self, el):
return re.sub(r'\s+', ' ', str(el))
+class CSVExport(AnswersExportMixin, Export):
+
+ delimiter = ','
+
+ def render(self):
+ rows = [item.values() for item in self.get_data()]
+ return render_to_csv(self.project.title, rows, self.delimiter)
+
+
class CSVCommaExport(CSVExport):
delimiter = ','
@@ -124,6 +145,12 @@ class CSVSemicolonExport(CSVExport):
delimiter = ';'
+class JSONExport(AnswersExportMixin, Export):
+
+ def render(self):
+ return render_to_json(self.project.title, self.get_data())
+
+
class RDMOXMLExport(Export):
def render(self):
diff --git a/rdmo/projects/filters.py b/rdmo/projects/filters.py
index 843bc576fb..064f712363 100644
--- a/rdmo/projects/filters.py
+++ b/rdmo/projects/filters.py
@@ -1,6 +1,7 @@
-from django_filters import CharFilter, FilterSet
from rest_framework.filters import BaseFilterBackend
+from django_filters import CharFilter, FilterSet
+
from .models import Project
diff --git a/rdmo/projects/forms.py b/rdmo/projects/forms.py
index 7ef1761f3a..4ac7d42ab2 100644
--- a/rdmo/projects/forms.py
+++ b/rdmo/projects/forms.py
@@ -12,8 +12,7 @@
from rdmo.core.utils import markdown2html
from .constants import ROLE_CHOICES
-from .models import (Integration, IntegrationOption, Invite, Membership,
- Project, Snapshot)
+from .models import Integration, IntegrationOption, Invite, Membership, Project, Snapshot
class CatalogChoiceField(forms.ModelChoiceField):
@@ -22,21 +21,23 @@ class CatalogChoiceField(forms.ModelChoiceField):
def label_from_instance(self, obj):
if obj.available is False:
- return mark_safe('%s%s%s
' % (obj.title, self._unavailable_icon, markdown2html(obj.help)))
+ return mark_safe('{}{}{}
'.format(
+ obj.title, self._unavailable_icon, markdown2html(obj.help)
+ ))
- return mark_safe('%s %s' % (obj.title, markdown2html(obj.help)))
+ return mark_safe(f'{obj.title} {markdown2html(obj.help)}')
class TasksMultipleChoiceField(forms.ModelMultipleChoiceField):
def label_from_instance(self, obj):
- return mark_safe('%s %s' % (obj.title, markdown2html(obj.text)))
+ return mark_safe(f'{obj.title} {markdown2html(obj.text)}')
class ViewsMultipleChoiceField(forms.ModelMultipleChoiceField):
def label_from_instance(self, obj):
- return mark_safe('%s %s' % (obj.title, markdown2html(obj.help)))
+ return mark_safe(f'{obj.title} {markdown2html(obj.help)}')
class ProjectForm(forms.ModelForm):
@@ -166,11 +167,11 @@ class Meta:
def __init__(self, *args, **kwargs):
self.project = kwargs.pop('project')
- super(SnapshotCreateForm, self).__init__(*args, **kwargs)
+ super().__init__(*args, **kwargs)
def save(self, *args, **kwargs):
self.instance.project = self.project
- return super(SnapshotCreateForm, self).save(*args, **kwargs)
+ return super().save(*args, **kwargs)
class MembershipCreateForm(forms.Form):
@@ -192,7 +193,8 @@ def __init__(self, *args, **kwargs):
self.fields['silent'] = forms.BooleanField(
required=False,
label=_('Add member silently'),
- help_text=_('As site manager or admin, you can directly add users without notifying them via e-mail, when you check the following checkbox.')
+ help_text=_('As site manager or admin, you can directly add users without notifying them via e-mail, '
+ 'when you check the following checkbox.')
)
def clean_username_or_email(self):
@@ -201,13 +203,14 @@ def clean_username_or_email(self):
# check if it is a registered user
try:
- self.cleaned_data['user'] = usermodel.objects.get(Q(username=username_or_email) | Q(email__iexact=username_or_email))
+ self.cleaned_data['user'] = usermodel.objects.get(Q(username=username_or_email) |
+ Q(email__iexact=username_or_email))
self.cleaned_data['email'] = self.cleaned_data['user'].email
if self.cleaned_data['user'] in self.project.user.all():
raise ValidationError(_('The user is already a member of the project.'))
- except (usermodel.DoesNotExist, usermodel.MultipleObjectsReturned):
+ except (usermodel.DoesNotExist, usermodel.MultipleObjectsReturned) as e:
if settings.PROJECT_SEND_INVITE:
# check if it is a valid email address, this will raise the correct ValidationError
EmailValidator()(username_or_email)
@@ -217,7 +220,8 @@ def clean_username_or_email(self):
else:
self.cleaned_data['user'] = None
self.cleaned_data['email'] = None
- raise ValidationError(_('A user with this username or e-mail was not found. Only registered users can be invited.'))
+ raise ValidationError(_('A user with this username or e-mail was not found. '
+ 'Only registered users can be invited.')) from e
def clean(self):
if self.cleaned_data.get('silent') is True and self.cleaned_data.get('user') is None:
@@ -262,10 +266,15 @@ def __init__(self, *args, **kwargs):
# add fields for the integration options
for field in self.provider.fields:
- try:
- initial = IntegrationOption.objects.get(integration=self.instance, key=field.get('key')).value
- except IntegrationOption.DoesNotExist:
+ # new integration instance is going to be created
+ if self.instance.pk is None:
initial = None
+ # existing integration is going to be updated
+ else:
+ try:
+ initial = IntegrationOption.objects.get(integration=self.instance, key=field.get('key')).value
+ except IntegrationOption.DoesNotExist:
+ initial = None
if field.get('placeholder'):
attrs = {'placeholder': field.get('placeholder')}
diff --git a/rdmo/projects/handlers.py b/rdmo/projects/handlers.py
index b6cf047684..6f65709cbe 100644
--- a/rdmo/projects/handlers.py
+++ b/rdmo/projects/handlers.py
@@ -5,8 +5,8 @@
from django.db.models.signals import m2m_changed
from django.dispatch import receiver
+from rdmo.projects.models import Membership, Project
from rdmo.questions.models import Catalog
-from rdmo.projects.models import Project, Membership
from rdmo.views.models import View
logger = logging.getLogger(__name__)
diff --git a/rdmo/projects/imports.py b/rdmo/projects/imports.py
index 4b444e71a2..3b20736a4e 100644
--- a/rdmo/projects/imports.py
+++ b/rdmo/projects/imports.py
@@ -2,18 +2,17 @@
import io
import logging
import mimetypes
-from urllib.parse import urlparse, quote
-
-import requests
+from urllib.parse import quote
from django import forms
from django.core.files import File
-from django.core.exceptions import ValidationError
from django.shortcuts import redirect, render
from django.utils.translation import gettext_lazy as _
-from rdmo.core.plugins import Plugin
+import requests
+
from rdmo.core.imports import handle_fetched_file
+from rdmo.core.plugins import Plugin
from rdmo.core.xml import get_ns_map, get_uri, read_xml_file
from rdmo.domain.models import Attribute
from rdmo.options.models import Option
@@ -21,6 +20,7 @@
from rdmo.services.providers import GitHubProviderMixin, GitLabProviderMixin
from rdmo.tasks.models import Task
from rdmo.views.models import View
+
from .models import Project, Snapshot, Value
log = logging.getLogger(__name__)
@@ -169,6 +169,18 @@ def get_value(self, value_node):
value.set_prefix = ''
value.set_index = int(value_node.find('set_index').text)
+
+ try:
+ set_collection_text = value_node.find('set_collection').text
+ if set_collection_text == 'True':
+ value.set_collection = True
+ elif set_collection_text == 'False':
+ value.set_collection = False
+ else:
+ value.set_collection = None
+ except AttributeError:
+ value.set_collection = None
+
value.collection_index = int(value_node.find('collection_index').text)
value.text = value_node.find('text').text or ''
diff --git a/rdmo/projects/management/commands/export_projects.py b/rdmo/projects/management/commands/export_projects.py
index 527f83af8c..f754b3f433 100644
--- a/rdmo/projects/management/commands/export_projects.py
+++ b/rdmo/projects/management/commands/export_projects.py
@@ -1,5 +1,4 @@
import logging
-
from pathlib import Path
from django.conf import settings
@@ -14,7 +13,6 @@
from rdmo.views.models import View
from rdmo.views.utils import ProjectWrapper
-
logger = logging.getLogger(__name__)
@@ -42,18 +40,18 @@ def get_queryset(self):
Prefetch('catalog__sections__questionsets',
queryset=QuestionSet.objects.select_related('attribute')),
Prefetch('catalog__sections__questionsets__questions',
- queryset=Question.objects.select_related('attribute', 'questionset')),
+ queryset=Question.objects.select_related('attribute')),
Prefetch('catalog__sections__questionsets__questionsets',
queryset=QuestionSet.objects.select_related('attribute')),
Prefetch('catalog__sections__questionsets__questionsets__questions',
- queryset=Question.objects.select_related('attribute', 'questionset')),
+ queryset=Question.objects.select_related('attribute')),
)
def export_answers(self):
current_snapshot = None
if self.format not in dict(settings.EXPORT_FORMATS):
- raise CommandError('Format "{}" is not supported for answers.'.format(self.format))
+ raise CommandError(f'Format "{self.format}" is not supported for answers.')
for project in self.get_queryset():
context = {
@@ -65,19 +63,20 @@ def export_answers(self):
'resource_path': get_value_path(project, current_snapshot)
}
- response = render_to_format(None, context['format'], context['title'], 'projects/project_answers_export.html', context)
+ response = render_to_format(None, context['format'], context['title'],
+ 'projects/project_answers_export.html', context)
self.write_file(self.path / str(project.id) / 'answers', response)
def export_view(self, key):
current_snapshot = None
if self.format not in dict(settings.EXPORT_FORMATS):
- raise CommandError('Format "{}" is not supported for answers.'.format(self.format))
+ raise CommandError(f'Format "{self.format}" is not supported for answers.')
try:
view = View.objects.get(key=key)
- except View.DoesNotExist:
- raise CommandError('A view with the key "{}" was not found.'.format(key))
+ except View.DoesNotExist as e:
+ raise CommandError(f'A view with the key "{key}" was not found.') from e
for project in self.get_queryset():
context = {
@@ -91,14 +90,15 @@ def export_view(self, key):
'resource_path': get_value_path(project, current_snapshot)
}
- response = render_to_format(None, context['format'], context['title'], 'projects/project_view_export.html', context)
+ response = render_to_format(None, context['format'], context['title'],
+ 'projects/project_view_export.html', context)
self.write_file(self.path / str(project.id) / key, response)
def export_projects(self):
for project in self.get_queryset():
export_plugin = get_plugin('PROJECT_EXPORTS', self.format)
if export_plugin is None:
- raise CommandError('Format "{}" is not supported.'.format(self.format))
+ raise CommandError(f'Format "{self.format}" is not supported.')
export_plugin.project = project
export_plugin.snapshot = None
@@ -111,7 +111,7 @@ def write_file(self, path, response):
file_path = path / file_name
file_path.parent.mkdir(exist_ok=True, parents=True)
- print('Writing {}'.format(file_path))
+ print(f'Writing {file_path}')
with file_path.open('wb') as fp:
fp.write(response.content)
diff --git a/rdmo/projects/management/commands/find_inactive_projects.py b/rdmo/projects/management/commands/find_inactive_projects.py
index 63f1713fac..a540846022 100644
--- a/rdmo/projects/management/commands/find_inactive_projects.py
+++ b/rdmo/projects/management/commands/find_inactive_projects.py
@@ -1,14 +1,12 @@
-import sys
import csv
-
+import sys
from datetime import datetime
-import pytz
-
+from django.core.management.base import BaseCommand
from django.db import models
from django.db.models.functions import Greatest
-from django.core.management.base import BaseCommand
+import pytz
from rdmo.projects.models import Project, Value
diff --git a/rdmo/projects/management/commands/prune_projects.py b/rdmo/projects/management/commands/prune_projects.py
index c101cc91c7..41cd724ee3 100644
--- a/rdmo/projects/management/commands/prune_projects.py
+++ b/rdmo/projects/management/commands/prune_projects.py
@@ -1,6 +1,6 @@
from django.core.management.base import BaseCommand, CommandError
-from rdmo.projects.models import Project, Membership
+from rdmo.projects.models import Membership, Project
class Command(BaseCommand):
@@ -36,7 +36,7 @@ def handle(self, *args, **options):
self.stdout.write('Found projects without %s:' % (roles))
for proj in candidates:
- self.stdout.write('%s (id=%s)' % (proj, proj.id))
+ self.stdout.write(f'{proj} (id={proj.id})')
if options['remove']:
self.stdout.write('...removing...', ending='')
proj.delete()
diff --git a/rdmo/projects/managers.py b/rdmo/projects/managers.py
index 95bd5c763d..8ee22b0da2 100644
--- a/rdmo/projects/managers.py
+++ b/rdmo/projects/managers.py
@@ -1,5 +1,6 @@
from django.conf import settings
from django.db import models
+
from mptt.models import TreeManager
from mptt.querysets import TreeQuerySet
diff --git a/rdmo/projects/migrations/0053_alter_continuation_questionset.py b/rdmo/projects/migrations/0053_alter_continuation_questionset.py
new file mode 100644
index 0000000000..3f86b66732
--- /dev/null
+++ b/rdmo/projects/migrations/0053_alter_continuation_questionset.py
@@ -0,0 +1,19 @@
+# Generated by Django 3.2.14 on 2022-12-16 12:56
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('projects', '0052_meta'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='continuation',
+ name='questionset',
+ field=models.ForeignKey(help_text='The question set for this continuation.', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='questions.questionset', verbose_name='Question set'),
+ ),
+ ]
diff --git a/rdmo/projects/migrations/0054_continuation_page.py b/rdmo/projects/migrations/0054_continuation_page.py
new file mode 100644
index 0000000000..22d4358707
--- /dev/null
+++ b/rdmo/projects/migrations/0054_continuation_page.py
@@ -0,0 +1,20 @@
+# Generated by Django 3.2.14 on 2022-12-16 12:57
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('questions', '0074_data_migration'),
+ ('projects', '0053_alter_continuation_questionset'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='continuation',
+ name='page',
+ field=models.ForeignKey(help_text='The page for this continuation.', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='questions.page', verbose_name='Page'),
+ ),
+ ]
diff --git a/rdmo/projects/migrations/0055_data_migration.py b/rdmo/projects/migrations/0055_data_migration.py
new file mode 100644
index 0000000000..6a06c9d64b
--- /dev/null
+++ b/rdmo/projects/migrations/0055_data_migration.py
@@ -0,0 +1,22 @@
+from __future__ import unicode_literals
+
+from django.db import migrations
+
+
+def run_data_migration(apps, schema_editor):
+ Continuation = apps.get_model('projects', 'Continuation')
+
+ for continuation in Continuation.objects.all():
+ continuation.page_id = continuation.questionset_id
+ continuation.save()
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('projects', '0054_continuation_page'),
+ ]
+
+ operations = [
+ migrations.RunPython(run_data_migration),
+ ]
diff --git a/rdmo/projects/migrations/0056_remove_continuation_questionset.py b/rdmo/projects/migrations/0056_remove_continuation_questionset.py
new file mode 100644
index 0000000000..7fc17bb5aa
--- /dev/null
+++ b/rdmo/projects/migrations/0056_remove_continuation_questionset.py
@@ -0,0 +1,17 @@
+# Generated by Django 3.2.14 on 2022-12-16 13:00
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('projects', '0055_data_migration'),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name='continuation',
+ name='questionset',
+ ),
+ ]
diff --git a/rdmo/projects/migrations/0057_value_set_collection.py b/rdmo/projects/migrations/0057_value_set_collection.py
new file mode 100644
index 0000000000..4250705bbb
--- /dev/null
+++ b/rdmo/projects/migrations/0057_value_set_collection.py
@@ -0,0 +1,18 @@
+# Generated by Django 3.2.14 on 2023-02-01 09:41
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('projects', '0056_remove_continuation_questionset'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='value',
+ name='set_collection',
+ field=models.BooleanField(help_text='Indicates if this value was entered as part of a set (important for conditions).', null=True, verbose_name='Set collection'),
+ ),
+ ]
diff --git a/rdmo/projects/migrations/0058_meta.py b/rdmo/projects/migrations/0058_meta.py
new file mode 100644
index 0000000000..945f2ab50c
--- /dev/null
+++ b/rdmo/projects/migrations/0058_meta.py
@@ -0,0 +1,25 @@
+# Generated by Django 3.2.14 on 2023-05-02 08:16
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('tasks', '0033_task_locked'),
+ ('views', '0026_view_locked'),
+ ('projects', '0057_value_set_collection'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='project',
+ name='tasks',
+ field=models.ManyToManyField(blank=True, help_text='The tasks that will be used for this project.', related_name='projects', through='projects.Issue', to='tasks.Task', verbose_name='Tasks'),
+ ),
+ migrations.AlterField(
+ model_name='project',
+ name='views',
+ field=models.ManyToManyField(blank=True, help_text='The views that will be used for this project.', related_name='projects', to='views.View', verbose_name='Views'),
+ ),
+ ]
diff --git a/rdmo/projects/mixins.py b/rdmo/projects/mixins.py
index c506de839f..5a18cab513 100644
--- a/rdmo/projects/mixins.py
+++ b/rdmo/projects/mixins.py
@@ -3,20 +3,19 @@
from django.contrib.sites.shortcuts import get_current_site
from django.core.exceptions import ValidationError
-from django.http import HttpResponseRedirect, Http404, HttpResponse
-from django.shortcuts import get_object_or_404, render, redirect
+from django.http import Http404, HttpResponseRedirect
+from django.shortcuts import get_object_or_404, redirect, render
from django.utils.translation import gettext_lazy as _
-from rdmo.core.imports import handle_uploaded_file, file_path_exists
+from rdmo.core.imports import handle_uploaded_file
from rdmo.core.plugins import get_plugin, get_plugins
from rdmo.questions.models import Question
from .models import Membership, Project
-from .utils import (save_import_snapshot_values, save_import_tasks,
- save_import_values, save_import_views)
+from .utils import save_import_snapshot_values, save_import_tasks, save_import_values, save_import_views
-class ProjectImportMixin(object):
+class ProjectImportMixin:
def get_current_values(self, current_project):
queryset = current_project.values.filter(snapshot=None).select_related('attribute', 'option')
@@ -27,7 +26,7 @@ def get_current_values(self, current_project):
return current_values
def get_questions(self, catalog):
- queryset = Question.objects.filter(questionset__section__catalog=catalog) \
+ queryset = Question.objects.filter_by_catalog(catalog) \
.select_related('attribute') \
.order_by('attribute__uri')
@@ -116,7 +115,8 @@ def import_form(self):
self.request.session['import_key'] = import_key
# attach questions and current values
- self.update_values(current_project, import_plugin.catalog, import_plugin.values, import_plugin.snapshots)
+ self.update_values(current_project, import_plugin.catalog,
+ import_plugin.values, import_plugin.snapshots)
return render(self.request, 'projects/project_import.html', {
'method': 'import_file',
@@ -168,7 +168,8 @@ def import_file(self):
}, status=400)
# attach questions and current values
- self.update_values(current_project, import_plugin.catalog, import_plugin.values, import_plugin.snapshots)
+ self.update_values(current_project, import_plugin.catalog,
+ import_plugin.values, import_plugin.snapshots)
if current_project:
save_import_values(self.object, import_plugin.values, checked)
diff --git a/rdmo/projects/models/continuation.py b/rdmo/projects/models/continuation.py
index f404c556b5..05b32baf12 100644
--- a/rdmo/projects/models/continuation.py
+++ b/rdmo/projects/models/continuation.py
@@ -3,7 +3,7 @@
from django.utils.translation import gettext_lazy as _
from rdmo.core.models import Model
-from rdmo.questions.models import QuestionSet
+from rdmo.questions.models import Page
class Continuation(Model):
@@ -18,10 +18,10 @@ class Continuation(Model):
verbose_name=_('User'),
help_text=_('The user for this continuation.')
)
- questionset = models.ForeignKey(
- QuestionSet, on_delete=models.CASCADE, related_name='+',
- verbose_name=_('Question set'),
- help_text=_('The question set for this continuation.')
+ page = models.ForeignKey(
+ Page, null=True, on_delete=models.CASCADE, related_name='+',
+ verbose_name=_('Page'),
+ help_text=_('The page for this continuation.')
)
class Meta:
@@ -30,4 +30,4 @@ class Meta:
verbose_name_plural = _('Continuations')
def __str__(self):
- return '%s/%s/%s' % (self.project, self.user, self.questionset)
+ return f'{self.project}/{self.user}/{self.page}'
diff --git a/rdmo/projects/models/integration.py b/rdmo/projects/models/integration.py
index b8e29c2926..b8cdc52169 100644
--- a/rdmo/projects/models/integration.py
+++ b/rdmo/projects/models/integration.py
@@ -27,7 +27,7 @@ class Meta:
verbose_name_plural = _('Integrations')
def __str__(self):
- return '%s / %s' % (self.project.title, self.provider_key)
+ return f'{self.project.title} / {self.provider_key}'
@property
def provider(self):
@@ -76,4 +76,4 @@ class Meta:
verbose_name_plural = _('Integration options')
def __str__(self):
- return '%s / %s / %s = %s' % (self.integration.project.title, self.integration.provider_key, self.key, self.value)
+ return f'{self.integration.project.title} / {self.integration.provider_key} / {self.key} = {self.value}'
diff --git a/rdmo/projects/models/invite.py b/rdmo/projects/models/invite.py
index 174868e580..1c1c202b79 100644
--- a/rdmo/projects/models/invite.py
+++ b/rdmo/projects/models/invite.py
@@ -51,7 +51,7 @@ class Meta:
verbose_name_plural = _('Invites')
def __str__(self):
- return '%s / %s / %s' % (self.project.title, self.email, self.role)
+ return f'{self.project.title} / {self.email} / {self.role}'
def save(self, *args, **kwargs):
if self.timestamp is None:
diff --git a/rdmo/projects/models/issue.py b/rdmo/projects/models/issue.py
index 0d0c1ab3fa..bfaf95474d 100644
--- a/rdmo/projects/models/issue.py
+++ b/rdmo/projects/models/issue.py
@@ -45,7 +45,7 @@ class Meta:
verbose_name_plural = _('Issues')
def __str__(self):
- return '%s / %s / %s' % (self.project.title, self.task, self.status)
+ return f'{self.project.title} / {self.task} / {self.status}'
def get_absolute_url(self):
return reverse('project', kwargs={'pk': self.project.pk})
@@ -118,4 +118,4 @@ class Meta:
verbose_name_plural = _('Issue resources')
def __str__(self):
- return '%s / %s / %s' % (self.issue.project.title, self.issue, self.url)
+ return f'{self.issue.project.title} / {self.issue} / {self.url}'
diff --git a/rdmo/projects/models/membership.py b/rdmo/projects/models/membership.py
index 0ec26a92f1..f794dff3f9 100644
--- a/rdmo/projects/models/membership.py
+++ b/rdmo/projects/models/membership.py
@@ -39,7 +39,7 @@ class Meta:
verbose_name_plural = _('Memberships')
def __str__(self):
- return '%s / %s / %s' % (self.project.title, self.user.username, self.role)
+ return f'{self.project.title} / {self.user.username} / {self.role}'
def get_absolute_url(self):
return reverse('project', kwargs={'pk': self.project.pk})
diff --git a/rdmo/projects/models/project.py b/rdmo/projects/models/project.py
index 8d577d8f8a..eae2128fe2 100644
--- a/rdmo/projects/models/project.py
+++ b/rdmo/projects/models/project.py
@@ -8,6 +8,7 @@
from django.urls import reverse
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
+
from mptt.models import MPTTModel, TreeForeignKey
from rdmo.core.models import Model
@@ -55,12 +56,12 @@ class Project(MPTTModel, Model):
help_text=_('The catalog which will be used for this project.')
)
tasks = models.ManyToManyField(
- Task, blank=True, through='Issue',
+ Task, blank=True, through='Issue', related_name='projects',
verbose_name=_('Tasks'),
help_text=_('The tasks that will be used for this project.')
)
views = models.ManyToManyField(
- View, blank=True,
+ View, blank=True, related_name='projects',
verbose_name=_('Views'),
help_text=_('The views that will be used for this project.')
)
@@ -89,8 +90,8 @@ def clean(self):
def progress(self):
# create a queryset for the attributes of the catalog for this project
# the subquery is used to query only attributes which have a question in the catalog, which is not optional
- questions = Question.objects.filter(attribute_id=OuterRef('pk'), questionset__section__catalog_id=self.catalog.id) \
- .exclude(is_optional=True)
+ questions = Question.objects.filter_by_catalog(self.catalog) \
+ .filter(attribute_id=OuterRef('pk')).exclude(is_optional=True)
attributes = Attribute.objects.annotate(active=Exists(questions)).filter(active=True).distinct()
# query the total number of attributes from the qs above
diff --git a/rdmo/projects/models/snapshot.py b/rdmo/projects/models/snapshot.py
index 284f9947cd..0403da40cc 100644
--- a/rdmo/projects/models/snapshot.py
+++ b/rdmo/projects/models/snapshot.py
@@ -34,7 +34,7 @@ class Meta:
verbose_name_plural = _('Snapshots')
def __str__(self):
- return '%s / %s' % (self.project.title, self.title)
+ return f'{self.project.title} / {self.title}'
def get_absolute_url(self):
return reverse('project', kwargs={'pk': self.project.pk})
diff --git a/rdmo/projects/models/value.py b/rdmo/projects/models/value.py
index a6516f3760..39fec66834 100644
--- a/rdmo/projects/models/value.py
+++ b/rdmo/projects/models/value.py
@@ -1,14 +1,14 @@
import mimetypes
from pathlib import Path
-import iso8601
from django.db import models
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
+
+import iso8601
from django_cleanup import cleanup
-from rdmo.core.constants import (VALUE_TYPE_BOOLEAN, VALUE_TYPE_CHOICES,
- VALUE_TYPE_DATETIME, VALUE_TYPE_TEXT)
+from rdmo.core.constants import VALUE_TYPE_BOOLEAN, VALUE_TYPE_CHOICES, VALUE_TYPE_DATETIME, VALUE_TYPE_TEXT
from rdmo.core.models import Model
from rdmo.domain.models import Attribute
from rdmo.options.models import Option
@@ -54,6 +54,11 @@ class Value(Model):
verbose_name=_('Set index'),
help_text=_('The position of this value in a set (i.e. for a question set tagged as collection).')
)
+ set_collection = models.BooleanField(
+ null=True,
+ verbose_name=_('Set collection'),
+ help_text=_('Indicates if this value was entered as part of a set (important for conditions).')
+ )
collection_index = models.IntegerField(
default=0,
verbose_name=_('Collection index'),
@@ -108,6 +113,7 @@ def as_dict(self):
'updated': self.updated,
'set_prefix': self.set_prefix,
'set_index': self.set_index,
+ 'set_collection': self.set_collection,
'collection_index': self.collection_index,
'value_type': self.value_type,
'unit': self.unit,
@@ -164,7 +170,7 @@ def value_and_unit(self):
if value is None:
return ''
elif self.unit:
- return '%s %s' % (value, self.unit)
+ return f'{value} {self.unit}'
else:
return value
diff --git a/rdmo/projects/permissions.py b/rdmo/projects/permissions.py
new file mode 100644
index 0000000000..c549b02e49
--- /dev/null
+++ b/rdmo/projects/permissions.py
@@ -0,0 +1,64 @@
+from rdmo.core.permissions import HasObjectPermission, log_result
+
+
+class HasProjectsPermission(HasObjectPermission):
+
+ @log_result
+ def has_permission(self, request, view):
+ if not (request.user and request.user.is_authenticated):
+ return False
+
+ # always return True:
+ # for retrieve, update, partial_update, the permission will be checked on the
+ # object level (in the next step), list and create is allowed for every user since
+ # the filtering is done in the queryset
+ return True
+
+ @log_result
+ def has_object_permission(self, request, view, obj):
+ if not (request.user and request.user.is_authenticated):
+ return False
+
+ # get the project object from the obj (or the take the obj) and check its permissions
+ try:
+ return super().has_object_permission(request, view, obj.project)
+ except AttributeError:
+ return super().has_object_permission(request, view, obj)
+
+
+class HasProjectPermission(HasObjectPermission):
+
+ @log_result
+ def has_permission(self, request, view):
+ if not (request.user and request.user.is_authenticated):
+ return False
+
+ # check if this is a detail view (retrieve, update, partial_update, destroy) or not (list, create)
+ if view.detail:
+ # for retrieve, update, partial_update, or destroy return True
+ # the permission will be checked on object level (in the next step)
+ return True
+ else:
+ # for list or create we need to get the project from the view
+ # and check that the user has the correct permission
+ try:
+ return super().has_object_permission(request, view, view.project)
+ except AttributeError: # needed for swagger /api/v1
+ return super().has_permission(request, view)
+
+ @log_result
+ def has_object_permission(self, request, view, obj):
+ if not (request.user and request.user.is_authenticated):
+ return False
+
+ # get the project object from the view (or the take the obj) and check its permissions
+ try:
+ return super().has_object_permission(request, view, view.project)
+ except AttributeError:
+ return super().has_object_permission(request, view, obj)
+
+
+class HasProjectPagePermission(HasProjectPermission):
+
+ def get_required_object_permissions(self, method, model_cls):
+ return ('projects.view_page_object', )
diff --git a/rdmo/projects/providers.py b/rdmo/projects/providers.py
index 9bbadf0447..a891ffc8ee 100644
--- a/rdmo/projects/providers.py
+++ b/rdmo/projects/providers.py
@@ -1,13 +1,14 @@
import hmac
import json
-from urllib.parse import quote, urlencode
+from urllib.parse import quote
from django.core.exceptions import ObjectDoesNotExist
from django.http import Http404, HttpResponse, HttpResponseRedirect
+from django.shortcuts import render
from django.utils.translation import gettext_lazy as _
from rdmo.core.plugins import Plugin
-from rdmo.services.providers import OauthProviderMixin, GitHubProviderMixin, GitLabProviderMixin
+from rdmo.services.providers import GitHubProviderMixin, GitLabProviderMixin, OauthProviderMixin
class IssueProvider(Plugin):
@@ -37,7 +38,7 @@ def send_issue(self, request, issue, integration, subject, message, attachments)
return self.post(request, url, data)
def post_success(self, request, response):
- from rdmo.projects.models import Issue, Integration, IssueResource
+ from rdmo.projects.models import Integration, Issue, IssueResource
# get the upstream url of the issue
remote_url = self.get_issue_url(response)
@@ -92,7 +93,7 @@ def get_secret(self, integration):
def get_post_url(self, request, issue, integration, subject, message, attachments):
repo = self.get_repo(integration)
if repo:
- return 'https://api.github.com/repos/{}/issues'.format(repo)
+ return f'https://api.github.com/repos/{repo}/issues'
def get_post_data(self, request, issue, integration, subject, message, attachments):
return {
@@ -159,8 +160,8 @@ class GitLabIssueProvider(GitLabProviderMixin, OauthIssueProvider):
@property
def description(self):
- return _('This integration allow the creation of issues in arbitrary repositories on {}. '
- 'The upload of attachments is not supported by GitLab.'.format(self.gitlab_url))
+ return _(f'This integration allow the creation of issues in arbitrary repositories on {self.gitlab_url}. '
+ 'The upload of attachments is not supported by GitLab.')
def get_repo(self, integration):
try:
diff --git a/rdmo/projects/renderers.py b/rdmo/projects/renderers.py
index 419aa30a1d..7833ff09ae 100644
--- a/rdmo/projects/renderers.py
+++ b/rdmo/projects/renderers.py
@@ -6,6 +6,7 @@ class XMLRenderer(BaseXMLRenderer):
def render_document(self, xml, project):
xml.startElement('project', {
'xmlns:dc': 'http://purl.org/dc/elements/1.1/',
+ 'version': self.version,
'created': self.created
})
self.render_text_element(xml, 'title', {}, project['title'])
@@ -60,6 +61,7 @@ def render_value(self, xml, value):
self.render_text_element(xml, 'attribute', {'dc:uri': value['attribute']}, None)
self.render_text_element(xml, 'set_prefix', {}, value['set_prefix'])
self.render_text_element(xml, 'set_index', {}, value['set_index'])
+ self.render_text_element(xml, 'set_collection', {}, value['set_collection'])
self.render_text_element(xml, 'collection_index', {}, value['collection_index'])
self.render_text_element(xml, 'text', {}, value['text'])
self.render_text_element(xml, 'option', {'dc:uri': value['option']}, None)
diff --git a/rdmo/projects/rules.py b/rdmo/projects/rules.py
index 0d01231224..93a2fec87e 100644
--- a/rdmo/projects/rules.py
+++ b/rdmo/projects/rules.py
@@ -1,4 +1,7 @@
+from django.contrib.sites.shortcuts import get_current_site
+
import rules
+from rules.predicates import is_superuser
@rules.predicate
@@ -39,6 +42,19 @@ def is_site_manager(user, project):
return False
+@rules.predicate
+def is_site_manager_for_current_site(user, request):
+ if user.is_authenticated:
+ current_site = get_current_site(request)
+ return user.role.manager.filter(pk=current_site.pk).exists()
+ else:
+ return False
+
+
+# Add rule for check in template
+rules.add_rule('projects.can_view_all_projects', is_site_manager_for_current_site | is_superuser)
+
+
rules.add_perm('projects.view_project_object', is_project_member | is_site_manager)
rules.add_perm('projects.change_project_object', is_project_manager | is_project_owner | is_site_manager)
rules.add_perm('projects.delete_project_object', is_project_owner | is_site_manager)
@@ -63,7 +79,7 @@ def is_site_manager(user, project):
rules.add_perm('projects.view_issue_object', is_project_member | is_site_manager)
rules.add_perm('projects.add_issue_object', is_project_manager | is_project_owner | is_site_manager)
-rules.add_perm('projects.change_issue_object', is_project_author | is_project_manager | is_project_owner | is_site_manager)
+rules.add_perm('projects.change_issue_object', is_project_author | is_project_manager | is_project_owner | is_site_manager) # noqa: E501
rules.add_perm('projects.delete_issue_object', is_project_manager | is_project_owner | is_site_manager)
rules.add_perm('projects.view_snapshot_object', is_project_member | is_site_manager)
@@ -73,10 +89,10 @@ def is_site_manager(user, project):
rules.add_perm('projects.view_value_object', is_project_member | is_site_manager)
rules.add_perm('projects.add_value_object', is_project_author | is_project_manager | is_project_owner | is_site_manager)
-rules.add_perm('projects.change_value_object', is_project_author | is_project_manager | is_project_owner | is_site_manager)
-rules.add_perm('projects.delete_value_object', is_project_author | is_project_manager | is_project_owner | is_site_manager)
+rules.add_perm('projects.change_value_object', is_project_author | is_project_manager | is_project_owner | is_site_manager) # noqa: E501
+rules.add_perm('projects.delete_value_object', is_project_author | is_project_manager | is_project_owner | is_site_manager) # noqa: E501
-rules.add_perm('questions.view_questionset_object', is_project_member | is_site_manager)
+rules.add_perm('projects.view_page_object', is_project_member | is_site_manager)
# TODO: use one of the permissions above
rules.add_perm('projects.is_project_owner', is_project_owner)
diff --git a/rdmo/projects/serializers/export.py b/rdmo/projects/serializers/export.py
index d2aa7c7233..9c9dee649b 100644
--- a/rdmo/projects/serializers/export.py
+++ b/rdmo/projects/serializers/export.py
@@ -17,6 +17,7 @@ class Meta:
'attribute',
'set_prefix',
'set_index',
+ 'set_collection',
'collection_index',
'text',
'option',
@@ -49,7 +50,7 @@ class Meta:
)
def get_values(self, obj):
- values = Value.objects.filter(snapshot=obj)
+ values = Value.objects.filter(snapshot=obj).select_related('attribute', 'option')
serializer = ValueSerializer(instance=values, many=True)
return serializer.data
@@ -78,7 +79,7 @@ class Meta:
)
def get_values(self, obj):
- values = Value.objects.filter(project=obj, snapshot=None)
+ values = Value.objects.filter(project=obj, snapshot=None).select_related('attribute', 'option')
serializer = ValueSerializer(instance=values, many=True)
return serializer.data
diff --git a/rdmo/projects/serializers/v1/__init__.py b/rdmo/projects/serializers/v1/__init__.py
index 09a104cf17..7e65e7cb9f 100644
--- a/rdmo/projects/serializers/v1/__init__.py
+++ b/rdmo/projects/serializers/v1/__init__.py
@@ -1,12 +1,13 @@
from django.conf import settings
from django.contrib.auth import get_user_model
from django.utils.translation import gettext_lazy as _
+
from rest_framework import serializers
+from rdmo.questions.models import Catalog
from rdmo.services.validators import ProviderValidator
-from ...models import (Integration, IntegrationOption, Invite, Issue,
- IssueResource, Membership, Project, Snapshot, Value)
+from ...models import Integration, IntegrationOption, Invite, Issue, IssueResource, Membership, Project, Snapshot, Value
from ...validators import ValueValidator
@@ -28,11 +29,20 @@ class Meta:
class ProjectSerializer(serializers.ModelSerializer):
+ class CatalogField(serializers.PrimaryKeyRelatedField):
+
+ def get_queryset(self):
+ return Catalog.objects.filter_current_site() \
+ .filter_group(self.context['request'].user) \
+ .filter_availability(self.context['request'].user) \
+ .order_by('-available', 'order')
+
class ParentField(serializers.PrimaryKeyRelatedField):
def get_queryset(self):
return Project.objects.filter_user(self.context['request'].user)
+ catalog = CatalogField(required=True)
parent = ParentField(required=False)
owners = UserSerializer(many=True, read_only=True)
@@ -238,6 +248,7 @@ class Meta:
'attribute_uri',
'set_prefix',
'set_index',
+ 'set_collection',
'collection_index',
'text',
'option',
@@ -349,6 +360,7 @@ class Meta:
'attribute_uri',
'set_prefix',
'set_index',
+ 'set_collection',
'collection_index',
'text',
'option',
diff --git a/rdmo/projects/serializers/v1/overview.py b/rdmo/projects/serializers/v1/overview.py
index 775f85d70f..a0428b9836 100644
--- a/rdmo/projects/serializers/v1/overview.py
+++ b/rdmo/projects/serializers/v1/overview.py
@@ -1,13 +1,13 @@
from rest_framework import serializers
from rdmo.projects.models import Project
-from rdmo.questions.models import Catalog, QuestionSet, Section
+from rdmo.questions.models import Catalog, Page, Section
-class QuestionSetSerializer(serializers.ModelSerializer):
+class PageSerializer(serializers.ModelSerializer):
class Meta:
- model = QuestionSet
+ model = Page
fields = (
'id',
'title',
@@ -17,20 +17,23 @@ class Meta:
class SectionSerializer(serializers.ModelSerializer):
- questionsets = QuestionSetSerializer(many=True, read_only=True)
+ pages = serializers.SerializerMethodField()
class Meta:
model = Section
fields = (
'id',
'title',
- 'questionsets'
+ 'pages'
)
+ def get_pages(self, obj):
+ return PageSerializer(obj.elements, many=True, read_only=True).data
+
class CatalogSerializer(serializers.ModelSerializer):
- sections = SectionSerializer(many=True, read_only=True)
+ sections = serializers.SerializerMethodField()
class Meta:
model = Catalog
@@ -40,6 +43,9 @@ class Meta:
'sections'
)
+ def get_sections(self, obj):
+ return SectionSerializer(obj.elements, many=True, read_only=True).data
+
class ProjectOverviewSerializer(serializers.ModelSerializer):
diff --git a/rdmo/projects/serializers/v1/questionset.py b/rdmo/projects/serializers/v1/page.py
similarity index 70%
rename from rdmo/projects/serializers/v1/questionset.py
rename to rdmo/projects/serializers/v1/page.py
index 028a77c773..838cfe4591 100644
--- a/rdmo/projects/serializers/v1/questionset.py
+++ b/rdmo/projects/serializers/v1/page.py
@@ -1,10 +1,11 @@
from django.utils.translation import gettext_lazy as _
+
from rest_framework import serializers
from rdmo.conditions.models import Condition
from rdmo.core.serializers import MarkdownSerializerMixin
from rdmo.options.models import Option, OptionSet
-from rdmo.questions.models import Question, QuestionSet
+from rdmo.questions.models import Page, Question, QuestionSet
from rdmo.questions.utils import get_widget_class
@@ -14,7 +15,6 @@ class Meta:
model = Option
fields = (
'id',
- 'optionset',
'text',
'additional_input'
)
@@ -64,7 +64,6 @@ class Meta:
model = Question
fields = (
'id',
- 'order',
'help',
'text',
'default_text',
@@ -108,8 +107,6 @@ class QuestionSetSerializer(MarkdownSerializerMixin, serializers.ModelSerializer
questionsets = serializers.SerializerMethodField()
questions = QuestionSerializer(many=True)
- section = serializers.SerializerMethodField()
-
verbose_name = serializers.SerializerMethodField()
verbose_name_plural = serializers.SerializerMethodField()
@@ -117,16 +114,12 @@ class Meta:
model = QuestionSet
fields = (
'id',
- 'order',
'title',
'help',
'verbose_name',
'verbose_name_plural',
'attribute',
'is_collection',
- 'next',
- 'prev',
- 'section',
'questionsets',
'questions',
'has_conditions'
@@ -135,11 +128,58 @@ class Meta:
def get_questionsets(self, obj):
return QuestionSetSerializer(obj.questionsets.all(), many=True, read_only=True).data
+ def get_verbose_name(self, obj):
+ return obj.verbose_name or _('set')
+
+ def get_verbose_name_plural(self, obj):
+ return obj.verbose_name_plural or _('sets')
+
+
+class PageSerializer(MarkdownSerializerMixin, serializers.ModelSerializer):
+
+ markdown_fields = ('help', )
+
+ questionsets = QuestionSetSerializer(many=True)
+ questions = QuestionSerializer(many=True)
+
+ section = serializers.SerializerMethodField()
+ prev_page = serializers.SerializerMethodField()
+ next_page = serializers.SerializerMethodField()
+ verbose_name = serializers.SerializerMethodField()
+ verbose_name_plural = serializers.SerializerMethodField()
+
+ class Meta:
+ model = Page
+ fields = (
+ 'id',
+ 'title',
+ 'help',
+ 'verbose_name',
+ 'verbose_name_plural',
+ 'attribute',
+ 'is_collection',
+ 'section',
+ 'prev_page',
+ 'next_page',
+ 'questionsets',
+ 'questions',
+ 'has_conditions'
+ )
+
def get_section(self, obj):
+ section = self.context['catalog'].get_section_for_page(obj)
return {
- 'id': obj.section.id,
- 'title': obj.section.title
- }
+ 'id': section.id,
+ 'title': section.title,
+ } if section else {}
+
+ def get_prev_page(self, obj):
+ page = self.context['catalog'].get_prev_page(obj)
+ return page.id if page else None
+
+ def get_next_page(self, obj):
+ page = self.context['catalog'].get_next_page(obj)
+ return page.id if page else None
def get_verbose_name(self, obj):
return obj.verbose_name or _('set')
diff --git a/rdmo/projects/static/projects/css/project.scss b/rdmo/projects/static/projects/css/project.scss
index 1feccf80a7..1fda0c6b55 100644
--- a/rdmo/projects/static/projects/css/project.scss
+++ b/rdmo/projects/static/projects/css/project.scss
@@ -21,4 +21,4 @@
.fa-sign-out {
width: 11px;
}
-}
\ No newline at end of file
+}
diff --git a/rdmo/projects/static/projects/js/project_questions/services.js b/rdmo/projects/static/projects/js/project_questions/services.js
index 87817c7b88..9829b917ff 100644
--- a/rdmo/projects/static/projects/js/project_questions/services.js
+++ b/rdmo/projects/static/projects/js/project_questions/services.js
@@ -11,7 +11,7 @@ angular.module('project_questions')
var resources = {
projects: $resource(baseurl + 'api/v1/projects/projects/:id/:detail_action/'),
values: $resource(baseurl + 'api/v1/projects/projects/:project/values/:id/:detail_action/'),
- questionsets: $resource(baseurl + 'api/v1/projects/projects/:project/questionsets/:list_action/:id/'),
+ pages: $resource(baseurl + 'api/v1/projects/projects/:project/pages/:list_action/:id/'),
settings: $resource(baseurl + 'api/v1/core/settings/')
};
@@ -97,16 +97,16 @@ angular.module('project_questions')
// if users go back to /project/questions/ they just go back once more
$window.history.back();
} else if (path == 'done') {
- if (angular.isUndefined(service.questionset.done)) {
+ if (angular.isUndefined(service.page.done)) {
service.initDone();
}
} else if (path == 'error') {
- if (angular.isUndefined(service.questionset.error)) {
+ if (angular.isUndefined(service.page.error)) {
service.initError();
}
} else {
// this needs to be != and not !== since path is a string!
- if (path != service.questionset.id) {
+ if (path != service.page.id) {
service.initView(path);
}
}
@@ -134,40 +134,40 @@ angular.module('project_questions')
});
};
- service.initView = function(questionset_id) {
+ service.initView = function(page_id) {
if (initializing) return;
- if (questionset_id !== null) {
+ if (page_id !== null) {
// enable initializing flag
initializing = true;
- if (angular.isDefined(service.questionset)) {
- past.questionset = service.questionset
+ if (angular.isDefined(service.page)) {
+ past.page = service.page
past.set_index = service.set_index
}
- return service.fetchQuestionSet(questionset_id)
+ return service.fetchPage(page_id)
.then(service.fetchOptions)
.then(service.fetchValues)
.then(service.fetchConditions)
.then(function () {
// copy future objects
angular.forEach([
- 'questionset', 'progress', 'attributes', 'questionsets', 'questions', 'valuesets', 'values'
+ 'page', 'progress', 'attributes', 'questionsets', 'questions', 'valuesets', 'values'
], function (key) {
service[key] = angular.copy(future[key]);
});
// activate fist valueset
- if (angular.isDefined(service.valuesets[service.questionset.id][service.set_prefix])) {
- if (angular.isDefined(past.questionset) &&
- past.questionset.is_collection &&
- past.questionset.attribute == service.questionset.attribute &&
+ if (angular.isDefined(service.valuesets[service.page.id][service.set_prefix])) {
+ if (angular.isDefined(past.page) &&
+ past.page.is_collection &&
+ past.page.attribute == service.page.attribute &&
!service.settings.project_questions_cycle_sets) {
// use the same set index as before
service.set_index = past.set_index
} else {
- service.set_index = service.valuesets[service.questionset.id][service.set_prefix][0].set_index;
+ service.set_index = service.valuesets[service.page.id][service.set_prefix][0].set_index;
}
} else {
service.set_index = null;
@@ -175,9 +175,9 @@ angular.module('project_questions')
// focus the first field
if (service.values && Object.keys(service.values).length > 0
- && service.questionset.questionsets.length == 0
- && service.questionset.questions.length > 0) {
- var first_question = service.questionset.questions[0];
+ && service.page.questionsets.length == 0
+ && service.page.questions.length > 0) {
+ var first_question = service.page.questions[0];
if (first_question.widget_class == 'text') {
service.focusField(first_question.attribute, service.set_prefix, service.set_index, 0);
}
@@ -185,7 +185,7 @@ angular.module('project_questions')
// disable initializing flag again, set browser location, scroll to top and set back flag
initializing = false;
- $location.path('/' + service.questionset.id + '/');
+ $location.path('/' + service.page.id + '/');
$window.scrollTo(0, 0);
back = false;
@@ -194,7 +194,7 @@ angular.module('project_questions')
});
}, function (result) {
if (angular.isDefined(result)) {
- // an actual error occured
+ // an actual error occurred
service.initError(result.status, result.statusText);
} else {
// this is the end of the interview
@@ -207,11 +207,11 @@ angular.module('project_questions')
};
service.initError = function(status, statusText) {
- service.questionset = {
+ service.page = {
id: false,
progress: 0,
- next: null,
- prev: null,
+ next_page: null,
+ prev_page: null,
error: true,
status: status,
statusText: statusText
@@ -225,11 +225,11 @@ angular.module('project_questions')
};
service.initDone = function() {
- service.questionset = {
+ service.page = {
id: false,
progress: 100,
- next: null,
- prev: service.questionset ? service.questionset.id : null,
+ next_page: null,
+ prev_page: service.page ? service.page.id : null,
done: true
};
@@ -240,24 +240,24 @@ angular.module('project_questions')
back = false;
};
- service.fetchQuestionSet = function(questionset_id) {
- // fetch the current (or the first) question set from the server
- if (questionset_id) {
- future.questionset = resources.questionsets.get({
+ service.fetchPage = function(page_id) {
+ // fetch the current (or the first) page from the server
+ if (page_id) {
+ future.page = resources.pages.get({
project: service.project.id,
- id: questionset_id,
+ id: page_id,
back: back
});
} else {
- future.questionset = resources.questionsets.get({
+ future.page = resources.pages.get({
project: service.project.id,
list_action: 'continue'
});
}
- // store the questionset and return the promise
- return future.questionset.$promise.then(function() {
- if (angular.isUndefined(future.questionset.id)) {
+ // store the page and return the promise
+ return future.page.$promise.then(function() {
+ if (angular.isUndefined(future.page.id)) {
// this is the end of the interview
return $q.reject();
}
@@ -268,19 +268,35 @@ angular.module('project_questions')
future.questions = [];
// loop over all elements
- // (a) create seperate questions array
+ // (a) create separate questions array
// (b) mark the help text of the question set 'save'
// (c) sort questionsets and questions by order in one list called elements
// using recursive functions!
- service.initQuestionSet(future.questionset);
+ service.initPage(future.page);
});
};
+ service.initPage = function(page) {
+ // store attributes in a separate array
+ if (page.attribute !== null) future.attributes.push(page.attribute);
+
+ // mark the help text of the question set 'save'
+ page.help = $sce.trustAsHtml(page.help);
+
+ // sort questionsets and questions by order in one list called elements
+ page.elements = page.questionsets.map(function(qs) {
+ return service.initQuestionSet(qs);
+ })
+ .concat(page.questions.map(function(q) {
+ return service.initQuestion(q, page);
+ }))
+ .sort(function(a, b) { return a.order - b.order; });
+
+ return page;
+ };
+
service.initQuestionSet = function(questionset) {
- // store attributes and questionset in seperate array
- if (questionset.attribute !== null) {
- future.attributes.push(questionset.attribute);
- }
+ // store questionsets in a separate array
future.questionsets.push(questionset);
// mark the help text of the question set 'save'
@@ -290,17 +306,17 @@ angular.module('project_questions')
questionset.elements = questionset.questionsets.map(function(qs) {
return service.initQuestionSet(qs);
})
- .concat(questionset.questions.map(service.initQuestion))
+ .concat(questionset.questions.map(function(q) {
+ return service.initQuestion(q, questionset);
+ }))
.sort(function(a, b) { return a.order - b.order; });
return questionset;
};
- service.initQuestion = function(question) {
- // store attributes and questionset in seperate array
- if (question.attribute !== null) {
- future.attributes.push(question.attribute);
- }
+ service.initQuestion = function(question, parent) {
+ // store attributes and questionset in separate array
+ if (question.attribute !== null) future.attributes.push(question.attribute);
future.questions.push(question);
// mark the help text of the question set 'save'
@@ -309,6 +325,10 @@ angular.module('project_questions')
// this is a question!
question.isQuestion = true;
+ // store if this question is part of a set collection
+ // to store value.set_collection later
+ question.set_collection = parent.is_collection;
+
return question;
};
@@ -322,24 +342,31 @@ angular.module('project_questions')
angular.forEach(question.optionsets, function(optionset) {
if (optionset.has_provider) {
- // call the provider to get addtional options
+ // call the provider to get additional options
promises.push(resources.projects.query({
detail_action: 'options',
optionset: optionset.id,
id: service.project.id,
}, function(response) {
question.options = question.options.concat(response.map(function(option) {
+ option.optionset = optionset.id
option.has_provider = optionset.has_provider
return option
}));
// if any, add regular options from the optionset
if (question.optionsets.options !== false) {
- question.options = question.options.concat(optionset.options);
+ question.options = question.options.concat(optionset.options.map(function(option) {
+ option.optionset = optionset.id
+ return option
+ }));
}
}).$promise);
} else {
- question.options = question.options.concat(optionset.options);
+ question.options = question.options.concat(optionset.options.map(function(option) {
+ option.optionset = optionset.id
+ return option
+ }));
}
});
}
@@ -389,9 +416,9 @@ angular.module('project_questions')
// init valuesets
future.valuesets = {};
- // loop over all questionsets to initlize valuesets
+ // loop over the page and all questionsets to initialize valuesets
// valuesets store the set_index for each questionset and set_prefix
- angular.forEach(future.questionsets, function(questionset) {
+ angular.forEach([future.page].concat(future.questionsets), function(questionset) {
// add a valueset for each questionset
if (angular.isUndefined(future.valuesets[questionset.id])) {
future.valuesets[questionset.id] = {};
@@ -442,15 +469,15 @@ angular.module('project_questions')
}
return $q.all(promises).then(function() {
- service.initValues(future.questionset, '');
+ service.initValues(future.page, '');
});
};
service.fetchConditions = function() {
promises = [];
- // check conditions for current questionsets and questions
- angular.forEach(future.questionsets, function(questionset) {
+ // loop over the page and all questionsets to check conditions
+ angular.forEach([future.page].concat(future.questionsets), function(questionset) {
angular.forEach(future.valuesets[questionset.id], function(valuesets, set_prefix) {
angular.forEach(valuesets, function(valueset, set_index) {
angular.forEach(questionset.questionsets, function(qs) {
@@ -511,7 +538,7 @@ angular.module('project_questions')
}
}
- // loop over valuesets and initialize at leat one value for each question
+ // loop over valuesets and initialize at least one value for each question
angular.forEach(future.valuesets[questionset.id][set_prefix], function(valueset) {
// loop over questions to initialize them with at least one value, and init checkboxes and widgets
angular.forEach(questionset.questions, function(question) {
@@ -589,7 +616,7 @@ angular.module('project_questions')
}
// for autocomplete
- // fuse in initalized and the widget is locked for existing values
+ // fuse in initialized and the widget is locked for existing values
if (question.widget_class === 'autocomplete') {
if (angular.isArray(question.options)) {
question.options_fuse = new Fuse(question.options, {
@@ -669,7 +696,7 @@ angular.module('project_questions')
// remove additional_input from unselected checkboxes
value.additional_input = {};
- // delete the value if it alredy exists on the server
+ // delete the value if it already exists on the server
if (angular.isDefined(value.id)) {
return resources.values.delete({
id: value.id,
@@ -688,6 +715,14 @@ angular.module('project_questions')
value.set_index = set_index;
value.collection_index = collection_index;
+ // store if the question is part of a set_collection
+ if (question === null) {
+ // this is the id of a new valueset
+ value.set_collection = true
+ } else {
+ value.set_collection = question.set_collection
+ }
+
// get value_type and unit from question
if (question === null) {
// this is the id of a new valueset
@@ -802,68 +837,64 @@ angular.module('project_questions')
service.prev = function() {
service.error = null; // reset error when moving to previous questionset
- if (service.questionset.prev !== null) {
- if (service.settings.project_questions_autosave) {
- service.save(false).then(function() {
- back = true;
- service.initView(service.questionset.prev);
- })
- } else {
+ if (service.settings.project_questions_autosave) {
+ service.save(false).then(function() {
back = true;
- service.initView(service.questionset.prev);
- }
+ service.initView(service.page.prev_page);
+ })
+ } else {
+ back = true;
+ service.initView(service.page.prev_page);
}
};
service.next = function() {
service.error = null; // reset error when moving to next questionset
- if (service.questionset.id !== null) {
- if (service.settings.project_questions_autosave) {
- service.save(false).then(function() {
- service.initView(service.questionset.next);
- })
- } else {
- service.initView(service.questionset.next);
- }
+ if (service.settings.project_questions_autosave) {
+ service.save(false).then(function() {
+ service.initView(service.page.next_page);
+ })
+ } else {
+ service.initView(service.page.next_page);
}
};
- service.jump = function(section, questionset) {
+ service.jump = function(section, page) {
service.error = null; // reset error before saving
if (service.settings.project_questions_autosave) {
service.save(false).then(function() {
if (service.error !== null) {
// pass, dont jump
- } else if (angular.isDefined(questionset)) {
- service.initView(questionset.id);
+ } else if (angular.isDefined(page)) {
+ service.initView(page.id);
} else if (angular.isDefined(section)) {
- if (angular.isDefined(section.questionset)) {
- service.initView(section.questionsets[0].id);
+ if (angular.isDefined(section.pages)) {
+ service.initView(section.pages[0].id);
} else {
- // jump to first questionset of the section in breadcrumb
+ // jump to first page of the section in breadcrumb
// let section_from_service = service.project.catalog.sections.find(x => x.id === section.id)
var section_from_service = $filter('filter')(service.project.catalog.sections, {
id: section.id
})[0]
- service.initView(section_from_service.questionsets[0].id);
+ service.initView(section_from_service.pages[0].id);
}
} else {
service.initView(null);
}
});
} else {
- if (angular.isDefined(questionset)) {
- service.initView(questionset.id);
+ if (angular.isDefined(page)) {
+ service.initView(page.id);
} else if (angular.isDefined(section)) {
- if (angular.isDefined(section.questionset)) {
- service.initView(section.questionsets[0].id);
+ if (angular.isDefined(section.pages)) {
+ service.initView(section.pages[0].id);
} else {
- // jump to first questionset of the section in breadcrumb
+ // jump to first page of the section in breadcrumb
// let section_from_service = service.project.catalog.sections.find(x => x.id === section.id)
var section_from_service = $filter('filter')(service.project.catalog.sections, {
id: section.id
})[0]
- service.initView(section_from_service.questionsets[0].id);
+ service.initView(section_from_service.pages[0].id);
}
} else {
service.initView(null);
@@ -876,12 +907,14 @@ angular.module('project_questions')
return service.storeValues().then(function() {
if (service.error !== null) {
// pass
+ } else if (service.page.id == false) {
+ // pass, the interview is done
} else if (angular.isDefined(proceed) && proceed) {
- if (service.settings.project_questions_cycle_sets && service.questionset.is_collection) {
+ if (service.settings.project_questions_cycle_sets && service.page.is_collection) {
if (service.set_index === null) {
service.next();
} else {
- var valuesets = service.valuesets[service.questionset.id][service.set_prefix];
+ var valuesets = service.valuesets[service.page.id][service.set_prefix];
var index = service.findIndex(valuesets, 'set_index', service.set_index);
if (index === valuesets.length - 1) {
@@ -894,6 +927,7 @@ angular.module('project_questions')
}
}
} else {
+ console.log('next');
service.next();
}
} else {
@@ -919,7 +953,7 @@ angular.module('project_questions')
});
// re-evaluate conditions
- angular.forEach(service.questionsets, function(questionset) {
+ angular.forEach([service.page].concat(service.questionsets), function(questionset) {
angular.forEach(service.valuesets[questionset.id], function(valuesets, set_prefix) {
angular.forEach(valuesets, function(valueset, set_index) {
angular.forEach(questionset.questionsets, function(qs) {
@@ -1170,7 +1204,7 @@ angular.module('project_questions')
});
// switch set_index if this is the top level questionset
- if (questionset == service.questionset) {
+ if (questionset == service.page) {
service.set_index = set_index;
}
};
@@ -1202,7 +1236,7 @@ angular.module('project_questions')
service.removeValueSet = function(questionset, set_prefix, set_index) {
// check if this is the main questionset and not a questionset-in-questionset
- if (service.questionset.id == questionset.id && questionset.attribute) {
+ if (service.page.id == questionset.id && questionset.attribute) {
// delete all values of this set in the project using the special /set endpoint
if (angular.isDefined(service.values[questionset.attribute])) {
angular.forEach(service.values[questionset.attribute][set_prefix][set_index], function(value) {
@@ -1243,7 +1277,7 @@ angular.module('project_questions')
// if this is the top level questionset,
// activate the set before the current one,
// otherwise the previous set
- if (questionset == service.questionset) {
+ if (questionset == service.page) {
// get list of valuesets which are not removed yet
var valuesets = $filter('filter')(service.valuesets[questionset.id][set_prefix], function(valueset) {
return valueset.removed === false;
diff --git a/rdmo/projects/templates/projects/project_answers_element.html b/rdmo/projects/templates/projects/project_answers_element.html
index 9d27766f17..c1d04ce366 100644
--- a/rdmo/projects/templates/projects/project_answers_element.html
+++ b/rdmo/projects/templates/projects/project_answers_element.html
@@ -1,53 +1,48 @@
{% load view_tags %}
-{% load projects_tags %}
-{% if element.is_question %}
+{% if element.text %}
+ {# this is a question #}
- {% with element as question %}
+ {{ element.text }}
- {{ question.text }}
+ {% get_set_prefixes element.attribute project=project_wrapper as set_prefixes %}
+ {% for set_prefix in set_prefixes %}
- {% get_set_prefixes question.attribute.uri project=project_wrapper as set_prefixes %}
- {% for set_prefix in set_prefixes %}
+ {% get_set_indexes element.attribute set_prefix=set_prefix project=project_wrapper as set_indexes %}
+ {% for set_index in set_indexes %}
- {% get_set_indexes question.attribute.uri set_prefix=set_prefix project=project_wrapper as set_indexes %}
- {% for set_index in set_indexes %}
+ {% get_values element.attribute set_prefix=set_prefix set_index=set_index project=project_wrapper as values %}
+ {% get_labels element set_prefix=set_prefix set_index=set_index project=project_wrapper as labels %}
+ {% check_element element set_prefix=set_prefix set_index=set_index project=project_wrapper as question_result %}
- {% get_labels question set_prefix=set_prefix set_index=set_index project=project_wrapper as labels %}
- {% get_values question.attribute.uri set_prefix=set_prefix set_index=set_index project=project_wrapper as values %}
+ {% if question_result %}
+ {% if values|is_not_empty|length > 1 %}
- {% check_question question set_prefix=set_prefix set_index=set_index project=project_wrapper as question_result %}
- {% check_questionset question.questionset set_prefix=set_prefix set_index=set_index project=project_wrapper as questionset_result %}
-
- {% if question_result and questionset_result %}
- {% if values|is_not_empty|length > 1 %}
-
- {% if labels %}
-
- {{ labels|join:', ' }}:
-
- {% endif %}
+ {% if labels %}
+
+ {{ labels|join:', ' }}:
+
+ {% endif %}
-
- {% include 'views/tags/value_list.html' %}
-
+
+ {% include 'views/tags/value_list.html' %}
+
- {% elif values|is_not_empty|length == 1 %}
+ {% elif values|is_not_empty|length == 1 %}
-
- {% if labels %}{{ labels|join:', ' }}: {% endif %}
- {% include 'views/tags/value.html' with value=values.0 %}
-
+
+ {% if labels %}{{ labels|join:', ' }}: {% endif %}
+ {% include 'views/tags/value.html' with value=values.0 %}
+
- {% endif %}
{% endif %}
+ {% endif %}
- {% endfor %}
{% endfor %}
-
- {% endwith %}
+ {% endfor %}
{% else %}
+ {# this is a questionset #}
{% for element in element.elements %}
{% include 'projects/project_answers_element.html' %}
diff --git a/rdmo/projects/templates/projects/project_answers_tree.html b/rdmo/projects/templates/projects/project_answers_tree.html
index 37edb29b82..be50726517 100644
--- a/rdmo/projects/templates/projects/project_answers_tree.html
+++ b/rdmo/projects/templates/projects/project_answers_tree.html
@@ -1,22 +1,16 @@
-{% load view_tags %}
-
-{% for section in project.catalog.sections.all %}
+{% for section in project_wrapper.catalog.sections %}
{{ section.title }}
- {% for questionset in section.questionsets.all %}
-
- {% if not questionset.questionset %}
-
- {{ questionset.title }}
+ {% for page in section.pages %}
- {% for element in questionset.elements %}
+ {{ page.title }}
- {% include 'projects/project_answers_element.html' %}
+ {% for element in page.elements %}
- {% endfor %}
+ {% include 'projects/project_answers_element.html' %}
- {% endif %}
+ {% endfor %}
{% endfor %}
diff --git a/rdmo/projects/templates/projects/project_questions.html b/rdmo/projects/templates/projects/project_questions.html
index 892877e5a0..3f2dc7c7f4 100644
--- a/rdmo/projects/templates/projects/project_questions.html
+++ b/rdmo/projects/templates/projects/project_questions.html
@@ -48,23 +48,24 @@
{% block page %}
-