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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + RDMO + + 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',