diff --git a/.gitignore b/.gitignore
index 2af3bf59da..3d2c0d88b1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -36,8 +36,12 @@ dist
rdmo/management/static
-rdmo/projects/static/projects/js/projects.js
+rdmo/core/static/core/js/base.js
+rdmo/core/static/core/fonts
+rdmo/core/static/core/css/base.css
+
+rdmo/projects/static/projects/js/*.js
rdmo/projects/static/projects/fonts
-rdmo/projects/static/projects/css/projects.css
+rdmo/projects/static/projects/css/*.css
screenshots
diff --git a/rdmo/core/static/core/fonts/DroidSans-Bold.ttf b/rdmo/core/assets/fonts/DroidSans-Bold.ttf
similarity index 100%
rename from rdmo/core/static/core/fonts/DroidSans-Bold.ttf
rename to rdmo/core/assets/fonts/DroidSans-Bold.ttf
diff --git a/rdmo/core/static/core/fonts/DroidSans.ttf b/rdmo/core/assets/fonts/DroidSans.ttf
similarity index 100%
rename from rdmo/core/static/core/fonts/DroidSans.ttf
rename to rdmo/core/assets/fonts/DroidSans.ttf
diff --git a/rdmo/core/static/core/fonts/DroidSansMono.ttf b/rdmo/core/assets/fonts/DroidSansMono.ttf
similarity index 100%
rename from rdmo/core/static/core/fonts/DroidSansMono.ttf
rename to rdmo/core/assets/fonts/DroidSansMono.ttf
diff --git a/rdmo/core/static/core/fonts/DroidSerif-Bold.ttf b/rdmo/core/assets/fonts/DroidSerif-Bold.ttf
similarity index 100%
rename from rdmo/core/static/core/fonts/DroidSerif-Bold.ttf
rename to rdmo/core/assets/fonts/DroidSerif-Bold.ttf
diff --git a/rdmo/core/static/core/fonts/DroidSerif-BoldItalic.ttf b/rdmo/core/assets/fonts/DroidSerif-BoldItalic.ttf
similarity index 100%
rename from rdmo/core/static/core/fonts/DroidSerif-BoldItalic.ttf
rename to rdmo/core/assets/fonts/DroidSerif-BoldItalic.ttf
diff --git a/rdmo/core/static/core/fonts/DroidSerif-Italic.ttf b/rdmo/core/assets/fonts/DroidSerif-Italic.ttf
similarity index 100%
rename from rdmo/core/static/core/fonts/DroidSerif-Italic.ttf
rename to rdmo/core/assets/fonts/DroidSerif-Italic.ttf
diff --git a/rdmo/core/static/core/fonts/DroidSerif.ttf b/rdmo/core/assets/fonts/DroidSerif.ttf
similarity index 100%
rename from rdmo/core/static/core/fonts/DroidSerif.ttf
rename to rdmo/core/assets/fonts/DroidSerif.ttf
diff --git a/rdmo/core/assets/img/favicon.png b/rdmo/core/assets/img/favicon.png
new file mode 100644
index 0000000000..042bcf2bbb
Binary files /dev/null and b/rdmo/core/assets/img/favicon.png differ
diff --git a/rdmo/core/assets/img/rdmo-logo.svg b/rdmo/core/assets/img/rdmo-logo.svg
new file mode 100644
index 0000000000..93fc2eae24
--- /dev/null
+++ b/rdmo/core/assets/img/rdmo-logo.svg
@@ -0,0 +1,260 @@
+
+
+
+
diff --git a/rdmo/core/assets/js/actions/actionTypes.js b/rdmo/core/assets/js/actions/actionTypes.js
new file mode 100644
index 0000000000..1f97c54f3d
--- /dev/null
+++ b/rdmo/core/assets/js/actions/actionTypes.js
@@ -0,0 +1,17 @@
+export const UPDATE_CONFIG = 'UPDATE_CONFIG'
+export const DELETE_CONFIG = 'DELETE_CONFIG'
+
+export const ADD_TO_PENDING = 'ADD_TO_PENDING'
+export const REMOVE_FROM_PENDING = 'REMOVE_FROM_PENDING'
+
+export const FETCH_SETTINGS_ERROR = 'FETCH_SETTINGS_ERROR'
+export const FETCH_SETTINGS_INIT = 'FETCH_SETTINGS_INIT'
+export const FETCH_SETTINGS_SUCCESS = 'FETCH_SETTINGS_SUCCESS'
+
+export const FETCH_TEMPLATES_ERROR = 'FETCH_TEMPLATES_ERROR'
+export const FETCH_TEMPLATES_INIT = 'FETCH_TEMPLATES_INIT'
+export const FETCH_TEMPLATES_SUCCESS = 'FETCH_TEMPLATES_SUCCESS'
+
+export const FETCH_CURRENT_USER_ERROR = 'FETCH_CURRENT_USER_ERROR'
+export const FETCH_CURRENT_USER_INIT = 'FETCH_CURRENT_USER_INIT'
+export const FETCH_CURRENT_USER_SUCCESS = 'FETCH_CURRENT_USER_SUCCESS'
diff --git a/rdmo/core/assets/js/actions/configActions.js b/rdmo/core/assets/js/actions/configActions.js
new file mode 100644
index 0000000000..0aaebe47b9
--- /dev/null
+++ b/rdmo/core/assets/js/actions/configActions.js
@@ -0,0 +1,9 @@
+import { UPDATE_CONFIG, DELETE_CONFIG } from './actionTypes'
+
+export function updateConfig(path, value, ls = false) {
+ return {type: UPDATE_CONFIG, path, value, ls}
+}
+
+export function deleteConfig(path, ls = false) {
+ return {type: DELETE_CONFIG, path, ls}
+}
diff --git a/rdmo/core/assets/js/actions/pendingActions.js b/rdmo/core/assets/js/actions/pendingActions.js
new file mode 100644
index 0000000000..2ee4aaf514
--- /dev/null
+++ b/rdmo/core/assets/js/actions/pendingActions.js
@@ -0,0 +1,9 @@
+import { ADD_TO_PENDING, REMOVE_FROM_PENDING } from './actionTypes'
+
+export function addToPending(item) {
+ return {type: ADD_TO_PENDING, item}
+}
+
+export function removeFromPending(item) {
+ return {type: REMOVE_FROM_PENDING, item}
+}
diff --git a/rdmo/core/assets/js/actions/settingsActions.js b/rdmo/core/assets/js/actions/settingsActions.js
new file mode 100644
index 0000000000..d95f6be36a
--- /dev/null
+++ b/rdmo/core/assets/js/actions/settingsActions.js
@@ -0,0 +1,25 @@
+import CoreApi from '../api/CoreApi'
+
+import { FETCH_SETTINGS_ERROR, FETCH_SETTINGS_INIT, FETCH_SETTINGS_SUCCESS } from './actionTypes'
+
+export function fetchSettings() {
+ return function(dispatch) {
+ dispatch(fetchSettingsInit())
+
+ return CoreApi.fetchSettings()
+ .then((settings) => dispatch(fetchSettingsSuccess(settings)))
+ .catch((errors) => dispatch(fetchSettingsError(errors)))
+ }
+}
+
+export function fetchSettingsInit() {
+ return {type: FETCH_SETTINGS_INIT}
+}
+
+export function fetchSettingsSuccess(settings) {
+ return {type: FETCH_SETTINGS_SUCCESS, settings}
+}
+
+export function fetchSettingsError(errors) {
+ return {type: FETCH_SETTINGS_ERROR, errors}
+}
diff --git a/rdmo/core/assets/js/actions/templateActions.js b/rdmo/core/assets/js/actions/templateActions.js
new file mode 100644
index 0000000000..90e598e342
--- /dev/null
+++ b/rdmo/core/assets/js/actions/templateActions.js
@@ -0,0 +1,25 @@
+import CoreApi from '../api/CoreApi'
+
+import { FETCH_TEMPLATES_ERROR, FETCH_TEMPLATES_INIT, FETCH_TEMPLATES_SUCCESS } from './actionTypes'
+
+export function fetchTemplates() {
+ return function(dispatch) {
+ dispatch(fetchTemplatesInit())
+
+ return CoreApi.fetchTemplates()
+ .then((templates) => dispatch(fetchTemplatesSuccess(templates)))
+ .catch((errors) => dispatch(fetchTemplatesError(errors)))
+ }
+}
+
+export function fetchTemplatesInit() {
+ return {type: FETCH_TEMPLATES_INIT}
+}
+
+export function fetchTemplatesSuccess(templates) {
+ return {type: FETCH_TEMPLATES_SUCCESS, templates}
+}
+
+export function fetchTemplatesError(errors) {
+ return {type: FETCH_TEMPLATES_ERROR, errors}
+}
diff --git a/rdmo/core/assets/js/actions/userActions.js b/rdmo/core/assets/js/actions/userActions.js
new file mode 100644
index 0000000000..2c2922428d
--- /dev/null
+++ b/rdmo/core/assets/js/actions/userActions.js
@@ -0,0 +1,25 @@
+import AccountsApi from '../api/AccountsApi'
+
+import { FETCH_CURRENT_USER_ERROR, FETCH_CURRENT_USER_INIT, FETCH_CURRENT_USER_SUCCESS } from './actionTypes'
+
+export function fetchCurrentUser() {
+ return function(dispatch) {
+ dispatch(fetchCurrentUserInit())
+
+ return AccountsApi.fetchCurrentUser(true)
+ .then(currentUser => dispatch(fetchCurrentUserSuccess({ currentUser })))
+ .catch(error => dispatch(fetchCurrentUserError(error)))
+ }
+}
+
+export function fetchCurrentUserInit() {
+ return {type: FETCH_CURRENT_USER_INIT}
+}
+
+export function fetchCurrentUserSuccess(currentUser) {
+ return {type: FETCH_CURRENT_USER_SUCCESS, currentUser}
+}
+
+export function fetchCurrentUserError(error) {
+ return {type: FETCH_CURRENT_USER_ERROR, error}
+}
diff --git a/rdmo/projects/assets/js/api/AccountsApi.js b/rdmo/core/assets/js/api/AccountsApi.js
similarity index 100%
rename from rdmo/projects/assets/js/api/AccountsApi.js
rename to rdmo/core/assets/js/api/AccountsApi.js
diff --git a/rdmo/core/assets/js/api/BaseApi.js b/rdmo/core/assets/js/api/BaseApi.js
index c2d81e9473..ae0281bb69 100644
--- a/rdmo/core/assets/js/api/BaseApi.js
+++ b/rdmo/core/assets/js/api/BaseApi.js
@@ -1,7 +1,7 @@
import Cookies from 'js-cookie'
import isUndefined from 'lodash/isUndefined'
-import baseUrl from '../utils/baseUrl'
+import { baseUrl } from '../utils/meta'
function ApiError(statusText, status) {
this.status = status
@@ -52,6 +52,28 @@ class BaseApi {
})
}
+ static postFormData(url, formData) {
+ return fetch(baseUrl + url, {
+ method: 'POST',
+ headers: {
+ 'X-CSRFToken': Cookies.get('csrftoken')
+ },
+ body: formData
+ }).catch(error => {
+ throw new ApiError(error.message)
+ }).then(response => {
+ if (response.ok) {
+ return response.json()
+ } else if (response.status == 400) {
+ return response.json().then(errors => {
+ throw new ValidationError(errors)
+ })
+ } else {
+ throw new ApiError(response.statusText, response.status)
+ }
+ })
+ }
+
static put(url, data) {
return fetch(baseUrl + url, {
method: 'PUT',
diff --git a/rdmo/core/assets/js/api/CoreApi.js b/rdmo/core/assets/js/api/CoreApi.js
index ef77eb4701..d97cfc2983 100644
--- a/rdmo/core/assets/js/api/CoreApi.js
+++ b/rdmo/core/assets/js/api/CoreApi.js
@@ -14,6 +14,10 @@ class CoreApi extends BaseApi {
return this.get('/api/v1/core/groups/')
}
+ static fetchTemplates() {
+ return this.get('/api/v1/core/templates/')
+ }
+
}
export default CoreApi
diff --git a/rdmo/core/assets/js/base.js b/rdmo/core/assets/js/base.js
new file mode 100644
index 0000000000..3bab0caba5
--- /dev/null
+++ b/rdmo/core/assets/js/base.js
@@ -0,0 +1 @@
+import 'bootstrap-sass'
diff --git a/rdmo/core/assets/js/components/Html.js b/rdmo/core/assets/js/components/Html.js
new file mode 100644
index 0000000000..acd13bbd23
--- /dev/null
+++ b/rdmo/core/assets/js/components/Html.js
@@ -0,0 +1,16 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import { isEmpty } from 'lodash'
+
+const Html = ({ html = '' }) => {
+ return !isEmpty(html) && (
+
+ )
+}
+
+Html.propTypes = {
+ className: PropTypes.string,
+ html: PropTypes.string
+}
+
+export default Html
diff --git a/rdmo/core/assets/js/components/Modal.js b/rdmo/core/assets/js/components/Modal.js
index a3f2cd70b8..22c07abeab 100644
--- a/rdmo/core/assets/js/components/Modal.js
+++ b/rdmo/core/assets/js/components/Modal.js
@@ -2,9 +2,9 @@ import React from 'react'
import PropTypes from 'prop-types'
import { Modal as BootstrapModal } from 'react-bootstrap'
-const Modal = ({ bsSize, buttonLabel, buttonProps, title, show, onClose, onSave, children }) => {
+const Modal = ({ title, show, modalProps, submitLabel, submitProps, onClose, onSubmit, children }) => {
return (
-
+
{title}
@@ -15,11 +15,12 @@ const Modal = ({ bsSize, buttonLabel, buttonProps, title, show, onClose, onSave,
- { onSave ?
-
- : null
+ {
+ onSubmit && (
+
+ )
}
@@ -27,14 +28,14 @@ const Modal = ({ bsSize, buttonLabel, buttonProps, title, show, onClose, onSave,
}
Modal.propTypes = {
- bsSize: PropTypes.oneOf(['lg', 'large', 'sm', 'small']),
- buttonLabel: PropTypes.string,
- buttonProps: PropTypes.object,
- children: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.node), PropTypes.node]).isRequired,
- onClose: PropTypes.func.isRequired,
- onSave: PropTypes.func,
- show: PropTypes.bool.isRequired,
title: PropTypes.string.isRequired,
+ show: PropTypes.bool.isRequired,
+ modalProps: PropTypes.object,
+ submitLabel: PropTypes.string,
+ submitProps: PropTypes.object,
+ onClose: PropTypes.func.isRequired,
+ onSubmit: PropTypes.func,
+ children: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.node), PropTypes.node]).isRequired,
}
export default Modal
diff --git a/rdmo/core/assets/js/containers/Pending.js b/rdmo/core/assets/js/containers/Pending.js
new file mode 100644
index 0000000000..07609f8a79
--- /dev/null
+++ b/rdmo/core/assets/js/containers/Pending.js
@@ -0,0 +1,24 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import { connect } from 'react-redux'
+import { isEmpty } from 'lodash'
+
+const Pending = ({ pending }) => {
+ return (
+ !isEmpty(pending.items) && (
+
+ )
+ )
+}
+
+Pending.propTypes = {
+ pending: PropTypes.object.isRequired,
+}
+
+function mapStateToProps(state) {
+ return {
+ pending: state.pending,
+ }
+}
+
+export default connect(mapStateToProps)(Pending)
diff --git a/rdmo/core/assets/js/reducers/configReducer.js b/rdmo/core/assets/js/reducers/configReducer.js
new file mode 100644
index 0000000000..9001d4e8c4
--- /dev/null
+++ b/rdmo/core/assets/js/reducers/configReducer.js
@@ -0,0 +1,22 @@
+import { updateConfig, deleteConfig, setConfigInLocalStorage, deleteConfigInLocalStorage } from '../utils/config'
+
+import { DELETE_CONFIG, UPDATE_CONFIG } from '../actions/actionTypes'
+
+const initialState = {}
+
+export default function configReducer(state = initialState, action) {
+ switch(action.type) {
+ case UPDATE_CONFIG:
+ if (action.ls) {
+ setConfigInLocalStorage(state.prefix, action.path, action.value)
+ }
+ return updateConfig(state, action.path, action.value)
+ case DELETE_CONFIG:
+ if (action.ls) {
+ deleteConfigInLocalStorage(state.prefix, action.path)
+ }
+ return deleteConfig(state, action.path)
+ default:
+ return state
+ }
+}
diff --git a/rdmo/core/assets/js/reducers/pendingReducer.js b/rdmo/core/assets/js/reducers/pendingReducer.js
new file mode 100644
index 0000000000..7f5b9de1cc
--- /dev/null
+++ b/rdmo/core/assets/js/reducers/pendingReducer.js
@@ -0,0 +1,16 @@
+import { ADD_TO_PENDING, REMOVE_FROM_PENDING } from '../actions/actionTypes'
+
+const initialState = {
+ items: []
+}
+
+export default function pendingReducer(state = initialState, action) {
+ switch(action.type) {
+ case ADD_TO_PENDING:
+ return { ...state, items: [...state.items, action.item] }
+ case REMOVE_FROM_PENDING:
+ return { ...state, items: state.items.filter((item) => (item != action.item)) }
+ default:
+ return state
+ }
+}
diff --git a/rdmo/core/assets/js/reducers/settingsReducer.js b/rdmo/core/assets/js/reducers/settingsReducer.js
new file mode 100644
index 0000000000..379f1829de
--- /dev/null
+++ b/rdmo/core/assets/js/reducers/settingsReducer.js
@@ -0,0 +1,14 @@
+import { FETCH_SETTINGS_ERROR, FETCH_SETTINGS_SUCCESS } from '../actions/actionTypes'
+
+const initialState = {}
+
+export default function settingsReducer(state = initialState, action) {
+ switch(action.type) {
+ case FETCH_SETTINGS_SUCCESS:
+ return { ...state, ...action.settings }
+ case FETCH_SETTINGS_ERROR:
+ return { ...state, errors: action.errors }
+ default:
+ return state
+ }
+}
diff --git a/rdmo/core/assets/js/reducers/templateReducer.js b/rdmo/core/assets/js/reducers/templateReducer.js
new file mode 100644
index 0000000000..b7897f2eb8
--- /dev/null
+++ b/rdmo/core/assets/js/reducers/templateReducer.js
@@ -0,0 +1,14 @@
+import { FETCH_TEMPLATES_ERROR, FETCH_TEMPLATES_SUCCESS } from '../actions/actionTypes'
+
+const initialState = {}
+
+export default function templateReducer(state = initialState, action) {
+ switch(action.type) {
+ case FETCH_TEMPLATES_SUCCESS:
+ return { ...state, ...action.templates }
+ case FETCH_TEMPLATES_ERROR:
+ return { ...state, errors: action.errors }
+ default:
+ return state
+ }
+}
diff --git a/rdmo/core/assets/js/reducers/userReducer.js b/rdmo/core/assets/js/reducers/userReducer.js
new file mode 100644
index 0000000000..8c76285f56
--- /dev/null
+++ b/rdmo/core/assets/js/reducers/userReducer.js
@@ -0,0 +1,18 @@
+import { FETCH_CURRENT_USER_ERROR, FETCH_CURRENT_USER_INIT, FETCH_CURRENT_USER_SUCCESS } from '../actions/actionTypes'
+
+const initialState = {
+ currentUser: {},
+}
+
+export default function userReducer(state = initialState, action) {
+ switch(action.type) {
+ case FETCH_CURRENT_USER_INIT:
+ return {...state, ...action.currentUser}
+ case FETCH_CURRENT_USER_SUCCESS:
+ return {...state, ...action.currentUser}
+ case FETCH_CURRENT_USER_ERROR:
+ return {...state, errors: action.error.errors}
+ default:
+ return state
+ }
+}
diff --git a/rdmo/core/assets/js/utils/baseUrl.js b/rdmo/core/assets/js/utils/baseUrl.js
deleted file mode 100644
index 22a17df960..0000000000
--- a/rdmo/core/assets/js/utils/baseUrl.js
+++ /dev/null
@@ -1,2 +0,0 @@
-// take the baseurl from the of the django template
-export default document.querySelector('meta[name="baseurl"]').content.replace(/\/+$/, '')
diff --git a/rdmo/core/assets/js/utils/config.js b/rdmo/core/assets/js/utils/config.js
new file mode 100644
index 0000000000..22b1be6460
--- /dev/null
+++ b/rdmo/core/assets/js/utils/config.js
@@ -0,0 +1,58 @@
+import { set, unset, toNumber, isNaN } from 'lodash'
+
+const updateConfig = (config, path, value) => {
+ const newConfig = {...config}
+ set(newConfig, path, value)
+ return newConfig
+}
+
+const deleteConfig = (config, path) => {
+ const newConfig = {...config}
+ unset(newConfig, path)
+ return newConfig
+}
+
+const getConfigFromLocalStorage = (prefix) => {
+ const ls = {...localStorage}
+
+ return Object.entries(ls)
+ .filter(([lsPath,]) => lsPath.startsWith(prefix))
+ .map(([lsPath, lsValue]) => {
+ if (lsPath.startsWith(prefix)) {
+ const path = lsPath.replace(`${prefix}.`, '')
+
+ // check if it is literal 'true' or 'false'
+ if (lsValue === 'true') {
+ return [path, true]
+ } else if (lsValue === 'false') {
+ return [path, false]
+ }
+
+ // check if the value is number or a string
+ const numberValue = toNumber(lsValue)
+ if (isNaN(numberValue)) {
+ return [path, lsValue]
+ } else {
+ return [path, numberValue]
+ }
+ } else {
+ return null
+ }
+ })
+}
+
+const setConfigInLocalStorage = (prefix, path, value) => {
+ localStorage.setItem(`${prefix}.${path}`, value)
+}
+
+const deleteConfigInLocalStorage = (prefix, path) => {
+ localStorage.removeItem(`${prefix}.${path}`)
+}
+
+export {
+ updateConfig,
+ deleteConfig,
+ getConfigFromLocalStorage,
+ setConfigInLocalStorage,
+ deleteConfigInLocalStorage
+}
diff --git a/rdmo/core/assets/js/utils/index.js b/rdmo/core/assets/js/utils/index.js
index c2d32da182..30d03229a7 100644
--- a/rdmo/core/assets/js/utils/index.js
+++ b/rdmo/core/assets/js/utils/index.js
@@ -1,5 +1,2 @@
export * from './api'
-export { default as baseUrl } from './baseUrl'
-export { default as language } from './language'
-export { default as siteId } from './siteId'
-export { default as staticUrl } from './staticUrl'
+export { baseUrl, language, siteId, staticUrl } from './meta'
diff --git a/rdmo/core/assets/js/utils/language.js b/rdmo/core/assets/js/utils/language.js
deleted file mode 100644
index 58dc8a369e..0000000000
--- a/rdmo/core/assets/js/utils/language.js
+++ /dev/null
@@ -1,2 +0,0 @@
-// take the language from the of the django template
-export default document.querySelector('meta[name="language"]').content
diff --git a/rdmo/core/assets/js/utils/meta.js b/rdmo/core/assets/js/utils/meta.js
new file mode 100644
index 0000000000..d8186e6c2f
--- /dev/null
+++ b/rdmo/core/assets/js/utils/meta.js
@@ -0,0 +1,9 @@
+// take information from the of the django template
+
+export const baseUrl = document.querySelector('meta[name="baseurl"]').content.replace(/\/+$/, '')
+
+export const staticUrl = document.querySelector('meta[name="staticurl"]').content.replace(/\/+$/, '')
+
+export const siteId = Number(document.querySelector('meta[name="site_id"]').content)
+
+export const language = document.querySelector('meta[name="language"]').content
diff --git a/rdmo/core/assets/js/utils/siteId.js b/rdmo/core/assets/js/utils/siteId.js
deleted file mode 100644
index 7b413b672e..0000000000
--- a/rdmo/core/assets/js/utils/siteId.js
+++ /dev/null
@@ -1,2 +0,0 @@
-// take the site_id from the of the django template
-export default Number(document.querySelector('meta[name="site_id"]').content)
diff --git a/rdmo/core/assets/js/utils/staticUrl.js b/rdmo/core/assets/js/utils/staticUrl.js
deleted file mode 100644
index 0a1323cb10..0000000000
--- a/rdmo/core/assets/js/utils/staticUrl.js
+++ /dev/null
@@ -1,2 +0,0 @@
-// take the staticurl from the of the django template
-export default document.querySelector('meta[name="staticurl"]').content.replace(/\/+$/, '')
diff --git a/rdmo/core/assets/js/utils/store.js b/rdmo/core/assets/js/utils/store.js
new file mode 100644
index 0000000000..d30203fd59
--- /dev/null
+++ b/rdmo/core/assets/js/utils/store.js
@@ -0,0 +1,14 @@
+import Cookies from 'js-cookie'
+import isEmpty from 'lodash/isEmpty'
+
+const checkStoreId = () => {
+ const currentStoreId = Cookies.get('storeid')
+ const localStoreId = localStorage.getItem('rdmo.storeid')
+
+ if (isEmpty(localStoreId) || localStoreId !== currentStoreId) {
+ localStorage.clear()
+ localStorage.setItem('rdmo.storeid', currentStoreId)
+ }
+}
+
+export { checkStoreId }
diff --git a/rdmo/core/assets/scss/base.scss b/rdmo/core/assets/scss/base.scss
new file mode 100644
index 0000000000..dadff686a5
--- /dev/null
+++ b/rdmo/core/assets/scss/base.scss
@@ -0,0 +1,15 @@
+$icon-font-path: "bootstrap-sass/assets/fonts/bootstrap/";
+@import '~bootstrap-sass';
+@import '~font-awesome/css/font-awesome.css';
+
+@import 'react-datepicker/dist/react-datepicker.css';
+
+@import 'variables';
+@import 'style';
+
+@import 'codemirror';
+@import 'fonts';
+@import 'footer';
+@import 'header';
+@import 'swagger';
+@import 'utils';
diff --git a/rdmo/core/assets/scss/codemirror.scss b/rdmo/core/assets/scss/codemirror.scss
new file mode 100644
index 0000000000..be9f2c0bef
--- /dev/null
+++ b/rdmo/core/assets/scss/codemirror.scss
@@ -0,0 +1,9 @@
+.CodeMirror {
+ font-family: DroidSans-Mono, mono;
+}
+
+formgroup .CodeMirror {
+ border-radius: 4px;
+ border: 1px solid #ccc;
+ color: #555;
+}
diff --git a/rdmo/core/assets/scss/fonts.scss b/rdmo/core/assets/scss/fonts.scss
new file mode 100644
index 0000000000..4d1f217320
--- /dev/null
+++ b/rdmo/core/assets/scss/fonts.scss
@@ -0,0 +1,44 @@
+@font-face {
+ font-family: "DroidSans";
+ src: url('../fonts/DroidSans.ttf');
+}
+@font-face {
+ font-family: "DroidSans";
+ src: url('../fonts/DroidSans-Bold.ttf');
+ font-weight: bold;
+}
+@font-face {
+ font-family: "DroidSans-Mono";
+ src: url('../fonts/DroidSansMono.ttf');
+}
+@font-face {
+ font-family: "DroidSerif";
+ src: url('../fonts/DroidSerif.ttf');
+}
+@font-face {
+ font-family: "DroidSerif";
+ src: url('../fonts/DroidSerif-Bold.ttf');
+ font-weight: bold;
+}
+@font-face {
+ font-family: "DroidSerif";
+ src: url('../fonts/DroidSerif-Italic.ttf');
+ font-style: italic;
+}
+@font-face {
+ font-family: "DroidSerif";
+ src: url('../fonts/DroidSerif-BoldItalic.ttf');
+ font-style: italic;
+ font-weight: bold;
+}
+
+body {
+ font-family: DroidSans, sans;
+}
+h1, h2, h3, h4, h5, h6 {
+ font-family: DroidSerif, serif;
+}
+
+a.fa {
+ text-decoration: none !important;
+}
diff --git a/rdmo/core/assets/scss/footer.scss b/rdmo/core/assets/scss/footer.scss
new file mode 100644
index 0000000000..7b4838c053
--- /dev/null
+++ b/rdmo/core/assets/scss/footer.scss
@@ -0,0 +1,55 @@
+$footer-height: 280px;
+$footer-height-md: 600px;
+$footer-height-sm: 260px;
+
+/* footer layout */
+
+.content {
+ min-height: 100%;
+ margin-bottom: -$footer-height;
+ padding-bottom: $footer-height;
+}
+footer {
+ height: $footer-height;
+}
+@media (max-width: $screen-sm-max) {
+ .content {
+ margin-bottom: -$footer-height-md;
+ padding-bottom: $footer-height-md;
+ }
+ footer {
+ height: $footer-height-md;
+ }
+}
+@media (max-width: $screen-xs-max) {
+ .content {
+ margin-bottom: -$footer-height-md;
+ padding-bottom: $footer-height-md;
+ }
+ footer {
+ height: $footer-height-md;
+ }
+}
+
+/* footer style */
+
+footer {
+ color: $footer-color;
+ background-color: $footer-background-color;
+ padding-top: 20px;
+
+ a,
+ a:visited,
+ a:hover {
+ color: $footer-link-color;
+ }
+ h4 {
+ color: $footer-link-color;
+ }
+ p {
+ text-align: left;
+ }
+ img {
+ display: block;
+ }
+}
diff --git a/rdmo/core/assets/scss/header.scss b/rdmo/core/assets/scss/header.scss
new file mode 100644
index 0000000000..769b65c0df
--- /dev/null
+++ b/rdmo/core/assets/scss/header.scss
@@ -0,0 +1,89 @@
+$header-height: 400px;
+$header-height-md: 300px;
+
+header {
+ position: relative;
+
+ height: $header-height;
+ background-color: black;
+
+ .header-image {
+ position: absolute;
+ left: 0;
+ right: 0;
+
+ opacity: 0;
+ -webkit-transition: $image-transition;
+ -moz-transition: $image-transition;
+ -ms-transition: $image-transition;
+ -o-transition: $image-transition;
+ transition: $image-transition;
+
+ &.visible {
+ opacity: 1;
+ }
+ img {
+ display: block;
+ width: 100%;
+ height: $header-height;
+ }
+ p {
+ position: absolute;
+ bottom: 0;
+ right: 0;
+ z-index: 10;
+
+ padding-right: 5px;
+ margin-bottom: 5px;
+ font-size: 10px;
+ color: $footer-link-color;
+
+ }
+ a,
+ a:visited,
+ a:hover {
+ color: $footer-link-color;
+ }
+ }
+ .header-text {
+ position: relative;
+ padding-top: 100px;
+
+ h1 {
+ font-size: 60px;
+ color: white;
+ }
+ p {
+ font-size: 30px;
+ color: white;
+ }
+ }
+}
+@media (max-width: $screen-md-max) {
+ header {
+ height: $header-height-md;
+ }
+ header .header-image img {
+ height: $header-height-md;
+ }
+ header .header-text {
+ padding-top: 50px;
+ }
+}
+@media (max-width: $screen-xs-max) {
+ header {
+ background-color: inherit;
+ height: auto;
+ }
+ header .header-text {
+ padding-top: 0;
+ }
+ header .header-text h1 {
+ font-size: 40px;
+ color: $headline-color;
+ }
+ header .header-text p {
+ font-size: 20px;
+ color: $variant-color;
+ }
+}
diff --git a/rdmo/core/assets/scss/style.scss b/rdmo/core/assets/scss/style.scss
new file mode 100644
index 0000000000..795a6f9228
--- /dev/null
+++ b/rdmo/core/assets/scss/style.scss
@@ -0,0 +1,497 @@
+html, body {
+ height: 100%;
+ background-color: $background-color;
+}
+
+h1, h2, h3, h4 {
+ color: $headline-color;
+ background-color: $headline-background-color;
+ line-height: 40px;
+}
+h5, h6 {
+ color: $headline-color;
+ background-color: $headline-background-color;
+ font-size: medium;
+ line-height: 20px;
+}
+h1 {
+ font-size: 28px;
+}
+h2 {
+ font-size: 24px;
+}
+.sidebar h2,
+.modal h2 {
+ font-size: 20px;
+}
+h3 {
+ font-size: 16px;
+}
+h4 {
+ font-size: 14px;
+}
+form {
+ margin-bottom: 20px;
+}
+.extend {
+ width: 100%;
+}
+
+a {
+ color: $link-color;
+
+ &:visited {
+ color: $link-color-visited;
+ }
+ &:hover {
+ color: $link-color-hover;
+ }
+ &:focus {
+ color: $link-color-focus;
+ }
+
+ &.btn {
+ color: white;
+
+ &:visited,
+ &:hover,
+ &:focus {
+ color: white;
+ }
+ }
+ &.text-warning {
+ &:visited,
+ &:hover,
+ &:focus {
+ color: #8a6d3b;
+ }
+ }
+ &.text-danger {
+ &:visited,
+ &:hover,
+ &:focus {
+ color: #a94442;
+ }
+ }
+
+ &.disabled {
+ cursor: not-allowed;
+ }
+}
+
+code {
+ word-wrap: break-word;
+
+ &.code-questions {
+ color: rgb(16, 31, 112);
+ background-color: rgba(16, 31, 112, 0.1);
+ }
+ &.code-options {
+ color: rgb(255, 100, 0);
+ background-color: rgba(255, 100, 0, 0.1);
+ }
+ &.code-options-provider {
+ color: white;
+ background-color: rgba(255, 100, 0, 0.8);
+ }
+ &.code-conditions {
+ color: rgb(128, 0, 128);
+ background-color: rgba(128, 0, 128, 0.1);
+ }
+ &.code-tasks {
+ color: rgb(128, 0, 0);
+ background-color: rgba(128, 0, 0, 0.1);
+ }
+ &.code-views {
+ color: rgb(0, 128, 0);
+ background-color: rgba(0, 128, 0, 0.1);
+ }
+ &.code-order {
+ color: rgb(96, 96, 96);
+ background-color: rgba(96, 96, 96, 0.1);
+ }
+ &.code-import {
+ color: black;
+ background-color: rgba(96, 96, 96, 0.1);
+ }
+}
+
+table {
+ p {
+ margin-bottom: 5px;
+ }
+ p:last-child {
+ margin-bottom: 0;
+ }
+}
+
+.table-break-word {
+ td {
+ word-break: break-all;
+ }
+}
+
+details {
+ margin-bottom: 10px;
+}
+
+summary {
+ display: list-item;
+ cursor: pointer;
+ margin-bottom: 5px;
+}
+
+metadata {
+ display: none;
+}
+
+/* navbar */
+
+.navbar-default {
+ background-color: $navigation-background-color;
+ border-bottom: none;
+
+ .navbar-brand,
+ .navbar-nav > li > a,
+ .navbar-nav > li > a:focus {
+ color: $navigation-color;
+ background-color: transparent;
+ }
+ .navbar-brand:hover,
+ .navbar-nav > li > a:hover,
+ .navbar-nav > .open > a,
+ .navbar-nav > .open > a:focus,
+ .navbar-nav > .open > a:hover {
+ color: $navigation-hover-color;
+ background-color: $navigation-hover-background-color;
+ }
+
+ .dropdown li.divider:first-child {
+ display: none;
+ }
+}
+
+/* content */
+
+.content {
+ padding-top: 50px; /* same height as the navbar */
+}
+.sidebar {
+ /* make the sidebar sticky */
+ position: -webkit-sticky;
+ position: sticky;
+ top: 0;
+}
+.page, .sidebar {
+ height: 100%;
+ margin-top: 10px;
+ margin-bottom: 60px;
+}
+.page h2:nth-child(2) {
+ margin-top: 0;
+}
+.sidebar h2:first-child,
+.sidebar .import-buttons {
+ margin-top: 70px;
+}
+
+/* questions overview */
+
+.section-panel {
+
+}
+
+.subsection-panel {
+ margin-left: 40px;
+}
+
+.group-panel {
+ margin-left: 80px;
+
+ table th:first-child,
+ table td:first-child {
+ padding-left: 15px;
+ }
+
+ table th:last-child,
+ table td:last-child {
+ padding-right: 15px;
+ }
+}
+
+/* angular forms */
+
+.input-collection {
+ margin-bottom: 15px;
+}
+
+/* forms */
+
+.form-label {
+ margin-bottom: 5px;
+ font-weight: 700;
+}
+
+form .yesno label {
+ margin-right: 10px;
+}
+
+.row {
+ .checkbox,
+ .radio {
+ margin-top: 10px;
+ margin-bottom: 10px;
+ }
+
+ @media (min-width: $screen-xs-max) {
+ .checkbox-padding .checkbox,
+ .radio-padding .radio {
+ margin-top: 32px;
+ margin-bottom: 11px;
+ }
+ }
+}
+
+.input-xs {
+ height: 24px;
+ padding: 5px 10px;
+ font-size: 11px;
+ line-height: 1;
+ border-radius: 2px;
+}
+
+.help-block.info {
+ margin-top: 0;
+}
+
+.sidebar-form {
+ display: flex;
+ gap: 5px;
+}
+
+.upload-form {
+ .upload-form-field {
+ position: relative;
+
+ cursor: pointer;
+ border-radius: 4px;
+
+ flex-grow: 1;
+ overflow: hidden;
+
+ p,
+ input {
+ height: 34px;
+ margin: 0px;
+ }
+
+ p {
+ text-align: left;
+ cursor: pointer;
+
+ color: $link-color;
+ border: 1px solid silver;
+ border-radius: 4px;
+
+ width: calc(100% - 1px);
+ padding: 6px 14px;
+
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ input {
+ position: absolute;
+ z-index: 1;
+ padding: 0;
+ opacity: 0;
+ }
+
+ &:hover {
+ background-color: #e6e6e6;
+ }
+ }
+}
+
+/* modals */
+
+.modal-body {
+ > p:last-child,
+ formgroup:last-child .form-group {
+ margin-bottom: 0;
+ }
+
+ .copy-block {
+ margin-bottom: 20px;
+ }
+
+ .help-block {
+ font-size: small;
+ word-break: break-word;
+ }
+
+ .nav.nav-tabs {
+ margin-bottom: 20px;
+ }
+}
+
+/* options */
+
+.options-dropdown {
+ display: inline-block;
+
+ > a {
+ cursor: pointer;
+ }
+}
+
+/* panels */
+
+.panel-default {
+ min-height: 5px;
+}
+
+.panel-body {
+ padding-top: 10px;
+ padding-bottom: 10px;
+}
+
+.panel li > p:last-child {
+ margin-bottom: 0;
+}
+
+/* lists */
+
+ul.list-arrow li {
+ margin-left: 20px;
+
+ &.active {
+ margin-left: 0;
+ }
+
+ &.active a:before {
+ float: left;
+ width: 20px;
+ text-align: right;
+ content: '\2192\0000a0'; /* right-arrow followed by a space */
+ }
+}
+
+/* misc */
+.form-errors {
+ margin-bottom: 20px;
+}
+li > a.control-label > i {
+ display: none;
+}
+li.has-error > a.control-label > i,
+li.has-warning > a.control-label > i {
+ display: inline;
+}
+.email-form label,
+.connections-form label {
+ display: block;
+ margin: 0;
+ line-height: 40px;
+ border-bottom: 1px solid $modal-border-color;
+}
+.email-form label:first-child,
+.connections-form label:first-child {
+ border-top: 1px solid $modal-border-color;
+}
+.email-form label input,
+.connections-form label input {
+ margin-left: 5px;
+ margin-right: 5px;
+}
+.email-form .email-form-buttons,
+.connections-form .connections-form-buttons {
+ margin-top: 10px;
+}
+.socialaccount_providers {
+ margin: 0;
+ padding: 0;
+ height: 42px;
+}
+.socialaccount_providers li {
+ float: left;
+ margin: 0 5px 10px 5px;
+ list-style: none;
+}
+.socialaccount_providers li.socialaccount_provider_break {
+ float: none;
+ margin-left: 0;
+ margin-right: 0;
+}
+.socialaccount_provider_name {
+ line-height: 29px;
+ font-weight: bold;
+}
+.logout-form {
+ margin: 0;
+}
+.logout-form .btn-link {
+ padding: 3px 20px;
+ color: $navigation-dropdown-color;
+ display: block;
+ width: 100%;
+ text-align: left;
+ border: none;
+ clear: both;
+ font-weight: 400;
+ line-height: 1.42857143;
+ white-space: nowrap;
+}
+.logout-form .btn-link:hover {
+ color: $navigation-dropdown-hover-color;
+ background-color: $navigation-dropdown-hover-background-color;
+ text-decoration: none;
+}
+.logout-form .btn-link:focus {
+ color: $navigation-dropdown-hover-color;
+ background-color: $navigation-dropdown-hover-background-color;
+ text-decoration: none;
+ outline: none;
+}
+.rdmo-logo {
+ width: 240px;
+ margin-top: 40px;
+}
+
+// adjust background "hover" color in select2 to $link-color
+.select2-results__option--highlighted{
+ background-color: $link-color !important,
+}
+
+.cc-myself {
+ .checkbox {
+ margin: 0;
+ }
+}
+
+.ng-binding {
+ :last-child {
+ margin-bottom: 0;
+ }
+}
+
+.inline_image {
+ max-width: 100%;
+}
+
+[data-toggle="tooltip"] {
+ cursor: help;
+ text-decoration: underline;
+ text-decoration-style: dotted;
+}
+
+.more,
+.show-less {
+ display: none;
+}
+.show-more,
+.show-less {
+ color: $link-color;
+ cursor: pointer;
+}
diff --git a/rdmo/core/assets/scss/swagger.scss b/rdmo/core/assets/scss/swagger.scss
new file mode 100644
index 0000000000..0a0ecc6cac
--- /dev/null
+++ b/rdmo/core/assets/scss/swagger.scss
@@ -0,0 +1,28 @@
+.topbar {
+ background-color: $headline-color !important;
+}
+
+.swagger-ui .info {
+ margin: 30px;
+}
+
+.swagger-ui .btn.authorize {
+ border-color: $footer-background-color;
+ color: $text-color;
+}
+
+.swagger-ui .btn.authorize svg {
+ fill: $footer-background-color;
+}
+
+.swagger-ui .btn.authorize {
+ color: $footer-background-color !important;
+}
+
+.topbar img {
+ filter: hue-rotate(180deg)
+}
+
+.download-url-wrapper .download-url-button {
+ background-color: $headline-color !important;
+}
diff --git a/rdmo/core/assets/scss/utils.scss b/rdmo/core/assets/scss/utils.scss
new file mode 100644
index 0000000000..0e45f92470
--- /dev/null
+++ b/rdmo/core/assets/scss/utils.scss
@@ -0,0 +1,92 @@
+.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;
+}
diff --git a/rdmo/core/settings.py b/rdmo/core/settings.py
index 422e7886fc..999d5be52b 100644
--- a/rdmo/core/settings.py
+++ b/rdmo/core/settings.py
@@ -218,6 +218,8 @@
'PROJECT_TABLE_PAGE_SIZE'
]
+TEMPLATES_API = []
+
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
DEFAULT_FROM_EMAIL = 'info@example.com'
diff --git a/rdmo/core/tests/test_viewset_templates.py b/rdmo/core/tests/test_viewset_templates.py
new file mode 100644
index 0000000000..ea90ffc989
--- /dev/null
+++ b/rdmo/core/tests/test_viewset_templates.py
@@ -0,0 +1,32 @@
+import pytest
+
+from django.urls import reverse
+
+users = (
+ ('owner', 'owner'),
+ ('manager', 'manager'),
+ ('author', 'author'),
+ ('guest', 'guest'),
+ ('api', 'api'),
+ ('user', 'user'),
+ ('anonymous', None),
+)
+
+status_map = {
+ 'list': {
+ 'owner': 200, 'manager': 200, 'author': 200, 'guest': 200, 'api': 200, 'user': 200, 'anonymous': 401
+ }
+}
+
+urlnames = {
+ 'list': 'template-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()
diff --git a/rdmo/core/urls/v1.py b/rdmo/core/urls/v1.py
index 442d612de5..6e7139a9a4 100644
--- a/rdmo/core/urls/v1.py
+++ b/rdmo/core/urls/v1.py
@@ -2,12 +2,13 @@
from rest_framework import routers
-from ..viewsets import GroupViewSet, SettingsViewSet, SitesViewSet
+from ..viewsets import GroupViewSet, SettingsViewSet, SitesViewSet, TemplatesViewSet
router = routers.DefaultRouter()
router.register(r'settings', SettingsViewSet, basename='setting')
router.register(r'sites', SitesViewSet, basename='site')
router.register(r'groups', GroupViewSet, basename='group')
+router.register(r'templates', TemplatesViewSet, basename='template')
urlpatterns = [
path('accounts/', include('rdmo.accounts.urls.v1')),
diff --git a/rdmo/core/viewsets.py b/rdmo/core/viewsets.py
index eaa97a2072..ea4e41af1a 100644
--- a/rdmo/core/viewsets.py
+++ b/rdmo/core/viewsets.py
@@ -1,6 +1,9 @@
+from pathlib import Path
+
from django.conf import settings
from django.contrib.auth.models import Group
from django.contrib.sites.models import Site
+from django.template.loader import get_template
from rest_framework import viewsets
from rest_framework.permissions import IsAuthenticated
@@ -31,3 +34,14 @@ class GroupViewSet(viewsets.ReadOnlyModelViewSet):
permission_classes = (HasModelPermission, )
queryset = Group.objects.all()
serializer_class = GroupSerializer
+
+
+class TemplatesViewSet(viewsets.GenericViewSet):
+
+ permission_classes = (IsAuthenticated, )
+
+ def list(self, request, *args, **kwargs):
+ return Response({
+ Path(template_path).stem: get_template(template_path).render(request=request).strip()
+ for template_path in settings.TEMPLATES_API
+ })
diff --git a/rdmo/management/assets/js/reducers/configReducer.js b/rdmo/management/assets/js/reducers/configReducer.js
index 31029c17f0..1476203c16 100644
--- a/rdmo/management/assets/js/reducers/configReducer.js
+++ b/rdmo/management/assets/js/reducers/configReducer.js
@@ -1,6 +1,6 @@
import set from 'lodash/set'
-import baseUrl from 'rdmo/core/assets/js/utils/baseUrl'
+import { baseUrl } from 'rdmo/core/assets/js/utils/meta'
const initialState = {
baseUrl: baseUrl + '/management/',
diff --git a/rdmo/projects/admin.py b/rdmo/projects/admin.py
index 1c82a6a5e8..926885e1c5 100644
--- a/rdmo/projects/admin.py
+++ b/rdmo/projects/admin.py
@@ -1,3 +1,4 @@
+from django import forms
from django.contrib import admin
from django.db.models import Prefetch
from django.urls import reverse
@@ -15,12 +16,35 @@
Snapshot,
Value,
)
+from .validators import ProjectParentValidator
+
+
+class ProjectAdminForm(forms.ModelForm):
+
+ class Meta:
+ model = Project
+ fields = [
+ 'parent',
+ 'site',
+ 'title',
+ 'description',
+ 'catalog',
+ 'views'
+ ]
+
+
+ def clean(self):
+ super().clean()
+ ProjectParentValidator(self.instance)(self.cleaned_data)
@admin.register(Project)
class ProjectAdmin(admin.ModelAdmin):
+ form = ProjectAdminForm
+
search_fields = ('title', 'user__username')
list_display = ('title', 'owners', 'updated', 'created')
+ readonly_fields = ('progress_count', 'progress_total')
def get_queryset(self, request):
return Project.objects.prefetch_related(
diff --git a/rdmo/projects/assets/js/projects.js b/rdmo/projects/assets/js/projects.js
index 414a8c915f..e4c4e562e7 100644
--- a/rdmo/projects/assets/js/projects.js
+++ b/rdmo/projects/assets/js/projects.js
@@ -2,12 +2,12 @@ import React from 'react'
import { createRoot } from 'react-dom/client'
import { Provider } from 'react-redux'
-import configureStore from './store/configureStore'
+import configureStore from './projects/store/configureStore'
import { DndProvider } from 'react-dnd'
import { HTML5Backend } from 'react-dnd-html5-backend'
-import Main from './containers/Main'
+import Main from './projects/containers/Main'
const store = configureStore()
diff --git a/rdmo/projects/assets/js/actions/actionTypes.js b/rdmo/projects/assets/js/projects/actions/actionTypes.js
similarity index 100%
rename from rdmo/projects/assets/js/actions/actionTypes.js
rename to rdmo/projects/assets/js/projects/actions/actionTypes.js
diff --git a/rdmo/projects/assets/js/actions/configActions.js b/rdmo/projects/assets/js/projects/actions/configActions.js
similarity index 100%
rename from rdmo/projects/assets/js/actions/configActions.js
rename to rdmo/projects/assets/js/projects/actions/configActions.js
diff --git a/rdmo/projects/assets/js/actions/projectsActions.js b/rdmo/projects/assets/js/projects/actions/projectsActions.js
similarity index 100%
rename from rdmo/projects/assets/js/actions/projectsActions.js
rename to rdmo/projects/assets/js/projects/actions/projectsActions.js
diff --git a/rdmo/projects/assets/js/actions/userActions.js b/rdmo/projects/assets/js/projects/actions/userActions.js
similarity index 100%
rename from rdmo/projects/assets/js/actions/userActions.js
rename to rdmo/projects/assets/js/projects/actions/userActions.js
diff --git a/rdmo/projects/assets/js/projects/api/AccountsApi.js b/rdmo/projects/assets/js/projects/api/AccountsApi.js
new file mode 100644
index 0000000000..94e42a94ad
--- /dev/null
+++ b/rdmo/projects/assets/js/projects/api/AccountsApi.js
@@ -0,0 +1,9 @@
+import BaseApi from 'rdmo/core/assets/js/api/BaseApi'
+
+class AccountsApi extends BaseApi {
+ static fetchCurrentUser() {
+ return this.get('/api/v1/accounts/users/current/')
+ }
+}
+
+export default AccountsApi
diff --git a/rdmo/projects/assets/js/api/ProjectsApi.js b/rdmo/projects/assets/js/projects/api/ProjectsApi.js
similarity index 98%
rename from rdmo/projects/assets/js/api/ProjectsApi.js
rename to rdmo/projects/assets/js/projects/api/ProjectsApi.js
index 5fc35590ad..d07cb6a54d 100644
--- a/rdmo/projects/assets/js/api/ProjectsApi.js
+++ b/rdmo/projects/assets/js/projects/api/ProjectsApi.js
@@ -1,7 +1,7 @@
import Cookies from 'js-cookie'
import BaseApi from 'rdmo/core/assets/js/api/BaseApi'
import { encodeParams } from 'rdmo/core/assets/js/utils/api'
-import baseUrl from 'rdmo/core/assets/js/utils/baseUrl'
+import { baseUrl } from 'rdmo/core/assets/js/utils/meta'
function BadRequestError(errors) {
this.errors = errors
diff --git a/rdmo/projects/assets/js/components/helper/PendingInvitations.js b/rdmo/projects/assets/js/projects/components/helper/PendingInvitations.js
similarity index 100%
rename from rdmo/projects/assets/js/components/helper/PendingInvitations.js
rename to rdmo/projects/assets/js/projects/components/helper/PendingInvitations.js
diff --git a/rdmo/projects/assets/js/components/helper/ProjectFilters.js b/rdmo/projects/assets/js/projects/components/helper/ProjectFilters.js
similarity index 100%
rename from rdmo/projects/assets/js/components/helper/ProjectFilters.js
rename to rdmo/projects/assets/js/projects/components/helper/ProjectFilters.js
diff --git a/rdmo/projects/assets/js/components/helper/ProjectImport.js b/rdmo/projects/assets/js/projects/components/helper/ProjectImport.js
similarity index 100%
rename from rdmo/projects/assets/js/components/helper/ProjectImport.js
rename to rdmo/projects/assets/js/projects/components/helper/ProjectImport.js
diff --git a/rdmo/projects/assets/js/components/helper/Table.js b/rdmo/projects/assets/js/projects/components/helper/Table.js
similarity index 100%
rename from rdmo/projects/assets/js/components/helper/Table.js
rename to rdmo/projects/assets/js/projects/components/helper/Table.js
diff --git a/rdmo/projects/assets/js/components/helper/index.js b/rdmo/projects/assets/js/projects/components/helper/index.js
similarity index 100%
rename from rdmo/projects/assets/js/components/helper/index.js
rename to rdmo/projects/assets/js/projects/components/helper/index.js
diff --git a/rdmo/projects/assets/js/components/main/Projects.js b/rdmo/projects/assets/js/projects/components/main/Projects.js
similarity index 92%
rename from rdmo/projects/assets/js/components/main/Projects.js
rename to rdmo/projects/assets/js/projects/components/main/Projects.js
index aac7337816..234d4b5c4d 100644
--- a/rdmo/projects/assets/js/components/main/Projects.js
+++ b/rdmo/projects/assets/js/projects/components/main/Projects.js
@@ -134,21 +134,27 @@ const Projects = ({ config, configActions, currentUserObject, projectsActions, p
const { isProjectManager, isProjectOwner } = getUserRoles(row, currentUserId, ['managers', 'owners'])
return (
- {(isProjectManager || isProjectOwner || isManager) &&
window.location.href = `${rowUrl}/update/${path}`}
+ href={`${rowUrl}/copy`}
+ className="fa fa-copy"
+ title={gettext('Copy project')}
+ onClick={() => window.location.href = `${rowUrl}/copy/${path}`}
/>
+ {(isProjectManager || isProjectOwner || isManager) &&
+ window.location.href = `${rowUrl}/update/${path}`}
+ />
}
{(isProjectOwner || isManager) &&
- window.location.href = `${rowUrl}/delete/${path}`}
- />
+ window.location.href = `${rowUrl}/delete/${path}`}
+ />
}
)
diff --git a/rdmo/projects/assets/js/containers/Main.js b/rdmo/projects/assets/js/projects/containers/Main.js
similarity index 100%
rename from rdmo/projects/assets/js/containers/Main.js
rename to rdmo/projects/assets/js/projects/containers/Main.js
diff --git a/rdmo/projects/assets/js/hooks/useDatePicker.js b/rdmo/projects/assets/js/projects/hooks/useDatePicker.js
similarity index 100%
rename from rdmo/projects/assets/js/hooks/useDatePicker.js
rename to rdmo/projects/assets/js/projects/hooks/useDatePicker.js
diff --git a/rdmo/projects/assets/js/reducers/configReducer.js b/rdmo/projects/assets/js/projects/reducers/configReducer.js
similarity index 100%
rename from rdmo/projects/assets/js/reducers/configReducer.js
rename to rdmo/projects/assets/js/projects/reducers/configReducer.js
diff --git a/rdmo/projects/assets/js/reducers/projectsReducer.js b/rdmo/projects/assets/js/projects/reducers/projectsReducer.js
similarity index 100%
rename from rdmo/projects/assets/js/reducers/projectsReducer.js
rename to rdmo/projects/assets/js/projects/reducers/projectsReducer.js
diff --git a/rdmo/projects/assets/js/reducers/rootReducer.js b/rdmo/projects/assets/js/projects/reducers/rootReducer.js
similarity index 100%
rename from rdmo/projects/assets/js/reducers/rootReducer.js
rename to rdmo/projects/assets/js/projects/reducers/rootReducer.js
diff --git a/rdmo/projects/assets/js/reducers/userReducer.js b/rdmo/projects/assets/js/projects/reducers/userReducer.js
similarity index 100%
rename from rdmo/projects/assets/js/reducers/userReducer.js
rename to rdmo/projects/assets/js/projects/reducers/userReducer.js
diff --git a/rdmo/projects/assets/js/store/configureStore.js b/rdmo/projects/assets/js/projects/store/configureStore.js
similarity index 100%
rename from rdmo/projects/assets/js/store/configureStore.js
rename to rdmo/projects/assets/js/projects/store/configureStore.js
diff --git a/rdmo/projects/assets/js/utils/constants.js b/rdmo/projects/assets/js/projects/utils/constants.js
similarity index 100%
rename from rdmo/projects/assets/js/utils/constants.js
rename to rdmo/projects/assets/js/projects/utils/constants.js
diff --git a/rdmo/projects/assets/js/utils/getProjectTitlePath.js b/rdmo/projects/assets/js/projects/utils/getProjectTitlePath.js
similarity index 100%
rename from rdmo/projects/assets/js/utils/getProjectTitlePath.js
rename to rdmo/projects/assets/js/projects/utils/getProjectTitlePath.js
diff --git a/rdmo/projects/assets/js/utils/getUserRoles.js b/rdmo/projects/assets/js/projects/utils/getUserRoles.js
similarity index 100%
rename from rdmo/projects/assets/js/utils/getUserRoles.js
rename to rdmo/projects/assets/js/projects/utils/getUserRoles.js
diff --git a/rdmo/projects/assets/js/utils/index.js b/rdmo/projects/assets/js/projects/utils/index.js
similarity index 100%
rename from rdmo/projects/assets/js/utils/index.js
rename to rdmo/projects/assets/js/projects/utils/index.js
diff --git a/rdmo/projects/assets/js/utils/translations.js b/rdmo/projects/assets/js/projects/utils/translations.js
similarity index 100%
rename from rdmo/projects/assets/js/utils/translations.js
rename to rdmo/projects/assets/js/projects/utils/translations.js
diff --git a/rdmo/projects/assets/js/utils/userIsManager.js b/rdmo/projects/assets/js/projects/utils/userIsManager.js
similarity index 82%
rename from rdmo/projects/assets/js/utils/userIsManager.js
rename to rdmo/projects/assets/js/projects/utils/userIsManager.js
index 435d866a33..9b4e77430d 100644
--- a/rdmo/projects/assets/js/utils/userIsManager.js
+++ b/rdmo/projects/assets/js/projects/utils/userIsManager.js
@@ -1,4 +1,4 @@
-import siteId from 'rdmo/core/assets/js/utils/siteId'
+import { siteId } from 'rdmo/core/assets/js/utils/meta'
const userIsManager = (currentUser) => {
if (currentUser.is_superuser ||
diff --git a/rdmo/projects/assets/scss/projects.scss b/rdmo/projects/assets/scss/projects.scss
index dbe1811707..f24a9fd12e 100644
--- a/rdmo/projects/assets/scss/projects.scss
+++ b/rdmo/projects/assets/scss/projects.scss
@@ -207,6 +207,7 @@ a.disabled {
display: flex;
gap: 5px;
margin-bottom: 10px;
+ justify-content: flex-end;
}
.projects {
diff --git a/rdmo/projects/forms.py b/rdmo/projects/forms.py
index 4594fca7fa..11adc0f1b9 100644
--- a/rdmo/projects/forms.py
+++ b/rdmo/projects/forms.py
@@ -13,6 +13,7 @@
from .constants import ROLE_CHOICES
from .models import Integration, IntegrationOption, Invite, Membership, Project, Snapshot
+from .validators import ProjectParentValidator
class CatalogChoiceField(forms.ModelChoiceField):
@@ -53,6 +54,8 @@ class ProjectForm(forms.ModelForm):
use_required_attribute = False
def __init__(self, *args, **kwargs):
+ self.copy = kwargs.pop('copy', False)
+
catalogs = kwargs.pop('catalogs')
projects = kwargs.pop('projects')
super().__init__(*args, **kwargs)
@@ -66,6 +69,11 @@ def __init__(self, *args, **kwargs):
if settings.NESTED_PROJECTS:
self.fields['parent'].queryset = projects
+ def clean(self):
+ if not self.copy:
+ ProjectParentValidator(self.instance)(self.cleaned_data)
+ super().clean()
+
class Meta:
model = Project
@@ -160,6 +168,10 @@ def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['parent'].queryset = projects
+ def clean(self):
+ ProjectParentValidator(self.instance)(self.cleaned_data)
+ super().clean()
+
class Meta:
model = Project
fields = ('parent', )
diff --git a/rdmo/projects/models/project.py b/rdmo/projects/models/project.py
index 6425f37f48..b992c0dc9d 100644
--- a/rdmo/projects/models/project.py
+++ b/rdmo/projects/models/project.py
@@ -1,6 +1,5 @@
from django.conf import settings
from django.contrib.sites.models import Site
-from django.core.exceptions import ValidationError
from django.db import models
from django.db.models.signals import pre_delete
from django.dispatch import receiver
@@ -88,11 +87,12 @@ def __str__(self):
def get_absolute_url(self):
return reverse('project', kwargs={'pk': self.pk})
- def clean(self):
+ def save(self, *args, **kwargs):
+ # ensure that the project hierarchy is not disturbed
if self.id and self.parent in self.get_descendants(include_self=True):
- raise ValidationError({
- 'parent': [_('A project may not be moved to be a child of itself or one of its descendants.')]
- })
+ raise RuntimeError('A project may not be moved to be a child of itself or one of its descendants.')
+
+ super().save(*args, **kwargs)
@property
def catalog_uri(self):
diff --git a/rdmo/projects/serializers/v1/__init__.py b/rdmo/projects/serializers/v1/__init__.py
index 8816dc9ca7..fe72127295 100644
--- a/rdmo/projects/serializers/v1/__init__.py
+++ b/rdmo/projects/serializers/v1/__init__.py
@@ -9,7 +9,7 @@
from rdmo.services.validators import ProviderValidator
from ...models import Integration, IntegrationOption, Invite, Issue, IssueResource, Membership, Project, Snapshot, Value
-from ...validators import ValueConflictValidator, ValueQuotaValidator, ValueTypeValidator
+from ...validators import ProjectParentValidator, ValueConflictValidator, ValueQuotaValidator, ValueTypeValidator
class UserSerializer(serializers.ModelSerializer):
@@ -78,6 +78,17 @@ class Meta:
read_only_fields = (
'snapshots',
)
+ validators = [
+ ProjectParentValidator()
+ ]
+
+
+class ProjectCopySerializer(ProjectSerializer):
+
+ class Meta:
+ model = Project
+ fields = ProjectSerializer.Meta.fields
+ read_only_fields = ProjectSerializer.Meta.read_only_fields
class ProjectMembershipSerializer(serializers.ModelSerializer):
diff --git a/rdmo/projects/templates/projects/project_detail_sidebar.html b/rdmo/projects/templates/projects/project_detail_sidebar.html
index 4c4be91ff7..c1886388a9 100644
--- a/rdmo/projects/templates/projects/project_detail_sidebar.html
+++ b/rdmo/projects/templates/projects/project_detail_sidebar.html
@@ -38,7 +38,6 @@ {% trans 'Options' %}
{% endif %}
-{% if can_change_project or can_delete_project %}
-{% endif %}
{% has_perm 'projects.add_membership_object' request.user project as can_add_membership %}
{% if can_add_membership %}
diff --git a/rdmo/projects/tests/test_utils.py b/rdmo/projects/tests/test_utils.py
index edbc5a8d3b..c2ccb1a49c 100644
--- a/rdmo/projects/tests/test_utils.py
+++ b/rdmo/projects/tests/test_utils.py
@@ -1,9 +1,12 @@
import pytest
+from django.contrib.auth.models import User
+from django.contrib.sites.models import Site
from django.http import QueryDict
from ..filters import ProjectFilter
-from ..utils import set_context_querystring_with_filter_and_page
+from ..models import Project
+from ..utils import copy_project, set_context_querystring_with_filter_and_page
GET_queries = [
'page=2&title=project',
@@ -32,3 +35,82 @@ def test_set_context_querystring_with_filter_and_page(GET_query):
assert context.get('querystring', 'not-in-context') == ''
else:
assert context.get('querystring', 'not-in-context') == 'not-in-context'
+
+
+def test_copy_project(db, files):
+ project = Project.objects.get(id=1)
+ site = Site.objects.get(id=2)
+ user = User.objects.get(id=1)
+ project_copy = copy_project(project, site, [user])
+
+ # re fetch the original project
+ project = Project.objects.get(id=1)
+
+ # check that site, owners, tasks, and views are correct
+ assert project_copy.site == site
+ assert list(project_copy.owners) == [user]
+ assert list(project_copy.user.values('id')) == [{'id': user.id}]
+ assert list(project_copy.tasks.values('id')) == list(project.tasks.values('id'))
+ assert list(project_copy.views.values('id')) == list(project.views.values('id'))
+
+ # check that no ids are the same
+ assert project_copy.id != project.id
+ assert not set(project_copy.snapshots.values_list('id')).intersection(set(project.snapshots.values_list('id')))
+ assert not set(project_copy.values.values_list('id')).intersection(set(project.values.values_list('id')))
+
+ # check the snapshots
+ snapshot_fields = (
+ 'title',
+ 'description'
+ )
+ for snapshot_copy, snapshot in zip(
+ project_copy.snapshots.values(*snapshot_fields),
+ project.snapshots.values(*snapshot_fields)
+ ):
+ assert snapshot_copy == snapshot
+
+ # check the values
+ value_fields = (
+ 'attribute',
+ 'set_prefix',
+ 'set_collection',
+ 'set_index',
+ 'collection_index',
+ 'text',
+ 'option',
+ 'value_type',
+ 'unit',
+ 'external_id'
+ )
+ for value_copy, value in zip(
+ project_copy.values.filter(snapshot=None),
+ project.values.filter(snapshot=None)
+ ):
+ for field in value_fields:
+ assert getattr(value_copy, field) == getattr(value, field)
+
+ if value_copy.file:
+ assert value_copy.file.path == value_copy.file.path.replace(
+ f'/projects/{project.id}/values/{value.id}/',
+ f'/projects/{project_copy.id}/values/{value_copy.id}/'
+ )
+ assert value_copy.file.size == value_copy.file.size
+ else:
+ assert not value.file
+
+ for snapshot_copy, snapshot in zip(project_copy.snapshots.all(), project.snapshots.all()):
+ for value_copy, value in zip(
+ project_copy.values.filter(snapshot=snapshot_copy),
+ project.values.filter(snapshot=snapshot)
+ ):
+ for field in value_fields:
+ assert getattr(value_copy, field) == getattr(value, field)
+
+ if value_copy.file:
+ assert value_copy.file.path == value_copy.file.path.replace(
+ f'/projects/{project.id}/snapshot/{snapshot.id}/values/{value.id}/',
+ f'/projects/{project_copy.id}/snapshot/{snapshot.id}/values/{value_copy.id}/'
+ )
+ assert value_copy.file.open('rb').read() == value_copy.file.open('rb').read()
+ else:
+ assert not value.file
diff --git a/rdmo/projects/tests/test_view_project.py b/rdmo/projects/tests/test_view_project.py
index b43660cf14..8a1465c765 100644
--- a/rdmo/projects/tests/test_view_project.py
+++ b/rdmo/projects/tests/test_view_project.py
@@ -58,7 +58,9 @@
export_formats = ('rtf', 'odt', 'docx', 'html', 'markdown', 'tex', 'pdf')
site_id = 1
-parent_project_id = 1
+project_id = 1
+parent_id = 3
+parent_ancestors = [2, 3]
catalog_id = 1
@@ -266,7 +268,7 @@ def test_project_create_parent_post(db, client, username, password):
'title': 'A new project',
'description': 'Some description',
'catalog': catalog_id,
- 'parent': parent_project_id
+ 'parent': project_id
}
response = client.post(url, data)
@@ -335,17 +337,21 @@ def test_project_update_post_parent(db, client, username, password, project_id):
'title': project.title,
'description': project.description,
'catalog': project.catalog.pk,
- 'parent': parent_project_id
+ 'parent': parent_id
}
response = client.post(url, data)
if project_id in change_project_permission_map.get(username, []):
- if project_id == parent_project_id:
+ if parent_id in view_project_permission_map.get(username, []):
+ if project_id in parent_ancestors:
+ assert response.status_code == 200
+ assert Project.objects.get(pk=project_id).parent == project.parent
+ else:
+ assert response.status_code == 302
+ assert Project.objects.get(pk=project_id).parent_id == parent_id
+ else:
assert response.status_code == 200
assert Project.objects.get(pk=project_id).parent == project.parent
- else:
- assert response.status_code == 302
- assert Project.objects.get(pk=project_id).parent_id == parent_project_id
else:
if password:
assert response.status_code == 403
@@ -545,17 +551,21 @@ def test_project_update_parent_post(db, client, username, password, project_id):
url = reverse('project_update_parent', args=[project_id])
data = {
- 'parent': parent_project_id
+ 'parent': parent_id
}
response = client.post(url, data)
if project_id in change_project_permission_map.get(username, []):
- if project_id == parent_project_id:
+ if parent_id in view_project_permission_map.get(username, []):
+ if project_id in parent_ancestors:
+ assert response.status_code == 200
+ assert Project.objects.get(pk=project_id).parent == project.parent
+ else:
+ assert response.status_code == 302
+ assert Project.objects.get(pk=project_id).parent_id == parent_id
+ else:
assert response.status_code == 200
assert Project.objects.get(pk=project_id).parent == project.parent
- else:
- assert response.status_code == 302
- assert Project.objects.get(pk=project_id).parent_id == parent_project_id
else:
if password:
assert response.status_code == 403
diff --git a/rdmo/projects/tests/test_view_project_copy.py b/rdmo/projects/tests/test_view_project_copy.py
new file mode 100644
index 0000000000..8ecec135da
--- /dev/null
+++ b/rdmo/projects/tests/test_view_project_copy.py
@@ -0,0 +1,207 @@
+import pytest
+
+from django.contrib.auth.models import Group, User
+from django.urls import reverse
+
+from ..models import Project, Snapshot, Value
+
+users = (
+ ('owner', 'owner'),
+ ('manager', 'manager'),
+ ('author', 'author'),
+ ('guest', 'guest'),
+ ('user', 'user'),
+ ('site', 'site'),
+ ('anonymous', None),
+ ('editor', 'editor'),
+ ('reviewer', 'reviewer'),
+ ('api', 'api'),
+)
+
+view_project_permission_map = {
+ 'owner': [1, 2, 3, 4, 5, 10],
+ 'manager': [1, 3, 5, 7],
+ 'author': [1, 3, 5, 8],
+ 'guest': [1, 3, 5, 9],
+ 'api': [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11],
+ 'site': [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]
+}
+
+change_project_permission_map = {
+ 'owner': [1, 2, 3, 4, 5, 10],
+ 'manager': [1, 3, 5, 7],
+ 'api': [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11],
+ 'site': [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]
+}
+
+delete_project_permission_map = {
+ 'owner': [1, 2, 3, 4, 5, 10],
+ 'api': [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11],
+ 'site': [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11],
+}
+
+export_project_permission_map = {
+ 'owner': [1, 2, 3, 4, 5, 10],
+ 'manager': [1, 3, 5, 7],
+ 'api': [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11],
+ 'site': [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11],
+}
+
+projects = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
+
+project_id = 1
+site_id = 1
+parent_id = 1
+catalog_id = 1
+
+
+@pytest.mark.parametrize('username,password', users)
+@pytest.mark.parametrize('project_id', projects)
+def test_project_copy_get(db, client, username, password, project_id):
+ client.login(username=username, password=password)
+
+ url = reverse('project_copy', args=[project_id])
+ response = client.get(url)
+
+ if project_id in view_project_permission_map.get(username, []):
+ assert response.status_code == 200
+ else:
+ if password:
+ assert response.status_code == 403
+ else:
+ assert response.status_code == 302
+
+
+def test_project_copy_restricted_get(db, client, settings):
+ settings.PROJECT_CREATE_RESTRICTED = True
+ settings.PROJECT_CREATE_GROUPS = ['projects']
+
+ group = Group.objects.create(name='projects')
+ guest = User.objects.get(username='guest')
+ guest.groups.add(group)
+
+ client.login(username='guest', password='guest')
+
+ url = reverse('project_copy', args=[project_id])
+ response = client.get(url)
+
+ assert response.status_code == 200
+
+
+def test_project_copy_forbidden_get(db, client, settings):
+ settings.PROJECT_CREATE_RESTRICTED = True
+
+ client.login(username='guest', password='guest')
+
+ url = reverse('project_copy', args=[project_id])
+ response = client.get(url)
+
+ assert response.status_code == 403
+
+
+@pytest.mark.parametrize('username,password', users)
+@pytest.mark.parametrize('project_id', projects)
+def test_project_copy_post(db, files, client, username, password, project_id):
+ client.login(username=username, password=password)
+
+ project_count = Project.objects.count()
+ snapshot_count = Snapshot.objects.count()
+ value_count = Value.objects.count()
+
+ project = Project.objects.get(id=project_id)
+ project_snapshots_count = project.snapshots.count()
+ project_values_count = project.values.count()
+
+ url = reverse('project_copy', args=[project_id])
+ data = {
+ 'title': 'A new project',
+ 'description': 'Some description',
+ 'catalog': catalog_id
+ }
+ response = client.post(url, data)
+
+ if project_id in view_project_permission_map.get(username, []):
+ assert response.status_code == 302
+ assert Project.objects.count() == project_count + 1
+ assert Snapshot.objects.count() == snapshot_count + project_snapshots_count
+ assert Value.objects.count() == value_count + project_values_count
+ else:
+ assert response.status_code == 403 if password else 302
+ assert Project.objects.count() == project_count
+ assert Value.objects.count() == value_count
+
+
+def test_project_copy_post_restricted(db, files, client, settings):
+ settings.PROJECT_CREATE_RESTRICTED = True
+ settings.PROJECT_CREATE_GROUPS = ['projects']
+
+ group = Group.objects.create(name='projects')
+ guest = User.objects.get(username='guest')
+ guest.groups.add(group)
+
+ client.login(username='guest', password='guest')
+
+ url = reverse('project_copy', args=[project_id])
+ data = {
+ 'title': 'A new project',
+ 'description': 'Some description',
+ 'catalog': catalog_id
+ }
+ response = client.post(url, data)
+
+ assert response.status_code == 302
+
+
+def test_project_copy_post_forbidden(db, files, client, settings):
+ settings.PROJECT_CREATE_RESTRICTED = True
+
+ client.login(username='guest', password='guest')
+
+ url = reverse('project_copy', args=[project_id])
+ data = {
+ 'title': 'A new project',
+ 'description': 'Some description',
+ 'catalog': catalog_id
+ }
+ response = client.post(url, data)
+
+ assert response.status_code == 403
+
+
+@pytest.mark.parametrize('username,password', users)
+@pytest.mark.parametrize('project_id', projects)
+def test_project_copy_parent_post(db, files, client, username, password, project_id):
+ client.login(username=username, password=password)
+ project_count = Project.objects.count()
+
+ project_count = Project.objects.count()
+ snapshot_count = Snapshot.objects.count()
+ value_count = Value.objects.count()
+
+ project = Project.objects.get(id=project_id)
+ project_snapshots_count = project.snapshots.count()
+ project_values_count = project.values.count()
+
+ url = reverse('project_copy', args=[project_id])
+ data = {
+ 'title': 'A new project',
+ 'description': 'Some description',
+ 'catalog': catalog_id,
+ 'parent': parent_id
+ }
+ response = client.post(url, data)
+
+ if project_id in view_project_permission_map.get(username, []):
+ if parent_id in view_project_permission_map.get(username, []):
+ assert response.status_code == 302
+ assert Project.objects.count() == project_count + 1
+ assert Snapshot.objects.count() == snapshot_count + project_snapshots_count
+ assert Value.objects.count() == value_count + project_values_count
+ else:
+ assert response.status_code == 200
+ assert Project.objects.count() == project_count
+ assert Value.objects.count() == value_count
+ else:
+ assert response.status_code == 403 if password else 302
+ assert Project.objects.count() == project_count
+ assert Value.objects.count() == value_count
diff --git a/rdmo/projects/tests/test_viewset_project.py b/rdmo/projects/tests/test_viewset_project.py
index 0069d25777..860860f691 100644
--- a/rdmo/projects/tests/test_viewset_project.py
+++ b/rdmo/projects/tests/test_viewset_project.py
@@ -3,7 +3,7 @@
from django.contrib.auth.models import Group, User
from django.urls import reverse
-from ..models import Project
+from ..models import Membership, Project, Snapshot, Value
users = (
('owner', 'owner'),
@@ -41,6 +41,7 @@
urlnames = {
'list': 'v1-projects:project-list',
'detail': 'v1-projects:project-detail',
+ 'copy': 'v1-projects:project-copy',
'overview': 'v1-projects:project-overview',
'navigation': 'v1-projects:project-navigation',
'options': 'v1-projects:project-options',
@@ -60,6 +61,8 @@
optionset_id = 4
project_id = 1
+parent_id = 3
+parent_ancestors = [2, 3]
page_size = 5
@@ -216,6 +219,133 @@ def test_create_parent(db, client, username, password, project_id):
assert response.status_code == 401
+@pytest.mark.parametrize('username,password', users)
+@pytest.mark.parametrize('project_id', projects)
+def test_copy(db, files, client, username, password, project_id):
+ client.login(username=username, password=password)
+
+ project_count = Project.objects.count()
+ snapshot_count = Snapshot.objects.count()
+ value_count = Value.objects.count()
+
+ project = Project.objects.get(id=project_id)
+ project_snapshots_count = project.snapshots.count()
+ project_values_count = project.values.count()
+
+ url = reverse(urlnames['copy'], args=[project_id])
+ data = {
+ 'title': 'New title',
+ 'description': project.description,
+ 'catalog': project.catalog.id
+ }
+ response = client.post(url, data, content_type='application/json')
+
+ if project_id in view_project_permission_map.get(username, []):
+ assert response.status_code == 201
+
+ for key, value in response.json().items():
+ if key in data:
+ assert value == data[key]
+
+ assert Project.objects.count() == project_count + 1
+ assert Snapshot.objects.count() == snapshot_count + project_snapshots_count
+ assert Value.objects.count() == value_count + project_values_count
+ else:
+ if password:
+ assert response.status_code == 404
+ else:
+ assert response.status_code == 401
+
+ assert Project.objects.count() == project_count
+ assert Value.objects.count() == value_count
+
+
+def test_copy_restricted(db, files, client, settings):
+ settings.PROJECT_CREATE_RESTRICTED = True
+ settings.PROJECT_CREATE_GROUPS = ['projects']
+
+ group = Group.objects.create(name='projects')
+ user = User.objects.get(username='user')
+ user.groups.add(group)
+
+ Membership.objects.create(user=user, project_id=project_id, role='guest')
+
+ client.login(username='user', password='user')
+
+ url = reverse(urlnames['copy'], args=[project_id])
+ data = {
+ 'title': 'Lorem ipsum dolor sit amet',
+ 'description': 'At vero eos et accusam et justo duo dolores et ea rebum.',
+ 'catalog': catalog_id
+ }
+ response = client.post(url, data, content_type='application/json')
+
+ assert response.status_code == 201
+
+
+def test_copy_forbidden(db, client, settings):
+ settings.PROJECT_CREATE_RESTRICTED = True
+
+ user = User.objects.get(username='user')
+
+ Membership.objects.create(user=user, project_id=project_id, role='guest')
+
+ client.login(username='user', password='user')
+
+ url = reverse(urlnames['copy'], args=[project_id])
+ data = {
+ 'title': 'Lorem ipsum dolor sit amet',
+ 'description': 'At vero eos et accusam et justo duo dolores et ea rebum.',
+ 'catalog': catalog_id
+ }
+ response = client.post(url, data)
+
+ assert response.status_code == 403
+
+
+def test_copy_catalog_missing(db, client):
+ client.login(username='guest', password='guest')
+
+ url = reverse(urlnames['copy'], args=[project_id])
+ data = {
+ 'title': 'Lorem ipsum dolor sit amet',
+ 'description': 'At vero eos et accusam et justo duo dolores et ea rebum.'
+ }
+ response = client.post(url, data)
+
+ assert response.status_code == 400
+
+
+def test_copy_catalog_not_available(db, client):
+ client.login(username='guest', password='guest')
+
+ url = reverse(urlnames['copy'], args=[project_id])
+ data = {
+ 'title': 'Lorem ipsum dolor sit amet',
+ 'description': 'At vero eos et accusam et justo duo dolores et ea rebum.',
+ 'catalog': catalog_id_not_available
+ }
+ response = client.post(url, data)
+
+ assert response.status_code == 400
+
+@pytest.mark.parametrize('project_id', projects)
+def test_copy_parent(db, client, project_id):
+ client.login(username='owner', password='owner')
+ project = Project.objects.get(pk=project_id)
+
+ url = reverse(urlnames['copy'], args=[project_id])
+ data = {
+ 'title': 'New title',
+ 'description': project.description,
+ 'catalog': project.catalog.id,
+ 'parent': parent_id
+ }
+ response = client.post(url, data, content_type='application/json')
+
+ assert response.status_code == 201
+
+
@pytest.mark.parametrize('username,password', users)
@pytest.mark.parametrize('project_id', projects)
def test_update(db, client, username, password, project_id):
@@ -246,6 +376,43 @@ def test_update(db, client, username, password, project_id):
assert Project.objects.get(id=project_id).description == project.description
+@pytest.mark.parametrize('username,password', users)
+@pytest.mark.parametrize('project_id', projects)
+def test_update_parent(db, client, username, password, project_id):
+ client.login(username=username, password=password)
+ project = Project.objects.get(pk=project_id)
+
+ url = reverse(urlnames['detail'], args=[project_id])
+ data = {
+ 'title': 'New title',
+ 'description': project.description,
+ 'catalog': project.catalog.id,
+ 'parent': parent_id
+ }
+ response = client.put(url, data, content_type='application/json')
+
+ if project_id in change_project_permission_map.get(username, []):
+ if parent_id in view_project_permission_map.get(username, []):
+ if project_id in parent_ancestors:
+ assert response.status_code == 400
+ assert Project.objects.get(pk=project_id).parent == project.parent
+ else:
+ assert response.status_code == 200
+ assert Project.objects.get(pk=project_id).parent_id == parent_id
+ else:
+ assert response.status_code == 404
+ assert Project.objects.get(pk=project_id).parent == project.parent
+ else:
+ if project_id in view_project_permission_map.get(username, []):
+ assert response.status_code == 403
+ elif password:
+ assert response.status_code == 404
+ else:
+ assert response.status_code == 401
+
+ assert Project.objects.get(pk=project_id).parent == project.parent
+
+
@pytest.mark.parametrize('username,password', users)
@pytest.mark.parametrize('project_id', projects)
def test_delete(db, client, username, password, project_id):
diff --git a/rdmo/projects/urls/__init__.py b/rdmo/projects/urls/__init__.py
index 47472eeeaf..35bf6ea8cd 100644
--- a/rdmo/projects/urls/__init__.py
+++ b/rdmo/projects/urls/__init__.py
@@ -15,6 +15,7 @@
ProjectAnswersExportView,
ProjectAnswersView,
ProjectCancelView,
+ ProjectCopyView,
ProjectCreateImportView,
ProjectCreateView,
ProjectDeleteView,
@@ -55,6 +56,8 @@
re_path(r'^(?P[0-9]+)/$',
ProjectDetailView.as_view(), name='project'),
+ re_path(r'^(?P[0-9]+)/copy/$',
+ ProjectCopyView.as_view(), name='project_copy'),
re_path(r'^(?P[0-9]+)/update/$',
ProjectUpdateView.as_view(), name='project_update'),
re_path(r'^(?P[0-9]+)/update/information/$',
diff --git a/rdmo/projects/utils.py b/rdmo/projects/utils.py
index c23b0137fa..b0edaf068a 100644
--- a/rdmo/projects/utils.py
+++ b/rdmo/projects/utils.py
@@ -5,6 +5,7 @@
from django.contrib.sites.models import Site
from django.template.loader import render_to_string
from django.urls import reverse
+from django.utils.timezone import now
from rdmo.core.mail import send_mail
from rdmo.core.plugins import get_plugins
@@ -38,6 +39,86 @@ def check_conditions(conditions, values, set_prefix=None, set_index=None):
return True
+def copy_project(project, site, owners):
+ from .models import Membership, Value # to prevent circular inclusion
+
+ timestamp = now()
+
+ tasks = project.tasks.all()
+ views = project.views.all()
+
+ values = project.values.filter(snapshot=None)
+ snapshots = {
+ snapshot: project.values.filter(snapshot=snapshot)
+ for snapshot in project.snapshots.all()
+ }
+
+ # create a temporary buffer for all values with files
+ file_values = []
+
+ # unset the id, set current site and update timestamps
+ project.id = None
+ project.site = site
+ project.created = timestamp
+
+ # save the new project
+ project.save()
+
+ # save project tasks
+ for task in tasks:
+ project.tasks.add(task)
+
+ # save project views
+ for view in views:
+ project.views.add(view)
+
+ # save current project values
+ project_values = []
+ for value in values:
+ value.id = None
+ value.project = project
+ value.created = timestamp
+
+ if value.file:
+ file_values.append((value, value.file_name, value.file))
+
+ project_values.append(value)
+
+ # insert the new values using bulk_create
+ Value.objects.bulk_create(project_values)
+
+ # save project snapshots
+ for snapshot, snapshot_values in snapshots.items():
+ snapshot.id = None
+ snapshot.project = project
+ snapshot.created = timestamp
+ snapshot.save(copy_values=False)
+
+ project_snapshot_values = []
+ for value in snapshot_values:
+ value.id = None
+ value.project = project
+ value.snapshot = snapshot
+ value.created = timestamp
+
+ if value.file:
+ file_values.append((value, value.file_name, value.file))
+
+ project_snapshot_values.append(value)
+
+ # insert the new snapshot values using bulk_create
+ Value.objects.bulk_create(project_snapshot_values)
+
+ for value, file_name, file_content in file_values:
+ value.copy_file(file_name, file_content)
+
+ for owner in owners:
+ membership = Membership(project=project, user=owner, role='owner')
+ membership.save()
+
+ return project
+
+
def save_import_values(project, values, checked):
for value in values:
if value.attribute:
diff --git a/rdmo/projects/validators.py b/rdmo/projects/validators.py
index da0ace2423..e3bd382923 100644
--- a/rdmo/projects/validators.py
+++ b/rdmo/projects/validators.py
@@ -20,6 +20,19 @@
VALUE_TYPE_URL,
)
from rdmo.core.utils import human2bytes
+from rdmo.core.validators import InstanceValidator
+
+
+class ProjectParentValidator(InstanceValidator):
+
+ def __call__(self, data, serializer=None):
+ super().__call__(data, serializer)
+
+ if self.instance and self.instance.id \
+ and data.get('parent') in self.instance.get_descendants(include_self=True):
+ raise self.raise_validation_error({
+ 'parent': [_('A project may not be moved to be a child of itself or one of its descendants.')]
+ })
class ValueConflictValidator:
diff --git a/rdmo/projects/views/__init__.py b/rdmo/projects/views/__init__.py
index f42a45b5ab..b0de9620e7 100644
--- a/rdmo/projects/views/__init__.py
+++ b/rdmo/projects/views/__init__.py
@@ -14,6 +14,7 @@
ProjectsView,
)
from .project_answers import ProjectAnswersExportView, ProjectAnswersView
+from .project_copy import ProjectCopyView
from .project_create import ProjectCreateImportView, ProjectCreateView
from .project_update import (
ProjectUpdateCatalogView,
diff --git a/rdmo/projects/views/project_copy.py b/rdmo/projects/views/project_copy.py
new file mode 100644
index 0000000000..6d9df32c3e
--- /dev/null
+++ b/rdmo/projects/views/project_copy.py
@@ -0,0 +1,42 @@
+import logging
+
+from django.contrib.sites.shortcuts import get_current_site
+from django.http import HttpResponseRedirect
+from django.views.generic import UpdateView
+
+from rdmo.core.views import ObjectPermissionMixin, RedirectViewMixin
+from rdmo.questions.models import Catalog
+
+from ..forms import ProjectForm
+from ..models import Project
+from ..utils import copy_project
+
+logger = logging.getLogger(__name__)
+
+
+class ProjectCopyView(ObjectPermissionMixin, RedirectViewMixin, UpdateView):
+
+ model = Project
+ form_class = ProjectForm
+ permission_required = ('projects.add_project', 'projects.view_project_object')
+
+ def get_form_kwargs(self):
+ catalogs = Catalog.objects.filter_current_site() \
+ .filter_group(self.request.user) \
+ .filter_availability(self.request.user) \
+ .order_by('-available', 'order')
+ projects = Project.objects.filter_user(self.request.user)
+
+ form_kwargs = super().get_form_kwargs()
+ form_kwargs.update({
+ 'copy': True,
+ 'catalogs': catalogs,
+ 'projects': projects
+ })
+ return form_kwargs
+
+ def form_valid(self, form):
+ site = get_current_site(self.request)
+ owners = [self.request.user]
+ project = copy_project(form.instance, site, owners)
+ return HttpResponseRedirect(project.get_absolute_url())
diff --git a/rdmo/projects/viewsets.py b/rdmo/projects/viewsets.py
index 3ca669e769..6bc75b1c78 100644
--- a/rdmo/projects/viewsets.py
+++ b/rdmo/projects/viewsets.py
@@ -6,7 +6,7 @@
from django.http import Http404, HttpResponseRedirect
from django.utils.translation import gettext_lazy as _
-from rest_framework import serializers
+from rest_framework import serializers, status
from rest_framework.decorators import action
from rest_framework.exceptions import NotFound
from rest_framework.mixins import CreateModelMixin, ListModelMixin, RetrieveModelMixin, UpdateModelMixin
@@ -48,6 +48,7 @@
InviteSerializer,
IssueSerializer,
MembershipSerializer,
+ ProjectCopySerializer,
ProjectIntegrationSerializer,
ProjectInviteSerializer,
ProjectInviteUpdateSerializer,
@@ -63,7 +64,7 @@
)
from .serializers.v1.overview import CatalogSerializer, ProjectOverviewSerializer
from .serializers.v1.page import PageSerializer
-from .utils import check_conditions, get_upload_accept, send_invite_email
+from .utils import check_conditions, copy_project, get_upload_accept, send_invite_email
class ProjectPagination(PageNumberPagination):
@@ -116,6 +117,25 @@ def get_queryset(self):
return queryset
+ @action(detail=True, methods=['POST'],
+ permission_classes=(HasModelPermission | HasProjectPermission, ))
+ def copy(self, request, pk=None):
+ instance = self.get_object()
+ serializer = ProjectCopySerializer(instance, data=request.data, context=self.get_serializer_context())
+ serializer.is_valid(raise_exception=True)
+
+ # update instance
+ for key, value in serializer.validated_data.items():
+ setattr(instance, key, value)
+
+ site = get_current_site(self.request)
+ owners = [self.request.user]
+ project_copy = copy_project(instance, site, owners)
+
+ serializer = self.get_serializer(project_copy)
+ headers = self.get_success_headers(serializer.data)
+ return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
+
@action(detail=True, permission_classes=(HasModelPermission | HasProjectPermission, ))
def overview(self, request, pk=None):
project = self.get_object()
diff --git a/webpack.config.js b/webpack.config.js
index cc71e803bb..d1d630dd32 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -6,6 +6,18 @@ const TerserPlugin = require('terser-webpack-plugin')
// list of separate config objects for each django app and their corresponding java script applications
const configList = [
+ {
+ name: 'core',
+ entry: {
+ base: [
+ './rdmo/core/assets/js/base.js',
+ './rdmo/core/assets/scss/base.scss'
+ ]
+ },
+ output: {
+ path: path.resolve(__dirname, './rdmo/core/static/core/'),
+ }
+ },
{
name: 'management',
entry: {
@@ -15,7 +27,6 @@ const configList = [
]
},
output: {
- filename: 'js/management.js',
path: path.resolve(__dirname, './rdmo/management/static/management/'),
}
},
@@ -28,7 +39,6 @@ const configList = [
]
},
output: {
- filename: 'js/projects.js',
path: path.resolve(__dirname, './rdmo/projects/static/projects/'),
}
}
@@ -42,6 +52,9 @@ const baseConfig = {
},
extensions: ['*', '.js', '.jsx']
},
+ output: {
+ filename: 'js/[name].js'
+ },
plugins: [
new MiniCssExtractPlugin({
filename: 'css/[name].css',