From b12bf15c43bfc26a7d6206aee9a6867959f666f1 Mon Sep 17 00:00:00 2001 From: Jochen Klar Date: Sun, 25 Aug 2024 15:52:59 +0200 Subject: [PATCH 001/165] Prepare new project page --- rdmo/projects/assets/js/project.js | 22 ++++++ .../assets/js/project/actions/actionTypes.js | 3 + .../js/project/actions/projectActions.js | 40 ++++++++++ .../assets/js/project/api/ProjectApi.js | 9 +++ .../assets/js/project/containers/Main.js | 45 ++++++++++++ .../js/project/reducers/projectReducer.js | 22 ++++++ .../assets/js/project/store/configureStore.js | 73 +++++++++++++++++++ rdmo/projects/assets/js/project/utils/meta.js | 3 + rdmo/projects/assets/scss/project.scss | 0 .../projects/old/project_detail.html | 51 +++++++++++++ .../{ => old}/project_detail_header.html | 6 +- .../project_detail_header_catalog.html | 0 .../project_detail_header_description.html | 0 .../project_detail_header_hierarchy.html | 0 .../project_detail_integrations.html | 2 +- .../project_detail_integrations_help.html | 0 .../{ => old}/project_detail_invites.html | 0 .../{ => old}/project_detail_issues.html | 2 +- .../{ => old}/project_detail_issues_help.html | 0 .../{ => old}/project_detail_memberships.html | 4 +- .../project_detail_memberships_help.html | 0 ...ect_detail_memberships_socialaccounts.html | 0 .../{ => old}/project_detail_sidebar.html | 0 .../project_detail_sidebar_parent_import.html | 0 .../{ => old}/project_detail_snapshots.html | 2 +- .../project_detail_snapshots_help.html | 0 .../{ => old}/project_detail_views.html | 2 +- .../{ => old}/project_detail_views_help.html | 0 .../templates/projects/project_detail.html | 52 ++++--------- rdmo/projects/urls/__init__.py | 3 + rdmo/projects/views/__init__.py | 1 + rdmo/projects/views/project.py | 6 ++ webpack.config.js | 4 + 33 files changed, 305 insertions(+), 47 deletions(-) create mode 100644 rdmo/projects/assets/js/project.js create mode 100644 rdmo/projects/assets/js/project/actions/actionTypes.js create mode 100644 rdmo/projects/assets/js/project/actions/projectActions.js create mode 100644 rdmo/projects/assets/js/project/api/ProjectApi.js create mode 100644 rdmo/projects/assets/js/project/containers/Main.js create mode 100644 rdmo/projects/assets/js/project/reducers/projectReducer.js create mode 100644 rdmo/projects/assets/js/project/store/configureStore.js create mode 100644 rdmo/projects/assets/js/project/utils/meta.js create mode 100644 rdmo/projects/assets/scss/project.scss create mode 100644 rdmo/projects/templates/projects/old/project_detail.html rename rdmo/projects/templates/projects/{ => old}/project_detail_header.html (85%) rename rdmo/projects/templates/projects/{ => old}/project_detail_header_catalog.html (100%) rename rdmo/projects/templates/projects/{ => old}/project_detail_header_description.html (100%) rename rdmo/projects/templates/projects/{ => old}/project_detail_header_hierarchy.html (100%) rename rdmo/projects/templates/projects/{ => old}/project_detail_integrations.html (97%) rename rdmo/projects/templates/projects/{ => old}/project_detail_integrations_help.html (100%) rename rdmo/projects/templates/projects/{ => old}/project_detail_invites.html (100%) rename rdmo/projects/templates/projects/{ => old}/project_detail_issues.html (98%) rename rdmo/projects/templates/projects/{ => old}/project_detail_issues_help.html (100%) rename rdmo/projects/templates/projects/{ => old}/project_detail_memberships.html (94%) rename rdmo/projects/templates/projects/{ => old}/project_detail_memberships_help.html (100%) rename rdmo/projects/templates/projects/{ => old}/project_detail_memberships_socialaccounts.html (100%) rename rdmo/projects/templates/projects/{ => old}/project_detail_sidebar.html (100%) rename rdmo/projects/templates/projects/{ => old}/project_detail_sidebar_parent_import.html (100%) rename rdmo/projects/templates/projects/{ => old}/project_detail_snapshots.html (98%) rename rdmo/projects/templates/projects/{ => old}/project_detail_snapshots_help.html (100%) rename rdmo/projects/templates/projects/{ => old}/project_detail_views.html (97%) rename rdmo/projects/templates/projects/{ => old}/project_detail_views_help.html (100%) diff --git a/rdmo/projects/assets/js/project.js b/rdmo/projects/assets/js/project.js new file mode 100644 index 0000000000..c4085a2921 --- /dev/null +++ b/rdmo/projects/assets/js/project.js @@ -0,0 +1,22 @@ +import React from 'react' +import { createRoot } from 'react-dom/client' +import { Provider } from 'react-redux' + +import configureStore from './project/store/configureStore' + +import { DndProvider } from 'react-dnd' +import { HTML5Backend } from 'react-dnd-html5-backend' + +import Main from './project/containers/Main' + +const store = configureStore() + +console.log(document.getElementById('main')) + +createRoot(document.getElementById('main')).render( + + +
+ + +) diff --git a/rdmo/projects/assets/js/project/actions/actionTypes.js b/rdmo/projects/assets/js/project/actions/actionTypes.js new file mode 100644 index 0000000000..26e07aa26e --- /dev/null +++ b/rdmo/projects/assets/js/project/actions/actionTypes.js @@ -0,0 +1,3 @@ +export const FETCH_PROJECT_INIT = 'FETCH_PROJECT_INIT' +export const FETCH_PROJECT_SUCCESS = 'FETCH_PROJECT_SUCCESS' +export const FETCH_PROJECT_ERROR = 'FETCH_PROJECT_ERROR' diff --git a/rdmo/projects/assets/js/project/actions/projectActions.js b/rdmo/projects/assets/js/project/actions/projectActions.js new file mode 100644 index 0000000000..e28110dd17 --- /dev/null +++ b/rdmo/projects/assets/js/project/actions/projectActions.js @@ -0,0 +1,40 @@ +import ProjectsApi from '../api/ProjectApi' + +import { projectId } from '../utils/meta' + +import { + FETCH_PROJECT_INIT, + FETCH_PROJECT_SUCCESS, + FETCH_PROJECT_ERROR +} from './actionTypes' + +import { addToPending, removeFromPending } from 'rdmo/core/assets/js/actions/pendingActions' + +export function fetchProject() { + return (dispatch) => { + dispatch(addToPending('fetchProject')) + dispatch(fetchProjectInit()) + + return ProjectsApi.fetchProject(projectId) + .then((overview) => { + dispatch(removeFromPending('fetchOverview')) + dispatch(fetchProjectSuccess(overview)) + }) + .catch((error) => { + dispatch(removeFromPending('fetchOverview')) + dispatch(fetchProjectError(error)) + }) + } +} + +export function fetchProjectInit() { + return {type: FETCH_PROJECT_INIT} +} + +export function fetchProjectSuccess(project) { + return {type: FETCH_PROJECT_SUCCESS, project} +} + +export function fetchProjectError(error) { + return {type: FETCH_PROJECT_ERROR, error} +} diff --git a/rdmo/projects/assets/js/project/api/ProjectApi.js b/rdmo/projects/assets/js/project/api/ProjectApi.js new file mode 100644 index 0000000000..023e8b2891 --- /dev/null +++ b/rdmo/projects/assets/js/project/api/ProjectApi.js @@ -0,0 +1,9 @@ +import BaseApi from 'rdmo/core/assets/js/api/BaseApi' + +export default class ProjectsApi extends BaseApi { + + static fetchProject(projectId) { + return this.get(`/api/v1/projects/projects/${projectId}/overview/`) + } + +} diff --git a/rdmo/projects/assets/js/project/containers/Main.js b/rdmo/projects/assets/js/project/containers/Main.js new file mode 100644 index 0000000000..1e6d319305 --- /dev/null +++ b/rdmo/projects/assets/js/project/containers/Main.js @@ -0,0 +1,45 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { bindActionCreators } from 'redux' +import { connect } from 'react-redux' + +import * as configActions from 'rdmo/core/assets/js/actions/configActions' +import * as projectActions from '../actions/projectActions' + +const Main = ({ config, settings, templates, user, project, configActions, projectActions }) => { + console.log(config, settings, templates, user, project) + console.log(configActions, projectActions) + + return project && ( + 👍 + ) +} + +Main.propTypes = { + config: PropTypes.object.isRequired, + settings: PropTypes.object.isRequired, + templates: PropTypes.object.isRequired, + user: PropTypes.object.isRequired, + project: PropTypes.object.isRequired, + configActions: PropTypes.object.isRequired, + projectActions: PropTypes.object.isRequired +} + +function mapStateToProps(state) { + return { + config: state.config, + settings: state.settings, + templates: state.templates, + user: state.user, + project: state.project + } +} + +function mapDispatchToProps(dispatch) { + return { + configActions: bindActionCreators(configActions, dispatch), + projectActions: bindActionCreators(projectActions, dispatch) + } +} + +export default connect(mapStateToProps, mapDispatchToProps)(Main) diff --git a/rdmo/projects/assets/js/project/reducers/projectReducer.js b/rdmo/projects/assets/js/project/reducers/projectReducer.js new file mode 100644 index 0000000000..57ea60013b --- /dev/null +++ b/rdmo/projects/assets/js/project/reducers/projectReducer.js @@ -0,0 +1,22 @@ +import { + FETCH_PROJECT_INIT, + FETCH_PROJECT_SUCCESS, + FETCH_PROJECT_ERROR +} from '../actions/actionTypes' + +const initialState = { + project: null +} + +export default function interviewReducer(state = initialState, action) { + switch(action.type) { + case FETCH_PROJECT_SUCCESS: + return { ...state, project: action.project } + case FETCH_PROJECT_INIT: + return { ...state, errors: [] } + case FETCH_PROJECT_ERROR: + return { ...state, errors: [...state.errors, { actionType: action.type, ...action.error }] } + default: + return state + } +} diff --git a/rdmo/projects/assets/js/project/store/configureStore.js b/rdmo/projects/assets/js/project/store/configureStore.js new file mode 100644 index 0000000000..5e03595bae --- /dev/null +++ b/rdmo/projects/assets/js/project/store/configureStore.js @@ -0,0 +1,73 @@ +import { applyMiddleware, createStore, combineReducers } from 'redux' +import thunk from 'redux-thunk' + +import { checkStoreId } from 'rdmo/core/assets/js/utils/store' +import { getConfigFromLocalStorage } from 'rdmo/core/assets/js/utils/config' + +import configReducer from 'rdmo/core/assets/js/reducers/configReducer' +import pendingReducer from 'rdmo/core/assets/js/reducers/pendingReducer' +import settingsReducer from 'rdmo/core/assets/js/reducers/settingsReducer' +import templateReducer from 'rdmo/core/assets/js/reducers/templateReducer' +import userReducer from 'rdmo/core/assets/js/reducers/userReducer' + +import projectReducer from '../reducers/projectReducer' + +import * as configActions from 'rdmo/core/assets/js/actions/configActions' +import * as settingsActions from 'rdmo/core/assets/js/actions/settingsActions' +import * as templateActions from 'rdmo/core/assets/js/actions/templateActions' +import * as userActions from 'rdmo/core/assets/js/actions/userActions' + +import * as projectActions from '../actions/projectActions' + + +export default function configureStore() { + // empty localStorage in new session + checkStoreId() + + const middlewares = [thunk] + + if (process.env.NODE_ENV === 'development') { + const { logger } = require('redux-logger') + middlewares.push(logger) + } + + const rootReducer = combineReducers({ + config: configReducer, + pending: pendingReducer, + project: projectReducer, + settings: settingsReducer, + templates: templateReducer, + user: userReducer, + }) + + const initialState = { + config: { + prefix: 'rdmo.project' + } + } + + const store = createStore( + rootReducer, + initialState, + applyMiddleware(...middlewares) + ) + + // this event is triggered when the page first loads + window.addEventListener('load', () => { + getConfigFromLocalStorage('rdmo.interview').forEach(([path, value]) => { + store.dispatch(configActions.updateConfig(path, value)) + }) + + store.dispatch(settingsActions.fetchSettings()) + store.dispatch(templateActions.fetchTemplates()) + store.dispatch(userActions.fetchCurrentUser()) + store.dispatch(projectActions.fetchProject()) + }) + + // this event is triggered when when the forward/back buttons are used + window.addEventListener('popstate', () => { + + }) + + return store +} diff --git a/rdmo/projects/assets/js/project/utils/meta.js b/rdmo/projects/assets/js/project/utils/meta.js new file mode 100644 index 0000000000..486f1842d5 --- /dev/null +++ b/rdmo/projects/assets/js/project/utils/meta.js @@ -0,0 +1,3 @@ +// take the baseurl from the of the django template +import { toNumber } from 'lodash' +export const projectId = toNumber(document.querySelector('meta[name="project"]').content.replace(/\/+$/, '')) diff --git a/rdmo/projects/assets/scss/project.scss b/rdmo/projects/assets/scss/project.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/rdmo/projects/templates/projects/old/project_detail.html b/rdmo/projects/templates/projects/old/project_detail.html new file mode 100644 index 0000000000..759291d5b0 --- /dev/null +++ b/rdmo/projects/templates/projects/old/project_detail.html @@ -0,0 +1,51 @@ +{% extends 'core/page.html' %} +{% load i18n %} +{% load static %} +{% load compress %} +{% load core_tags %} + +{% block head %} + {% compress css %} + + + {% endcompress %} + {% compress js %} + + {% endcompress %} + +{% endblock %} + +{% block sidebar %} + + {% include 'projects/old/project_detail_sidebar.html' %} + +{% endblock %} + +{% block page %} + + {% include 'projects/old/project_detail_header.html' %} + {% include 'projects/old/project_detail_issues.html' %} + {% include 'projects/old/project_detail_views.html' %} + {% include 'projects/old/project_detail_memberships.html' %} + {% include 'projects/old/project_detail_invites.html' %} + {% include 'projects/old/project_detail_snapshots.html' %} + {% include 'projects/old/project_detail_integrations.html' %} + +
+ + {% render_lang_template 'projects/overlays/project_project_questions' %} + {% render_lang_template 'projects/overlays/project_project_catalog' %} + {% render_lang_template 'projects/overlays/project_project_issues' %} + {% render_lang_template 'projects/overlays/project_project_views' %} + {% render_lang_template 'projects/overlays/project_project_memberships' %} + {% render_lang_template 'projects/overlays/project_project_snapshots' %} + {% render_lang_template 'projects/overlays/project_export_project' %} + {% render_lang_template 'projects/overlays/project_import_project' %} + {% render_lang_template 'projects/overlays/project_support_info' %} + +{% endblock %} diff --git a/rdmo/projects/templates/projects/project_detail_header.html b/rdmo/projects/templates/projects/old/project_detail_header.html similarity index 85% rename from rdmo/projects/templates/projects/project_detail_header.html rename to rdmo/projects/templates/projects/old/project_detail_header.html index c6ca098c90..ca4c10e779 100644 --- a/rdmo/projects/templates/projects/project_detail_header.html +++ b/rdmo/projects/templates/projects/old/project_detail_header.html @@ -16,7 +16,7 @@

{{ project.title }}

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

{{ project.title }}

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

{{ project.title }}

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

{% trans 'Integrations' %}

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

{% trans 'Tasks' %}

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

{% trans 'Members' %}

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

{% trans 'Members' %}

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

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

+

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

- {% if snapshots %} - -

{% trans 'Snapshots' %}

- - - {% endif %} - - -

{% trans 'Options' %}

- - -

{% trans 'Export' %}

- - - {% if attachments %} - -

{% trans 'Attachments' %}

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

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

-

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

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

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

-

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

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

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

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

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

+ + {% include 'projects/project_answers_tree.html' %} + +{% endblock %} diff --git a/rdmo/projects/templates/projects/project_view_export.html b/rdmo/projects/templates/projects/project_view_export.html index bb6165748d..6a4361c64e 100644 --- a/rdmo/projects/templates/projects/project_view_export.html +++ b/rdmo/projects/templates/projects/project_view_export.html @@ -1 +1,8 @@ +{% extends 'core/export.html' %} +{% load i18n %} + +{% block body %} + {{ html }} + +{% endblock %} From b4959e410e3ebcb58fe9a842758596e2a826e0e6 Mon Sep 17 00:00:00 2001 From: Jochen Klar Date: Fri, 24 Oct 2025 10:56:23 +0200 Subject: [PATCH 129/165] Add ProjectViewSerializer and ProjectViewSerializer --- rdmo/projects/serializers/v1/__init__.py | 21 +++++++++++++++++++++ rdmo/projects/viewsets.py | 24 ++++++++++++++++-------- 2 files changed, 37 insertions(+), 8 deletions(-) diff --git a/rdmo/projects/serializers/v1/__init__.py b/rdmo/projects/serializers/v1/__init__.py index eaab255bed..f508f0fb81 100644 --- a/rdmo/projects/serializers/v1/__init__.py +++ b/rdmo/projects/serializers/v1/__init__.py @@ -564,6 +564,27 @@ class Meta: ) +class ProjectAttachmentSerializer(serializers.ModelSerializer): + + class Meta: + model = Value + fields = ( + 'id', + 'created', + 'updated', + 'file_name', + 'file_url' + ) + + +class ProjectViewSerializer(serializers.Serializer): + + project = serializers.PrimaryKeyRelatedField(read_only=True) + snapshot = serializers.PrimaryKeyRelatedField(read_only=True) + html = serializers.CharField(read_only=True) + attachments = ProjectAttachmentSerializer(many=True, read_only=True) + + class MembershipSerializer(serializers.ModelSerializer): class Meta: diff --git a/rdmo/projects/viewsets.py b/rdmo/projects/viewsets.py index 53f38521c7..d1190df4a3 100644 --- a/rdmo/projects/viewsets.py +++ b/rdmo/projects/viewsets.py @@ -22,6 +22,7 @@ from rest_framework_extensions.mixins import NestedViewSetMixin from rdmo.conditions.models import Condition +from rdmo.core.constants import VALUE_TYPE_FILE from rdmo.core.permissions import HasModelPermission from rdmo.core.utils import human2bytes, is_truthy, render_to_format, return_file_response from rdmo.options.models import OptionSet @@ -78,6 +79,7 @@ ProjectSerializer, ProjectSnapshotSerializer, ProjectValueSerializer, + ProjectViewSerializer, ProjectVisibilitySerializer, SnapshotSerializer, UserInviteSerializer, @@ -429,16 +431,19 @@ def answers(self, request, pk, snapshot_id=None): except Snapshot.DoesNotExist: snapshot = None - return Response({ - 'project': pk, - 'snapshot': snapshot_id, + serializer = ProjectViewSerializer({ + 'project': project, + 'snapshot': snapshot, 'html': render_to_string('projects/project_answers.html', { 'project': project, 'snapshot': snapshot, 'project_wrapper': ProjectWrapper(project, snapshot), 'export_formats': settings.EXPORT_FORMATS - }) + }), + 'attachments': project.values.filter(snapshot=snapshot).filter(value_type=VALUE_TYPE_FILE).order_by('file') }) + return Response(serializer.data) + @action(detail=True, methods=['get'], permission_classes=(HasModelPermission | HasProjectPermission, ), url_path=r'(snapshots/(?P\d+)/)?answers/export/(?P[a-z]+)') @@ -473,11 +478,14 @@ def views(self, request, pk, view_id, snapshot_id=None): except Snapshot.DoesNotExist: snapshot = None - return Response({ - 'project': pk, - 'snapshot': snapshot_id, - 'html': view.render(project, snapshot) + serializer = ProjectViewSerializer({ + 'project': project, + 'snapshot': snapshot, + 'html': view.render(project, snapshot), + 'attachments': project.values.filter(snapshot=snapshot).filter(value_type=VALUE_TYPE_FILE).order_by('file') }) + return Response(serializer.data) + @action(detail=True, methods=['get'], permission_classes=(HasModelPermission | HasProjectPermission, ), url_path=r'(snapshots/(?P\d+)/)?views/(?P\d+)/export/(?P[a-z]+)') From b21207f9b4c9da73da718b9accd49e652b5b13bd Mon Sep 17 00:00:00 2001 From: Jochen Klar Date: Fri, 24 Oct 2025 11:44:00 +0200 Subject: [PATCH 130/165] Use extra methods for snapshot answers and views --- rdmo/projects/viewsets.py | 33 ++++++++++++++++++++++++++++----- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/rdmo/projects/viewsets.py b/rdmo/projects/viewsets.py index d1190df4a3..b313bc2357 100644 --- a/rdmo/projects/viewsets.py +++ b/rdmo/projects/viewsets.py @@ -420,8 +420,7 @@ def hierarchy(self, request, pk): serializer = ProjectHierarchySerializer(cached_trees[0], context=serializer_context) return Response(serializer.data) - @action(detail=True, methods=['get'], permission_classes=(HasModelPermission | HasProjectPermission, ), - url_path=r'(snapshots/(?P\d+)/)?answers') + @action(detail=True, methods=['get'], permission_classes=(HasModelPermission | HasProjectPermission, )) def answers(self, request, pk, snapshot_id=None): project = self.get_object() project.catalog.prefetch_elements() @@ -444,9 +443,14 @@ def answers(self, request, pk, snapshot_id=None): }) return Response(serializer.data) + @action(detail=True, methods=['get'], permission_classes=(HasModelPermission | HasProjectPermission, ), + url_path=r'snapshots/(?P\d+)/answers') + def answers_snapshot(self, request, pk, snapshot_id=None): + # extra method since DRF does not officially support optional named parameters inside url_path + return self.answers(request, pk, snapshot_id) @action(detail=True, methods=['get'], permission_classes=(HasModelPermission | HasProjectPermission, ), - url_path=r'(snapshots/(?P\d+)/)?answers/export/(?P[a-z]+)') + url_path=r'answers/export/(?P[a-z]+)') def answers_export(self, request, pk, export_format, snapshot_id=None): project = self.get_object() project.catalog.prefetch_elements() @@ -463,7 +467,13 @@ def answers_export(self, request, pk, export_format, snapshot_id=None): }) @action(detail=True, methods=['get'], permission_classes=(HasModelPermission | HasProjectPermission, ), - url_path=r'(snapshots/(?P\d+)/)?views/(?P\d+)') + url_path=r'snapshots/(?P\d+)/answers/export/(?P[a-z]+)') + def answers_export_snapshot(self, request, pk, export_format, snapshot_id=None): + # extra method since DRF does not officially support optional named parameters inside url_path + return self.answers_export(request, pk, export_format, snapshot_id) + + @action(detail=True, methods=['get'], permission_classes=(HasModelPermission | HasProjectPermission, ), + url_path=r'views/(?P\d+)') def views(self, request, pk, view_id, snapshot_id=None): project = self.get_object() project.catalog.prefetch_elements() @@ -488,7 +498,14 @@ def views(self, request, pk, view_id, snapshot_id=None): @action(detail=True, methods=['get'], permission_classes=(HasModelPermission | HasProjectPermission, ), - url_path=r'(snapshots/(?P\d+)/)?views/(?P\d+)/export/(?P[a-z]+)') + url_path=r'snapshots/(?P\d+)/views/(?P\d+)') + def views_snapshot(self, request, pk, view_id, snapshot_id): + # extra method since DRF does not officially support optional named parameters inside url_path + return self.views(request, pk, view_id, snapshot_id) + + + @action(detail=True, methods=['get'], permission_classes=(HasModelPermission | HasProjectPermission, ), + url_path=r'views/(?P\d+)/export/(?P[a-z]+)') def views_export(self, request, pk, view_id, export_format, snapshot_id=None): project = self.get_object() project.catalog.prefetch_elements() @@ -510,6 +527,12 @@ def views_export(self, request, pk, view_id, export_format, snapshot_id=None): 'resource_path': get_value_path(project, snapshot) }) + @action(detail=True, methods=['get'], permission_classes=(HasModelPermission | HasProjectPermission, ), + url_path=r'snapshots/(?P\d+)/views/(?P\d+)/export/(?P[a-z]+)') + def views_export_snapshot(self, request, pk, view_id, export_format, snapshot_id): + # extra method since DRF does not officially support optional named parameters inside url_path + return self.views_export(request, pk, view_id, export_format, snapshot_id) + @action(detail=False, url_path='upload-accept', permission_classes=(IsAuthenticated, )) def upload_accept(self, request): return Response(get_upload_accept()) From d540ba6321da3742c24a48794053131387b74e4b Mon Sep 17 00:00:00 2001 From: Jochen Klar Date: Fri, 24 Oct 2025 11:44:12 +0200 Subject: [PATCH 131/165] Update tests --- rdmo/core/tests/test_openapi.py | 2 +- .../tests/test_viewset_project_answers.py | 114 ++++++++++++++++ .../tests/test_viewset_project_views.py | 126 ++++++++++++++++++ 3 files changed, 241 insertions(+), 1 deletion(-) create mode 100644 rdmo/projects/tests/test_viewset_project_answers.py create mode 100644 rdmo/projects/tests/test_viewset_project_views.py diff --git a/rdmo/core/tests/test_openapi.py b/rdmo/core/tests/test_openapi.py index bc0d3dc95c..e9292ccdaf 100644 --- a/rdmo/core/tests/test_openapi.py +++ b/rdmo/core/tests/test_openapi.py @@ -10,7 +10,7 @@ 'anonymous' ) -n_path = 128 +n_path = 136 @pytest.mark.parametrize('username', users) def test_openapi_schema(db, client, login, settings, username): diff --git a/rdmo/projects/tests/test_viewset_project_answers.py b/rdmo/projects/tests/test_viewset_project_answers.py new file mode 100644 index 0000000000..5538d8b248 --- /dev/null +++ b/rdmo/projects/tests/test_viewset_project_answers.py @@ -0,0 +1,114 @@ +import pytest + +from django.urls import reverse + +from ..models import Snapshot + +users = ( + ('owner', 'owner'), + ('manager', 'manager'), + ('author', 'author'), + ('guest', 'guest'), + ('admin', 'admin'), + ('api', 'api'), + ('site', 'site'), + ('user', 'user'), + ('anonymous', None), +) + +view_project_permission_map = { + 'owner': [1, 2, 3, 4, 5, 10, 12], + 'manager': [1, 3, 5, 7, 12], + 'author': [1, 3, 5, 8, 12], + 'guest': [1, 3, 5, 9, 12], + 'admin': [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], + 'api': [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], + 'site': [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], + 'user': [12] +} + +projects = [1, 2, 3, 4, 5, 12] + +snapshots = [1, 3] + +export_formats = ['html'] + +urlnames = { + 'answers': 'v1-projects:project-answers', + 'answers-snapshot': 'v1-projects:project-answers-snapshot', + 'answers-export': 'v1-projects:project-answers-export', + 'answers-export-snapshot': 'v1-projects:project-answers-export-snapshot', +} + +@pytest.mark.parametrize('username,password', users) +@pytest.mark.parametrize('project_id', projects) +def test_view(db, client, username, password, project_id): + client.login(username=username, password=password) + + url = reverse(urlnames['answers'], args=[project_id]) + response = client.get(url) + + if project_id in view_project_permission_map.get(username, []): + assert response.status_code == 200 + assert isinstance(response.json(), dict) + else: + if password: + assert response.status_code == 404 + else: + assert response.status_code == 401 + + +@pytest.mark.parametrize('username,password', users) +@pytest.mark.parametrize('snapshot_id', snapshots) +def test_view_snapshot(db, client, username, password, snapshot_id): + client.login(username=username, password=password) + snapshot = Snapshot.objects.get(pk=snapshot_id) + + url = reverse(urlnames['answers-snapshot'], args=[snapshot.project.id, snapshot_id]) + response = client.get(url) + + if snapshot.project.id in view_project_permission_map.get(username, []): + assert response.status_code == 200 + assert isinstance(response.json(), dict) + else: + if password: + assert response.status_code == 404 + else: + assert response.status_code == 401 + + +@pytest.mark.parametrize('username,password', users) +@pytest.mark.parametrize('project_id', projects) +@pytest.mark.parametrize('export_format', export_formats) +def test_view_export(db, client, username, password, project_id, export_format): + client.login(username=username, password=password) + + url = reverse(urlnames['answers-export'], args=[project_id, export_format]) + response = client.get(url) + + if project_id in view_project_permission_map.get(username, []): + assert response.status_code == 200 + else: + if password: + assert response.status_code == 404 + else: + assert response.status_code == 401 + + +@pytest.mark.parametrize('username,password', users) +@pytest.mark.parametrize('snapshot_id', snapshots) +@pytest.mark.parametrize('export_format', export_formats) +def test_view_snapshot_export(db, client, username, password, snapshot_id, export_format): + client.login(username=username, password=password) + snapshot = Snapshot.objects.get(pk=snapshot_id) + + url = reverse(urlnames['answers-export-snapshot'], args=[snapshot.project.id, snapshot_id, export_format]) + response = client.get(url) + + if snapshot.project.id in view_project_permission_map.get(username, []): + assert response.status_code == 200 + else: + if password: + assert response.status_code == 404 + else: + assert response.status_code == 401 diff --git a/rdmo/projects/tests/test_viewset_project_views.py b/rdmo/projects/tests/test_viewset_project_views.py new file mode 100644 index 0000000000..9b8b5ec925 --- /dev/null +++ b/rdmo/projects/tests/test_viewset_project_views.py @@ -0,0 +1,126 @@ +import pytest + +from django.urls import reverse + +from ..models import Project, Snapshot + +users = ( + ('owner', 'owner'), + ('manager', 'manager'), + ('author', 'author'), + ('guest', 'guest'), + ('admin', 'admin'), + ('api', 'api'), + ('site', 'site'), + ('user', 'user'), + ('anonymous', None), +) + +views = (1, 2) + +view_project_permission_map = { + 'owner': [1, 2, 3, 4, 5, 10, 12], + 'manager': [1, 3, 5, 7, 12], + 'author': [1, 3, 5, 8, 12], + 'guest': [1, 3, 5, 9, 12], + 'admin': [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], + 'api': [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], + 'site': [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], + 'user': [12] +} + +projects = [1, 2, 3, 4, 5, 12] + +snapshots = [1, 3] + +export_formats = ['html'] + +urlnames = { + 'views': 'v1-projects:project-views', + 'views-snapshot': 'v1-projects:project-views-snapshot', + 'views-export': 'v1-projects:project-views-export', + 'views-export-snapshot': 'v1-projects:project-views-export-snapshot', +} + +@pytest.mark.parametrize('username,password', users) +@pytest.mark.parametrize('project_id', projects) +@pytest.mark.parametrize('view_id', views) +def test_view(db, client, username, password, project_id, view_id): + client.login(username=username, password=password) + project = Project.objects.get(pk=project_id) + project_views = list(project.views.values_list('id', flat=True)) + + url = reverse(urlnames['views'], args=[project_id, view_id]) + response = client.get(url) + + if project_id in view_project_permission_map.get(username, []) and view_id in project_views: + assert response.status_code == 200 + assert isinstance(response.json(), dict) + else: + if password: + assert response.status_code == 404 + else: + assert response.status_code == 401 + + +@pytest.mark.parametrize('username,password', users) +@pytest.mark.parametrize('snapshot_id', snapshots) +@pytest.mark.parametrize('view_id', views) +def test_view_snapshot(db, client, username, password, snapshot_id, view_id): + client.login(username=username, password=password) + snapshot = Snapshot.objects.get(pk=snapshot_id) + project_views = list(snapshot.project.views.values_list('id', flat=True)) + + url = reverse(urlnames['views-snapshot'], args=[snapshot.project.id, snapshot_id, view_id]) + response = client.get(url) + + if snapshot.project.id in view_project_permission_map.get(username, []) and view_id in project_views: + assert response.status_code == 200 + assert isinstance(response.json(), dict) + else: + if password: + assert response.status_code == 404 + else: + assert response.status_code == 401 + + +@pytest.mark.parametrize('username,password', users) +@pytest.mark.parametrize('project_id', projects) +@pytest.mark.parametrize('view_id', views) +@pytest.mark.parametrize('export_format', export_formats) +def test_view_export(db, client, username, password, project_id, view_id, export_format): + client.login(username=username, password=password) + project = Project.objects.get(pk=project_id) + project_views = list(project.views.values_list('id', flat=True)) + + url = reverse(urlnames['views-export'], args=[project_id, view_id, export_format]) + response = client.get(url) + + if project_id in view_project_permission_map.get(username, []) and view_id in project_views: + assert response.status_code == 200 + else: + if password: + assert response.status_code == 404 + else: + assert response.status_code == 401 + + +@pytest.mark.parametrize('username,password', users) +@pytest.mark.parametrize('snapshot_id', snapshots) +@pytest.mark.parametrize('view_id', views) +@pytest.mark.parametrize('export_format', export_formats) +def test_view_snapshot_export(db, client, username, password, snapshot_id, view_id, export_format): + client.login(username=username, password=password) + snapshot = Snapshot.objects.get(pk=snapshot_id) + project_views = list(snapshot.project.views.values_list('id', flat=True)) + + url = reverse(urlnames['views-export-snapshot'], args=[snapshot.project.id, snapshot_id, view_id, export_format]) + response = client.get(url) + + if snapshot.project.id in view_project_permission_map.get(username, []) and view_id in project_views: + assert response.status_code == 200 + else: + if password: + assert response.status_code == 404 + else: + assert response.status_code == 401 From 9d88f05dbabf0f4125869398c72ffd1e186325e6 Mon Sep 17 00:00:00 2001 From: Jochen Klar Date: Thu, 30 Oct 2025 09:39:05 +0100 Subject: [PATCH 132/165] Gardening --- rdmo/projects/viewsets.py | 31 ++++++++++++++++++++++++------- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/rdmo/projects/viewsets.py b/rdmo/projects/viewsets.py index b313bc2357..2f33c3afb6 100644 --- a/rdmo/projects/viewsets.py +++ b/rdmo/projects/viewsets.py @@ -420,7 +420,12 @@ def hierarchy(self, request, pk): serializer = ProjectHierarchySerializer(cached_trees[0], context=serializer_context) return Response(serializer.data) - @action(detail=True, methods=['get'], permission_classes=(HasModelPermission | HasProjectPermission, )) + @action( + detail=True, + methods=['get'], + url_path=r'answers', + permission_classes=(HasModelPermission | HasProjectPermission, ) + ) def answers(self, request, pk, snapshot_id=None): project = self.get_object() project.catalog.prefetch_elements() @@ -443,14 +448,22 @@ def answers(self, request, pk, snapshot_id=None): }) return Response(serializer.data) - @action(detail=True, methods=['get'], permission_classes=(HasModelPermission | HasProjectPermission, ), - url_path=r'snapshots/(?P\d+)/answers') + @action( + detail=True, + methods=['get'], + url_path=r'snapshots/(?P\d+)/answers', + permission_classes=(HasModelPermission | HasProjectPermission, ) + ) def answers_snapshot(self, request, pk, snapshot_id=None): # extra method since DRF does not officially support optional named parameters inside url_path return self.answers(request, pk, snapshot_id) - @action(detail=True, methods=['get'], permission_classes=(HasModelPermission | HasProjectPermission, ), - url_path=r'answers/export/(?P[a-z]+)') + @action( + detail=True, + methods=['get'], + url_path=r'answers/export/(?P[a-z]+)', + permission_classes=(HasModelPermission | HasProjectPermission, ) + ) def answers_export(self, request, pk, export_format, snapshot_id=None): project = self.get_object() project.catalog.prefetch_elements() @@ -466,8 +479,12 @@ def answers_export(self, request, pk, export_format, snapshot_id=None): 'project_wrapper': ProjectWrapper(project, snapshot) }) - @action(detail=True, methods=['get'], permission_classes=(HasModelPermission | HasProjectPermission, ), - url_path=r'snapshots/(?P\d+)/answers/export/(?P[a-z]+)') + @action( + detail=True, + methods=['get'], + url_path=r'snapshots/(?P\d+)/answers/export/(?P[a-z]+)', + permission_classes=(HasModelPermission | HasProjectPermission, ) + ) def answers_export_snapshot(self, request, pk, export_format, snapshot_id=None): # extra method since DRF does not officially support optional named parameters inside url_path return self.answers_export(request, pk, export_format, snapshot_id) From 3356d79915e476ffcc3906ce6a2f7f4b6651e496 Mon Sep 17 00:00:00 2001 From: Jochen Klar Date: Thu, 30 Oct 2025 17:44:27 +0100 Subject: [PATCH 133/165] More gardening --- rdmo/projects/serializers/v1/__init__.py | 1 + rdmo/projects/viewsets.py | 5 +++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/rdmo/projects/serializers/v1/__init__.py b/rdmo/projects/serializers/v1/__init__.py index f508f0fb81..088d33c074 100644 --- a/rdmo/projects/serializers/v1/__init__.py +++ b/rdmo/projects/serializers/v1/__init__.py @@ -581,6 +581,7 @@ class ProjectViewSerializer(serializers.Serializer): project = serializers.PrimaryKeyRelatedField(read_only=True) snapshot = serializers.PrimaryKeyRelatedField(read_only=True) + view = serializers.PrimaryKeyRelatedField(read_only=True) html = serializers.CharField(read_only=True) attachments = ProjectAttachmentSerializer(many=True, read_only=True) diff --git a/rdmo/projects/viewsets.py b/rdmo/projects/viewsets.py index 2f33c3afb6..dc28b292c5 100644 --- a/rdmo/projects/viewsets.py +++ b/rdmo/projects/viewsets.py @@ -454,7 +454,7 @@ def answers(self, request, pk, snapshot_id=None): url_path=r'snapshots/(?P\d+)/answers', permission_classes=(HasModelPermission | HasProjectPermission, ) ) - def answers_snapshot(self, request, pk, snapshot_id=None): + def answers_snapshot(self, request, pk, snapshot_id): # extra method since DRF does not officially support optional named parameters inside url_path return self.answers(request, pk, snapshot_id) @@ -485,7 +485,7 @@ def answers_export(self, request, pk, export_format, snapshot_id=None): url_path=r'snapshots/(?P\d+)/answers/export/(?P[a-z]+)', permission_classes=(HasModelPermission | HasProjectPermission, ) ) - def answers_export_snapshot(self, request, pk, export_format, snapshot_id=None): + def answers_export_snapshot(self, request, pk, export_format, snapshot_id): # extra method since DRF does not officially support optional named parameters inside url_path return self.answers_export(request, pk, export_format, snapshot_id) @@ -508,6 +508,7 @@ def views(self, request, pk, view_id, snapshot_id=None): serializer = ProjectViewSerializer({ 'project': project, 'snapshot': snapshot, + 'view': view, 'html': view.render(project, snapshot), 'attachments': project.values.filter(snapshot=snapshot).filter(value_type=VALUE_TYPE_FILE).order_by('file') }) From b6ec96ac8bcae5db7e3dccfd97bc0a171f817ae6 Mon Sep 17 00:00:00 2001 From: Jochen Klar Date: Thu, 13 Nov 2025 20:44:02 +0100 Subject: [PATCH 134/165] Add views action to ProjectViewSet and refactor view actions and serializers --- rdmo/core/tests/test_openapi.py | 2 +- rdmo/projects/serializers/v1/__init__.py | 42 +++++++++++++++++-- .../tests/test_viewset_project_views.py | 37 ++++++++++++---- rdmo/projects/viewsets.py | 32 +++++++------- 4 files changed, 86 insertions(+), 27 deletions(-) diff --git a/rdmo/core/tests/test_openapi.py b/rdmo/core/tests/test_openapi.py index e9292ccdaf..fdb93e32d8 100644 --- a/rdmo/core/tests/test_openapi.py +++ b/rdmo/core/tests/test_openapi.py @@ -10,7 +10,7 @@ 'anonymous' ) -n_path = 136 +n_path = 137 @pytest.mark.parametrize('username', users) def test_openapi_schema(db, client, login, settings, username): diff --git a/rdmo/projects/serializers/v1/__init__.py b/rdmo/projects/serializers/v1/__init__.py index 088d33c074..05b7b29f56 100644 --- a/rdmo/projects/serializers/v1/__init__.py +++ b/rdmo/projects/serializers/v1/__init__.py @@ -9,9 +9,11 @@ from rdmo.accounts.serializers.v1 import UserLookupSerializer from rdmo.accounts.utils import get_full_name +from rdmo.core.serializers import TranslationSerializerMixin from rdmo.domain.models import Attribute from rdmo.questions.models import Catalog from rdmo.services.validators import ProviderValidator +from rdmo.views.models import View from ...models import ( Integration, @@ -564,6 +566,17 @@ class Meta: ) +class ProjectViewsSerializer(serializers.ModelSerializer): + + class Meta: + model = View + fields = ( + 'id', + 'title', + 'help' + ) + + class ProjectAttachmentSerializer(serializers.ModelSerializer): class Meta: @@ -577,15 +590,36 @@ class Meta: ) -class ProjectViewSerializer(serializers.Serializer): +class ProjectAnswersSerializer(serializers.Serializer): - project = serializers.PrimaryKeyRelatedField(read_only=True) - snapshot = serializers.PrimaryKeyRelatedField(read_only=True) - view = serializers.PrimaryKeyRelatedField(read_only=True) html = serializers.CharField(read_only=True) attachments = ProjectAttachmentSerializer(many=True, read_only=True) +class ProjectViewSerializer(serializers.ModelSerializer): + + html = serializers.SerializerMethodField() + attachments = serializers.SerializerMethodField() + + class Meta: + model = View + fields = ( + 'id', + 'title', + 'help', + 'html', + 'attachments' + ) + + def get_html(self, obj): + return self.context.get('html', '') + + def get_attachments(self, obj): + attachments = self.context.get('attachments', []) + serializer = ProjectAttachmentSerializer(attachments, many=True, read_only=True) + return serializer.data + + class MembershipSerializer(serializers.ModelSerializer): class Meta: diff --git a/rdmo/projects/tests/test_viewset_project_views.py b/rdmo/projects/tests/test_viewset_project_views.py index 9b8b5ec925..59c4672192 100644 --- a/rdmo/projects/tests/test_viewset_project_views.py +++ b/rdmo/projects/tests/test_viewset_project_views.py @@ -37,11 +37,34 @@ urlnames = { 'views': 'v1-projects:project-views', - 'views-snapshot': 'v1-projects:project-views-snapshot', - 'views-export': 'v1-projects:project-views-export', - 'views-export-snapshot': 'v1-projects:project-views-export-snapshot', + 'view': 'v1-projects:project-view', + 'view-snapshot': 'v1-projects:project-view-snapshot', + 'view-export': 'v1-projects:project-view-export', + 'view-export-snapshot': 'v1-projects:project-view-export-snapshot', } + +@pytest.mark.parametrize('username,password', users) +@pytest.mark.parametrize('project_id', projects) +def test_views(db, client, username, password, project_id): + client.login(username=username, password=password) + project = Project.objects.get(pk=project_id) + project_views = list(project.views.values_list('id', flat=True)) + + url = reverse(urlnames['views'], args=[project_id]) + response = client.get(url) + + if project_id in view_project_permission_map.get(username, []): + assert response.status_code == 200 + assert isinstance(response.json(), list) + assert [item['id'] for item in response.json()] == project_views + else: + if password: + assert response.status_code == 404 + else: + assert response.status_code == 401 + + @pytest.mark.parametrize('username,password', users) @pytest.mark.parametrize('project_id', projects) @pytest.mark.parametrize('view_id', views) @@ -50,7 +73,7 @@ def test_view(db, client, username, password, project_id, view_id): project = Project.objects.get(pk=project_id) project_views = list(project.views.values_list('id', flat=True)) - url = reverse(urlnames['views'], args=[project_id, view_id]) + url = reverse(urlnames['view'], args=[project_id, view_id]) response = client.get(url) if project_id in view_project_permission_map.get(username, []) and view_id in project_views: @@ -71,7 +94,7 @@ def test_view_snapshot(db, client, username, password, snapshot_id, view_id): snapshot = Snapshot.objects.get(pk=snapshot_id) project_views = list(snapshot.project.views.values_list('id', flat=True)) - url = reverse(urlnames['views-snapshot'], args=[snapshot.project.id, snapshot_id, view_id]) + url = reverse(urlnames['view-snapshot'], args=[snapshot.project.id, snapshot_id, view_id]) response = client.get(url) if snapshot.project.id in view_project_permission_map.get(username, []) and view_id in project_views: @@ -93,7 +116,7 @@ def test_view_export(db, client, username, password, project_id, view_id, export project = Project.objects.get(pk=project_id) project_views = list(project.views.values_list('id', flat=True)) - url = reverse(urlnames['views-export'], args=[project_id, view_id, export_format]) + url = reverse(urlnames['view-export'], args=[project_id, view_id, export_format]) response = client.get(url) if project_id in view_project_permission_map.get(username, []) and view_id in project_views: @@ -114,7 +137,7 @@ def test_view_snapshot_export(db, client, username, password, snapshot_id, view_ snapshot = Snapshot.objects.get(pk=snapshot_id) project_views = list(snapshot.project.views.values_list('id', flat=True)) - url = reverse(urlnames['views-export-snapshot'], args=[snapshot.project.id, snapshot_id, view_id, export_format]) + url = reverse(urlnames['view-export-snapshot'], args=[snapshot.project.id, snapshot_id, view_id, export_format]) response = client.get(url) if snapshot.project.id in view_project_permission_map.get(username, []) and view_id in project_views: diff --git a/rdmo/projects/viewsets.py b/rdmo/projects/viewsets.py index dc28b292c5..ababf8bd07 100644 --- a/rdmo/projects/viewsets.py +++ b/rdmo/projects/viewsets.py @@ -64,6 +64,7 @@ InviteSerializer, IssueSerializer, MembershipSerializer, + ProjectAnswersSerializer, ProjectCopySerializer, ProjectHierarchySerializer, ProjectIntegrationSerializer, @@ -80,6 +81,7 @@ ProjectSnapshotSerializer, ProjectValueSerializer, ProjectViewSerializer, + ProjectViewsSerializer, ProjectVisibilitySerializer, SnapshotSerializer, UserInviteSerializer, @@ -435,9 +437,7 @@ def answers(self, request, pk, snapshot_id=None): except Snapshot.DoesNotExist: snapshot = None - serializer = ProjectViewSerializer({ - 'project': project, - 'snapshot': snapshot, + serializer = ProjectAnswersSerializer({ 'html': render_to_string('projects/project_answers.html', { 'project': project, 'snapshot': snapshot, @@ -489,9 +489,16 @@ def answers_export_snapshot(self, request, pk, export_format, snapshot_id): # extra method since DRF does not officially support optional named parameters inside url_path return self.answers_export(request, pk, export_format, snapshot_id) + @action(detail=True, methods=['get'], permission_classes=(HasModelPermission | HasProjectPermission, ), + url_path=r'views') + def views(self, request, pk): + project = self.get_object() + serializer = ProjectViewsSerializer(project.views, many=True) + return Response(serializer.data) + @action(detail=True, methods=['get'], permission_classes=(HasModelPermission | HasProjectPermission, ), url_path=r'views/(?P\d+)') - def views(self, request, pk, view_id, snapshot_id=None): + def view(self, request, pk, view_id, snapshot_id=None): project = self.get_object() project.catalog.prefetch_elements() @@ -505,26 +512,21 @@ def views(self, request, pk, view_id, snapshot_id=None): except Snapshot.DoesNotExist: snapshot = None - serializer = ProjectViewSerializer({ - 'project': project, - 'snapshot': snapshot, - 'view': view, + serializer = ProjectViewSerializer(view, context={ 'html': view.render(project, snapshot), 'attachments': project.values.filter(snapshot=snapshot).filter(value_type=VALUE_TYPE_FILE).order_by('file') }) return Response(serializer.data) - @action(detail=True, methods=['get'], permission_classes=(HasModelPermission | HasProjectPermission, ), url_path=r'snapshots/(?P\d+)/views/(?P\d+)') - def views_snapshot(self, request, pk, view_id, snapshot_id): + def view_snapshot(self, request, pk, view_id, snapshot_id): # extra method since DRF does not officially support optional named parameters inside url_path - return self.views(request, pk, view_id, snapshot_id) - + return self.view(request, pk, view_id, snapshot_id) @action(detail=True, methods=['get'], permission_classes=(HasModelPermission | HasProjectPermission, ), url_path=r'views/(?P\d+)/export/(?P[a-z]+)') - def views_export(self, request, pk, view_id, export_format, snapshot_id=None): + def view_export(self, request, pk, view_id, export_format, snapshot_id=None): project = self.get_object() project.catalog.prefetch_elements() @@ -547,9 +549,9 @@ def views_export(self, request, pk, view_id, export_format, snapshot_id=None): @action(detail=True, methods=['get'], permission_classes=(HasModelPermission | HasProjectPermission, ), url_path=r'snapshots/(?P\d+)/views/(?P\d+)/export/(?P[a-z]+)') - def views_export_snapshot(self, request, pk, view_id, export_format, snapshot_id): + def view_export_snapshot(self, request, pk, view_id, export_format, snapshot_id): # extra method since DRF does not officially support optional named parameters inside url_path - return self.views_export(request, pk, view_id, export_format, snapshot_id) + return self.view_export(request, pk, view_id, export_format, snapshot_id) @action(detail=False, url_path='upload-accept', permission_classes=(IsAuthenticated, )) def upload_accept(self, request): From 95b5d801a26f308d38d328133b7bc878669be63d Mon Sep 17 00:00:00 2001 From: David Wallace Date: Fri, 11 Jul 2025 08:43:07 +0200 Subject: [PATCH 135/165] projects: update export and add import command Signed-off-by: David Wallace --- .../management/commands/export_projects.py | 291 +++++++++++------ .../management/commands/import_projects.py | 294 ++++++++++++++++++ rdmo/projects/management/commands/utils.py | 75 +++++ 3 files changed, 571 insertions(+), 89 deletions(-) create mode 100644 rdmo/projects/management/commands/import_projects.py create mode 100644 rdmo/projects/management/commands/utils.py diff --git a/rdmo/projects/management/commands/export_projects.py b/rdmo/projects/management/commands/export_projects.py index f754b3f433..e002483ef0 100644 --- a/rdmo/projects/management/commands/export_projects.py +++ b/rdmo/projects/management/commands/export_projects.py @@ -1,15 +1,18 @@ +from __future__ import annotations + +import json import logging +from collections.abc import Iterable from pathlib import Path from django.conf import settings from django.core.management.base import BaseCommand, CommandError -from django.db.models import Prefetch +from django.db.models import Prefetch, QuerySet from rdmo.core.plugins import get_plugin from rdmo.core.utils import render_to_format -from rdmo.projects.models import Project +from rdmo.projects.models import Membership, Project from rdmo.projects.utils import get_value_path -from rdmo.questions.models import Question, QuestionSet from rdmo.views.models import View from rdmo.views.utils import ProjectWrapper @@ -19,99 +22,209 @@ class Command(BaseCommand): def add_arguments(self, parser): - parser.add_argument('--answers', action='store_true', help='Export the answers instead of a project') - parser.add_argument('--view', help='Export a specific view instead of a project') - parser.add_argument('--format', default='xml', help='Format for the export [default: xml]') - parser.add_argument('--path', default='exports', help='Directory for the exported files [default: exports]') + parser.add_argument('--answers', action='store_true', help='Export answers instead of the full project.') + parser.add_argument('--view', metavar='URI', help='Export the given view instead of the full project.') + parser.add_argument( + '--projects', nargs='*', type=int, metavar='ID', help='Limit the export to specific project IDs.' + ) + parser.add_argument( + '--site-id', + type=int, + default=settings.SITE_ID, + help='Filter projects by site ID (default: settings.SITE_ID).', + ) + parser.add_argument('--catalog-uri', metavar='URI', help='Filter projects by the catalog URI.') + parser.add_argument( + '--format', default='xml', help='Export format (answers/view honour settings.EXPORT_FORMATS).' + ) + parser.add_argument('--path', default='exports', metavar='DIR', help='Target directory [default: exports/].') + parser.add_argument( + '--with-members', action='store_true', help='Write a members.json file with user/role info.' + ) def handle(self, *args, **options): - self.format = options['format'] - self.path = Path(options['path']) - - if options['answers']: - self.export_answers() - elif options['view']: - self.export_view(options['view']) + export_mode = 'answers' if options['answers'] else 'view' if options['view'] else 'project' + + self.format: str = options['format'] + self.path: Path = Path(options['path']).expanduser().resolve() + self.with_members: bool = options['with_members'] + project_ids = options.get('projects') or None + site_id: int = options['site_id'] + catalog_uri: str | None = options.get('catalog_uri') or None + + # upfront validations ------------------------------------------------ + if export_mode in ('answers', 'view'): + if self.format not in dict(settings.EXPORT_FORMATS): + raise CommandError(f'Format "{self.format}" is not configured in settings.EXPORT_FORMATS.') + + view_obj: View | None = None + if export_mode == 'view': + try: + view_obj = View.objects.get(uri=options['view']) + except View.DoesNotExist as exc: + raise CommandError(f'View with key "{options["view"]}" does not exist.') from exc + + projects = self._get_queryset(project_ids, site_id, catalog_uri) + if not projects.exists(): + if catalog_uri: + project_catalogs = sorted( + set(self._get_queryset(project_ids, site_id, None).values_list('catalog__uri', flat=True)) + ) + project_catalogs_str = [f"\t- {i} \n" for i in project_catalogs] + self.stdout.write(self.style.WARNING(f'Choose a catalog from:\n {"".join(project_catalogs_str)}')) + + if site_id != settings.SITE_ID: + project_sites = sorted( + set(self._get_queryset(project_ids, None, None).values_list('site__id', 'site__domain')) + ) + project_sites_str = [f"\t- {id} {domain} \n" for id, domain in project_sites] + self.stdout.write(self.style.WARNING(f'Choose a site from:\n {"".join(project_sites_str)}')) + + raise CommandError('No matching projects found.') + + for project in projects: + self._export_project(project, mode=export_mode, view=view_obj) + + self.stdout.write(self.style.SUCCESS(f'Exported {projects.count()} project(s) to {self.path}')) + + def _get_queryset( + self, + ids: Iterable[int] | None, + site_id: int | None, + catalog_uri: str | None, + ) -> QuerySet[Project]: + """ + Build a base queryset filtered by site_id and optional catalog_uri, + then by explicit project IDs if given. + """ + # start from the current site + if site_id is not None: + qs = Project.objects.filter(site_id=site_id) else: - self.export_projects() - - def get_queryset(self): - return Project.objects.prefetch_related( - Prefetch('catalog__sections__questionsets', - queryset=QuestionSet.objects.select_related('attribute')), - Prefetch('catalog__sections__questionsets__questions', - queryset=Question.objects.select_related('attribute')), - Prefetch('catalog__sections__questionsets__questionsets', - queryset=QuestionSet.objects.select_related('attribute')), - Prefetch('catalog__sections__questionsets__questionsets__questions', - queryset=Question.objects.select_related('attribute')), - ) + qs = Project.objects.all() - def export_answers(self): - current_snapshot = None + # optional catalog URI filter + if catalog_uri: + qs = qs.filter(catalog__uri=catalog_uri) - if self.format not in dict(settings.EXPORT_FORMATS): - raise CommandError(f'Format "{self.format}" is not supported for answers.') + # explicit project IDs + if ids: + qs = qs.filter(id__in=set(ids)) - for project in self.get_queryset(): - context = { - 'project': project, - 'current_snapshot': current_snapshot, - 'project_wrapper': ProjectWrapper(project, current_snapshot), - 'title': project.title, - 'format': self.format, - 'resource_path': get_value_path(project, current_snapshot) - } + return qs.select_related('catalog', 'site').prefetch_related( + Prefetch( + 'memberships', + queryset=Membership.objects.select_related('user'), + ) + ) - response = render_to_format(None, context['format'], context['title'], - 'projects/project_answers_export.html', context) - self.write_file(self.path / str(project.id) / 'answers', response) - - def export_view(self, key): - current_snapshot = None - - if self.format not in dict(settings.EXPORT_FORMATS): - raise CommandError(f'Format "{self.format}" is not supported for answers.') - - try: - view = View.objects.get(key=key) - except View.DoesNotExist as e: - raise CommandError(f'A view with the key "{key}" was not found.') from e - - for project in self.get_queryset(): - context = { - 'project': project, - 'current_snapshot': current_snapshot, - 'view': project.views.get(pk=view.id), - 'rendered_view': view.render(project, snapshot=current_snapshot, export_format=self.format), - 'project_wrapper': ProjectWrapper(project, current_snapshot), - 'title': project.title, - 'format': self.format, - 'resource_path': get_value_path(project, current_snapshot) + def _export_project( + self, + project: Project, + *, + mode: str, + view: View | None = None, + ) -> None: + """ + Orchestrate the chosen export *mode* for one project and write the file + to disk. + """ + if mode == 'answers': + response = self._render_answers(project) + subdir = 'answers' + + elif mode == 'view': + assert view is not None # guarded in `handle` + response = self._render_view(project, view) + subdir = view.uri_path + + else: # full project + response = self._render_full_project(project) + subdir = '' + + self._write_response(project, subdir, response) + + if self.with_members: + self._write_members_json(project) + + def _render_answers(self, project: Project): + snapshot = None + context = { + 'project': project, + 'current_snapshot': snapshot, + 'project_wrapper': ProjectWrapper(project, snapshot), + 'title': project.title, + 'format': self.format, + 'resource_path': get_value_path(project, snapshot), + } + return render_to_format(None, self.format, context['title'], 'projects/project_answers_export.html', context) + + def _render_view(self, project: Project, view: View): + snapshot = None + context = { + 'project': project, + 'current_snapshot': snapshot, + 'view': project.views.get(pk=view.id), + 'rendered_view': view.render(project, snapshot=snapshot, export_format=self.format), + 'project_wrapper': ProjectWrapper(project, snapshot), + 'title': project.title, + 'format': self.format, + 'resource_path': get_value_path(project, snapshot), + } + return render_to_format(None, self.format, context['title'], 'projects/project_view_export.html', context) + + def _render_full_project(self, project: Project): + plugin_cls = get_plugin('PROJECT_EXPORTS', self.format) + if plugin_cls is None: + raise CommandError(f'Format "{self.format}" is not supported.') + plugin = plugin_cls + plugin.project = project + plugin.snapshot = None + return plugin.render() + + def _write_members_json(self, project: Project) -> None: + """ + Dump a `members.json` file with `[{"user_id": …, "username": …, "role": …}, …]`. + """ + payload = [ + { + 'user_id': m.user_id, + 'username': m.user.get_username(), + 'first_name': m.user.first_name, + 'last_name': m.user.last_name, + 'email': m.user.email, + 'role': m.role, + 'project_title': project.title, + 'project_site_domain': project.site.domain, } - - response = render_to_format(None, context['format'], context['title'], - 'projects/project_view_export.html', context) - self.write_file(self.path / str(project.id) / key, response) - - def export_projects(self): - for project in self.get_queryset(): - export_plugin = get_plugin('PROJECT_EXPORTS', self.format) - if export_plugin is None: - raise CommandError(f'Format "{self.format}" is not supported.') - - export_plugin.project = project - export_plugin.snapshot = None - response = export_plugin.render() - - self.write_file(self.path / str(project.id), response) - - def write_file(self, path, response): - file_name = response.headers['Content-Disposition'].replace('filename=', '').replace('"', '') - file_path = path / file_name - file_path.parent.mkdir(exist_ok=True, parents=True) - - print(f'Writing {file_path}') - + for m in project.memberships.all() + ] + + if not payload: # skip empty projects + return + + target_dir = self.path / str(project.id) + target_dir.mkdir(parents=True, exist_ok=True) + file_path = target_dir / 'members.json' + + logger.info('Writing %s', file_path) + with file_path.open('w', encoding='utf-8') as fp: + json.dump(payload, fp, ensure_ascii=False, indent=2) + + def _write_response( + self, + project: Project, + subdir: str, + response, + ) -> None: + filename = response.headers.get('Content-Disposition', '').split('filename=')[-1].strip('"') + if not filename: + raise CommandError('Export response did not include a filename header.') + + target_dir = self.path / str(project.id) / subdir + target_dir.mkdir(parents=True, exist_ok=True) + + file_path = target_dir / filename + logger.info('Writing %s', file_path) with file_path.open('wb') as fp: fp.write(response.content) diff --git a/rdmo/projects/management/commands/import_projects.py b/rdmo/projects/management/commands/import_projects.py new file mode 100644 index 0000000000..aba8b6f9e3 --- /dev/null +++ b/rdmo/projects/management/commands/import_projects.py @@ -0,0 +1,294 @@ +from __future__ import annotations + +import json +import logging +from collections.abc import Iterable +from pathlib import Path + +from django.contrib.auth import get_user_model +from django.contrib.sites.models import Site +from django.core.management.base import BaseCommand, CommandError +from django.db import IntegrityError, transaction + +from rdmo.core.plugins import get_plugin +from rdmo.projects.models import Membership, Project +from rdmo.projects.utils import ( + save_import_snapshot_values, + save_import_tasks, + save_import_values, + save_import_views, +) + +from .utils import FakeRequest, get_cli_user, make_unique_username + +logger = logging.getLogger(__name__) +User = get_user_model() + + +class Command(BaseCommand): + """ + Import projects that were previously exported via ``export_projects``. + The command expects the directory structure created by the export + (each project in a numbered sub-folder containing one ``*.xml`` file + and optionally a ``members.json``). + + Examples + -------- + # import everything that is inside ./exports + $ python manage.py import_projects + + # only import projects 2 and 5, create memberships, use a custom plugin + $ python manage.py import_projects --projects 2 5 --with-members --format madmp + """ + + # --------------------------------------------------------------------- + # CLI argument definitions + # --------------------------------------------------------------------- + def add_arguments(self, parser): + parser.add_argument( + "--path", + metavar="DIR", + default="exports", + help="Directory with exported project sub-folders (default: exports/).", + ) + parser.add_argument( + "--projects", + nargs="*", + type=int, + metavar="ID", + help="Only import the listed project-ID folders.", + ) + parser.add_argument( + "--format", + default="xml", + help="Import plugin key to use (must be configured in ``settings.PROJECT_IMPORTS``).", + ) + parser.add_argument( + "--with-members", + action="store_true", + help="Also read a companion ``members.json`` and recreate memberships.", + ) + parser.add_argument( + "--as-user", + metavar="USER", + help="Pretend the import is executed by this user (pk or username). " + "Defaults to the first superuser.", + ) + + # --------------------------------------------------------------------- + # main entry point + # --------------------------------------------------------------------- + def handle(self, *args, **options): + base_path = Path(options["path"]).expanduser().resolve() + if not base_path.is_dir(): + raise CommandError( + f'Path "{base_path}" does not exist or is not a directory.' + ) + + project_filter: set[int] | None = ( + set(options["projects"]) if options.get("projects") else None + ) + plugin_key: str = options["format"] + import_members: bool = options["with_members"] + self.import_user = get_cli_user(options.get("as_user")) + + # sanity-check plugin key + if get_plugin("PROJECT_IMPORTS", plugin_key) is None: + raise CommandError( + f'Import format "{plugin_key}" is not configured. ' + "Check your ``PROJECT_IMPORTS`` setting." + ) + + failures: list[tuple[Path, str]] = [] + + # iterate over sub-folders -------------------------------------------------- + for project_dir in sorted(base_path.iterdir(), key=lambda p: p.name): + if not project_dir.is_dir(): + continue + + try: + dir_id = int(project_dir.name) + except ValueError: + self.stdout.write( + self.style.WARNING( + f'Skip "{project_dir.name}", folder name is not a number.' + ) + ) + continue + + if project_filter and dir_id not in project_filter: + continue + + # each sub-folder *must* contain exactly one XML file ------------------ + xml_candidates = list(project_dir.glob("*.xml")) + if not xml_candidates: + self.stdout.write( + self.style.WARNING(f'No XML file found in "{project_dir}".') + ) + continue + + xml_file = xml_candidates[0] + self.stdout.write(f"→ Importing {xml_file.relative_to(base_path)}") + + try: + with transaction.atomic(): + project = self._import_single_project(xml_file, plugin_key) + if import_members: + self._import_members(project, project_dir / "members.json") + + self.stdout.write( + self.style.SUCCESS( + f' ✓ Project {project.pk} "{project.title}" imported successfully.' + ) + ) + except CommandError as exc: # expected / user-facing + logger.exception("Import failed for %s", xml_file) + self.stdout.write(self.style.ERROR(f" ✗ {exc} (see log)")) + failures.append((xml_file.relative_to(base_path), str(exc))) + continue + except Exception as exc: + # unexpected; cancel this project but keep processing others + logger.exception("Import failed for %s", xml_file) + self.stdout.write(self.style.ERROR(f" ✗ {exc} (see log)")) + failures.append((xml_file.relative_to(base_path), str(exc))) + raise + + if failures: + self.stdout.write(self.style.NOTICE("\nImport finished with errors:")) + for path, msg in failures: + self.stdout.write(f" • {path}: {msg}") + self.stdout.write("") # final newline for readability + + # --------------------------------------------------------------------- + # helpers + # --------------------------------------------------------------------- + def _import_single_project(self, xml_file: Path, plugin_key: str) -> Project: + """Run the configured import plugin and persist project + values.""" + # fresh plugin instance each time + plugin = get_plugin("PROJECT_IMPORTS", plugin_key) + plugin.file_name = str(xml_file) + plugin.request = FakeRequest(self.import_user) # CLI → Fake HTTP request + plugin.current_project = None # always *create* a new project + + if not plugin.check(): + raise CommandError( + f'Plugin "{plugin_key}" rejected file "{xml_file.name}".' + ) + + plugin.process() + + for val in plugin.values: + val.current = None + for snap in plugin.snapshots: + for val in snap.snapshot_values: + val.current = None + + project: Project = plugin.project + if project.pk is None: # ensure object is saved before M2M relations + project.site = Site.objects.get_current() + project.save() + + # ----------------------------------------------------------------- + # build “checked” sets so that *all* answers / snapshots are imported + # (the utils functions only import rows that we explicitly mark) + # ----------------------------------------------------------------- + checked_values: set[str] = { + f"{v.attribute.uri}[{v.set_prefix}][{v.set_index}][{v.collection_index}]" + for v in plugin.values + if v.attribute + } + + checked_snapshots: set[str] = set() + for snapshot in plugin.snapshots: + checked_snapshots.update( + f"{val.attribute.uri}[{snapshot.snapshot_index}][{val.set_prefix}][{val.set_index}][{val.collection_index}]" + for val in snapshot.snapshot_values + if val.attribute + ) + + # persist everything ------------------------------------------------ + save_import_values(project, plugin.values, checked_values) + save_import_snapshot_values(project, plugin.snapshots, checked_snapshots) + save_import_tasks(project, plugin.tasks) + save_import_views(project, plugin.views) + + return project + + # --------------------------------------------------------------------- + # optional membership import + # --------------------------------------------------------------------- + def _import_members(self, project: Project, members_json: Path) -> None: + if not members_json.is_file(): + self.stdout.write( + self.style.WARNING( + " ↺ No members.json present, skipping memberships." + ) + ) + return + + try: + payload: Iterable[dict] = json.loads( + members_json.read_text(encoding="utf-8") + ) + except (json.JSONDecodeError, OSError) as exc: + raise CommandError(f"Failed to read {members_json}: {exc}") from exc + + created, skipped = 0, 0 + for record in payload: + + email = (record.get("email") or "").lower() + user = None + + if email: + user = User.objects.filter(email__iexact=email).first() + + if not user and email: + # choose seed: provided username or local-part of e-mail + desired = record.get("username") or email.split("@")[0] + + username = make_unique_username(desired) + + user = User.objects.create_user( + username=username, + email=email, + first_name=record.get("first_name", ""), + last_name=record.get("last_name", ""), + is_active=True, + ) + user.set_unusable_password() + user.save(update_fields=["password"]) + + if username != desired: + logger.info( + "Username '%s' already taken, auto-created unique name '%s'.", + desired, + username, + ) + + if user is None: + skipped += 1 + self.stdout.write( + self.style.WARNING(f" ↺ No user for {record!r}, skipping.") + ) + continue + + role = record.get("role") or "guest" + try: + Membership.objects.update_or_create( + project=project, user=user, defaults={"role": role} + ) + except IntegrityError as exc: + logger.warning( + " ↺ Duplicate membership %s / %s: %s", project.pk, user.pk, exc + ) + skipped += 1 + else: + self.stdout.write(f' • added "{user.username}" as {role}') + created += 1 + + if created: + logger.info("Added %d membership(s) to project %s", created, project.pk) + if skipped: + logger.info( + "Skipped %d membership record(s) for project %s", skipped, project.pk + ) diff --git a/rdmo/projects/management/commands/utils.py b/rdmo/projects/management/commands/utils.py new file mode 100644 index 0000000000..d6b56268cb --- /dev/null +++ b/rdmo/projects/management/commands/utils.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import dataclasses +from typing import Any + +from django.contrib.auth import get_user_model +from django.contrib.auth.models import AbstractBaseUser, AnonymousUser +from django.utils.crypto import get_random_string +from django.utils.text import slugify + +User = get_user_model() + + +def replace_uri_in_template_string( + template: str, source_uri: str, target_uri: str +) -> str: + replacements = [ + (f"'{source_uri}'", f"'{target_uri}'"), + (f'"{source_uri}"', f'"{target_uri}"'), + ] + for pattern, replacement in replacements: + template = template.replace(pattern, replacement) + return template + + +def get_cli_user(spec: str | None = None) -> AbstractBaseUser | AnonymousUser: + """ + Resolve *spec* to a real User instance (or AnonymousUser if nothing fits). + + * ``None`` → first superuser (fallback: AnonymousUser) + * ``"42"`` → by primary key + * ``"alice"`` → by username + """ + + if spec is None: + return User.objects.filter(is_superuser=True).first() or AnonymousUser() + + if spec.isdigit(): + return User.objects.filter(pk=int(spec)).first() or AnonymousUser() + + return User.objects.filter(username=spec).first() or AnonymousUser() + + +@dataclasses.dataclass +class FakeRequest: + """ + Minimal stand-in so legacy import plugins can call ``self.request.user`` + and ``self.request.session`` while running inside a management command. + """ + + user: AbstractBaseUser | AnonymousUser + session: dict[str, Any] = dataclasses.field(default_factory=dict) + + +def make_unique_username(seed: str) -> str: + """ + Return a DB-unique username derived from *seed*. + + * seed -> "markus" → "markus" (if free) + * ... → "markus_1", "markus_2", … + * after 99 tries → "markus_<8-random-chars>" + """ + base = slugify(seed) or "user" + candidate = base + suffix = 0 + + while User.objects.filter(username=candidate).exists(): + suffix += 1 + if suffix <= 99: + candidate = f"{base}_{suffix}" + else: # extreme edge case + candidate = f"{base}_{get_random_string(8)}" + break + + return candidate From 696201b032312d0b81b9670ce3266a7f1c0d5baf Mon Sep 17 00:00:00 2001 From: David Wallace Date: Thu, 17 Jul 2025 16:01:08 +0200 Subject: [PATCH 136/165] projects(viewsets): add export action Signed-off-by: David Wallace --- rdmo/projects/viewsets.py | 41 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/rdmo/projects/viewsets.py b/rdmo/projects/viewsets.py index ababf8bd07..1a51213dfb 100644 --- a/rdmo/projects/viewsets.py +++ b/rdmo/projects/viewsets.py @@ -23,6 +23,7 @@ from rdmo.conditions.models import Condition from rdmo.core.constants import VALUE_TYPE_FILE +from rdmo.core.exports import XMLResponse from rdmo.core.permissions import HasModelPermission from rdmo.core.utils import human2bytes, is_truthy, render_to_format, return_file_response from rdmo.options.models import OptionSet @@ -59,6 +60,8 @@ compute_show_page, resolve_conditions, ) +from .renderers import XMLRenderer +from .serializers.export import ProjectSerializer as ProjectExportSerializer from .serializers.v1 import ( IntegrationSerializer, InviteSerializer, @@ -566,6 +569,44 @@ def imports(self, request): 'href': reverse('project_create_import', args=[key]) } for key, label, class_name in settings.PROJECT_IMPORTS if key in settings.PROJECT_IMPORTS_LIST] ) + @action(detail=True, methods=['get'], + permission_classes=(HasModelPermission | HasProjectPermission, ), + url_path='export(?:/(?P[a-z]+))?') + def export(self, request, pk=None, export_format='xml'): + if export_format == 'xml': + serializer = ProjectExportSerializer(self.get_object()) + xml = XMLRenderer().render(serializer.data, context=self.get_export_renderer_context(request)) + return XMLResponse(xml, name=self.get_object().title) + else: + context = self.get_export_renderer_context(request) + context["project"] = self.get_object() + # prefetch most elements of the catalog + context["project"].catalog.prefetch_elements() + + try: + context["current_snapshot"] = context["project"].snapshots.get(pk=self.kwargs.get("snapshot_id")) + except Snapshot.DoesNotExist: + context["current_snapshot"] = None + + context.update( + { + "project_wrapper": ProjectWrapper(context["project"], context["current_snapshot"]), + "title": context["project"].title, + "format": self.kwargs.get("format"), + "resource_path": get_value_path(context["project"], context["current_snapshot"]), + } + ) + return render_to_format( + self.request, export_format, self.get_object().title, 'projects/project_answers_export.html', context + ) + + def get_export_renderer_context(self, request): + full = is_truthy(request.GET.get('full')) + return { + 'snapshots': full or is_truthy(request.GET.get('snapshots', True)), + } + + def perform_create(self, serializer): project = serializer.save(site=get_current_site(self.request)) From 9ababd3f3f9a7aefe4d6c9411621fe28073b2ccb Mon Sep 17 00:00:00 2001 From: David Wallace Date: Fri, 18 Jul 2025 11:31:26 +0200 Subject: [PATCH 137/165] projects(viewsets): use project export plugins instead for export action Signed-off-by: David Wallace --- rdmo/projects/viewsets.py | 41 ++++++++++++++------------------------- 1 file changed, 15 insertions(+), 26 deletions(-) diff --git a/rdmo/projects/viewsets.py b/rdmo/projects/viewsets.py index 1a51213dfb..0a2d1965c3 100644 --- a/rdmo/projects/viewsets.py +++ b/rdmo/projects/viewsets.py @@ -25,13 +25,14 @@ from rdmo.core.constants import VALUE_TYPE_FILE from rdmo.core.exports import XMLResponse from rdmo.core.permissions import HasModelPermission -from rdmo.core.utils import human2bytes, is_truthy, render_to_format, return_file_response +from rdmo.core.utils import human2bytes, is_truthy, return_file_response from rdmo.options.models import OptionSet from rdmo.questions.models import Catalog, Page, Question, QuestionSet from rdmo.tasks.models import Task from rdmo.views.models import View from rdmo.views.utils import ProjectWrapper +from ..core.plugins import get_plugin from .filters import ( AttributeFilterBackend, OptionFilterBackend, @@ -100,7 +101,6 @@ copy_project, get_contact_message, get_upload_accept, - get_value_path, send_contact_message, send_invite_email, ) @@ -573,32 +573,21 @@ def imports(self, request): permission_classes=(HasModelPermission | HasProjectPermission, ), url_path='export(?:/(?P[a-z]+))?') def export(self, request, pk=None, export_format='xml'): + project = self.get_object() + project.catalog.prefetch_elements() + context = self.get_export_renderer_context(request) + if export_format == 'xml': - serializer = ProjectExportSerializer(self.get_object()) - xml = XMLRenderer().render(serializer.data, context=self.get_export_renderer_context(request)) - return XMLResponse(xml, name=self.get_object().title) + serializer = ProjectExportSerializer(project) + xml = XMLRenderer().render(serializer.data, context=context) + return XMLResponse(xml, name=project.title) else: - context = self.get_export_renderer_context(request) - context["project"] = self.get_object() - # prefetch most elements of the catalog - context["project"].catalog.prefetch_elements() - - try: - context["current_snapshot"] = context["project"].snapshots.get(pk=self.kwargs.get("snapshot_id")) - except Snapshot.DoesNotExist: - context["current_snapshot"] = None - - context.update( - { - "project_wrapper": ProjectWrapper(context["project"], context["current_snapshot"]), - "title": context["project"].title, - "format": self.kwargs.get("format"), - "resource_path": get_value_path(context["project"], context["current_snapshot"]), - } - ) - return render_to_format( - self.request, export_format, self.get_object().title, 'projects/project_answers_export.html', context - ) + plugin = get_plugin("PROJECT_EXPORTS", export_format) + if plugin is None: + raise Http404 + plugin.project = project + plugin.snapshot = None + return plugin.render() def get_export_renderer_context(self, request): full = is_truthy(request.GET.get('full')) From 8b3dfd07ff26bfe869df92c8fadfd4f88c5abc60 Mon Sep 17 00:00:00 2001 From: David Wallace Date: Fri, 18 Jul 2025 11:32:17 +0200 Subject: [PATCH 138/165] projects(tests): add test for export action Signed-off-by: David Wallace --- rdmo/projects/tests/test_viewset_project.py | 73 ++++++++++++++++++--- 1 file changed, 63 insertions(+), 10 deletions(-) diff --git a/rdmo/projects/tests/test_viewset_project.py b/rdmo/projects/tests/test_viewset_project.py index fd56761a8b..4f530349c6 100644 --- a/rdmo/projects/tests/test_viewset_project.py +++ b/rdmo/projects/tests/test_viewset_project.py @@ -1,3 +1,6 @@ +import json +import xml.etree.ElementTree as et + import pytest from django.contrib.auth.models import Group, User @@ -45,16 +48,17 @@ } urlnames = { - 'list': 'v1-projects:project-list', - 'user': 'v1-projects:project-user', - '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', - 'resolve': 'v1-projects:project-resolve', - 'upload_accept': 'v1-projects:project-upload-accept', - 'imports': 'v1-projects:project-imports' + "list": "v1-projects:project-list", + "user": "v1-projects:project-user", + "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", + "resolve": "v1-projects:project-resolve", + "upload_accept": "v1-projects:project-upload-accept", + "imports": "v1-projects:project-imports", + "export": "v1-projects:project-export", } projects = [1, 2, 3, 4, 5, 12] @@ -76,6 +80,7 @@ owner_projects = [1, 2, 3, 4, 5, 10, 12] page_size = 5 +export_formats = [None, 'xml','csvcomma', 'json'] @pytest.mark.parametrize('username,password', users) def test_list(db, client, username, password): @@ -673,3 +678,51 @@ def test_imports(db, client, username, password): assert response.json()[0]['key'] == 'url' else: assert response.status_code == 401 + +@pytest.mark.parametrize('username,password', users) +@pytest.mark.parametrize('export_format', export_formats) +def test_export(db, client, username, password, export_format): + client.login(username=username, password=password) + + if export_format: + url = reverse(urlnames["export"], kwargs={"pk": project_id, "export_format": export_format}) + else: + url = reverse(urlnames["export"], kwargs={"pk": project_id}) + + response = client.get(url) + + if project_id in view_project_permission_map.get(username, []): + assert response.status_code == 200 + assert response.content + + # XML (default or explicit) + if export_format in (None, "xml"): + root = et.fromstring(response.content) + assert root.tag == "project" + for child in root: + assert child.tag in ['title', 'description', 'catalog', 'tasks', 'views', + 'snapshots', 'values', 'created', 'updated'] + + # JSON + elif export_format == "json": + data = json.loads(response.content.decode()) + assert len(data) > 1 + assert all( + all( + a in ['question','set', 'values'] + for a in i.keys() + ) + for i in data + ) + + # HTML (or any other plugin-provided format) + elif export_format == "csvcomma": + data = response.content.decode().splitlines() + assert len(data) > 1 + assert any("Lorem" in i for i in data) + else: + if password: + # you have permission to see detail but not export + assert response.status_code in (403, 404) + else: + assert response.status_code == 401 From cdf99e9857ac4766983a81c0eb60c7dc072165a8 Mon Sep 17 00:00:00 2001 From: David Wallace Date: Fri, 18 Jul 2025 11:37:57 +0200 Subject: [PATCH 139/165] projects(viewsets): use rdmo.core import Signed-off-by: David Wallace --- rdmo/projects/viewsets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rdmo/projects/viewsets.py b/rdmo/projects/viewsets.py index 0a2d1965c3..0a7d923627 100644 --- a/rdmo/projects/viewsets.py +++ b/rdmo/projects/viewsets.py @@ -25,6 +25,7 @@ from rdmo.core.constants import VALUE_TYPE_FILE from rdmo.core.exports import XMLResponse from rdmo.core.permissions import HasModelPermission +from rdmo.core.plugins import get_plugin from rdmo.core.utils import human2bytes, is_truthy, return_file_response from rdmo.options.models import OptionSet from rdmo.questions.models import Catalog, Page, Question, QuestionSet @@ -32,7 +33,6 @@ from rdmo.views.models import View from rdmo.views.utils import ProjectWrapper -from ..core.plugins import get_plugin from .filters import ( AttributeFilterBackend, OptionFilterBackend, From ffc466caac563833693cf79decf7a67c3deeb6d7 Mon Sep 17 00:00:00 2001 From: David Wallace Date: Fri, 18 Jul 2025 11:52:42 +0200 Subject: [PATCH 140/165] projects(tests): update test_export Signed-off-by: David Wallace --- rdmo/projects/tests/test_viewset_project.py | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/rdmo/projects/tests/test_viewset_project.py b/rdmo/projects/tests/test_viewset_project.py index 4f530349c6..4eb2b107ae 100644 --- a/rdmo/projects/tests/test_viewset_project.py +++ b/rdmo/projects/tests/test_viewset_project.py @@ -1,5 +1,7 @@ +import csv import json import xml.etree.ElementTree as et +from io import StringIO import pytest @@ -707,22 +709,19 @@ def test_export(db, client, username, password, export_format): elif export_format == "json": data = json.loads(response.content.decode()) assert len(data) > 1 - assert all( - all( - a in ['question','set', 'values'] - for a in i.keys() - ) - for i in data - ) + for item in data: + # each item should have exactly these keys + assert set(item) == {"question", "set", "values"} # HTML (or any other plugin-provided format) elif export_format == "csvcomma": - data = response.content.decode().splitlines() + f = StringIO(response.content.decode()) + reader = csv.reader(f) + data = list(reader) assert len(data) > 1 - assert any("Lorem" in i for i in data) + assert any("Lorem" in cell for row in data for cell in row) else: if password: - # you have permission to see detail but not export - assert response.status_code in (403, 404) + assert response.status_code == 404 else: assert response.status_code == 401 From 2c0666a2d1ffd8a5d5a3577563a5d50f0294bf26 Mon Sep 17 00:00:00 2001 From: David Wallace Date: Fri, 18 Jul 2025 17:09:33 +0200 Subject: [PATCH 141/165] projects(imports): refactor and reuse import plugin Signed-off-by: David Wallace --- rdmo/accounts/utils.py | 27 ++ rdmo/projects/imports.py | 134 ++++++++ .../management/commands/import_projects.py | 314 ++++++------------ rdmo/projects/management/commands/utils.py | 25 -- rdmo/projects/utils.py | 74 ++++- 5 files changed, 339 insertions(+), 235 deletions(-) diff --git a/rdmo/accounts/utils.py b/rdmo/accounts/utils.py index 75348fa65b..627bcaff46 100644 --- a/rdmo/accounts/utils.py +++ b/rdmo/accounts/utils.py @@ -4,7 +4,11 @@ from django.contrib.auth.models import Group, Permission from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ObjectDoesNotExist +from django.utils.crypto import get_random_string +from django.utils.text import slugify + +from .models import Role from .settings import GROUPS log = logging.getLogger(__name__) @@ -78,3 +82,26 @@ def get_user_from_db_or_none(username: str, email: str): except ObjectDoesNotExist: log.error('Retrieval of user "%s" with email "%s" failed, user does not exist', username, email) return None + + +def make_unique_username(seed: str) -> str: + """ + Return a DB-unique username derived from *seed*. + + * seed -> "markus" → "markus" (if free) + * ... → "markus_1", "markus_2", … + * after 99 tries → "markus_<8-random-chars>" + """ + base = slugify(seed) or "user" + candidate = base + suffix = 0 + + while User.objects.filter(username=candidate).exists(): + suffix += 1 + if suffix <= 99: + candidate = f"{base}_{suffix}" + else: # extreme edge case + candidate = f"{base}_{get_random_string(8)}" + break + + return candidate diff --git a/rdmo/projects/imports.py b/rdmo/projects/imports.py index 47440f787b..54bc4ca05c 100644 --- a/rdmo/projects/imports.py +++ b/rdmo/projects/imports.py @@ -4,6 +4,7 @@ import mimetypes from django import forms +from django.contrib.sites.models import Site from django.core.exceptions import ValidationError from django.core.files import File from django.shortcuts import redirect, render @@ -21,6 +22,12 @@ from rdmo.views.models import View from .models import Project, Snapshot, Value +from .utils import ( + save_import_snapshot_values, + save_import_tasks, + save_import_values, + save_import_views, +) log = logging.getLogger(__name__) @@ -85,6 +92,133 @@ def get_view(self, view_uri): except KeyError: log.info('View %s not in db. Skipping.', view_uri) + def _clear_currents(self) -> None: + for value in self.values: + value.current = None + for snapshot in self.snapshots: + for val in snapshot.snapshot_values: + val.current = None + + def _gather_checked_keys(self) -> tuple[set[str], set[str]]: + checked_values = { + f'{v.attribute.uri}[{v.set_prefix}][{v.set_index}][{v.collection_index}]' + for v in self.values + if v.attribute + } + + checked_snapshots: set[str] = set() + for snapshot in self.snapshots: + for v in snapshot.snapshot_values: + if v.attribute: + checked_snapshots.add( + f'{v.attribute.uri}' + f'[{snapshot.snapshot_index}]' + f'[{v.set_prefix}][{v.set_index}][{v.collection_index}]' + ) + + return checked_values, checked_snapshots + + def prepare_import(self) -> dict: + """ + Step 1: run check()/process(), clear currents, gather keys, + and return a JSON-serializable preview dict: + { + "values": [{ "key": "...", "text": "...", "attribute": "...", "option": "..." }, …], + "snapshots":[ { "index": 0, "title": "...", "values": […] }, … ], + "tasks": [{ "uri": "...", "title": "..." }, …], + "views": [{ "uri": "...", "title": "..." }, …] + } + """ + # run the standard plugin steps + if not self.check(): + raise ValidationError(f'Import plugin rejected file "{self.file_name}".') + self.process() + + # clear any .current so we treat everything as new + self._clear_currents() + + # build raw keys (we'll use them client-side to POST back selections) + checked_values, checked_snapshots = self._gather_checked_keys() + + # build a minimal preview payload + preview = {"values": [], "snapshots": [], "tasks": [], "views": []} + + for v in self.values: + key = f"{v.attribute.uri}[{v.set_prefix}][{v.set_index}][{v.collection_index}]" + preview["values"].append( + { + "key": key, + "text": v.text, + "attribute": v.attribute.uri, + "option": v.option.uri if v.option else None, + } + ) + + for snap in self.snapshots: + vals = [] + for v in snap.snapshot_values: + key = f"{v.attribute.uri}[{snap.snapshot_index}][{v.set_prefix}][{v.set_index}][{v.collection_index}]" + vals.append( + { + "key": key, + "text": v.text, + "attribute": v.attribute.uri, + "option": v.option.uri if v.option else None, + } + ) + preview["snapshots"].append({"index": snap.snapshot_index, "title": snap.title, "values": vals}) + + for task in self.tasks: + preview["tasks"].append({"uri": task.uri, "title": task.title}) + + for view in self.views: + preview["views"].append({"uri": view.uri, "title": view.title}) + + return preview + + def import_to_project( + self, checked_values: set[str] | None = None, checked_snapshots: set[str] | None = None + ) -> Project: + """ + 1) If we have not yet run check()/process(), do so now (so self.project + is created). + 2) Clear any .current references. + 3) Build or accept the passed key-sets. + 4) Save the project (assigning Site if new). + 5) Call save_import_values, snapshot_values, tasks, views. + + Returns the saved Project. + """ + # 1) Ensure the plugin has been run + if self.project is None: + if not self.check(): + raise ValidationError(f'Plugin rejected file "{self.file_name}".') + self.process() + + # 2) Clear any stale .current pointers + self._clear_currents() + + # 3) Decide which keys to import + all_values, all_snapshots = self._gather_checked_keys() + cvs = checked_values if checked_values is not None else all_values + css = checked_snapshots if checked_snapshots is not None else all_snapshots + + # 4) Persist the Project object + proj = self.project + if proj is None: + raise ValidationError("Import plugin did not set a Project.") + if proj.pk is None: + proj.site = Site.objects.get_current() + proj.save() + + # 5) Write out values, snapshots, tasks, views + save_import_values(proj, self.values, cvs) + save_import_snapshot_values(proj, self.snapshots, css) + save_import_tasks(proj, self.tasks) + save_import_views(proj, self.views) + + return proj + class RDMOXMLImport(Import): diff --git a/rdmo/projects/management/commands/import_projects.py b/rdmo/projects/management/commands/import_projects.py index aba8b6f9e3..dc52ec9783 100644 --- a/rdmo/projects/management/commands/import_projects.py +++ b/rdmo/projects/management/commands/import_projects.py @@ -2,24 +2,17 @@ import json import logging -from collections.abc import Iterable from pathlib import Path from django.contrib.auth import get_user_model -from django.contrib.sites.models import Site +from django.core.exceptions import ValidationError from django.core.management.base import BaseCommand, CommandError -from django.db import IntegrityError, transaction +from django.db import transaction from rdmo.core.plugins import get_plugin -from rdmo.projects.models import Membership, Project -from rdmo.projects.utils import ( - save_import_snapshot_values, - save_import_tasks, - save_import_values, - save_import_views, -) +from rdmo.projects.utils import import_memberships -from .utils import FakeRequest, get_cli_user, make_unique_username +from .utils import FakeRequest, get_cli_user logger = logging.getLogger(__name__) User = get_user_model() @@ -28,68 +21,73 @@ class Command(BaseCommand): """ Import projects that were previously exported via ``export_projects``. - The command expects the directory structure created by the export - (each project in a numbered sub-folder containing one ``*.xml`` file - and optionally a ``members.json``). + You may either point to a directory of exported sub-folders, or + explicitly list XML file paths. Examples -------- - # import everything that is inside ./exports + # import all projects inside ./exports $ python manage.py import_projects - # only import projects 2 and 5, create memberships, use a custom plugin - $ python manage.py import_projects --projects 2 5 --with-members --format madmp + # import only project folders 2 and 5 + $ python manage.py import_projects --dir ./exports --projects 2 5 + + # import specific XML files directly + $ python manage.py import_projects --files /tmp/p1.xml /tmp/p2.xml + + # import with memberships and custom plugin + $ python manage.py import_projects --files /tmp/p1.xml \ + --with-members --format madmp --as-user admin """ - # --------------------------------------------------------------------- - # CLI argument definitions - # --------------------------------------------------------------------- def add_arguments(self, parser): parser.add_argument( - "--path", + "--dir", metavar="DIR", default="exports", - help="Directory with exported project sub-folders (default: exports/).", + help="Directory with exported project sub-folders (default: exports/)." + ) + parser.add_argument( + "--files", + nargs="*", + type=str, + metavar="XML", + help="Explicit paths to XML files to import." ) parser.add_argument( "--projects", nargs="*", type=int, metavar="ID", - help="Only import the listed project-ID folders.", + help="Only import the listed project-ID folders (when using --dir)." ) parser.add_argument( "--format", default="xml", - help="Import plugin key to use (must be configured in ``settings.PROJECT_IMPORTS``).", + help="Import plugin key to use (must be configured in ``settings.PROJECT_IMPORTS``)." ) parser.add_argument( "--with-members", action="store_true", - help="Also read a companion ``members.json`` and recreate memberships.", + help="Also read a companion ``members.json`` and recreate memberships." + ) + parser.add_argument( + "--allow-new-users", + action="store_true", + help="When importing members, auto-create missing users. " + "Default is to only use existing users (error if missing)." ) parser.add_argument( "--as-user", metavar="USER", help="Pretend the import is executed by this user (pk or username). " - "Defaults to the first superuser.", + "Defaults to the first superuser." ) - # --------------------------------------------------------------------- - # main entry point - # --------------------------------------------------------------------- def handle(self, *args, **options): - base_path = Path(options["path"]).expanduser().resolve() - if not base_path.is_dir(): - raise CommandError( - f'Path "{base_path}" does not exist or is not a directory.' - ) - - project_filter: set[int] | None = ( - set(options["projects"]) if options.get("projects") else None - ) - plugin_key: str = options["format"] - import_members: bool = options["with_members"] + plugin_key = options["format"] + import_members = options["with_members"] + create_users = options["allow_new_users"] self.import_user = get_cli_user(options.get("as_user")) # sanity-check plugin key @@ -99,196 +97,96 @@ def handle(self, *args, **options): "Check your ``PROJECT_IMPORTS`` setting." ) - failures: list[tuple[Path, str]] = [] + xml_files: list[Path] = [] + explicit = options.get("files") + if explicit: + # Use explicitly provided XML file paths + for file_str in explicit: + xml_file = Path(file_str).expanduser().resolve() + if not xml_file.is_file(): + raise CommandError(f'File "{xml_file}" does not exist or is not a file.') + xml_files.append(xml_file) + else: + # Scan a directory of numbered subfolders + base_path = Path(options["dir"]).expanduser().resolve() + if not base_path.is_dir(): + raise CommandError(f'Dir "{base_path}" does not exist or is not a directory.') + + project_filter: set[int] | None = ( + set(options["projects"]) if options.get("projects") else None + ) - # iterate over sub-folders -------------------------------------------------- - for project_dir in sorted(base_path.iterdir(), key=lambda p: p.name): - if not project_dir.is_dir(): - continue + for project_dir in sorted(base_path.iterdir(), key=lambda p: p.name): + if not project_dir.is_dir(): + continue + + try: + dir_id = int(project_dir.name) + except ValueError: + self.stdout.write( + self.style.WARNING( + f'Skip "{project_dir.name}", folder name is not a number.' + ) + ) + continue - try: - dir_id = int(project_dir.name) - except ValueError: - self.stdout.write( - self.style.WARNING( - f'Skip "{project_dir.name}", folder name is not a number.' + if project_filter and dir_id not in project_filter: + continue + + xml_candidates = list(project_dir.glob("*.xml")) + if not xml_candidates: + self.stdout.write( + self.style.WARNING(f'No XML file found in "{project_dir}".') ) - ) - continue + continue - if project_filter and dir_id not in project_filter: - continue + xml_files.append(xml_candidates[0]) - # each sub-folder *must* contain exactly one XML file ------------------ - xml_candidates = list(project_dir.glob("*.xml")) - if not xml_candidates: - self.stdout.write( - self.style.WARNING(f'No XML file found in "{project_dir}".') - ) - continue + if not xml_files: + self.stdout.write(self.style.WARNING("No XML files to import.")) + return - xml_file = xml_candidates[0] - self.stdout.write(f"→ Importing {xml_file.relative_to(base_path)}") + failures: list[tuple[Path, str]] = [] + for xml_file in xml_files: + self.stdout.write(f"→ Importing {xml_file}") try: with transaction.atomic(): - project = self._import_single_project(xml_file, plugin_key) + plugin = get_plugin("PROJECT_IMPORTS", plugin_key) + plugin.file_name = str(xml_file) + plugin.request = FakeRequest(self.import_user) + plugin.current_project = None + + project = plugin.import_to_project() + if import_members: - self._import_members(project, project_dir / "members.json") + members_path = xml_file.parent / "members.json" + if not members_path.is_file(): + raise CommandError(f"No members.json alongside {xml_file.name}") + data = json.loads(members_path.read_text(encoding="utf-8")) + try: + created, skipped = import_memberships(project, data, create_users=create_users) + except ValidationError as exc: + raise CommandError(str(exc)) from exc + self.stdout.write(self.style.SUCCESS(f" ✓ {created} memberships added, {skipped} skipped.")) self.stdout.write( self.style.SUCCESS( f' ✓ Project {project.pk} "{project.title}" imported successfully.' ) ) - except CommandError as exc: # expected / user-facing + except CommandError as exc: logger.exception("Import failed for %s", xml_file) self.stdout.write(self.style.ERROR(f" ✗ {exc} (see log)")) - failures.append((xml_file.relative_to(base_path), str(exc))) - continue + failures.append((xml_file, str(exc))) except Exception as exc: - # unexpected; cancel this project but keep processing others - logger.exception("Import failed for %s", xml_file) + # unexpected; cancel this project but continue others + logger.exception("Unexpected error importing %s", xml_file) self.stdout.write(self.style.ERROR(f" ✗ {exc} (see log)")) - failures.append((xml_file.relative_to(base_path), str(exc))) - raise + failures.append((xml_file, str(exc))) if failures: self.stdout.write(self.style.NOTICE("\nImport finished with errors:")) for path, msg in failures: self.stdout.write(f" • {path}: {msg}") - self.stdout.write("") # final newline for readability - - # --------------------------------------------------------------------- - # helpers - # --------------------------------------------------------------------- - def _import_single_project(self, xml_file: Path, plugin_key: str) -> Project: - """Run the configured import plugin and persist project + values.""" - # fresh plugin instance each time - plugin = get_plugin("PROJECT_IMPORTS", plugin_key) - plugin.file_name = str(xml_file) - plugin.request = FakeRequest(self.import_user) # CLI → Fake HTTP request - plugin.current_project = None # always *create* a new project - - if not plugin.check(): - raise CommandError( - f'Plugin "{plugin_key}" rejected file "{xml_file.name}".' - ) - - plugin.process() - - for val in plugin.values: - val.current = None - for snap in plugin.snapshots: - for val in snap.snapshot_values: - val.current = None - - project: Project = plugin.project - if project.pk is None: # ensure object is saved before M2M relations - project.site = Site.objects.get_current() - project.save() - - # ----------------------------------------------------------------- - # build “checked” sets so that *all* answers / snapshots are imported - # (the utils functions only import rows that we explicitly mark) - # ----------------------------------------------------------------- - checked_values: set[str] = { - f"{v.attribute.uri}[{v.set_prefix}][{v.set_index}][{v.collection_index}]" - for v in plugin.values - if v.attribute - } - - checked_snapshots: set[str] = set() - for snapshot in plugin.snapshots: - checked_snapshots.update( - f"{val.attribute.uri}[{snapshot.snapshot_index}][{val.set_prefix}][{val.set_index}][{val.collection_index}]" - for val in snapshot.snapshot_values - if val.attribute - ) - - # persist everything ------------------------------------------------ - save_import_values(project, plugin.values, checked_values) - save_import_snapshot_values(project, plugin.snapshots, checked_snapshots) - save_import_tasks(project, plugin.tasks) - save_import_views(project, plugin.views) - - return project - - # --------------------------------------------------------------------- - # optional membership import - # --------------------------------------------------------------------- - def _import_members(self, project: Project, members_json: Path) -> None: - if not members_json.is_file(): - self.stdout.write( - self.style.WARNING( - " ↺ No members.json present, skipping memberships." - ) - ) - return - - try: - payload: Iterable[dict] = json.loads( - members_json.read_text(encoding="utf-8") - ) - except (json.JSONDecodeError, OSError) as exc: - raise CommandError(f"Failed to read {members_json}: {exc}") from exc - - created, skipped = 0, 0 - for record in payload: - - email = (record.get("email") or "").lower() - user = None - - if email: - user = User.objects.filter(email__iexact=email).first() - - if not user and email: - # choose seed: provided username or local-part of e-mail - desired = record.get("username") or email.split("@")[0] - - username = make_unique_username(desired) - - user = User.objects.create_user( - username=username, - email=email, - first_name=record.get("first_name", ""), - last_name=record.get("last_name", ""), - is_active=True, - ) - user.set_unusable_password() - user.save(update_fields=["password"]) - - if username != desired: - logger.info( - "Username '%s' already taken, auto-created unique name '%s'.", - desired, - username, - ) - - if user is None: - skipped += 1 - self.stdout.write( - self.style.WARNING(f" ↺ No user for {record!r}, skipping.") - ) - continue - - role = record.get("role") or "guest" - try: - Membership.objects.update_or_create( - project=project, user=user, defaults={"role": role} - ) - except IntegrityError as exc: - logger.warning( - " ↺ Duplicate membership %s / %s: %s", project.pk, user.pk, exc - ) - skipped += 1 - else: - self.stdout.write(f' • added "{user.username}" as {role}') - created += 1 - - if created: - logger.info("Added %d membership(s) to project %s", created, project.pk) - if skipped: - logger.info( - "Skipped %d membership record(s) for project %s", skipped, project.pk - ) + self.stdout.write("") # final newline diff --git a/rdmo/projects/management/commands/utils.py b/rdmo/projects/management/commands/utils.py index d6b56268cb..39690cde5f 100644 --- a/rdmo/projects/management/commands/utils.py +++ b/rdmo/projects/management/commands/utils.py @@ -5,8 +5,6 @@ from django.contrib.auth import get_user_model from django.contrib.auth.models import AbstractBaseUser, AnonymousUser -from django.utils.crypto import get_random_string -from django.utils.text import slugify User = get_user_model() @@ -50,26 +48,3 @@ class FakeRequest: user: AbstractBaseUser | AnonymousUser session: dict[str, Any] = dataclasses.field(default_factory=dict) - - -def make_unique_username(seed: str) -> str: - """ - Return a DB-unique username derived from *seed*. - - * seed -> "markus" → "markus" (if free) - * ... → "markus_1", "markus_2", … - * after 99 tries → "markus_<8-random-chars>" - """ - base = slugify(seed) or "user" - candidate = base - suffix = 0 - - while User.objects.filter(username=candidate).exists(): - suffix += 1 - if suffix <= 99: - candidate = f"{base}_{suffix}" - else: # extreme edge case - candidate = f"{base}_{get_random_string(8)}" - break - - return candidate diff --git a/rdmo/projects/utils.py b/rdmo/projects/utils.py index fc2f9582b9..ed8fc2b929 100644 --- a/rdmo/projects/utils.py +++ b/rdmo/projects/utils.py @@ -1,21 +1,26 @@ import logging import mimetypes from collections import defaultdict +from collections.abc import Iterable from pathlib import Path from django.conf import settings +from django.contrib.auth import get_user_model from django.contrib.sites.models import Site -from django.core.exceptions import ObjectDoesNotExist +from django.core.exceptions import ObjectDoesNotExist, ValidationError +from django.db import IntegrityError from django.template.loader import render_to_string from django.urls import reverse from django.utils.timezone import now +from rdmo.accounts.utils import make_unique_username from rdmo.core.mail import send_mail from rdmo.core.plugins import get_plugins from rdmo.core.utils import remove_double_newlines +from rdmo.projects.models import Membership, Project logger = logging.getLogger(__name__) - +User = get_user_model() def get_value_path(project, snapshot=None): if snapshot is None: @@ -368,3 +373,68 @@ def send_contact_message(request, subject, message): send_mail(subject, message, to=settings.PROJECT_CONTACT_RECIPIENTS, cc=[request.user.email], reply_to=[request.user.email]) + + +def import_memberships(project: Project, records: Iterable[dict], create_users: bool = True) -> tuple[int, int]: + """ + Assigns Memberships on `project` based on `records`. + + Each record may include 'user_id', 'email', 'username', + 'first_name', 'last_name', 'role', etc. + + If create_users=False, any record for which no existing User + can be found will raise ValidationError. Otherwise, missing + users will be auto-created (with unusable password). + + Returns: (created_count, skipped_count) + """ + created = skipped = 0 + + for rec in records: + # 1) find or (optionally) create user + user = None + + # a) by PK + user_id = rec.get("user_id") + if user_id is not None: + user = User.objects.filter(pk=user_id).first() + + # b) by email + email = (rec.get("email") or "").lower() + if not user and email: + user = User.objects.filter(email__iexact=email).first() + + # c) by username + username = rec.get("username") + if not user and username: + user = User.objects.filter(username=username).first() + + if not user: + if not create_users: + raise ValidationError(f"No existing user for record {rec!r}") + # auto-create + desired = username or (email.split("@")[0] if email else "") + username = make_unique_username(desired) + user = User.objects.create_user( + username=username, + email=email, + first_name=rec.get("first_name", ""), + last_name=rec.get("last_name", ""), + is_active=True, + ) + user.set_unusable_password() + user.save(update_fields=["password"]) + if username != desired: + logger.info("Username '%s' taken, created unique name '%s'.", desired, username) + + # 2) assign Membership + role = rec.get("role") or "guest" + try: + Membership.objects.update_or_create(project=project, user=user, defaults={"role": role}) + except IntegrityError as exc: + logger.warning("Duplicate membership %s / %s: %s", project.pk, user.pk, exc) + skipped += 1 + else: + created += 1 + + return created, skipped From 6bbd196a293a9fc257f9035ce8af80c311a64a74 Mon Sep 17 00:00:00 2001 From: David Wallace Date: Fri, 18 Jul 2025 17:20:22 +0200 Subject: [PATCH 142/165] projects(viewsets): add import_preview and import_confirm actions Signed-off-by: David Wallace --- rdmo/core/settings.py | 1 + rdmo/projects/serializers/v1/__init__.py | 54 ++++++++++++++++ rdmo/projects/viewsets.py | 80 ++++++++++++++++++++++++ 3 files changed, 135 insertions(+) diff --git a/rdmo/core/settings.py b/rdmo/core/settings.py index 7719282465..2dcf82e254 100644 --- a/rdmo/core/settings.py +++ b/rdmo/core/settings.py @@ -206,6 +206,7 @@ 'SWAGGER_UI_DIST': 'SIDECAR', 'SWAGGER_UI_FAVICON_HREF': 'SIDECAR', 'REDOC_DIST': 'SIDECAR', + 'COMPONENT_SPLIT_REQUEST': True, # this makes file upload for FileField work in Swagger UI } SETTINGS_EXPORT = [ diff --git a/rdmo/projects/serializers/v1/__init__.py b/rdmo/projects/serializers/v1/__init__.py index 05b7b29f56..613226e76f 100644 --- a/rdmo/projects/serializers/v1/__init__.py +++ b/rdmo/projects/serializers/v1/__init__.py @@ -7,6 +7,9 @@ from rest_framework import serializers +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import extend_schema_field + from rdmo.accounts.serializers.v1 import UserLookupSerializer from rdmo.accounts.utils import get_full_name from rdmo.core.serializers import TranslationSerializerMixin @@ -271,6 +274,57 @@ class Meta: read_only_fields = ProjectSerializer.Meta.read_only_fields +class ProjectImportPreviewSerializer(serializers.Serializer): + file = serializers.FileField( + help_text="The file to inspect before import." + ) + format = serializers.CharField( + default="xml", + help_text="Import plugin key (e.g. 'xml' or custom)." + ) + + +class ProjectImportPreviewResponseSerializer(serializers.Serializer): + """ + Mirrors the dict returned by plugin.prepare_import(): + { values: […], snapshots: […], tasks: […], views: […] } + """ + values = serializers.ListField( + child=serializers.DictField(), + help_text="List of all candidate values (with their 'key', 'text', etc.)" + ) + snapshots = serializers.ListField( + child=serializers.DictField(), + help_text="List of snapshot groups (each with index, title and values)" + ) + tasks = serializers.ListField( + child=serializers.DictField(), + help_text="List of available tasks (each dict with 'uri'/'title')" + ) + views = serializers.ListField( + child=serializers.DictField(), + help_text="List of available views (each dict with 'uri'/'title')" + ) + + +class ProjectImportConfirmSerializer(serializers.Serializer): + file = serializers.FileField( + help_text="The same file (must match preview step)." + ) + format = serializers.CharField( + default="xml", + help_text="Import plugin key (same as preview)." + ) + checked_values = serializers.ListField( + child=serializers.CharField(), + help_text="List of value keys the user wants to import." + ) + checked_snapshots = serializers.ListField( + child=serializers.CharField(), + help_text="List of snapshot keys the user wants to import." + ) + + class ProjectVisibilitySerializer(serializers.ModelSerializer): class Meta: diff --git a/rdmo/projects/viewsets.py b/rdmo/projects/viewsets.py index 0a7d923627..4eadb119eb 100644 --- a/rdmo/projects/viewsets.py +++ b/rdmo/projects/viewsets.py @@ -13,17 +13,20 @@ from rest_framework.filters import SearchFilter from rest_framework.mixins import CreateModelMixin, ListModelMixin, RetrieveModelMixin, UpdateModelMixin from rest_framework.pagination import PageNumberPagination +from rest_framework.parsers import FormParser, MultiPartParser from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.reverse import reverse from rest_framework.viewsets import GenericViewSet, ModelViewSet, ReadOnlyModelViewSet from django_filters.rest_framework import DjangoFilterBackend +from drf_spectacular.utils import extend_schema from rest_framework_extensions.mixins import NestedViewSetMixin from rdmo.conditions.models import Condition from rdmo.core.constants import VALUE_TYPE_FILE from rdmo.core.exports import XMLResponse +from rdmo.core.imports import handle_uploaded_file from rdmo.core.permissions import HasModelPermission from rdmo.core.plugins import get_plugin from rdmo.core.utils import human2bytes, is_truthy, return_file_response @@ -71,6 +74,9 @@ ProjectAnswersSerializer, ProjectCopySerializer, ProjectHierarchySerializer, + ProjectImportConfirmSerializer, + ProjectImportPreviewResponseSerializer, + ProjectImportPreviewSerializer, ProjectIntegrationSerializer, ProjectInviteCreateSerializer, ProjectInviteSerializer, @@ -569,6 +575,80 @@ def imports(self, request): 'href': reverse('project_create_import', args=[key]) } for key, label, class_name in settings.PROJECT_IMPORTS if key in settings.PROJECT_IMPORTS_LIST] ) + @extend_schema( + request=ProjectImportPreviewSerializer, + responses={200: ProjectImportPreviewResponseSerializer}, + ) + @action( + detail=False, + methods=["post"], + url_path="import-preview", + parser_classes=[MultiPartParser, FormParser], + permission_classes=[IsAuthenticated], + ) + def import_preview(self, request): + upload = request.FILES.get("file") + fmt = request.data.get("format", "xml") + + if not upload: + return Response({"detail": "No file uploaded."}, status=status.HTTP_400_BAD_REQUEST) + + tmp_path = handle_uploaded_file(upload) + + plugin = get_plugin("PROJECT_IMPORTS", fmt) + if plugin is None: + return Response({"detail": f'Format "{fmt}" not configured.'}, status=status.HTTP_400_BAD_REQUEST) + + plugin.file_name = tmp_path + plugin.request = request + plugin.current_project = None + + try: + preview = plugin.prepare_import() + except ValidationError as exc: + return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST) + + return Response(preview, status=status.HTTP_200_OK) + + @extend_schema( + request=ProjectImportConfirmSerializer, + responses={201: ProjectSerializer}, + ) + @action( + detail=False, + methods=["post"], + url_path="import-confirm", + parser_classes=[MultiPartParser, FormParser], + permission_classes=[IsAuthenticated], + ) + def import_confirm(self, request): + upload = request.FILES.get("file") + fmt = request.data.get("format", "xml") + cvs = set(request.data.get("checked_values", [])) + css = set(request.data.get("checked_snapshots", [])) + + if not upload: + return Response({"detail": "No file uploaded."}, status=status.HTTP_400_BAD_REQUEST) + + tmp_path = handle_uploaded_file(upload) + + plugin = get_plugin("PROJECT_IMPORTS", fmt) + if plugin is None: + return Response({"detail": f'Format "{fmt}" not configured.'}, status=status.HTTP_400_BAD_REQUEST) + + plugin.file_name = tmp_path + plugin.request = request + plugin.current_project = None + + try: + project = plugin.import_to_project(checked_values=cvs, checked_snapshots=css) + except ValidationError as exc: + return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST) + + serializer = ProjectSerializer(project, context={"request": request}) + return Response(serializer.data, status=status.HTTP_201_CREATED) + + @action(detail=True, methods=['get'], permission_classes=(HasModelPermission | HasProjectPermission, ), url_path='export(?:/(?P[a-z]+))?') From aad248a486b982b4a6ac7b79b341525585fd9063 Mon Sep 17 00:00:00 2001 From: David Wallace Date: Fri, 18 Jul 2025 18:14:28 +0200 Subject: [PATCH 143/165] style(projects): remove typing Signed-off-by: David Wallace --- rdmo/projects/imports.py | 2 +- .../projects/management/commands/export_projects.py | 13 ++++++------- .../projects/management/commands/import_projects.py | 2 +- rdmo/projects/management/commands/utils.py | 2 +- rdmo/projects/utils.py | 5 ++--- 5 files changed, 11 insertions(+), 13 deletions(-) diff --git a/rdmo/projects/imports.py b/rdmo/projects/imports.py index 54bc4ca05c..3b714c616f 100644 --- a/rdmo/projects/imports.py +++ b/rdmo/projects/imports.py @@ -177,7 +177,7 @@ def prepare_import(self) -> dict: return preview def import_to_project( - self, checked_values: set[str] | None = None, checked_snapshots: set[str] | None = None + self, checked_values = None, checked_snapshots = None ) -> Project: """ 1) If we have not yet run check()/process(), do so now (so self.project diff --git a/rdmo/projects/management/commands/export_projects.py b/rdmo/projects/management/commands/export_projects.py index e002483ef0..a026bdcefa 100644 --- a/rdmo/projects/management/commands/export_projects.py +++ b/rdmo/projects/management/commands/export_projects.py @@ -2,7 +2,6 @@ import json import logging -from collections.abc import Iterable from pathlib import Path from django.conf import settings @@ -50,14 +49,14 @@ def handle(self, *args, **options): self.with_members: bool = options['with_members'] project_ids = options.get('projects') or None site_id: int = options['site_id'] - catalog_uri: str | None = options.get('catalog_uri') or None + catalog_uri= options.get('catalog_uri') or None # upfront validations ------------------------------------------------ if export_mode in ('answers', 'view'): if self.format not in dict(settings.EXPORT_FORMATS): raise CommandError(f'Format "{self.format}" is not configured in settings.EXPORT_FORMATS.') - view_obj: View | None = None + view_obj= None if export_mode == 'view': try: view_obj = View.objects.get(uri=options['view']) @@ -89,9 +88,9 @@ def handle(self, *args, **options): def _get_queryset( self, - ids: Iterable[int] | None, - site_id: int | None, - catalog_uri: str | None, + ids, + site_id, + catalog_uri, ) -> QuerySet[Project]: """ Build a base queryset filtered by site_id and optional catalog_uri, @@ -123,7 +122,7 @@ def _export_project( project: Project, *, mode: str, - view: View | None = None, + view= None, ) -> None: """ Orchestrate the chosen export *mode* for one project and write the file diff --git a/rdmo/projects/management/commands/import_projects.py b/rdmo/projects/management/commands/import_projects.py index dc52ec9783..3e51c9b412 100644 --- a/rdmo/projects/management/commands/import_projects.py +++ b/rdmo/projects/management/commands/import_projects.py @@ -112,7 +112,7 @@ def handle(self, *args, **options): if not base_path.is_dir(): raise CommandError(f'Dir "{base_path}" does not exist or is not a directory.') - project_filter: set[int] | None = ( + project_filter = ( set(options["projects"]) if options.get("projects") else None ) diff --git a/rdmo/projects/management/commands/utils.py b/rdmo/projects/management/commands/utils.py index 39690cde5f..647dfd0c38 100644 --- a/rdmo/projects/management/commands/utils.py +++ b/rdmo/projects/management/commands/utils.py @@ -21,7 +21,7 @@ def replace_uri_in_template_string( return template -def get_cli_user(spec: str | None = None) -> AbstractBaseUser | AnonymousUser: +def get_cli_user(spec=None): """ Resolve *spec* to a real User instance (or AnonymousUser if nothing fits). diff --git a/rdmo/projects/utils.py b/rdmo/projects/utils.py index ed8fc2b929..31d3ab4d79 100644 --- a/rdmo/projects/utils.py +++ b/rdmo/projects/utils.py @@ -1,7 +1,6 @@ import logging import mimetypes from collections import defaultdict -from collections.abc import Iterable from pathlib import Path from django.conf import settings @@ -17,7 +16,7 @@ from rdmo.core.mail import send_mail from rdmo.core.plugins import get_plugins from rdmo.core.utils import remove_double_newlines -from rdmo.projects.models import Membership, Project +from rdmo.projects.models import Membership logger = logging.getLogger(__name__) User = get_user_model() @@ -375,7 +374,7 @@ def send_contact_message(request, subject, message): cc=[request.user.email], reply_to=[request.user.email]) -def import_memberships(project: Project, records: Iterable[dict], create_users: bool = True) -> tuple[int, int]: +def import_memberships(project, records, create_users = True): """ Assigns Memberships on `project` based on `records`. From 8eefcf79847e23bde004a41e32887e5bb6f13596 Mon Sep 17 00:00:00 2001 From: David Wallace Date: Mon, 21 Jul 2025 14:45:41 +0200 Subject: [PATCH 144/165] refactor(core,imports): combine temp file handling into one function and use django FileSystemStorage Signed-off-by: David Wallace --- rdmo/core/imports.py | 49 ++++++++++--------- rdmo/management/viewsets.py | 4 +- rdmo/projects/imports.py | 4 +- rdmo/projects/mixins.py | 4 +- .../tests/test_viewset_project_import.py | 0 5 files changed, 32 insertions(+), 29 deletions(-) create mode 100644 rdmo/projects/tests/test_viewset_project_import.py diff --git a/rdmo/core/imports.py b/rdmo/core/imports.py index d0c216ee92..bbd554b7d0 100644 --- a/rdmo/core/imports.py +++ b/rdmo/core/imports.py @@ -1,13 +1,15 @@ import logging import tempfile -import time from collections import defaultdict from enum import Enum -from os.path import join as pj -from random import randint +from pathlib import Path from typing import Optional, Union -from django.core.exceptions import ObjectDoesNotExist, ValidationError +from django.conf import settings +from django.core.exceptions import ObjectDoesNotExist, SuspiciousOperation, ValidationError +from django.core.files.base import ContentFile +from django.core.files.storage import FileSystemStorage +from django.core.files.uploadedfile import UploadedFile from django.db import models from django.utils.translation import gettext_lazy as _ @@ -20,6 +22,7 @@ logger = logging.getLogger(__name__) +_temp_storage = FileSystemStorage(location=tempfile.gettempdir()) class ImportElementFields(str, Enum): DIFF = "updated_and_changed" @@ -32,26 +35,26 @@ class ImportElementFields(str, Enum): CHANGED_FIELDS = "changedFields" # for ignored_keys when ordering at save -def handle_uploaded_file(filedata): - tempfilename = generate_tempfile_name() - with open(tempfilename, 'wb+') as destination: - for chunk in filedata.chunks(): - destination.write(chunk) - return tempfilename - - -def handle_fetched_file(filedata): - tempfilename = generate_tempfile_name() - with open(tempfilename, 'wb+') as destination: - destination.write(filedata) - return tempfilename - +def store_temp_file(filedata, *, suffix=".xml") -> str: + max_size = getattr(settings, "MAX_UPLOAD_SIZE", None) + + if isinstance(filedata, bytes): + if max_size is not None and len(filedata) > max_size: + raise SuspiciousOperation("File too large.") + content = ContentFile(filedata) + filename = _temp_storage.get_available_name(f"upload{suffix}") + elif isinstance(filedata, UploadedFile): + if max_size is not None and filedata.size > max_size: + raise SuspiciousOperation("Uploaded file too large.") + content = filedata + filename = _temp_storage.get_available_name( + f"upload{Path(filedata.name).suffix or suffix}" + ) + else: + raise TypeError(f"Unsupported filedata type: {type(filedata)}") -def generate_tempfile_name(): - t = round(time.time() * 1000) - r = randint(10000, 99999) - fn = pj(tempfile.gettempdir(), 'upload_' + str(t) + '_' + str(r) + '.xml') - return fn + saved_path = _temp_storage.save(filename, content) + return _temp_storage.path(saved_path) def get_or_return_instance(model: models.Model, uri: Optional[str] = None) -> tuple[models.Model, bool]: diff --git a/rdmo/management/viewsets.py b/rdmo/management/viewsets.py index 2b5ec2d295..ed73e40d7a 100644 --- a/rdmo/management/viewsets.py +++ b/rdmo/management/viewsets.py @@ -9,7 +9,7 @@ from rest_framework.response import Response from rest_framework.serializers import ValidationError -from rdmo.core.imports import handle_uploaded_file +from rdmo.core.imports import store_temp_file from rdmo.core.permissions import CanToggleElementCurrentSite from rdmo.core.utils import get_model_field_meta, is_truthy from rdmo.core.xml import parse_xml_to_elements @@ -40,7 +40,7 @@ def create(self, request, *args, **kwargs): except KeyError as e: raise ValidationError({'file': [_('This field may not be blank.')]}) from e else: - import_tmpfile_name = handle_uploaded_file(uploaded_file) + import_tmpfile_name = store_temp_file(uploaded_file) try: # step 1.1: initialize parse_xml_to_elements # step 2-6: parse xml, validate and convert to diff --git a/rdmo/projects/imports.py b/rdmo/projects/imports.py index 3b714c616f..6def5e9a61 100644 --- a/rdmo/projects/imports.py +++ b/rdmo/projects/imports.py @@ -12,7 +12,7 @@ import requests -from rdmo.core.imports import handle_fetched_file +from rdmo.core.imports import store_temp_file from rdmo.core.plugins import Plugin from rdmo.core.xml import get_ns_map, get_uri, read_xml_file from rdmo.domain.models import Attribute @@ -400,7 +400,7 @@ def submit(self): self.source_title = form.cleaned_data['url'] response = requests.get(form.cleaned_data['url']) - self.request.session['import_file_name'] = handle_fetched_file(response.content) + self.request.session['import_file_name'] = store_temp_file(response.content) if self.current_project: return redirect('project_update_import', self.current_project.id) diff --git a/rdmo/projects/mixins.py b/rdmo/projects/mixins.py index 516386061c..727673065f 100644 --- a/rdmo/projects/mixins.py +++ b/rdmo/projects/mixins.py @@ -7,7 +7,7 @@ from django.shortcuts import get_object_or_404, redirect, render from django.utils.translation import gettext_lazy as _ -from rdmo.core.imports import handle_uploaded_file +from rdmo.core.imports import store_temp_file from rdmo.core.plugins import get_plugin, get_plugins from rdmo.questions.models import Question @@ -80,7 +80,7 @@ def upload_file(self): 'errors': [_('There has been an error with your import.')] }, status=400) else: - self.request.session['import_file_name'] = handle_uploaded_file(uploaded_file) + self.request.session['import_file_name'] = store_temp_file(uploaded_file) self.request.session['import_source_title'] = uploaded_file.name # redirect to the import form diff --git a/rdmo/projects/tests/test_viewset_project_import.py b/rdmo/projects/tests/test_viewset_project_import.py new file mode 100644 index 0000000000..e69de29bb2 From 975b7ac3d21df10e8500fa4999e068d5e8a3482a Mon Sep 17 00:00:00 2001 From: David Wallace Date: Mon, 21 Jul 2025 14:49:32 +0200 Subject: [PATCH 145/165] projects(import): rename import actions and add tests Signed-off-by: David Wallace --- .../tests/test_viewset_project_import.py | 90 +++++++++++++++++++ rdmo/projects/utils.py | 10 +++ rdmo/projects/viewsets.py | 33 ++++--- 3 files changed, 123 insertions(+), 10 deletions(-) diff --git a/rdmo/projects/tests/test_viewset_project_import.py b/rdmo/projects/tests/test_viewset_project_import.py index e69de29bb2..4fbeec3efa 100644 --- a/rdmo/projects/tests/test_viewset_project_import.py +++ b/rdmo/projects/tests/test_viewset_project_import.py @@ -0,0 +1,90 @@ +import json +import os + +import pytest + +from django.urls import reverse + +from .test_viewset_project import view_project_permission_map + +# Define test users and their passwords (None for anonymous) +users = ( + ('owner', 'owner'), + ('manager', 'manager'), + ('author', 'author'), + ('guest', 'guest'), + ('user', 'user'), # logged-in user with no project membership + ('site', 'site'), # staff/site manager user + ('anonymous', None), # not logged in +) + + +urlnames = { + "upload_accept": "v1-projects:project-upload-accept", + "imports": "v1-projects:project-imports", + "import-create-preview": "v1-projects:project-import-create-preview", + "import-create-confirm": "v1-projects:project-import-create-confirm", +} + + +@pytest.mark.parametrize('username,password', users) +def test_project_import_preview(db, client, settings, username, password): + """Test the import_preview endpoint with various user roles.""" + # Authenticate as the given user (if password is None, remain anonymous) + if password: + client.login(username=username, password=password) + url = reverse(urlnames["import-create-preview"]) + xml_path = os.path.join(settings.BASE_DIR, 'xml', 'project.xml') + with open(xml_path, 'rb') as xml_file: + response = client.post(url, {'file': xml_file, 'format': 'xml'}) + if password: + if username in set(view_project_permission_map): + # Authorized roles: expect HTTP 200 with preview data + assert response.status_code == 200 + data = json.loads(response.content.decode()) + # Preview response should include at least one value and one snapshot + assert 'values' in data + assert len(data['values']) > 0 + assert 'snapshots' in data + assert len(data['snapshots']) > 0 + else: + # Logged in but not permitted (author/guest/normal user) -> 403 Forbidden + assert response.status_code == 403 + else: + # Anonymous user -> 401 Unauthorized + assert response.status_code == 401 + +@pytest.mark.parametrize('username,password', users) +def test_project_import_confirm(db, client, settings, username, password): + """Test the import_confirm endpoint with various user roles.""" + # Authenticate as the given user (if password is None, remain anonymous) + if password: + client.login(username=username, password=password) + preview_url = reverse(urlnames["import-create-preview"]) + confirm_url = reverse(urlnames["import-create-confirm"]) + xml_path = os.path.join(settings.BASE_DIR, 'xml', 'project.xml') + if password and username in view_project_permission_map: + # Perform preview step to get values/snapshots for authorized users + with open(xml_path, 'rb') as xml_file: + preview_response = client.post(preview_url, {'file': xml_file, 'format': 'xml'}) + assert preview_response.status_code == 200 + preview_data = json.loads(preview_response.content.decode()) + # Prepare payload with all values and snapshots checked (selected) + # Include the file again for the confirm step + with open(xml_path, 'rb') as xml_file: + confirm_response = client.post(confirm_url, {**preview_data, 'file': xml_file}) + # Confirm should succeed with HTTP 201 and return new project details + assert confirm_response.status_code == 201 + result = json.loads(confirm_response.content.decode()) + assert 'id' in result + assert 'title' in result + else: + # Unauthorized roles or anonymous: attempt confirm (should be rejected) + with open(xml_path, 'rb') as xml_file: + confirm_response = client.post(confirm_url, {'file': xml_file}) + if password: + # Logged in but not allowed -> 403 Forbidden + assert confirm_response.status_code == 403 + else: + # Not authenticated -> 401 Unauthorized + assert confirm_response.status_code == 401 diff --git a/rdmo/projects/utils.py b/rdmo/projects/utils.py index 31d3ab4d79..e7cbc6edd0 100644 --- a/rdmo/projects/utils.py +++ b/rdmo/projects/utils.py @@ -305,6 +305,16 @@ def get_upload_accept(): return accept +def get_plugin_types_for_mimetype(mimetype: str) -> set[str]: + accept = get_upload_accept() + suffixes = accept.get(mimetype) + + if not suffixes: + return set() + + return {suffix.lstrip('.') for suffix in suffixes} + + def compute_set_prefix_from_set_value(set_value, value): set_prefix_length = len(set_value.set_prefix.split('|')) if set_value.set_prefix else 0 return '|'.join([ diff --git a/rdmo/projects/viewsets.py b/rdmo/projects/viewsets.py index 4eadb119eb..0e04955fc2 100644 --- a/rdmo/projects/viewsets.py +++ b/rdmo/projects/viewsets.py @@ -26,7 +26,7 @@ from rdmo.conditions.models import Condition from rdmo.core.constants import VALUE_TYPE_FILE from rdmo.core.exports import XMLResponse -from rdmo.core.imports import handle_uploaded_file +from rdmo.core.imports import store_temp_file from rdmo.core.permissions import HasModelPermission from rdmo.core.plugins import get_plugin from rdmo.core.utils import human2bytes, is_truthy, return_file_response @@ -106,6 +106,7 @@ compute_set_prefix_from_set_value, copy_project, get_contact_message, + get_plugin_types_for_mimetype, get_upload_accept, send_contact_message, send_invite_email, @@ -582,22 +583,34 @@ def imports(self, request): @action( detail=False, methods=["post"], - url_path="import-preview", + url_path="import-create-preview", parser_classes=[MultiPartParser, FormParser], permission_classes=[IsAuthenticated], ) - def import_preview(self, request): + def import_create_preview(self, request): + # validate upload, content_type and declared format upload = request.FILES.get("file") - fmt = request.data.get("format", "xml") + declared_format = request.data.get("format") if not upload: return Response({"detail": "No file uploaded."}, status=status.HTTP_400_BAD_REQUEST) - tmp_path = handle_uploaded_file(upload) + accepted_formats = get_plugin_types_for_mimetype(upload.content_type) + if ( + declared_format is None + or not accepted_formats + or declared_format not in accepted_formats + ): + return Response({"detail": "File format not accepted."}, status=status.HTTP_400_BAD_REQUEST) - plugin = get_plugin("PROJECT_IMPORTS", fmt) + tmp_path = store_temp_file(upload) + + plugin = get_plugin("PROJECT_IMPORTS", declared_format) if plugin is None: - return Response({"detail": f'Format "{fmt}" not configured.'}, status=status.HTTP_400_BAD_REQUEST) + return Response( + {"detail": f'Format "{declared_format}" not configured.'}, + status=status.HTTP_400_BAD_REQUEST + ) plugin.file_name = tmp_path plugin.request = request @@ -617,11 +630,11 @@ def import_preview(self, request): @action( detail=False, methods=["post"], - url_path="import-confirm", + url_path="import-create-confirm", parser_classes=[MultiPartParser, FormParser], permission_classes=[IsAuthenticated], ) - def import_confirm(self, request): + def import_create_confirm(self, request): upload = request.FILES.get("file") fmt = request.data.get("format", "xml") cvs = set(request.data.get("checked_values", [])) @@ -630,7 +643,7 @@ def import_confirm(self, request): if not upload: return Response({"detail": "No file uploaded."}, status=status.HTTP_400_BAD_REQUEST) - tmp_path = handle_uploaded_file(upload) + tmp_path = store_temp_file(upload) plugin = get_plugin("PROJECT_IMPORTS", fmt) if plugin is None: From 7737385b2ee407740309596b544cb0b0588871d3 Mon Sep 17 00:00:00 2001 From: David Wallace Date: Wed, 23 Jul 2025 15:05:50 +0200 Subject: [PATCH 146/165] projects(import): add import update actions,tests and refactor Signed-off-by: David Wallace --- rdmo/projects/imports.py | 43 ++-- rdmo/projects/permissions.py | 19 ++ rdmo/projects/serializers/v1/__init__.py | 33 ++- .../tests/test_viewset_project_import.py | 90 ------- .../test_viewset_project_import_create.py | 225 ++++++++++++++++++ .../test_viewset_project_import_update.py | 93 ++++++++ rdmo/projects/utils.py | 21 +- rdmo/projects/viewsets.py | 159 +++++++------ 8 files changed, 485 insertions(+), 198 deletions(-) delete mode 100644 rdmo/projects/tests/test_viewset_project_import.py create mode 100644 rdmo/projects/tests/test_viewset_project_import_create.py create mode 100644 rdmo/projects/tests/test_viewset_project_import_update.py diff --git a/rdmo/projects/imports.py b/rdmo/projects/imports.py index 6def5e9a61..67b193c62f 100644 --- a/rdmo/projects/imports.py +++ b/rdmo/projects/imports.py @@ -2,6 +2,7 @@ import io import logging import mimetypes +import xml.etree.ElementTree as et from django import forms from django.contrib.sites.models import Site @@ -36,6 +37,7 @@ class Import(Plugin): accept = None upload = True + raise_exception = False def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -131,7 +133,7 @@ def prepare_import(self) -> dict: """ # run the standard plugin steps if not self.check(): - raise ValidationError(f'Import plugin rejected file "{self.file_name}".') + raise ValidationError({'file': ['Import plugin rejected the file.']}) self.process() # clear any .current so we treat everything as new @@ -190,10 +192,9 @@ def import_to_project( Returns the saved Project. """ # 1) Ensure the plugin has been run - if self.project is None: - if not self.check(): - raise ValidationError(f'Plugin rejected file "{self.file_name}".') - self.process() + if not self.check(): + raise ValidationError({'file': ['Import plugin rejected the file.']}) + self.process() # 2) Clear any stale .current pointers self._clear_currents() @@ -204,32 +205,36 @@ def import_to_project( css = checked_snapshots if checked_snapshots is not None else all_snapshots # 4) Persist the Project object - proj = self.project - if proj is None: - raise ValidationError("Import plugin did not set a Project.") - if proj.pk is None: - proj.site = Site.objects.get_current() - proj.save() + if self.current_project is None: + if self.project.pk is None: + self.project.site = Site.objects.get_current() + self.project.save() + else: + self.project = self.current_project # 5) Write out values, snapshots, tasks, views - save_import_values(proj, self.values, cvs) - save_import_snapshot_values(proj, self.snapshots, css) - save_import_tasks(proj, self.tasks) - save_import_views(proj, self.views) + save_import_values(self.project, self.values, cvs) + save_import_snapshot_values(self.project, self.snapshots, css) + save_import_tasks(self.project, self.tasks) + save_import_views(self.project, self.views) - return proj + return self.project class RDMOXMLImport(Import): accept = { - 'application/xml': ['.xml'] + 'application/xml': ['.xml'], + 'text/xml': ['.xml'], } def check(self) -> bool: file_type, encoding = mimetypes.guess_type(self.file_name) - if file_type in ('application/xml', 'text/xml'): - self.root = read_xml_file(self.file_name) + if self.accept and file_type in self.accept: + try: + self.root = read_xml_file(self.file_name, raise_exception=self.raise_exception) + except et.ParseError as exc: + raise ValidationError({'file': [f'Parsing error: {exc}']}) from exc if self.root is not None and self.root.tag == 'project': self.ns_map = get_ns_map(self.root) return True diff --git a/rdmo/projects/permissions.py b/rdmo/projects/permissions.py index bf3ec6c858..930ea30be7 100644 --- a/rdmo/projects/permissions.py +++ b/rdmo/projects/permissions.py @@ -115,3 +115,22 @@ def get_required_object_permissions(self, method, model_cls): return ('projects.delete_visibility_object', ) else: return ('projects.view_visibility_object', ) + + +class HasProjectsImportCreatePermission(HasModelPermission): + + def get_required_permissions(self, method, model_cls): + if method == 'POST': + return ('projects.can_add_project', ) + else: + return ('projects.view_project', ) + + + +class HasProjectImportUpdatePermission(HasProjectPermission): + + def get_required_object_permissions(self, method, model_cls): + if method == 'POST': + return ('projects.change_project_object', ) + else: + return ('projects.view_project_object', ) diff --git a/rdmo/projects/serializers/v1/__init__.py b/rdmo/projects/serializers/v1/__init__.py index 613226e76f..2757441307 100644 --- a/rdmo/projects/serializers/v1/__init__.py +++ b/rdmo/projects/serializers/v1/__init__.py @@ -12,7 +12,7 @@ from rdmo.accounts.serializers.v1 import UserLookupSerializer from rdmo.accounts.utils import get_full_name -from rdmo.core.serializers import TranslationSerializerMixin +from rdmo.core.serializers import FileUploadSerializer, TranslationSerializerMixin from rdmo.domain.models import Attribute from rdmo.questions.models import Catalog from rdmo.services.validators import ProviderValidator @@ -30,6 +30,7 @@ Value, Visibility, ) +from ...utils import get_plugin_types_for_mimetype from ...validators import ProjectParentValidator, ValueConflictValidator, ValueQuotaValidator, ValueTypeValidator @@ -273,16 +274,17 @@ class Meta: fields = ProjectSerializer.Meta.fields read_only_fields = ProjectSerializer.Meta.read_only_fields +class ProjectFileUploadSerializer(FileUploadSerializer): -class ProjectImportPreviewSerializer(serializers.Serializer): - file = serializers.FileField( - help_text="The file to inspect before import." - ) - format = serializers.CharField( - default="xml", - help_text="Import plugin key (e.g. 'xml' or custom)." - ) + def validate(self, data): + file = data["file"] + _format = data["format"] + + accepted_formats = get_plugin_types_for_mimetype(file.content_type) + if not accepted_formats or _format not in accepted_formats: + raise serializers.ValidationError(f"File format not accepted for this MIME type: {file.content_type}.") + return data class ProjectImportPreviewResponseSerializer(serializers.Serializer): """ @@ -307,21 +309,14 @@ class ProjectImportPreviewResponseSerializer(serializers.Serializer): ) -class ProjectImportConfirmSerializer(serializers.Serializer): - file = serializers.FileField( - help_text="The same file (must match preview step)." - ) - format = serializers.CharField( - default="xml", - help_text="Import plugin key (same as preview)." - ) +class ProjectImportConfirmSerializer(ProjectFileUploadSerializer): checked_values = serializers.ListField( child=serializers.CharField(), + allow_empty=True, help_text="List of value keys the user wants to import." ) checked_snapshots = serializers.ListField( - child=serializers.CharField(), - help_text="List of snapshot keys the user wants to import." + child=serializers.CharField(), allow_empty=True, help_text="List of snapshot keys the user wants to import." ) diff --git a/rdmo/projects/tests/test_viewset_project_import.py b/rdmo/projects/tests/test_viewset_project_import.py deleted file mode 100644 index 4fbeec3efa..0000000000 --- a/rdmo/projects/tests/test_viewset_project_import.py +++ /dev/null @@ -1,90 +0,0 @@ -import json -import os - -import pytest - -from django.urls import reverse - -from .test_viewset_project import view_project_permission_map - -# Define test users and their passwords (None for anonymous) -users = ( - ('owner', 'owner'), - ('manager', 'manager'), - ('author', 'author'), - ('guest', 'guest'), - ('user', 'user'), # logged-in user with no project membership - ('site', 'site'), # staff/site manager user - ('anonymous', None), # not logged in -) - - -urlnames = { - "upload_accept": "v1-projects:project-upload-accept", - "imports": "v1-projects:project-imports", - "import-create-preview": "v1-projects:project-import-create-preview", - "import-create-confirm": "v1-projects:project-import-create-confirm", -} - - -@pytest.mark.parametrize('username,password', users) -def test_project_import_preview(db, client, settings, username, password): - """Test the import_preview endpoint with various user roles.""" - # Authenticate as the given user (if password is None, remain anonymous) - if password: - client.login(username=username, password=password) - url = reverse(urlnames["import-create-preview"]) - xml_path = os.path.join(settings.BASE_DIR, 'xml', 'project.xml') - with open(xml_path, 'rb') as xml_file: - response = client.post(url, {'file': xml_file, 'format': 'xml'}) - if password: - if username in set(view_project_permission_map): - # Authorized roles: expect HTTP 200 with preview data - assert response.status_code == 200 - data = json.loads(response.content.decode()) - # Preview response should include at least one value and one snapshot - assert 'values' in data - assert len(data['values']) > 0 - assert 'snapshots' in data - assert len(data['snapshots']) > 0 - else: - # Logged in but not permitted (author/guest/normal user) -> 403 Forbidden - assert response.status_code == 403 - else: - # Anonymous user -> 401 Unauthorized - assert response.status_code == 401 - -@pytest.mark.parametrize('username,password', users) -def test_project_import_confirm(db, client, settings, username, password): - """Test the import_confirm endpoint with various user roles.""" - # Authenticate as the given user (if password is None, remain anonymous) - if password: - client.login(username=username, password=password) - preview_url = reverse(urlnames["import-create-preview"]) - confirm_url = reverse(urlnames["import-create-confirm"]) - xml_path = os.path.join(settings.BASE_DIR, 'xml', 'project.xml') - if password and username in view_project_permission_map: - # Perform preview step to get values/snapshots for authorized users - with open(xml_path, 'rb') as xml_file: - preview_response = client.post(preview_url, {'file': xml_file, 'format': 'xml'}) - assert preview_response.status_code == 200 - preview_data = json.loads(preview_response.content.decode()) - # Prepare payload with all values and snapshots checked (selected) - # Include the file again for the confirm step - with open(xml_path, 'rb') as xml_file: - confirm_response = client.post(confirm_url, {**preview_data, 'file': xml_file}) - # Confirm should succeed with HTTP 201 and return new project details - assert confirm_response.status_code == 201 - result = json.loads(confirm_response.content.decode()) - assert 'id' in result - assert 'title' in result - else: - # Unauthorized roles or anonymous: attempt confirm (should be rejected) - with open(xml_path, 'rb') as xml_file: - confirm_response = client.post(confirm_url, {'file': xml_file}) - if password: - # Logged in but not allowed -> 403 Forbidden - assert confirm_response.status_code == 403 - else: - # Not authenticated -> 401 Unauthorized - assert confirm_response.status_code == 401 diff --git a/rdmo/projects/tests/test_viewset_project_import_create.py b/rdmo/projects/tests/test_viewset_project_import_create.py new file mode 100644 index 0000000000..812575eea3 --- /dev/null +++ b/rdmo/projects/tests/test_viewset_project_import_create.py @@ -0,0 +1,225 @@ +import json +import os + +import pytest + +from django.contrib.auth.models import Group, User +from django.urls import reverse + +from .test_viewset_project import change_project_permission_map, projects, users, view_project_permission_map + +urlnames = { + "upload_accept": "v1-projects:project-upload-accept", + "imports": "v1-projects:project-imports", + "import-create-preview": "v1-projects:project-import-create-preview", + "import-create-confirm": "v1-projects:project-import-create-confirm", + "import-update-preview": "v1-projects:project-import-update-preview", + "import-update-confirm": "v1-projects:project-import-update-confirm", +} + + +@pytest.mark.parametrize('username,password', users) +def test_project_import_create_preview(db, client, settings, username, password): + """Test the import_preview endpoint with various user roles.""" + # Authenticate as the given user (if password is None, remain anonymous) + if password: + client.login(username=username, password=password) + + url = reverse(urlnames["import-create-preview"]) + xml_path = os.path.join(settings.BASE_DIR, 'xml', 'project.xml') + with open(xml_path, 'rb') as xml_file: + response = client.post(url, {'file': xml_file, 'format': 'xml'}) + + if password: + # Authorized roles: expect HTTP 200 with preview data + assert response.status_code == 200 + data = json.loads(response.content.decode()) + # Preview response should include at least one value and one snapshot + assert 'values' in data + assert len(data['values']) > 0 + assert 'snapshots' in data + assert len(data['snapshots']) > 0 + else: + # Anonymous user -> 401 Unauthorized + assert response.status_code == 401 + +@pytest.mark.parametrize('username,password', users) +def test_project_import_create_confirm(db, client, settings, username, password): + """Test the import_confirm endpoint with various user roles.""" + # Authenticate as the given user (if password is None, remain anonymous) + if password: + client.login(username=username, password=password) + preview_url = reverse(urlnames["import-create-preview"]) + confirm_url = reverse(urlnames["import-create-confirm"]) + xml_path = os.path.join(settings.BASE_DIR, 'xml', 'project.xml') + if password: + # Perform preview step to get values/snapshots for authorized users + with open(xml_path, 'rb') as xml_file: + preview_response = client.post(preview_url, {'file': xml_file, 'format': 'xml'}) + assert preview_response.status_code == 200 + preview_data = json.loads(preview_response.content.decode()) + # Prepare pacyload with all values and snapshots checked (selected) + # Include the file again for the confirm step + with open(xml_path, 'rb') as xml_file: + confirm_payload = { + "file": xml_file, + "format": "xml", + "checked_values": [v["key"] for v in preview_data.get("values", [])], + "checked_snapshots": [s["index"] for s in preview_data.get("snapshots", [])], + } + confirm_response = client.post(confirm_url, confirm_payload) + # Confirm should succeed with HTTP 201 and return new project details + assert confirm_response.status_code == 201 + result = json.loads(confirm_response.content.decode()) + assert 'id' in result + assert 'title' in result + else: + # Unauthorized roles or anonymous: attempt confirm (should be rejected) + with open(xml_path, 'rb') as xml_file: + confirm_response = client.post(confirm_url, {'file': xml_file}) + assert confirm_response.status_code == 401 + + + +# @pytest.mark.parametrize('username,password', users) +def test_import_create_confirm_restricted(db, client, settings): + """ + When PROJECT_CREATE_RESTRICTED=True, only users in PROJECT_CREATE_GROUPS + may hit import-create-preview (200) and then import-create-confirm (201). + Others (including anon) get 403 or 401. + """ + settings.PROJECT_CREATE_RESTRICTED = True + settings.PROJECT_CREATE_GROUPS = ['projects'] + + # ensure 'projects' group exists and assign to logged-in user + username = password = 'user' + grp = Group.objects.create(name='projects') + user = User.objects.get(username=username) + user.groups.add(grp) + client.login(username=username, password=password) + + preview_url = reverse(urlnames["import-create-preview"]) + confirm_url = reverse(urlnames["import-create-confirm"]) + xml_path = os.path.join(settings.BASE_DIR, 'xml', 'project.xml') + + # Preview step + with open(xml_path, 'rb') as xml: + preview_resp = client.post(preview_url, {'file': xml, 'format': 'xml'}) + assert preview_resp.status_code == 200 + preview_data = preview_resp.json() + + with open(xml_path, 'rb') as xml: + confirm_resp = client.post(confirm_url, { + 'file': xml, + 'format': 'xml', + 'checked_values': [v["key"] for v in preview_data.get("values", [])], + 'checked_snapshots': [s["index"] for s in preview_data.get("snapshots", [])], + }) + assert confirm_resp.status_code == 201 + result = confirm_resp.json() + assert 'id' in result + assert 'title' in result + + + +def test_import_create_confirm_forbidden(db, client, settings): + """ + If PROJECT_CREATE_RESTRICTED=True and the user is NOT in PROJECT_CREATE_GROUPS, + import-create-preview and import-create-confirm must both 403. + """ + settings.PROJECT_CREATE_RESTRICTED = True + # no PROJECT_CREATE_GROUPS defined → no one is allowed + + client.login(username='user', password='user') + preview_url = reverse(urlnames["import-create-preview"]) + confirm_url = reverse(urlnames["import-create-confirm"]) + xml_path = os.path.join(settings.BASE_DIR, 'xml', 'project.xml') + + with open(xml_path, 'rb') as xml: + preview_resp = client.post(preview_url, {'file': xml, 'format': 'xml'}) + assert preview_resp.status_code == 403 + + with open(xml_path, 'rb') as xml: + confirm_resp = client.post(confirm_url, { + 'file': xml, + 'format': 'xml', + 'checked_values': [], + 'checked_snapshots': [], + }) + assert confirm_resp.status_code == 403 + + +@pytest.mark.parametrize('username,password', users) +@pytest.mark.parametrize('project_id', projects) +def test_project_import_update_preview(db, client, settings, username, password, project_id): + """Test the import-update-preview endpoint with various user roles.""" + if password: + client.login(username=username, password=password) + + url = reverse('v1-projects:project-import-update-preview', args=[project_id]) + xml_path = os.path.join(settings.BASE_DIR, 'xml', 'project.xml') + + with open(xml_path, 'rb') as xml_file: + response = client.post(url, {'file': xml_file, 'format': 'xml'}) + + if project_id in view_project_permission_map.get(username, []): + assert response.status_code == 200 + data = json.loads(response.content.decode()) + assert 'values' in data + assert 'snapshots' in data + assert isinstance(data['values'], list) + assert isinstance(data['snapshots'], list) + elif password: + assert response.status_code == 404 + else: + assert response.status_code == 401 + + +@pytest.mark.parametrize('username,password', users) +@pytest.mark.parametrize('project_id', projects) +def test_project_import_update_confirm(db, client, settings, username, password, project_id): + """Test the import-update-confirm endpoint with various user roles.""" + if password: + client.login(username=username, password=password) + + preview_url = reverse('v1-projects:project-import-update-preview', args=[project_id]) + confirm_url = reverse('v1-projects:project-import-update-confirm', args=[project_id]) + xml_path = os.path.join(settings.BASE_DIR, 'xml', 'project.xml') + + if project_id in change_project_permission_map.get(username, []): + with open(xml_path, 'rb') as xml_file: + preview_response = client.post(preview_url, {'file': xml_file, 'format': 'xml'}) + assert preview_response.status_code == 200 + preview_data = json.loads(preview_response.content.decode()) + + with open(xml_path, 'rb') as xml_file: + confirm_payload = { + 'file': xml_file, + 'format': 'xml', + 'checked_values': [v["key"] for v in preview_data.get("values", [])], + 'checked_snapshots': [s["index"] for s in preview_data.get("snapshots", [])], + } + confirm_response = client.post(confirm_url, confirm_payload) + + assert confirm_response.status_code == 201 + result = json.loads(confirm_response.content.decode()) + assert 'id' in result + assert 'title' in result + elif password: + with open(xml_path, 'rb') as xml_file: + confirm_response = client.post( + confirm_url, + {'file': xml_file,'checked_values': [], 'checked_snapshots': []} + ) + + if project_id in view_project_permission_map.get(username, []): + assert confirm_response.status_code == 400 + else: + assert confirm_response.status_code == 404 + else: + with open(xml_path, 'rb') as xml_file: + confirm_response = client.post( + confirm_url, + {'file': xml_file,'checked_values': [], 'checked_snapshots': []} + ) + assert confirm_response.status_code == 401 diff --git a/rdmo/projects/tests/test_viewset_project_import_update.py b/rdmo/projects/tests/test_viewset_project_import_update.py new file mode 100644 index 0000000000..b2c7c8e97c --- /dev/null +++ b/rdmo/projects/tests/test_viewset_project_import_update.py @@ -0,0 +1,93 @@ +import json +import os + +import pytest + +from django.urls import reverse + +from .test_viewset_project import change_project_permission_map, projects, users, view_project_permission_map + +urlnames = { + "upload_accept": "v1-projects:project-upload-accept", + "imports": "v1-projects:project-imports", + "import-create-preview": "v1-projects:project-import-create-preview", + "import-create-confirm": "v1-projects:project-import-create-confirm", + "import-update-preview": "v1-projects:project-import-update-preview", + "import-update-confirm": "v1-projects:project-import-update-confirm", +} + + +@pytest.mark.parametrize('username,password', users) +@pytest.mark.parametrize('project_id', projects) +def test_project_import_update_preview(db, client, settings, username, password, project_id): + """Test the import-update-preview endpoint with various user roles.""" + if password: + client.login(username=username, password=password) + + url = reverse('v1-projects:project-import-update-preview', args=[project_id]) + xml_path = os.path.join(settings.BASE_DIR, 'xml', 'project.xml') + + with open(xml_path, 'rb') as xml_file: + response = client.post(url, {'file': xml_file, 'format': 'xml'}) + + if project_id in view_project_permission_map.get(username, []): + assert response.status_code == 200 + data = json.loads(response.content.decode()) + assert 'values' in data + assert 'snapshots' in data + assert isinstance(data['values'], list) + assert isinstance(data['snapshots'], list) + elif password: + assert response.status_code == 404 + else: + assert response.status_code == 401 + + +@pytest.mark.parametrize('username,password', users) +@pytest.mark.parametrize('project_id', projects) +def test_project_import_update_confirm(db, client, settings, username, password, project_id): + """Test the import-update-confirm endpoint with various user roles.""" + if password: + client.login(username=username, password=password) + + preview_url = reverse('v1-projects:project-import-update-preview', args=[project_id]) + confirm_url = reverse('v1-projects:project-import-update-confirm', args=[project_id]) + xml_path = os.path.join(settings.BASE_DIR, 'xml', 'project.xml') + + if project_id in change_project_permission_map.get(username, []): + with open(xml_path, 'rb') as xml_file: + preview_response = client.post(preview_url, {'file': xml_file, 'format': 'xml'}) + assert preview_response.status_code == 200 + preview_data = json.loads(preview_response.content.decode()) + + with open(xml_path, 'rb') as xml_file: + confirm_payload = { + 'file': xml_file, + 'format': 'xml', + 'checked_values': [v["key"] for v in preview_data.get("values", [])], + 'checked_snapshots': [ + s["index"] for s in preview_data.get("snapshots", []) + ], + } + confirm_response = client.post(confirm_url, confirm_payload) + + assert confirm_response.status_code == 201 + result = json.loads(confirm_response.content.decode()) + assert 'id' in result + assert 'title' in result + elif password: + with open(xml_path, 'rb') as xml_file: + confirm_response = client.post( + confirm_url, {'file': xml_file,'checked_values': [], 'checked_snapshots': []} + ) + + if project_id in view_project_permission_map.get(username, []): + assert confirm_response.status_code == 400 + else: + assert confirm_response.status_code == 404 + else: + with open(xml_path, 'rb') as xml_file: + confirm_response = client.post( + confirm_url, {'file': xml_file,'checked_values': [], 'checked_snapshots': []} + ) + assert confirm_response.status_code == 401 diff --git a/rdmo/projects/utils.py b/rdmo/projects/utils.py index e7cbc6edd0..7bfb9ecc34 100644 --- a/rdmo/projects/utils.py +++ b/rdmo/projects/utils.py @@ -13,8 +13,9 @@ from django.utils.timezone import now from rdmo.accounts.utils import make_unique_username +from rdmo.core.imports import store_temp_file from rdmo.core.mail import send_mail -from rdmo.core.plugins import get_plugins +from rdmo.core.plugins import get_plugin, get_plugins from rdmo.core.utils import remove_double_newlines from rdmo.projects.models import Membership @@ -306,8 +307,7 @@ def get_upload_accept(): def get_plugin_types_for_mimetype(mimetype: str) -> set[str]: - accept = get_upload_accept() - suffixes = accept.get(mimetype) + suffixes = get_upload_accept().get(mimetype) if not suffixes: return set() @@ -447,3 +447,18 @@ def import_memberships(project, records, create_users = True): created += 1 return created, skipped + + +def validate_and_prepare_import_plugin(request, file=None, file_format=None, current_project=None): + tmp_path = store_temp_file(file) + + plugin = get_plugin("PROJECT_IMPORTS", file_format) + if plugin is None: + raise ValidationError({"detail": f'Format "{file_format}" not configured.'}) + + plugin.file_name = tmp_path + plugin.request = request + plugin.current_project = current_project + plugin.raise_exception = True + + return plugin diff --git a/rdmo/projects/viewsets.py b/rdmo/projects/viewsets.py index 0e04955fc2..e6607fe784 100644 --- a/rdmo/projects/viewsets.py +++ b/rdmo/projects/viewsets.py @@ -1,6 +1,6 @@ from django.conf import settings from django.contrib.sites.shortcuts import get_current_site -from django.core.exceptions import ObjectDoesNotExist +from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.db.models import F, OuterRef, Prefetch, Q, Subquery from django.db.models.functions import Coalesce, Greatest from django.http import Http404, HttpResponseRedirect @@ -26,7 +26,6 @@ from rdmo.conditions.models import Condition from rdmo.core.constants import VALUE_TYPE_FILE from rdmo.core.exports import XMLResponse -from rdmo.core.imports import store_temp_file from rdmo.core.permissions import HasModelPermission from rdmo.core.plugins import get_plugin from rdmo.core.utils import human2bytes, is_truthy, return_file_response @@ -47,11 +46,13 @@ ) from .models import Continuation, Integration, Invite, Issue, Membership, Project, Snapshot, Value, Visibility from .permissions import ( + HasProjectImportUpdatePermission, HasProjectLeavePermission, HasProjectPagePermission, HasProjectPermission, HasProjectProgressModelPermission, HasProjectProgressObjectPermission, + HasProjectsImportCreatePermission, HasProjectsPermission, HasProjectVisibilityModelPermission, HasProjectVisibilityObjectPermission, @@ -74,9 +75,9 @@ ProjectAnswersSerializer, ProjectCopySerializer, ProjectHierarchySerializer, + ProjectFileUploadSerializer, ProjectImportConfirmSerializer, ProjectImportPreviewResponseSerializer, - ProjectImportPreviewSerializer, ProjectIntegrationSerializer, ProjectInviteCreateSerializer, ProjectInviteSerializer, @@ -106,10 +107,10 @@ compute_set_prefix_from_set_value, copy_project, get_contact_message, - get_plugin_types_for_mimetype, get_upload_accept, send_contact_message, send_invite_email, + validate_and_prepare_import_plugin, ) @@ -412,7 +413,7 @@ def contact(self, request, pk): send_contact_message(request, subject, message) return Response(status=status.HTTP_204_NO_CONTENT) else: - raise ValidationError({ + raise serializers.ValidationError({ 'subject': [_('This field may not be blank.')] if not subject else [], 'message': [_('This field may not be blank.')] if not message else [] }) @@ -577,7 +578,7 @@ def imports(self, request): } for key, label, class_name in settings.PROJECT_IMPORTS if key in settings.PROJECT_IMPORTS_LIST] ) @extend_schema( - request=ProjectImportPreviewSerializer, + # request=ProjectFileUploadSerializer, responses={200: ProjectImportPreviewResponseSerializer}, ) @action( @@ -585,42 +586,13 @@ def imports(self, request): methods=["post"], url_path="import-create-preview", parser_classes=[MultiPartParser, FormParser], - permission_classes=[IsAuthenticated], + permission_classes=[HasModelPermission | HasProjectsImportCreatePermission], + serializer_class=ProjectFileUploadSerializer ) def import_create_preview(self, request): - # validate upload, content_type and declared format - upload = request.FILES.get("file") - declared_format = request.data.get("format") - - if not upload: - return Response({"detail": "No file uploaded."}, status=status.HTTP_400_BAD_REQUEST) - - accepted_formats = get_plugin_types_for_mimetype(upload.content_type) - if ( - declared_format is None - or not accepted_formats - or declared_format not in accepted_formats - ): - return Response({"detail": "File format not accepted."}, status=status.HTTP_400_BAD_REQUEST) - - tmp_path = store_temp_file(upload) - - plugin = get_plugin("PROJECT_IMPORTS", declared_format) - if plugin is None: - return Response( - {"detail": f'Format "{declared_format}" not configured.'}, - status=status.HTTP_400_BAD_REQUEST - ) - - plugin.file_name = tmp_path - plugin.request = request - plugin.current_project = None - - try: - preview = plugin.prepare_import() - except ValidationError as exc: - return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST) - + preview = self.handle_import_plugin_action( + plugin_call=lambda plugin, _: plugin.prepare_import(), + ) return Response(preview, status=status.HTTP_200_OK) @extend_schema( @@ -632,39 +604,72 @@ def import_create_preview(self, request): methods=["post"], url_path="import-create-confirm", parser_classes=[MultiPartParser, FormParser], - permission_classes=[IsAuthenticated], + permission_classes=[HasModelPermission | HasProjectsImportCreatePermission], + serializer_class=ProjectImportConfirmSerializer ) def import_create_confirm(self, request): - upload = request.FILES.get("file") - fmt = request.data.get("format", "xml") - cvs = set(request.data.get("checked_values", [])) - css = set(request.data.get("checked_snapshots", [])) - - if not upload: - return Response({"detail": "No file uploaded."}, status=status.HTTP_400_BAD_REQUEST) + project = self.handle_import_plugin_action( + plugin_call=lambda plugin, data: plugin.import_to_project( + checked_values=set(data["checked_values"]), + checked_snapshots=set(data["checked_snapshots"]) + ), + ) + serializer = ProjectSerializer(project, context={"request": request}) + return Response(serializer.data, status=status.HTTP_201_CREATED) - tmp_path = store_temp_file(upload) + @extend_schema( + # request=ProjectFileUploadSerializer, + responses={200: ProjectImportPreviewResponseSerializer}, + ) + @action( + detail=True, + methods=["post"], + url_path="import-update-preview", + parser_classes=[MultiPartParser, FormParser], + permission_classes=(HasModelPermission | HasProjectImportUpdatePermission,), + serializer_class=ProjectFileUploadSerializer, + ) + def import_update_preview(self, request, pk=None): + project = self.get_object() - plugin = get_plugin("PROJECT_IMPORTS", fmt) - if plugin is None: - return Response({"detail": f'Format "{fmt}" not configured.'}, status=status.HTTP_400_BAD_REQUEST) + preview = self.handle_import_plugin_action( + plugin_kwargs={"current_project": project}, + plugin_call=lambda plugin, _: plugin.prepare_import(), + ) + return Response(preview, status=status.HTTP_200_OK) - plugin.file_name = tmp_path - plugin.request = request - plugin.current_project = None + @extend_schema( + # request=ProjectImportConfirmSerializer, + responses={201: ProjectSerializer}, + ) + @action( + detail=True, + methods=["post"], + url_path="import-update-confirm", + parser_classes=[MultiPartParser, FormParser], + permission_classes=(HasModelPermission | HasProjectImportUpdatePermission,), + serializer_class=ProjectImportConfirmSerializer, - try: - project = plugin.import_to_project(checked_values=cvs, checked_snapshots=css) - except ValidationError as exc: - return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST) + ) + def import_update_confirm(self, request, pk=None): + project = self.get_object() - serializer = ProjectSerializer(project, context={"request": request}) + updated_project = self.handle_import_plugin_action( + plugin_kwargs={"current_project": project}, + plugin_call=lambda plugin, data: plugin.import_to_project( + checked_values=set(data["checked_values"]), + checked_snapshots=set(data["checked_snapshots"]) + ), + ) + serializer = ProjectSerializer(updated_project, context={"request": request}) return Response(serializer.data, status=status.HTTP_201_CREATED) - - @action(detail=True, methods=['get'], - permission_classes=(HasModelPermission | HasProjectPermission, ), - url_path='export(?:/(?P[a-z]+))?') + @action( + detail=True, + methods=["get"], + permission_classes=(HasModelPermission | HasProjectImportUpdatePermission,), + url_path="export(?:/(?P[a-z]+))?", + ) def export(self, request, pk=None, export_format='xml'): project = self.get_object() project.catalog.prefetch_elements() @@ -688,7 +693,6 @@ def get_export_renderer_context(self, request): 'snapshots': full or is_truthy(request.GET.get('snapshots', True)), } - def perform_create(self, serializer): project = serializer.save(site=get_current_site(self.request)) @@ -696,7 +700,6 @@ def perform_create(self, serializer): membership = Membership(project=project, user=self.request.user, role='owner') membership.save() - # add all tasks to project if self.request.data.get('tasks') is None: if not settings.PROJECT_TASKS_SYNC: @@ -711,6 +714,28 @@ def perform_create(self, serializer): for view in views: project.views.add(view) + def handle_import_plugin_action( + self, + plugin_kwargs=None, + plugin_call=None, + ): + serializer = self.get_serializer(data=self.request.data) + serializer.is_valid(raise_exception=True) + + plugin_kwargs = plugin_kwargs or {} + try: + plugin = validate_and_prepare_import_plugin( + self.request, + file=serializer.validated_data["file"], + file_format=serializer.validated_data["format"], + **plugin_kwargs, + ) + plugin_result = plugin_call(plugin, serializer.validated_data) + except ValidationError as exc: + raise serializers.ValidationError(exc) from exc + else: + return plugin_result + class ProjectNestedViewSetMixin(NestedViewSetMixin): @@ -914,7 +939,7 @@ def copy_set(self, request, parent_lookup_project, pk=None): try: copy_value_id = int(request.data.pop('copy_set_value')) except KeyError as e: - raise ValidationError({ + raise serializers.ValidationError({ 'copy_set_value': [_('This field may not be blank.')] }) from e except ValueError as e: From 41ecebcaeac0047c874cd6aef159599ff9ba6d26 Mon Sep 17 00:00:00 2001 From: David Wallace Date: Wed, 23 Jul 2025 15:06:09 +0200 Subject: [PATCH 147/165] core(serializers): add file upload serializer Signed-off-by: David Wallace --- rdmo/core/serializers.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/rdmo/core/serializers.py b/rdmo/core/serializers.py index 9a4ee79dac..298598b7b6 100644 --- a/rdmo/core/serializers.py +++ b/rdmo/core/serializers.py @@ -259,3 +259,16 @@ class Meta: 'id', 'name' ) + + +class FileUploadSerializer(serializers.Serializer): + file = serializers.FileField( + help_text="The file to upload." + ) + format = serializers.CharField( + default="xml", + required=False, + allow_blank=False, + allow_null=False, + help_text="Format that can be mapped to an import plugin key." + ) From ebd29d135fab017a14e4538b334e54a9df43815d Mon Sep 17 00:00:00 2001 From: David Wallace Date: Wed, 23 Jul 2025 16:00:51 +0200 Subject: [PATCH 148/165] projects(viewsets): fix error message for import actions Signed-off-by: David Wallace --- rdmo/projects/viewsets.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/rdmo/projects/viewsets.py b/rdmo/projects/viewsets.py index e6607fe784..06ef6805d2 100644 --- a/rdmo/projects/viewsets.py +++ b/rdmo/projects/viewsets.py @@ -732,7 +732,13 @@ def handle_import_plugin_action( ) plugin_result = plugin_call(plugin, serializer.validated_data) except ValidationError as exc: - raise serializers.ValidationError(exc) from exc + # exc.message_dict is a dict of field-names → list of messages, if available + if hasattr(exc, 'message_dict'): + detail = exc.message_dict + else: + # exc.messages is always a list of strings + detail = {'non_field_errors': exc.messages} + raise serializers.ValidationError(detail) from exc else: return plugin_result From a6c736f7f5b84e2b79e4657999e4756f2b94c03d Mon Sep 17 00:00:00 2001 From: David Wallace Date: Wed, 23 Jul 2025 16:01:24 +0200 Subject: [PATCH 149/165] tests(projects): add and fix tests for import Signed-off-by: David Wallace --- rdmo/projects/tests/test_viewset_project.py | 3 +- .../test_viewset_project_import_create.py | 53 ++++++++++++++++++- 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/rdmo/projects/tests/test_viewset_project.py b/rdmo/projects/tests/test_viewset_project.py index 4eb2b107ae..e98efa5fcf 100644 --- a/rdmo/projects/tests/test_viewset_project.py +++ b/rdmo/projects/tests/test_viewset_project.py @@ -661,7 +661,8 @@ def test_upload_accept(db, client, username, password): if password: assert response.status_code == 200 assert response.json() == { - 'application/xml': ['.xml'] + "application/xml": [".xml"], + "text/xml": [".xml"], } else: assert response.status_code == 401 diff --git a/rdmo/projects/tests/test_viewset_project_import_create.py b/rdmo/projects/tests/test_viewset_project_import_create.py index 812575eea3..217cc56330 100644 --- a/rdmo/projects/tests/test_viewset_project_import_create.py +++ b/rdmo/projects/tests/test_viewset_project_import_create.py @@ -17,6 +17,14 @@ "import-update-confirm": "v1-projects:project-import-update-confirm", } +def test_import_create_preview_get_not_allowed(db, client): + username = password = 'user' + client.login(username=username, password=password) + + url = reverse(urlnames["import-create-preview"]) + response = client.get(url) + assert response.status_code == 403 + @pytest.mark.parametrize('username,password', users) def test_project_import_create_preview(db, client, settings, username, password): @@ -43,6 +51,28 @@ def test_project_import_create_preview(db, client, settings, username, password) # Anonymous user -> 401 Unauthorized assert response.status_code == 401 + +def test_import_create_preview_missing_file(db, client): + username = password = 'user' + client.login(username=username, password=password) + + url = reverse(urlnames["import-create-preview"]) + resp = client.post(url, {'format': 'xml'}) # no 'file' key + assert resp.status_code == 400 + + +def test_import_create_preview_invalid_xml(db, client, settings): + username = password = 'user' + client.login(username=username, password=password) + + url = reverse(urlnames["import-create-preview"]) + bad_xml = os.path.join(settings.BASE_DIR, "xml", "error.xml") + with open(bad_xml, "rb") as f: + resp = client.post(url, {"file": f, "format": "xml"}) + assert resp.status_code == 400 + assert 'Parsing error' in ' '.join(resp.json()['file']) + + @pytest.mark.parametrize('username,password', users) def test_project_import_create_confirm(db, client, settings, username, password): """Test the import_confirm endpoint with various user roles.""" @@ -80,8 +110,15 @@ def test_project_import_create_confirm(db, client, settings, username, password) assert confirm_response.status_code == 401 +def test_import_create_confirm_get_not_allowed(db, client): + username = password = "user" + client.login(username=username, password=password) + + url = reverse(urlnames["import-create-confirm"]) + response = client.get(url) + assert response.status_code == 403 + -# @pytest.mark.parametrize('username,password', users) def test_import_create_confirm_restricted(db, client, settings): """ When PROJECT_CREATE_RESTRICTED=True, only users in PROJECT_CREATE_GROUPS @@ -121,7 +158,6 @@ def test_import_create_confirm_restricted(db, client, settings): assert 'title' in result - def test_import_create_confirm_forbidden(db, client, settings): """ If PROJECT_CREATE_RESTRICTED=True and the user is NOT in PROJECT_CREATE_GROUPS, @@ -175,6 +211,19 @@ def test_project_import_update_preview(db, client, settings, username, password, assert response.status_code == 401 +def test_import_update_preview_invalid_xml(db, client, settings): + username = password = 'owner' + client.login(username=username, password=password) + + url = reverse(urlnames["import-update-preview"], args=[1]) + bad_xml = os.path.join(settings.BASE_DIR, "xml", "error.xml") + with open(bad_xml, "rb") as f: + resp = client.post(url, {"file": f, "format": "xml"}) + assert resp.status_code == 400 + assert 'Parsing error' in ' '.join(resp.json()['file']) + + + @pytest.mark.parametrize('username,password', users) @pytest.mark.parametrize('project_id', projects) def test_project_import_update_confirm(db, client, settings, username, password, project_id): From 99f9f34719b36c8d23581d37e0730185004c71bd Mon Sep 17 00:00:00 2001 From: David Wallace Date: Thu, 24 Jul 2025 09:38:17 +0200 Subject: [PATCH 150/165] style(projects): clean up extend_schema Signed-off-by: David Wallace --- rdmo/projects/viewsets.py | 20 +------------------- 1 file changed, 1 insertion(+), 19 deletions(-) diff --git a/rdmo/projects/viewsets.py b/rdmo/projects/viewsets.py index 06ef6805d2..687bd06866 100644 --- a/rdmo/projects/viewsets.py +++ b/rdmo/projects/viewsets.py @@ -20,7 +20,6 @@ from rest_framework.viewsets import GenericViewSet, ModelViewSet, ReadOnlyModelViewSet from django_filters.rest_framework import DjangoFilterBackend -from drf_spectacular.utils import extend_schema from rest_framework_extensions.mixins import NestedViewSetMixin from rdmo.conditions.models import Condition @@ -77,7 +76,6 @@ ProjectHierarchySerializer, ProjectFileUploadSerializer, ProjectImportConfirmSerializer, - ProjectImportPreviewResponseSerializer, ProjectIntegrationSerializer, ProjectInviteCreateSerializer, ProjectInviteSerializer, @@ -577,10 +575,6 @@ def imports(self, request): 'href': reverse('project_create_import', args=[key]) } for key, label, class_name in settings.PROJECT_IMPORTS if key in settings.PROJECT_IMPORTS_LIST] ) - @extend_schema( - # request=ProjectFileUploadSerializer, - responses={200: ProjectImportPreviewResponseSerializer}, - ) @action( detail=False, methods=["post"], @@ -595,10 +589,6 @@ def import_create_preview(self, request): ) return Response(preview, status=status.HTTP_200_OK) - @extend_schema( - request=ProjectImportConfirmSerializer, - responses={201: ProjectSerializer}, - ) @action( detail=False, methods=["post"], @@ -617,10 +607,6 @@ def import_create_confirm(self, request): serializer = ProjectSerializer(project, context={"request": request}) return Response(serializer.data, status=status.HTTP_201_CREATED) - @extend_schema( - # request=ProjectFileUploadSerializer, - responses={200: ProjectImportPreviewResponseSerializer}, - ) @action( detail=True, methods=["post"], @@ -638,10 +624,6 @@ def import_update_preview(self, request, pk=None): ) return Response(preview, status=status.HTTP_200_OK) - @extend_schema( - # request=ProjectImportConfirmSerializer, - responses={201: ProjectSerializer}, - ) @action( detail=True, methods=["post"], @@ -667,7 +649,7 @@ def import_update_confirm(self, request, pk=None): @action( detail=True, methods=["get"], - permission_classes=(HasModelPermission | HasProjectImportUpdatePermission,), + permission_classes=(HasModelPermission | HasProjectPermission,), url_path="export(?:/(?P[a-z]+))?", ) def export(self, request, pk=None, export_format='xml'): From b3a0c177fbb446b0c4d613926940e48b8bd7ab4e Mon Sep 17 00:00:00 2001 From: David Wallace Date: Thu, 24 Jul 2025 09:42:07 +0200 Subject: [PATCH 151/165] tests(projects): clean up and add project count Signed-off-by: David Wallace --- .../test_viewset_project_import_create.py | 153 +++--------------- .../test_viewset_project_import_update.py | 42 +++-- 2 files changed, 53 insertions(+), 142 deletions(-) diff --git a/rdmo/projects/tests/test_viewset_project_import_create.py b/rdmo/projects/tests/test_viewset_project_import_create.py index 217cc56330..7ab41b6c64 100644 --- a/rdmo/projects/tests/test_viewset_project_import_create.py +++ b/rdmo/projects/tests/test_viewset_project_import_create.py @@ -6,40 +6,28 @@ from django.contrib.auth.models import Group, User from django.urls import reverse -from .test_viewset_project import change_project_permission_map, projects, users, view_project_permission_map +from ..models import Project +from .test_viewset_project import users urlnames = { - "upload_accept": "v1-projects:project-upload-accept", - "imports": "v1-projects:project-imports", "import-create-preview": "v1-projects:project-import-create-preview", "import-create-confirm": "v1-projects:project-import-create-confirm", - "import-update-preview": "v1-projects:project-import-update-preview", - "import-update-confirm": "v1-projects:project-import-update-confirm", } -def test_import_create_preview_get_not_allowed(db, client): - username = password = 'user' - client.login(username=username, password=password) - - url = reverse(urlnames["import-create-preview"]) - response = client.get(url) - assert response.status_code == 403 - @pytest.mark.parametrize('username,password', users) def test_project_import_create_preview(db, client, settings, username, password): - """Test the import_preview endpoint with various user roles.""" - # Authenticate as the given user (if password is None, remain anonymous) if password: client.login(username=username, password=password) url = reverse(urlnames["import-create-preview"]) + projects_count = Project.objects.all().count() + xml_path = os.path.join(settings.BASE_DIR, 'xml', 'project.xml') with open(xml_path, 'rb') as xml_file: response = client.post(url, {'file': xml_file, 'format': 'xml'}) if password: - # Authorized roles: expect HTTP 200 with preview data assert response.status_code == 200 data = json.loads(response.content.decode()) # Preview response should include at least one value and one snapshot @@ -47,11 +35,21 @@ def test_project_import_create_preview(db, client, settings, username, password) assert len(data['values']) > 0 assert 'snapshots' in data assert len(data['snapshots']) > 0 + assert projects_count == Project.objects.all().count() else: # Anonymous user -> 401 Unauthorized assert response.status_code == 401 +@pytest.mark.parametrize('action', ['preview','confirm']) +def test_import_create_preview_and_confirm_get_not_allowed(db, client, action): + username = password = 'admin' + client.login(username=username, password=password) + url = reverse(urlnames[f"import-create-{action}"]) + response = client.get(url) + assert response.status_code == 405 + + def test_import_create_preview_missing_file(db, client): username = password = 'user' client.login(username=username, password=password) @@ -75,20 +73,18 @@ def test_import_create_preview_invalid_xml(db, client, settings): @pytest.mark.parametrize('username,password', users) def test_project_import_create_confirm(db, client, settings, username, password): - """Test the import_confirm endpoint with various user roles.""" - # Authenticate as the given user (if password is None, remain anonymous) if password: client.login(username=username, password=password) preview_url = reverse(urlnames["import-create-preview"]) confirm_url = reverse(urlnames["import-create-confirm"]) + projects_count = Project.objects.all().count() xml_path = os.path.join(settings.BASE_DIR, 'xml', 'project.xml') if password: - # Perform preview step to get values/snapshots for authorized users with open(xml_path, 'rb') as xml_file: preview_response = client.post(preview_url, {'file': xml_file, 'format': 'xml'}) assert preview_response.status_code == 200 preview_data = json.loads(preview_response.content.decode()) - # Prepare pacyload with all values and snapshots checked (selected) + # Prepare payload with all values and snapshots checked (selected) # Include the file again for the confirm step with open(xml_path, 'rb') as xml_file: confirm_payload = { @@ -98,37 +94,22 @@ def test_project_import_create_confirm(db, client, settings, username, password) "checked_snapshots": [s["index"] for s in preview_data.get("snapshots", [])], } confirm_response = client.post(confirm_url, confirm_payload) - # Confirm should succeed with HTTP 201 and return new project details + assert confirm_response.status_code == 201 result = json.loads(confirm_response.content.decode()) assert 'id' in result assert 'title' in result + assert projects_count + 1 == Project.objects.all().count() else: - # Unauthorized roles or anonymous: attempt confirm (should be rejected) with open(xml_path, 'rb') as xml_file: confirm_response = client.post(confirm_url, {'file': xml_file}) assert confirm_response.status_code == 401 -def test_import_create_confirm_get_not_allowed(db, client): - username = password = "user" - client.login(username=username, password=password) - - url = reverse(urlnames["import-create-confirm"]) - response = client.get(url) - assert response.status_code == 403 - - def test_import_create_confirm_restricted(db, client, settings): - """ - When PROJECT_CREATE_RESTRICTED=True, only users in PROJECT_CREATE_GROUPS - may hit import-create-preview (200) and then import-create-confirm (201). - Others (including anon) get 403 or 401. - """ settings.PROJECT_CREATE_RESTRICTED = True settings.PROJECT_CREATE_GROUPS = ['projects'] - # ensure 'projects' group exists and assign to logged-in user username = password = 'user' grp = Group.objects.create(name='projects') user = User.objects.get(username=username) @@ -137,9 +118,9 @@ def test_import_create_confirm_restricted(db, client, settings): preview_url = reverse(urlnames["import-create-preview"]) confirm_url = reverse(urlnames["import-create-confirm"]) + projects_count = Project.objects.all().count() xml_path = os.path.join(settings.BASE_DIR, 'xml', 'project.xml') - # Preview step with open(xml_path, 'rb') as xml: preview_resp = client.post(preview_url, {'file': xml, 'format': 'xml'}) assert preview_resp.status_code == 200 @@ -156,19 +137,17 @@ def test_import_create_confirm_restricted(db, client, settings): result = confirm_resp.json() assert 'id' in result assert 'title' in result + assert projects_count + 1 == Project.objects.all().count() def test_import_create_confirm_forbidden(db, client, settings): - """ - If PROJECT_CREATE_RESTRICTED=True and the user is NOT in PROJECT_CREATE_GROUPS, - import-create-preview and import-create-confirm must both 403. - """ settings.PROJECT_CREATE_RESTRICTED = True # no PROJECT_CREATE_GROUPS defined → no one is allowed client.login(username='user', password='user') preview_url = reverse(urlnames["import-create-preview"]) confirm_url = reverse(urlnames["import-create-confirm"]) + projects_count = Project.objects.all().count() xml_path = os.path.join(settings.BASE_DIR, 'xml', 'project.xml') with open(xml_path, 'rb') as xml: @@ -183,92 +162,4 @@ def test_import_create_confirm_forbidden(db, client, settings): 'checked_snapshots': [], }) assert confirm_resp.status_code == 403 - - -@pytest.mark.parametrize('username,password', users) -@pytest.mark.parametrize('project_id', projects) -def test_project_import_update_preview(db, client, settings, username, password, project_id): - """Test the import-update-preview endpoint with various user roles.""" - if password: - client.login(username=username, password=password) - - url = reverse('v1-projects:project-import-update-preview', args=[project_id]) - xml_path = os.path.join(settings.BASE_DIR, 'xml', 'project.xml') - - with open(xml_path, 'rb') as xml_file: - response = client.post(url, {'file': xml_file, 'format': 'xml'}) - - if project_id in view_project_permission_map.get(username, []): - assert response.status_code == 200 - data = json.loads(response.content.decode()) - assert 'values' in data - assert 'snapshots' in data - assert isinstance(data['values'], list) - assert isinstance(data['snapshots'], list) - elif password: - assert response.status_code == 404 - else: - assert response.status_code == 401 - - -def test_import_update_preview_invalid_xml(db, client, settings): - username = password = 'owner' - client.login(username=username, password=password) - - url = reverse(urlnames["import-update-preview"], args=[1]) - bad_xml = os.path.join(settings.BASE_DIR, "xml", "error.xml") - with open(bad_xml, "rb") as f: - resp = client.post(url, {"file": f, "format": "xml"}) - assert resp.status_code == 400 - assert 'Parsing error' in ' '.join(resp.json()['file']) - - - -@pytest.mark.parametrize('username,password', users) -@pytest.mark.parametrize('project_id', projects) -def test_project_import_update_confirm(db, client, settings, username, password, project_id): - """Test the import-update-confirm endpoint with various user roles.""" - if password: - client.login(username=username, password=password) - - preview_url = reverse('v1-projects:project-import-update-preview', args=[project_id]) - confirm_url = reverse('v1-projects:project-import-update-confirm', args=[project_id]) - xml_path = os.path.join(settings.BASE_DIR, 'xml', 'project.xml') - - if project_id in change_project_permission_map.get(username, []): - with open(xml_path, 'rb') as xml_file: - preview_response = client.post(preview_url, {'file': xml_file, 'format': 'xml'}) - assert preview_response.status_code == 200 - preview_data = json.loads(preview_response.content.decode()) - - with open(xml_path, 'rb') as xml_file: - confirm_payload = { - 'file': xml_file, - 'format': 'xml', - 'checked_values': [v["key"] for v in preview_data.get("values", [])], - 'checked_snapshots': [s["index"] for s in preview_data.get("snapshots", [])], - } - confirm_response = client.post(confirm_url, confirm_payload) - - assert confirm_response.status_code == 201 - result = json.loads(confirm_response.content.decode()) - assert 'id' in result - assert 'title' in result - elif password: - with open(xml_path, 'rb') as xml_file: - confirm_response = client.post( - confirm_url, - {'file': xml_file,'checked_values': [], 'checked_snapshots': []} - ) - - if project_id in view_project_permission_map.get(username, []): - assert confirm_response.status_code == 400 - else: - assert confirm_response.status_code == 404 - else: - with open(xml_path, 'rb') as xml_file: - confirm_response = client.post( - confirm_url, - {'file': xml_file,'checked_values': [], 'checked_snapshots': []} - ) - assert confirm_response.status_code == 401 + assert projects_count == Project.objects.all().count() diff --git a/rdmo/projects/tests/test_viewset_project_import_update.py b/rdmo/projects/tests/test_viewset_project_import_update.py index b2c7c8e97c..1b4b0d7424 100644 --- a/rdmo/projects/tests/test_viewset_project_import_update.py +++ b/rdmo/projects/tests/test_viewset_project_import_update.py @@ -5,13 +5,10 @@ from django.urls import reverse +from ..models import Project from .test_viewset_project import change_project_permission_map, projects, users, view_project_permission_map urlnames = { - "upload_accept": "v1-projects:project-upload-accept", - "imports": "v1-projects:project-imports", - "import-create-preview": "v1-projects:project-import-create-preview", - "import-create-confirm": "v1-projects:project-import-create-confirm", "import-update-preview": "v1-projects:project-import-update-preview", "import-update-confirm": "v1-projects:project-import-update-confirm", } @@ -20,12 +17,12 @@ @pytest.mark.parametrize('username,password', users) @pytest.mark.parametrize('project_id', projects) def test_project_import_update_preview(db, client, settings, username, password, project_id): - """Test the import-update-preview endpoint with various user roles.""" if password: client.login(username=username, password=password) url = reverse('v1-projects:project-import-update-preview', args=[project_id]) xml_path = os.path.join(settings.BASE_DIR, 'xml', 'project.xml') + projects_count = Project.objects.all().count() with open(xml_path, 'rb') as xml_file: response = client.post(url, {'file': xml_file, 'format': 'xml'}) @@ -37,22 +34,44 @@ def test_project_import_update_preview(db, client, settings, username, password, assert 'snapshots' in data assert isinstance(data['values'], list) assert isinstance(data['snapshots'], list) + assert projects_count == Project.objects.all().count() elif password: assert response.status_code == 404 else: assert response.status_code == 401 +@pytest.mark.parametrize('action', ['preview','confirm']) +def test_import_update_preview_and_confirm_get_not_allowed(db, client, action): + username = password = 'owner' + client.login(username=username, password=password) + url = reverse(urlnames[f"import-update-{action}"], args=[1]) + response = client.get(url) + assert response.status_code == 405 + + +def test_import_update_preview_invalid_xml(db, client, settings): + username = password = 'owner' + client.login(username=username, password=password) + + url = reverse(urlnames["import-update-preview"], args=[1]) + bad_xml = os.path.join(settings.BASE_DIR, "xml", "error.xml") + with open(bad_xml, "rb") as f: + resp = client.post(url, {"file": f, "format": "xml"}) + assert resp.status_code == 400 + assert 'Parsing error' in ' '.join(resp.json()['file']) + + @pytest.mark.parametrize('username,password', users) @pytest.mark.parametrize('project_id', projects) def test_project_import_update_confirm(db, client, settings, username, password, project_id): - """Test the import-update-confirm endpoint with various user roles.""" if password: client.login(username=username, password=password) preview_url = reverse('v1-projects:project-import-update-preview', args=[project_id]) confirm_url = reverse('v1-projects:project-import-update-confirm', args=[project_id]) xml_path = os.path.join(settings.BASE_DIR, 'xml', 'project.xml') + projects_count = Project.objects.all().count() if project_id in change_project_permission_map.get(username, []): with open(xml_path, 'rb') as xml_file: @@ -65,9 +84,7 @@ def test_project_import_update_confirm(db, client, settings, username, password, 'file': xml_file, 'format': 'xml', 'checked_values': [v["key"] for v in preview_data.get("values", [])], - 'checked_snapshots': [ - s["index"] for s in preview_data.get("snapshots", []) - ], + 'checked_snapshots': [s["index"] for s in preview_data.get("snapshots", [])], } confirm_response = client.post(confirm_url, confirm_payload) @@ -75,10 +92,12 @@ def test_project_import_update_confirm(db, client, settings, username, password, result = json.loads(confirm_response.content.decode()) assert 'id' in result assert 'title' in result + assert projects_count == Project.objects.all().count() elif password: with open(xml_path, 'rb') as xml_file: confirm_response = client.post( - confirm_url, {'file': xml_file,'checked_values': [], 'checked_snapshots': []} + confirm_url, + {'file': xml_file,'checked_values': [], 'checked_snapshots': []} ) if project_id in view_project_permission_map.get(username, []): @@ -88,6 +107,7 @@ def test_project_import_update_confirm(db, client, settings, username, password, else: with open(xml_path, 'rb') as xml_file: confirm_response = client.post( - confirm_url, {'file': xml_file,'checked_values': [], 'checked_snapshots': []} + confirm_url, + {'file': xml_file,'checked_values': [], 'checked_snapshots': []} ) assert confirm_response.status_code == 401 From 882f5323b700e75d6f56a0aa9a820af124e6f4d0 Mon Sep 17 00:00:00 2001 From: David Wallace Date: Thu, 14 Aug 2025 13:37:49 +0200 Subject: [PATCH 152/165] accounts(utils): simplify make unique username Signed-off-by: David Wallace --- rdmo/accounts/utils.py | 26 +++++++------------------- 1 file changed, 7 insertions(+), 19 deletions(-) diff --git a/rdmo/accounts/utils.py b/rdmo/accounts/utils.py index 627bcaff46..5aebee053c 100644 --- a/rdmo/accounts/utils.py +++ b/rdmo/accounts/utils.py @@ -85,23 +85,11 @@ def get_user_from_db_or_none(username: str, email: str): def make_unique_username(seed: str) -> str: - """ - Return a DB-unique username derived from *seed*. - - * seed -> "markus" → "markus" (if free) - * ... → "markus_1", "markus_2", … - * after 99 tries → "markus_<8-random-chars>" - """ base = slugify(seed) or "user" - candidate = base - suffix = 0 - - while User.objects.filter(username=candidate).exists(): - suffix += 1 - if suffix <= 99: - candidate = f"{base}_{suffix}" - else: # extreme edge case - candidate = f"{base}_{get_random_string(8)}" - break - - return candidate + user_model = get_user_model() + for suffix in range(0, 8): + candidate = base if suffix == 0 else f"{base}_{suffix}" + if not user_model.objects.filter(username=candidate).exists(): + return candidate + # fallback + return f"{base}_{get_random_string(8)}" From f427e90a3ef0b965862fe098d0a9fea116670b28 Mon Sep 17 00:00:00 2001 From: David Wallace Date: Thu, 14 Aug 2025 16:45:03 +0200 Subject: [PATCH 153/165] projects(export): add tests for export_projects command Signed-off-by: David Wallace --- .../tests/test_command_export_projects.py | 133 ++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 rdmo/projects/tests/test_command_export_projects.py diff --git a/rdmo/projects/tests/test_command_export_projects.py b/rdmo/projects/tests/test_command_export_projects.py new file mode 100644 index 0000000000..f07dc51e27 --- /dev/null +++ b/rdmo/projects/tests/test_command_export_projects.py @@ -0,0 +1,133 @@ +import json +import xml.etree.ElementTree as ET + +import pytest + +from django.core.management import call_command + +from rdmo.projects.models import Project + + +@pytest.mark.django_db +@pytest.mark.parametrize("export_format", ["xml", "json"]) +@pytest.mark.parametrize("include_memberships", [True, False]) +def test_export_projects_full_project_xml_and_json(tmp_path, capsys, export_format, include_memberships): + """ + Full-project export (plugin-based) with xml/json, with and without memberships. + - Always checks the file exists and has sane structure + - Additionally checks that memberships are present iff --with-members is passed + """ + project = Project.objects.get(id=1) + + args = [ + "export_projects", + "--projects", str(project.id), + "--format", export_format, + "--path", str(tmp_path), + ] + if include_memberships: + args.append("--with-members") + + call_command(*args) + + project_dir = tmp_path / str(project.id) + assert project_dir.exists(), f"Export dir {project_dir} missing" + + exported = (project_dir / project.title).with_suffix(f".{export_format}") + assert exported.exists(), "Exported file does not exist." + + content_text = exported.read_text("utf-8") + + if export_format == "xml": + root = ET.fromstring(content_text.encode("utf-8")) + assert root.tag == "project" + child_tags = {c.tag for c in root} + assert {"title", "description", "catalog", "values"}.issubset(child_tags) + if include_memberships: + assert "memberships" in child_tags + + else: # json + # Basic shape check (existing expectation in your suite) + try: + data = json.loads(content_text) + except json.JSONDecodeError as e: + pytest.fail(f"JSON export not parseable: {e}") + + if isinstance(data, list): + # Legacy/list-shaped JSON export + assert len(data) >= 1 + assert all(set(item.keys()) == {"question", "set", "values"} for item in data) + else: + pytest.fail(f"Unexpected JSON root type: {type(data)}") + + out = capsys.readouterr().out + assert f"Exported 1 project(s) to {tmp_path.resolve()}" in out + + +@pytest.mark.django_db +def test_export_projects_answers_html(tmp_path, capsys): + """ + Exercise the answers export (template + render_to_format). + Validate file path layout: //answers/, and sniff-check for HTML + project title. + """ + project = Project.objects.get(id=1) + export_format = "html" + + call_command( + "export_projects", + "--answers", + "--format", export_format, + "--projects", str(project.id), + "--path", str(tmp_path), + ) + + # answers go into a dedicated subdir determined by mode selection + answers_dir = tmp_path / str(project.id) / "answers" + assert answers_dir.exists() + + exported = (answers_dir / project.title).with_suffix(f'.{export_format}') + assert exported.exists(), "Exported file does not exist." + + text = exported.read_text("utf-8") + + # Lightweight content checks: looks like HTML and mentions the project title. + assert ". + """ + project = Project.objects.get(id=1) + export_format = 'html' + # Pick any view attached to the project; if none, skip (fixture variability). + view = project.views.order_by("id").first() + if view is None: + pytest.skip("No project view available in fixtures") + + call_command( + "export_projects", + "--view", view.uri, + "--format", export_format, + "--projects", str(project.id), + "--path", str(tmp_path), + ) + + # View exports use subdir of the view's uri_path; files named via Content-Disposition header + view_dir = tmp_path / str(project.id) / view.uri_path + assert view_dir.exists() + + exported = (view_dir / project.title).with_suffix(f'.{export_format}') + assert exported.exists(), "Exported file does not exist." + + text = exported.read_text("utf-8") + assert " Date: Thu, 14 Aug 2025 16:48:33 +0200 Subject: [PATCH 154/165] core(imports): add dedicated subdir to temp dir Signed-off-by: David Wallace --- rdmo/core/imports.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/rdmo/core/imports.py b/rdmo/core/imports.py index bbd554b7d0..c555d24f6f 100644 --- a/rdmo/core/imports.py +++ b/rdmo/core/imports.py @@ -22,7 +22,9 @@ logger = logging.getLogger(__name__) -_temp_storage = FileSystemStorage(location=tempfile.gettempdir()) +_TEMP_DIR = Path(tempfile.gettempdir()) / "rdmo_uploads" +_TEMP_DIR.mkdir(exist_ok=True) +_temp_storage = FileSystemStorage(location=_TEMP_DIR) class ImportElementFields(str, Enum): DIFF = "updated_and_changed" From a4a8c87eb9afbf7cf0827ec6f938f03be4b4e9b1 Mon Sep 17 00:00:00 2001 From: David Wallace Date: Thu, 14 Aug 2025 16:57:19 +0200 Subject: [PATCH 155/165] projects(export): add optional memberships to export serializers Signed-off-by: David Wallace --- rdmo/projects/exports.py | 5 ++++- rdmo/projects/renderers.py | 30 +++++++++++++++++++++++++++++ rdmo/projects/serializers/export.py | 27 +++++++++++++++++++++++++- rdmo/projects/views/project.py | 1 + rdmo/projects/views/project_view.py | 2 ++ 5 files changed, 63 insertions(+), 2 deletions(-) diff --git a/rdmo/projects/exports.py b/rdmo/projects/exports.py index a6b681071c..34c58f58ab 100644 --- a/rdmo/projects/exports.py +++ b/rdmo/projects/exports.py @@ -21,6 +21,7 @@ def __init__(self, *args, **kwargs): self.project = None self.snapshot = None + self.include_memberships = False def render(self): raise NotImplementedError @@ -158,7 +159,9 @@ class RDMOXMLExport(Export): def render(self): if self.project: content_disposition = f'attachment; filename="{self.project.title}.xml"' - serializer = ProjectExportSerializer(self.project) + serializer = ProjectExportSerializer(self.project, context={ + 'include_memberships': self.include_memberships + }) else: content_disposition = f'attachment; filename="{self.snapshot.title}.xml"' diff --git a/rdmo/projects/renderers.py b/rdmo/projects/renderers.py index 75d18a1442..3b6d7b110f 100644 --- a/rdmo/projects/renderers.py +++ b/rdmo/projects/renderers.py @@ -14,6 +14,12 @@ def render_document(self, xml, project): self.render_text_element(xml, 'description', {}, project['description']) self.render_text_element(xml, 'catalog', {'dc:uri': project['catalog']}, None) + if project.get('memberships'): + xml.startElement('memberships', {}) + for member in project['memberships']: + self.render_member(xml, member) + xml.endElement('memberships') + if project.get('tasks'): xml.startElement('tasks', {}) for task in project['tasks']: @@ -73,3 +79,27 @@ def render_value(self, xml, value): self.render_text_element(xml, 'created', {}, value['created']) self.render_text_element(xml, 'updated', {}, value['updated']) xml.endElement('value') + + def render_member(self, xml, member): + # member node with role as attribute; rest as child nodes + xml.startElement('member', {}) + + role = member.get('role') + if role: + self.render_text_element(xml, 'role', {}, role) + + user = member.get('user') or {} + if isinstance(user, dict) and user: + self.render_member_user(xml, user) + + xml.endElement('member') + + def render_member_user(self, xml, user: dict): + field_order = [ + 'id', 'username', 'first_name', 'last_name', 'full_name', 'email' + ] # the presence of fields should be determined by the serializer + for key in field_order: + if key in user: + val = user.get(key) + if val not in (None, '', []): + self.render_text_element(xml, key, {}, val) diff --git a/rdmo/projects/serializers/export.py b/rdmo/projects/serializers/export.py index 75ce053578..c3c6b0c183 100644 --- a/rdmo/projects/serializers/export.py +++ b/rdmo/projects/serializers/export.py @@ -2,7 +2,8 @@ from rest_framework import serializers -from ..models import Project, Snapshot, Value +from ..models import Membership, Project, Snapshot, Value +from .v1 import ProjectUserSerializer class ValueSerializer(serializers.ModelSerializer): @@ -42,6 +43,7 @@ class SnapshotSerializer(serializers.ModelSerializer): catalog = serializers.CharField(source='catalog.uri', default=None, read_only=True) tasks = serializers.SerializerMethodField() views = serializers.SerializerMethodField() + memberships = serializers.SerializerMethodField() # optional from context class Meta: model = Snapshot @@ -52,6 +54,7 @@ class Meta: 'tasks', 'views', 'values', + 'memberships', # optional, from context 'created', 'updated' ) @@ -68,6 +71,12 @@ def get_tasks(self, obj): def get_views(self, obj): return [view.uri for view in obj.project.views.all()] + def get_memberships(self, obj): + if not self.context.get("include_memberships"): + return [] + qs = obj.memberships.select_related("user").all() + return MembershipForExportSerializer(qs, many=True, context=self.context).data + class ProjectSnapshotSerializer(serializers.ModelSerializer): @@ -93,6 +102,7 @@ class ProjectSerializer(serializers.ModelSerializer): snapshots = ProjectSnapshotSerializer(many=True) values = serializers.SerializerMethodField() + memberships = serializers.SerializerMethodField() # optional from context catalog = serializers.CharField(source='catalog.uri', default=None, read_only=True) tasks = serializers.SerializerMethodField() @@ -108,6 +118,7 @@ class Meta: 'views', 'snapshots', 'values', + 'memberships', # optional, from context 'created', 'updated' ) @@ -122,3 +133,17 @@ def get_tasks(self, obj): def get_views(self, obj): return [view.uri for view in obj.views.all()] + + def get_memberships(self, obj): + if not self.context.get("include_memberships"): + return [] + qs = obj.memberships.select_related("user").all() + return MembershipForExportSerializer(qs, many=True, context=self.context).data + + +class MembershipForExportSerializer(serializers.ModelSerializer): + user = ProjectUserSerializer(read_only=True) + + class Meta: + model = Membership + fields = ("user", "role") diff --git a/rdmo/projects/views/project.py b/rdmo/projects/views/project.py index 81a11e51b8..bd8a2eef54 100644 --- a/rdmo/projects/views/project.py +++ b/rdmo/projects/views/project.py @@ -195,6 +195,7 @@ def get_export_plugin(self): export_plugin.request = self.request export_plugin.project = self.object + export_plugin.include_memberships = False return export_plugin diff --git a/rdmo/projects/views/project_view.py b/rdmo/projects/views/project_view.py index d7d1d95e2c..3f6ad8a94f 100644 --- a/rdmo/projects/views/project_view.py +++ b/rdmo/projects/views/project_view.py @@ -93,6 +93,8 @@ def get_context_data(self, **kwargs): 'resource_path': get_value_path(context['project'], context['current_snapshot']) }) + context['include_memberships'] = False + return context def render_to_response(self, context, **response_kwargs): From 8f72585aa13c304cfdde8034a6125603f2ab17ef Mon Sep 17 00:00:00 2001 From: David Wallace Date: Thu, 14 Aug 2025 16:59:07 +0200 Subject: [PATCH 156/165] projects(export): update export_projects command Signed-off-by: David Wallace --- .../management/commands/export_projects.py | 159 ++++++------------ 1 file changed, 56 insertions(+), 103 deletions(-) diff --git a/rdmo/projects/management/commands/export_projects.py b/rdmo/projects/management/commands/export_projects.py index a026bdcefa..51606414ff 100644 --- a/rdmo/projects/management/commands/export_projects.py +++ b/rdmo/projects/management/commands/export_projects.py @@ -1,10 +1,10 @@ from __future__ import annotations -import json import logging from pathlib import Path from django.conf import settings +from django.contrib.sites.models import Site from django.core.management.base import BaseCommand, CommandError from django.db.models import Prefetch, QuerySet @@ -21,8 +21,12 @@ class Command(BaseCommand): def add_arguments(self, parser): - parser.add_argument('--answers', action='store_true', help='Export answers instead of the full project.') - parser.add_argument('--view', metavar='URI', help='Export the given view instead of the full project.') + parser.add_argument( + '--answers', action='store_true', help='Export answers instead of the full project.' + ) + parser.add_argument( + '--view', metavar='URI', help='Export the given view instead of the full project.' + ) parser.add_argument( '--projects', nargs='*', type=int, metavar='ID', help='Limit the export to specific project IDs.' ) @@ -36,9 +40,12 @@ def add_arguments(self, parser): parser.add_argument( '--format', default='xml', help='Export format (answers/view honour settings.EXPORT_FORMATS).' ) - parser.add_argument('--path', default='exports', metavar='DIR', help='Target directory [default: exports/].') parser.add_argument( - '--with-members', action='store_true', help='Write a members.json file with user/role info.' + '--path', default='exports', metavar='DIR', help='Target directory [default: exports/].' + ) + parser.add_argument( + '--with-members', action='store_true', + help='Write project memberships (user, role, email) to export.' ) def handle(self, *args, **options): @@ -50,57 +57,41 @@ def handle(self, *args, **options): project_ids = options.get('projects') or None site_id: int = options['site_id'] catalog_uri= options.get('catalog_uri') or None - - # upfront validations ------------------------------------------------ + # validations first if export_mode in ('answers', 'view'): - if self.format not in dict(settings.EXPORT_FORMATS): - raise CommandError(f'Format "{self.format}" is not configured in settings.EXPORT_FORMATS.') - - view_obj= None + if self.format not in dict((*settings.EXPORT_FORMATS,('xml',None))): + raise CommandError( + f'Format "{self.format}" is not configured in settings.EXPORT_FORMATS for export mode {export_mode}.' # noqa: E501 + ) if export_mode == 'view': try: view_obj = View.objects.get(uri=options['view']) except View.DoesNotExist as exc: - raise CommandError(f'View with key "{options["view"]}" does not exist.') from exc + raise CommandError(f'View with uri "{options["view"]}" does not exist.') from exc + else: + view_obj = None + + if site_id != settings.SITE_ID and not Site.objects.filter(id=site_id).exists(): + raise CommandError(f'No matching site for id={site_id} found.') projects = self._get_queryset(project_ids, site_id, catalog_uri) + if self.with_members: + projects = self._prefetch_memberships(projects) + if not projects.exists(): + self.stdout.write(self.style.WARNING('No projects found.')) if catalog_uri: - project_catalogs = sorted( - set(self._get_queryset(project_ids, site_id, None).values_list('catalog__uri', flat=True)) - ) - project_catalogs_str = [f"\t- {i} \n" for i in project_catalogs] - self.stdout.write(self.style.WARNING(f'Choose a catalog from:\n {"".join(project_catalogs_str)}')) - - if site_id != settings.SITE_ID: - project_sites = sorted( - set(self._get_queryset(project_ids, None, None).values_list('site__id', 'site__domain')) - ) - project_sites_str = [f"\t- {id} {domain} \n" for id, domain in project_sites] - self.stdout.write(self.style.WARNING(f'Choose a site from:\n {"".join(project_sites_str)}')) - - raise CommandError('No matching projects found.') + raise CommandError(f'No matching projects found for catalog(uri={catalog_uri}).') for project in projects: - self._export_project(project, mode=export_mode, view=view_obj) + self.export_project(project, mode=export_mode, view=view_obj, include_memberships=self.with_members) self.stdout.write(self.style.SUCCESS(f'Exported {projects.count()} project(s) to {self.path}')) - def _get_queryset( - self, - ids, - site_id, - catalog_uri, - ) -> QuerySet[Project]: - """ - Build a base queryset filtered by site_id and optional catalog_uri, - then by explicit project IDs if given. - """ - # start from the current site - if site_id is not None: - qs = Project.objects.filter(site_id=site_id) - else: - qs = Project.objects.all() + def _get_queryset(self,ids,site_id,catalog_uri,) -> QuerySet[Project]: + + # start from the selected site + qs = Project.objects.filter(site_id=site_id) # optional catalog URI filter if catalog_uri: @@ -110,43 +101,34 @@ def _get_queryset( if ids: qs = qs.filter(id__in=set(ids)) - return qs.select_related('catalog', 'site').prefetch_related( + return qs.select_related('catalog', 'site') + + @staticmethod + def _prefetch_memberships(qs): + return qs.prefetch_related( Prefetch( 'memberships', queryset=Membership.objects.select_related('user'), ) ) - def _export_project( - self, - project: Project, - *, - mode: str, - view= None, - ) -> None: - """ - Orchestrate the chosen export *mode* for one project and write the file - to disk. - """ + def export_project(self, project, *, mode: str, view=None, include_memberships=False) -> None: if mode == 'answers': - response = self._render_answers(project) + response = self.render_answers(project) subdir = 'answers' elif mode == 'view': - assert view is not None # guarded in `handle` - response = self._render_view(project, view) + response = self.render_view(project, view) subdir = view.uri_path else: # full project - response = self._render_full_project(project) + response = self.render_full_project(project, include_memberships=include_memberships) subdir = '' - self._write_response(project, subdir, response) - - if self.with_members: - self._write_members_json(project) + target_dir = self.path / str(project.id) / subdir + self.write_response_to_file(target_dir, response) - def _render_answers(self, project: Project): + def render_answers(self, project): snapshot = None context = { 'project': project, @@ -156,9 +138,11 @@ def _render_answers(self, project: Project): 'format': self.format, 'resource_path': get_value_path(project, snapshot), } - return render_to_format(None, self.format, context['title'], 'projects/project_answers_export.html', context) + return render_to_format( + None, self.format, context['title'], 'projects/project_answers_export.html', context + ) - def _render_view(self, project: Project, view: View): + def render_view(self, project, view: View): snapshot = None context = { 'project': project, @@ -170,57 +154,26 @@ def _render_view(self, project: Project, view: View): 'format': self.format, 'resource_path': get_value_path(project, snapshot), } - return render_to_format(None, self.format, context['title'], 'projects/project_view_export.html', context) + return render_to_format( + None, self.format, context['title'], 'projects/project_view_export.html', context + ) - def _render_full_project(self, project: Project): + def render_full_project(self, project, include_memberships: bool=False) -> dict: plugin_cls = get_plugin('PROJECT_EXPORTS', self.format) if plugin_cls is None: raise CommandError(f'Format "{self.format}" is not supported.') plugin = plugin_cls plugin.project = project plugin.snapshot = None + plugin.include_memberships = include_memberships return plugin.render() - def _write_members_json(self, project: Project) -> None: - """ - Dump a `members.json` file with `[{"user_id": …, "username": …, "role": …}, …]`. - """ - payload = [ - { - 'user_id': m.user_id, - 'username': m.user.get_username(), - 'first_name': m.user.first_name, - 'last_name': m.user.last_name, - 'email': m.user.email, - 'role': m.role, - 'project_title': project.title, - 'project_site_domain': project.site.domain, - } - for m in project.memberships.all() - ] - - if not payload: # skip empty projects - return - - target_dir = self.path / str(project.id) - target_dir.mkdir(parents=True, exist_ok=True) - file_path = target_dir / 'members.json' - - logger.info('Writing %s', file_path) - with file_path.open('w', encoding='utf-8') as fp: - json.dump(payload, fp, ensure_ascii=False, indent=2) - - def _write_response( - self, - project: Project, - subdir: str, - response, - ) -> None: + @staticmethod + def write_response_to_file(target_dir: Path, response,) -> None: filename = response.headers.get('Content-Disposition', '').split('filename=')[-1].strip('"') if not filename: raise CommandError('Export response did not include a filename header.') - target_dir = self.path / str(project.id) / subdir target_dir.mkdir(parents=True, exist_ok=True) file_path = target_dir / filename From f430ce41fc73f1d9bcd4b89060c2d7300ce72e0a Mon Sep 17 00:00:00 2001 From: David Wallace Date: Fri, 15 Aug 2025 10:31:49 +0200 Subject: [PATCH 157/165] projects(export): update export_projects and tests Signed-off-by: David Wallace --- .../management/commands/export_projects.py | 161 +++++++++++------- .../tests/test_command_export_projects.py | 12 +- 2 files changed, 109 insertions(+), 64 deletions(-) diff --git a/rdmo/projects/management/commands/export_projects.py b/rdmo/projects/management/commands/export_projects.py index 51606414ff..ce4fd696c7 100644 --- a/rdmo/projects/management/commands/export_projects.py +++ b/rdmo/projects/management/commands/export_projects.py @@ -22,10 +22,15 @@ class Command(BaseCommand): def add_arguments(self, parser): parser.add_argument( - '--answers', action='store_true', help='Export answers instead of the full project.' + '--export-mode', + choices=['project', 'answers', 'view'], + default='project', + help='What to export: project (default), answers or view.' ) parser.add_argument( - '--view', metavar='URI', help='Export the given view instead of the full project.' + '--view-uri', + metavar='URI', + help='Required if export-mode is "view". Specifies the view URI to export.' ) parser.add_argument( '--projects', nargs='*', type=int, metavar='ID', help='Limit the export to specific project IDs.' @@ -41,54 +46,92 @@ def add_arguments(self, parser): '--format', default='xml', help='Export format (answers/view honour settings.EXPORT_FORMATS).' ) parser.add_argument( - '--path', default='exports', metavar='DIR', help='Target directory [default: exports/].' + '--path', default='exports', metavar='DIR', help='Target directory (default: exports/).' ) parser.add_argument( - '--with-members', action='store_true', - help='Write project memberships (user, role, email) to export.' + '--include-memberships', action='store_true', + help='Write project memberships (role, user, email) to export.' ) def handle(self, *args, **options): - export_mode = 'answers' if options['answers'] else 'view' if options['view'] else 'project' - - self.format: str = options['format'] - self.path: Path = Path(options['path']).expanduser().resolve() - self.with_members: bool = options['with_members'] + export_mode = options['export_mode'] + export_format: str = options['format'] + export_path: Path = Path(options['path']).expanduser().resolve() + include_memberships: bool = options['include_memberships'] project_ids = options.get('projects') or None site_id: int = options['site_id'] - catalog_uri= options.get('catalog_uri') or None + catalog_uri = options.get('catalog_uri') or None # validations first if export_mode in ('answers', 'view'): - if self.format not in dict((*settings.EXPORT_FORMATS,('xml',None))): + if export_format not in dict((*settings.EXPORT_FORMATS, ('xml', None))): raise CommandError( - f'Format "{self.format}" is not configured in settings.EXPORT_FORMATS for export mode {export_mode}.' # noqa: E501 + f'Format "{export_format}" is not configured in settings.EXPORT_FORMATS for export mode {export_mode}.' # noqa: E501 ) + if export_mode == 'view': - try: - view_obj = View.objects.get(uri=options['view']) - except View.DoesNotExist as exc: - raise CommandError(f'View with uri "{options["view"]}" does not exist.') from exc - else: - view_obj = None + if not options.get('view_uri'): + raise CommandError('You must specify --view-uri when using export-mode="view".') + + if include_memberships and export_mode != 'project': + raise CommandError("Project memberships can only be exported with export mode 'project'.") if site_id != settings.SITE_ID and not Site.objects.filter(id=site_id).exists(): raise CommandError(f'No matching site for id={site_id} found.') - projects = self._get_queryset(project_ids, site_id, catalog_uri) - if self.with_members: - projects = self._prefetch_memberships(projects) + # start: query the projects + projects = self.get_project_queryset(project_ids, site_id, catalog_uri) + if include_memberships: + projects = self.prefetch_memberships(projects) if not projects.exists(): self.stdout.write(self.style.WARNING('No projects found.')) if catalog_uri: raise CommandError(f'No matching projects found for catalog(uri={catalog_uri}).') + # loop through projects for project in projects: - self.export_project(project, mode=export_mode, view=view_obj, include_memberships=self.with_members) + # export individual project based on format,mode,path and kwargs + self.export_project( + project, export_format, export_mode, export_path, + view_uri=options['view_uri'], + include_memberships=include_memberships + ) + + self.stdout.write(self.style.SUCCESS(f'Exported {projects.count()} project(s) to {export_path}')) + + def export_project(self, project, export_format, export_mode, export_path, + view_uri=None, include_memberships=False + ) -> None: + if export_mode == 'answers': + response = self.render_answers(project, export_format) + subdir = 'answers' + + elif export_mode == 'view': + try: + view = View.objects.get(uri=view_uri) + except View.DoesNotExist as exc: + raise CommandError(f'View with uri "{view_uri}" does not exist.') from exc + + response = self.render_view(project, export_format, view=view) + subdir = 'views/' + view.uri_path + + else: # full project + response = self.render_full_project(project, export_format, include_memberships=include_memberships) + subdir = '' - self.stdout.write(self.style.SUCCESS(f'Exported {projects.count()} project(s) to {self.path}')) + if response.status_code != 200: + raise CommandError(f"Failed to export project '{project.title}'\nResponse={response}\n\t{response.content.decode()}." # noqa: E501 + ) + + target_dir = export_path / str(project.id) / subdir + target_filename = self.get_filename_from_response(response) + target_file = target_dir / target_filename - def _get_queryset(self,ids,site_id,catalog_uri,) -> QuerySet[Project]: + self.write_content_to_file(target_file, response.content) + self.stdout.write(self.style.SUCCESS(f'Exported {project} to {target_file}')) + + @staticmethod + def get_project_queryset(ids, site_id, catalog_uri, ) -> QuerySet[Project]: # start from the selected site qs = Project.objects.filter(site_id=site_id) @@ -104,7 +147,7 @@ def _get_queryset(self,ids,site_id,catalog_uri,) -> QuerySet[Project]: return qs.select_related('catalog', 'site') @staticmethod - def _prefetch_memberships(qs): + def prefetch_memberships(qs): return qs.prefetch_related( Prefetch( 'memberships', @@ -112,56 +155,44 @@ def _prefetch_memberships(qs): ) ) - def export_project(self, project, *, mode: str, view=None, include_memberships=False) -> None: - if mode == 'answers': - response = self.render_answers(project) - subdir = 'answers' - - elif mode == 'view': - response = self.render_view(project, view) - subdir = view.uri_path - - else: # full project - response = self.render_full_project(project, include_memberships=include_memberships) - subdir = '' - - target_dir = self.path / str(project.id) / subdir - self.write_response_to_file(target_dir, response) - - def render_answers(self, project): + @staticmethod + def render_answers(project, export_format): snapshot = None context = { 'project': project, 'current_snapshot': snapshot, 'project_wrapper': ProjectWrapper(project, snapshot), 'title': project.title, - 'format': self.format, + 'format': export_format, 'resource_path': get_value_path(project, snapshot), } return render_to_format( - None, self.format, context['title'], 'projects/project_answers_export.html', context + None, export_format, + context['title'], 'projects/project_answers_export.html', context ) - def render_view(self, project, view: View): + @staticmethod + def render_view(project, export_format, view): snapshot = None context = { 'project': project, 'current_snapshot': snapshot, - 'view': project.views.get(pk=view.id), - 'rendered_view': view.render(project, snapshot=snapshot, export_format=self.format), + 'view': view, + 'rendered_view': view.render(project, snapshot=snapshot, export_format=export_format), 'project_wrapper': ProjectWrapper(project, snapshot), 'title': project.title, - 'format': self.format, + 'format': export_format, 'resource_path': get_value_path(project, snapshot), } return render_to_format( - None, self.format, context['title'], 'projects/project_view_export.html', context + None, export_format, context['title'], 'projects/project_view_export.html', context ) - def render_full_project(self, project, include_memberships: bool=False) -> dict: - plugin_cls = get_plugin('PROJECT_EXPORTS', self.format) + @staticmethod + def render_full_project(project, export_format, include_memberships: bool=False) -> dict: + plugin_cls = get_plugin('PROJECT_EXPORTS', export_format) if plugin_cls is None: - raise CommandError(f'Format "{self.format}" is not supported.') + raise CommandError(f'Format "{export_format}" is not supported.') plugin = plugin_cls plugin.project = project plugin.snapshot = None @@ -169,14 +200,24 @@ def render_full_project(self, project, include_memberships: bool=False) -> dict: return plugin.render() @staticmethod - def write_response_to_file(target_dir: Path, response,) -> None: + def get_filename_from_response(response): filename = response.headers.get('Content-Disposition', '').split('filename=')[-1].strip('"') if not filename: raise CommandError('Export response did not include a filename header.') + return filename - target_dir.mkdir(parents=True, exist_ok=True) - - file_path = target_dir / filename - logger.info('Writing %s', file_path) - with file_path.open('wb') as fp: - fp.write(response.content) + @staticmethod + def write_content_to_file(target_file: Path, content) -> None: + + if not target_file.parent.exists(): + target_file.parent.mkdir(parents=True, exist_ok=True) + + try: + logger.info('Writing %s', target_file) + with target_file.open('wb') as fp: + fp.write(content) + except FileNotFoundError as e: + raise CommandError(f'Failed to write to {target_file}: file not found.') from e + except OSError as e: + # Handle broader I/O errors like permission denied, disk full, etc. + raise CommandError(f'Failed to write to {target_file}: {e}') from e diff --git a/rdmo/projects/tests/test_command_export_projects.py b/rdmo/projects/tests/test_command_export_projects.py index f07dc51e27..eda31ab8a6 100644 --- a/rdmo/projects/tests/test_command_export_projects.py +++ b/rdmo/projects/tests/test_command_export_projects.py @@ -24,9 +24,10 @@ def test_export_projects_full_project_xml_and_json(tmp_path, capsys, export_form "--projects", str(project.id), "--format", export_format, "--path", str(tmp_path), + "--export-mode", "project" # is the default ] if include_memberships: - args.append("--with-members") + args.append("--include-memberships") call_command(*args) @@ -45,6 +46,8 @@ def test_export_projects_full_project_xml_and_json(tmp_path, capsys, export_form assert {"title", "description", "catalog", "values"}.issubset(child_tags) if include_memberships: assert "memberships" in child_tags + else: + assert "memberships" not in child_tags else: # json # Basic shape check (existing expectation in your suite) @@ -75,7 +78,7 @@ def test_export_projects_answers_html(tmp_path, capsys): call_command( "export_projects", - "--answers", + "--export-mode", "answers", "--format", export_format, "--projects", str(project.id), "--path", str(tmp_path), @@ -113,14 +116,15 @@ def test_export_projects_view_html_creates_file(tmp_path, capsys): call_command( "export_projects", - "--view", view.uri, + "--export-mode", "view", + "--view-uri", view.uri, "--format", export_format, "--projects", str(project.id), "--path", str(tmp_path), ) # View exports use subdir of the view's uri_path; files named via Content-Disposition header - view_dir = tmp_path / str(project.id) / view.uri_path + view_dir = tmp_path / str(project.id) / 'views' / view.uri_path assert view_dir.exists() exported = (view_dir / project.title).with_suffix(f'.{export_format}') From 6555ffabfe5ed04cfea77073e1795588642ce425 Mon Sep 17 00:00:00 2001 From: David Wallace Date: Fri, 15 Aug 2025 10:33:29 +0200 Subject: [PATCH 158/165] projects(export): prevent failure when value.file not found Signed-off-by: David Wallace --- rdmo/projects/serializers/export.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/rdmo/projects/serializers/export.py b/rdmo/projects/serializers/export.py index c3c6b0c183..3e7d293a0a 100644 --- a/rdmo/projects/serializers/export.py +++ b/rdmo/projects/serializers/export.py @@ -32,8 +32,14 @@ class Meta: ) def get_file_content(self, obj): - if obj.file: + if not obj.file: + return None + + try: return base64.b64encode(obj.file.read()) + except FileNotFoundError: + # file was saved but no longer exists + return None class SnapshotSerializer(serializers.ModelSerializer): From 5f425ee70ee21ce7055eaae925ec1de7c0d9d294 Mon Sep 17 00:00:00 2001 From: David Wallace Date: Fri, 15 Aug 2025 15:28:35 +0200 Subject: [PATCH 159/165] projects(import): add support for the import of memberships Signed-off-by: David Wallace --- rdmo/projects/imports.py | 36 ++- .../management/commands/import_projects.py | 206 +++++++++++------- rdmo/projects/management/commands/utils.py | 24 +- rdmo/projects/renderers.py | 21 +- rdmo/projects/utils.py | 69 ++---- 5 files changed, 191 insertions(+), 165 deletions(-) diff --git a/rdmo/projects/imports.py b/rdmo/projects/imports.py index 67b193c62f..6e5735577d 100644 --- a/rdmo/projects/imports.py +++ b/rdmo/projects/imports.py @@ -24,10 +24,12 @@ from .models import Project, Snapshot, Value from .utils import ( + import_memberships, save_import_snapshot_values, save_import_tasks, save_import_values, save_import_views, + save_project_progress, ) log = logging.getLogger(__name__) @@ -57,6 +59,7 @@ def __init__(self, *args, **kwargs): self.snapshots = [] self.tasks = [] self.views = [] + self.memberships = [] def render(self): raise NotImplementedError @@ -179,7 +182,8 @@ def prepare_import(self) -> dict: return preview def import_to_project( - self, checked_values = None, checked_snapshots = None + self, checked_values = None, checked_snapshots = None, + include_memberships = False, allow_creation_of_new_users = False ) -> Project: """ 1) If we have not yet run check()/process(), do so now (so self.project @@ -188,6 +192,7 @@ def import_to_project( 3) Build or accept the passed key-sets. 4) Save the project (assigning Site if new). 5) Call save_import_values, snapshot_values, tasks, views. + 6) optionally, import memberships to the project Returns the saved Project. """ @@ -217,6 +222,15 @@ def import_to_project( save_import_snapshot_values(self.project, self.snapshots, css) save_import_tasks(self.project, self.tasks) save_import_views(self.project, self.views) + save_project_progress(self.project) + + # 6) Optionally import memberships + if include_memberships and self.memberships: + created, skipped = import_memberships( + self.project, self.memberships, create_users=allow_creation_of_new_users + ) + log.info('Imported memberships for project %s: %s created, %s skipped.', + self.project.pk, created, skipped) return self.project @@ -311,6 +325,26 @@ def process(self): self.snapshots.append(snapshot) + memberships_node = self.root.find('memberships') + if memberships_node is not None: + for member_node in memberships_node.findall('member'): + role = (member_node.findtext('role') or '').strip() or 'guest' + + user_node = member_node.find('user') + if user_node is None: + continue + + membership = { + 'role': role, + 'id': (user_node.findtext('id') or '').strip(), + 'username': (user_node.findtext('username') or '').strip(), + 'email': (user_node.findtext('email') or '').strip().lower(), + 'first_name': (user_node.findtext('first_name') or '').strip(), + 'last_name': (user_node.findtext('last_name') or '').strip(), + } + self.memberships.append(membership) + + def get_value(self, value_node): value = Value() diff --git a/rdmo/projects/management/commands/import_projects.py b/rdmo/projects/management/commands/import_projects.py index 3e51c9b412..2f1d250fc6 100644 --- a/rdmo/projects/management/commands/import_projects.py +++ b/rdmo/projects/management/commands/import_projects.py @@ -1,21 +1,18 @@ from __future__ import annotations -import json import logging from pathlib import Path from django.contrib.auth import get_user_model -from django.core.exceptions import ValidationError from django.core.management.base import BaseCommand, CommandError from django.db import transaction +from rdmo.accounts.utils import find_user from rdmo.core.plugins import get_plugin -from rdmo.projects.utils import import_memberships -from .utils import FakeRequest, get_cli_user +from .utils import FakeRequest logger = logging.getLogger(__name__) -User = get_user_model() class Command(BaseCommand): @@ -37,7 +34,7 @@ class Command(BaseCommand): # import with memberships and custom plugin $ python manage.py import_projects --files /tmp/p1.xml \ - --with-members --format madmp --as-user admin + --include-memberships --create-new-users --format madmp --as-user admin """ def add_arguments(self, parser): @@ -67,126 +64,169 @@ def add_arguments(self, parser): help="Import plugin key to use (must be configured in ``settings.PROJECT_IMPORTS``)." ) parser.add_argument( - "--with-members", + "--include-memberships", action="store_true", - help="Also read a companion ``members.json`` and recreate memberships." + help="Read the memberships from the xml and recreate memberships." ) parser.add_argument( - "--allow-new-users", + "--create-new-users", action="store_true", help="When importing members, auto-create missing users. " "Default is to only use existing users (error if missing)." ) parser.add_argument( - "--as-user", + "--as-user-id", + type=int, metavar="USER", - help="Pretend the import is executed by this user (pk or username). " + help="Pretend the import is executed by this user (id). " + "Defaults to the first superuser." + ) + parser.add_argument( + "--as-username", + metavar="USERNAME", + help="Pretend the import is executed by this user (username). " "Defaults to the first superuser." ) def handle(self, *args, **options): - plugin_key = options["format"] - import_members = options["with_members"] - create_users = options["allow_new_users"] - self.import_user = get_cli_user(options.get("as_user")) + import_format = options["format"] + base_dir = Path(options["dir"]).expanduser().resolve() + explicit_files = options.get("files") + include_memberships = options["include_memberships"] + create_new_users = options["create_new_users"] + as_user_id = options.get("as_user_id") + as_username = options.get("as_username") + project_ids = sorted(set(options.get("projects"))) # sanity-check plugin key - if get_plugin("PROJECT_IMPORTS", plugin_key) is None: + if get_plugin("PROJECT_IMPORTS", import_format) is None: raise CommandError( - f'Import format "{plugin_key}" is not configured. ' + f'Import format "{import_format}" is not configured. ' "Check your ``PROJECT_IMPORTS`` setting." ) - xml_files: list[Path] = [] - explicit = options.get("files") - if explicit: - # Use explicitly provided XML file paths - for file_str in explicit: - xml_file = Path(file_str).expanduser().resolve() - if not xml_file.is_file(): - raise CommandError(f'File "{xml_file}" does not exist or is not a file.') - xml_files.append(xml_file) - else: - # Scan a directory of numbered subfolders - base_path = Path(options["dir"]).expanduser().resolve() - if not base_path.is_dir(): - raise CommandError(f'Dir "{base_path}" does not exist or is not a directory.') - - project_filter = ( - set(options["projects"]) if options.get("projects") else None - ) - - for project_dir in sorted(base_path.iterdir(), key=lambda p: p.name): - if not project_dir.is_dir(): - continue - - try: - dir_id = int(project_dir.name) - except ValueError: - self.stdout.write( - self.style.WARNING( - f'Skip "{project_dir.name}", folder name is not a number.' - ) - ) - continue - - if project_filter and dir_id not in project_filter: - continue - - xml_candidates = list(project_dir.glob("*.xml")) - if not xml_candidates: - self.stdout.write( - self.style.WARNING(f'No XML file found in "{project_dir}".') - ) - continue - - xml_files.append(xml_candidates[0]) - + # 1) collect xml files (from --files OR by scanning --dir) + xml_files = self._collect_xml_files(explicit_files, base_dir, project_ids) if not xml_files: self.stdout.write(self.style.WARNING("No XML files to import.")) return - failures: list[tuple[Path, str]] = [] + failures = [] + + # 2) import each file in its own transaction + + # need to get a user and a fake request for when the plugin calls self.request.user + user = find_user(user_id=as_user_id, username=as_username) + if user is None: + user = get_user_model().objects.filter(is_superuser=True).first() + fake_request = FakeRequest(user) for xml_file in xml_files: self.stdout.write(f"→ Importing {xml_file}") try: with transaction.atomic(): - plugin = get_plugin("PROJECT_IMPORTS", plugin_key) + plugin = get_plugin("PROJECT_IMPORTS", import_format) plugin.file_name = str(xml_file) - plugin.request = FakeRequest(self.import_user) + plugin.request = fake_request plugin.current_project = None - - project = plugin.import_to_project() - - if import_members: - members_path = xml_file.parent / "members.json" - if not members_path.is_file(): - raise CommandError(f"No members.json alongside {xml_file.name}") - data = json.loads(members_path.read_text(encoding="utf-8")) - try: - created, skipped = import_memberships(project, data, create_users=create_users) - except ValidationError as exc: - raise CommandError(str(exc)) from exc - self.stdout.write(self.style.SUCCESS(f" ✓ {created} memberships added, {skipped} skipped.")) + project = plugin.import_to_project( + include_memberships=include_memberships, + allow_creation_of_new_users=create_new_users, + ) self.stdout.write( - self.style.SUCCESS( - f' ✓ Project {project.pk} "{project.title}" imported successfully.' - ) + self.style.SUCCESS(f' ✓ Project {project.pk} "{project.title}" imported successfully.') ) except CommandError as exc: logger.exception("Import failed for %s", xml_file) self.stdout.write(self.style.ERROR(f" ✗ {exc} (see log)")) failures.append((xml_file, str(exc))) except Exception as exc: - # unexpected; cancel this project but continue others logger.exception("Unexpected error importing %s", xml_file) self.stdout.write(self.style.ERROR(f" ✗ {exc} (see log)")) failures.append((xml_file, str(exc))) if failures: - self.stdout.write(self.style.NOTICE("\nImport finished with errors:")) + self.stdout.write(self.style.WARNING("\nImport finished with errors:")) for path, msg in failures: self.stdout.write(f" • {path}: {msg}") self.stdout.write("") # final newline + + def _collect_xml_files(self, explicit_files, base_dir, project_ids): + """Return a list of XML Paths based on --files or scanning --dir.""" + if explicit_files: + return self._resolve_explicit_files(explicit_files) + + base_path = Path(base_dir).expanduser().resolve() + if not base_path.is_dir(): + raise CommandError(f'Dir "{base_path}" does not exist or is not a directory.') + + return self._scan_export_dir(base_path, project_ids) + + def _resolve_explicit_files(self, files): + """Validate explicit --files and return resolved Paths.""" + xml_files = [] + for file_str in files: + path = Path(file_str).expanduser().resolve() + if not path.is_file(): + raise CommandError(f'File "{path}" does not exist or is not a file.') + if path.suffix.lower() != ".xml": + self.stdout.write(self.style.WARNING(f' • Skipping non-XML file "{path.name}".')) + continue + xml_files.append(path) + return xml_files + + def _scan_export_dir(self, base_path, project_ids): + """ + Walk the export directory layout and pick one XML per numeric project folder. + Skips known non-project subfolders like "answers" and "views". + """ + xml_files = [] + projects_found = set() + + for project_dir in sorted(base_path.iterdir(), key=lambda p: p.name): + if not project_dir.is_dir(): + continue + if project_dir.name in ("answers", "views"): + continue # not project folders + + try: + dir_id = int(project_dir.name) + except ValueError: + self.stdout.write(self.style.WARNING(f'Skip "{project_dir.name}", folder name is not a number.')) + continue + + if project_ids and dir_id not in project_ids: + continue + + xml_path = self._pick_project_xml(project_dir) + if xml_path: + xml_files.append(xml_path) + projects_found.add(dir_id) + + missing_project_ids = set(project_ids) - projects_found + if project_ids and missing_project_ids: + self.stdout.write(self.style.WARNING(f"Some projects could not be found {missing_project_ids}.")) + + return xml_files + + def _pick_project_xml(self, project_dir): + """ + Choose a single XML deterministically from a project folder. + Warns when none or multiple are present. + """ + candidates = sorted(project_dir.glob("*.xml")) + if not candidates: + self.stdout.write(self.style.WARNING(f'No XML file found in "{project_dir}".')) + return None + if len(candidates) > 1: + chosen = candidates[0] + names = ", ".join(p.name for p in candidates[:3]) + more = "" if len(candidates) <= 3 else f" (+{len(candidates) - 3} more)" + self.stdout.write( + self.style.WARNING( + f'Multiple XML files in "{project_dir.name}" [{names}{more}], using "{chosen.name}".' + ) + ) + return chosen + return candidates[0] diff --git a/rdmo/projects/management/commands/utils.py b/rdmo/projects/management/commands/utils.py index 647dfd0c38..80a57c6f1d 100644 --- a/rdmo/projects/management/commands/utils.py +++ b/rdmo/projects/management/commands/utils.py @@ -3,11 +3,8 @@ import dataclasses from typing import Any -from django.contrib.auth import get_user_model from django.contrib.auth.models import AbstractBaseUser, AnonymousUser -User = get_user_model() - def replace_uri_in_template_string( template: str, source_uri: str, target_uri: str @@ -21,30 +18,11 @@ def replace_uri_in_template_string( return template -def get_cli_user(spec=None): - """ - Resolve *spec* to a real User instance (or AnonymousUser if nothing fits). - - * ``None`` → first superuser (fallback: AnonymousUser) - * ``"42"`` → by primary key - * ``"alice"`` → by username - """ - - if spec is None: - return User.objects.filter(is_superuser=True).first() or AnonymousUser() - - if spec.isdigit(): - return User.objects.filter(pk=int(spec)).first() or AnonymousUser() - - return User.objects.filter(username=spec).first() or AnonymousUser() - - @dataclasses.dataclass class FakeRequest: """ - Minimal stand-in so legacy import plugins can call ``self.request.user`` + Minimal mocked request so that import plugins can call ``self.request.user`` and ``self.request.session`` while running inside a management command. """ - user: AbstractBaseUser | AnonymousUser session: dict[str, Any] = dataclasses.field(default_factory=dict) diff --git a/rdmo/projects/renderers.py b/rdmo/projects/renderers.py index 3b6d7b110f..98f9b61f41 100644 --- a/rdmo/projects/renderers.py +++ b/rdmo/projects/renderers.py @@ -81,25 +81,22 @@ def render_value(self, xml, value): xml.endElement('value') def render_member(self, xml, member): - # member node with role as attribute; rest as child nodes xml.startElement('member', {}) - role = member.get('role') - if role: - self.render_text_element(xml, 'role', {}, role) - - user = member.get('user') or {} - if isinstance(user, dict) and user: - self.render_member_user(xml, user) + self.render_text_element(xml, 'role', {}, member['role']) + self.render_member_user(xml, member['user']) xml.endElement('member') def render_member_user(self, xml, user: dict): + xml.startElement('user', {}) + field_order = [ 'id', 'username', 'first_name', 'last_name', 'full_name', 'email' - ] # the presence of fields should be determined by the serializer + ] # the presence of fields should be determined by the serialized project for key in field_order: if key in user: - val = user.get(key) - if val not in (None, '', []): - self.render_text_element(xml, key, {}, val) + if user[key]: + self.render_text_element(xml, key, {}, user[key]) + + xml.endElement('user') diff --git a/rdmo/projects/utils.py b/rdmo/projects/utils.py index 7bfb9ecc34..3b984ff479 100644 --- a/rdmo/projects/utils.py +++ b/rdmo/projects/utils.py @@ -12,12 +12,13 @@ from django.urls import reverse from django.utils.timezone import now -from rdmo.accounts.utils import make_unique_username +from rdmo.accounts.utils import create_user_from_fields, find_user from rdmo.core.imports import store_temp_file from rdmo.core.mail import send_mail from rdmo.core.plugins import get_plugin, get_plugins from rdmo.core.utils import remove_double_newlines from rdmo.projects.models import Membership +from rdmo.projects.progress import compute_progress logger = logging.getLogger(__name__) User = get_user_model() @@ -242,6 +243,12 @@ def save_import_views(project, views): for view in views: project.views.add(view) +def save_project_progress(project): + progress_count, progress_total = compute_progress(project) + project.progress_count = progress_count + project.progress_total = progress_total + project.save() + def get_invite_email_project_path(invite) -> str: project_invite_path = reverse('project_join', args=[invite.token]) @@ -384,59 +391,29 @@ def send_contact_message(request, subject, message): cc=[request.user.email], reply_to=[request.user.email]) -def import_memberships(project, records, create_users = True): - """ - Assigns Memberships on `project` based on `records`. - - Each record may include 'user_id', 'email', 'username', - 'first_name', 'last_name', 'role', etc. - - If create_users=False, any record for which no existing User - can be found will raise ValidationError. Otherwise, missing - users will be auto-created (with unusable password). - - Returns: (created_count, skipped_count) - """ +def import_memberships(project, records, create_users = False): created = skipped = 0 for rec in records: - # 1) find or (optionally) create user - user = None - - # a) by PK - user_id = rec.get("user_id") - if user_id is not None: - user = User.objects.filter(pk=user_id).first() - # b) by email - email = (rec.get("email") or "").lower() - if not user and email: - user = User.objects.filter(email__iexact=email).first() + user_id = rec.get("id") + username = (rec.get("username") or "").strip() + email = (rec.get("email") or "").strip().lower() - # c) by username - username = rec.get("username") - if not user and username: - user = User.objects.filter(username=username).first() + # 1) resolve user + user = find_user(user_id=user_id, username=username, email=email) + # 2) optionally create if not user: if not create_users: - raise ValidationError(f"No existing user for record {rec!r}") - # auto-create - desired = username or (email.split("@")[0] if email else "") - username = make_unique_username(desired) - user = User.objects.create_user( - username=username, - email=email, - first_name=rec.get("first_name", ""), - last_name=rec.get("last_name", ""), - is_active=True, - ) - user.set_unusable_password() - user.save(update_fields=["password"]) - if username != desired: - logger.info("Username '%s' taken, created unique name '%s'.", desired, username) - - # 2) assign Membership + raise ValidationError( + f"No existing user for user_id={user_id!r}, username={username!r}, email={email!r}" + ) + first_name = (rec.get("first_name") or "").strip() + last_name = (rec.get("last_name") or "").strip() + user = create_user_from_fields(username, email, first_name, last_name) + + # 3) assign/update membership role = rec.get("role") or "guest" try: Membership.objects.update_or_create(project=project, user=user, defaults={"role": role}) From dcb67e41805e69494f4614285e9ef528b8416e88 Mon Sep 17 00:00:00 2001 From: David Wallace Date: Fri, 15 Aug 2025 15:31:02 +0200 Subject: [PATCH 160/165] accounts(utils): add find and create user functions Signed-off-by: David Wallace --- rdmo/accounts/utils.py | 45 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/rdmo/accounts/utils.py b/rdmo/accounts/utils.py index 5aebee053c..d453eef3b3 100644 --- a/rdmo/accounts/utils.py +++ b/rdmo/accounts/utils.py @@ -84,6 +84,26 @@ def get_user_from_db_or_none(username: str, email: str): return None +def find_user(user_id=None, username="", email=""): + username = (username or "").strip() + email = (email or "").strip().lower() + + if user_id: + user = get_user_model().objects.filter(pk=user_id).first() + if user: + return user + + if username: + user = get_user_model().objects.filter(username=username).first() + if user: + return user + + if email: + return get_user_model().objects.filter(email__iexact=email).first() + + return None + + def make_unique_username(seed: str) -> str: base = slugify(seed) or "user" user_model = get_user_model() @@ -93,3 +113,28 @@ def make_unique_username(seed: str) -> str: return candidate # fallback return f"{base}_{get_random_string(8)}" + + +def create_user_from_fields(username, email, first_name, last_name): + username = (username or "").strip() + email = (email or "").strip().lower() + first_name = (first_name or "").strip() + last_name = (last_name or "").strip() + + base = username or (email.split("@")[0] if email else "") or "imported" + unique = make_unique_username(base) + + user = get_user_model().objects.create_user( + username=unique, + email=email, + first_name=first_name, + last_name=last_name, + is_active=True, + ) + user.set_unusable_password() + user.save(update_fields=["password"]) + + if unique != base: + log.info("Username '%s' taken, created unique name '%s'.", base, unique) + + return user From b337a732163ace310194901621664637cb58196c Mon Sep 17 00:00:00 2001 From: David Wallace Date: Fri, 15 Aug 2025 17:17:30 +0200 Subject: [PATCH 161/165] projects(import): add tests for import command Signed-off-by: David Wallace --- .../management/commands/import_projects.py | 2 +- rdmo/projects/tests/conftest.py | 8 + rdmo/projects/tests/helpers/xml.py | 39 ++++ .../tests/test_command_import_projects.py | 170 ++++++++++++++++++ 4 files changed, 218 insertions(+), 1 deletion(-) create mode 100644 rdmo/projects/tests/helpers/xml.py create mode 100644 rdmo/projects/tests/test_command_import_projects.py diff --git a/rdmo/projects/management/commands/import_projects.py b/rdmo/projects/management/commands/import_projects.py index 2f1d250fc6..bfcd9ef2b6 100644 --- a/rdmo/projects/management/commands/import_projects.py +++ b/rdmo/projects/management/commands/import_projects.py @@ -96,7 +96,7 @@ def handle(self, *args, **options): create_new_users = options["create_new_users"] as_user_id = options.get("as_user_id") as_username = options.get("as_username") - project_ids = sorted(set(options.get("projects"))) + project_ids = sorted(set(options.get("projects") or [])) # sanity-check plugin key if get_plugin("PROJECT_IMPORTS", import_format) is None: diff --git a/rdmo/projects/tests/conftest.py b/rdmo/projects/tests/conftest.py index 1c99a862de..812c53bb93 100644 --- a/rdmo/projects/tests/conftest.py +++ b/rdmo/projects/tests/conftest.py @@ -1,3 +1,5 @@ +from pathlib import Path + import pytest from django.apps import apps @@ -13,3 +15,9 @@ def enable_project_views_sync(settings): def enable_project_tasks_sync(settings): settings.PROJECT_TASKS_SYNC = True apps.get_app_config('projects').ready() + +@pytest.fixture +def project_xml(settings): + xml_file = Path(settings.BASE_DIR) / 'xml/project.xml' + assert xml_file.exists(), f"Missing test XML at {xml_file}" + return xml_file diff --git a/rdmo/projects/tests/helpers/xml.py b/rdmo/projects/tests/helpers/xml.py new file mode 100644 index 0000000000..6b32d3b2a7 --- /dev/null +++ b/rdmo/projects/tests/helpers/xml.py @@ -0,0 +1,39 @@ +import io +import xml.etree.ElementTree as ET +from xml.sax.saxutils import XMLGenerator + +from rdmo.projects.renderers import XMLRenderer + + +def add_memberships_to_xml(xml_path: str, members: list[dict]) -> None: + """ + Replace or add in the given project.xml using the real XMLRenderer. + `members` is a list of {"role": ..., "user": {...}} dicts. + """ + # 1) render just the fragment with the real renderer + buf = io.StringIO() + xml = XMLGenerator(buf, encoding="utf-8") + xml.startDocument() + xml.startElement("root", {}) # wrapper so we can parse as a fragment + xml.startElement("memberships", {}) + + renderer = XMLRenderer() + for m in members: + renderer.render_member(xml, m) + + xml.endElement("memberships") + xml.endElement("root") + xml.endDocument() + + frag_root = ET.fromstring(buf.getvalue()) + memberships_fragment = frag_root.find("memberships") + + # 2) load target project.xml and replace existing + tree = ET.parse(xml_path) + root = tree.getroot() + old = root.find("memberships") + if old is not None: + root.remove(old) + root.append(memberships_fragment) + + tree.write(xml_path, encoding="utf-8", xml_declaration=True) diff --git a/rdmo/projects/tests/test_command_import_projects.py b/rdmo/projects/tests/test_command_import_projects.py new file mode 100644 index 0000000000..e47f08055f --- /dev/null +++ b/rdmo/projects/tests/test_command_import_projects.py @@ -0,0 +1,170 @@ +import shutil + +import pytest + +from django.core.management import call_command + +from rdmo.projects.models import Membership, Project +from rdmo.projects.tests.helpers.xml import add_memberships_to_xml + + +@pytest.mark.django_db +@pytest.mark.parametrize("include_memberships", [True, False]) +@pytest.mark.parametrize("xml_has_memberships", [True, False]) +def test_import_projects_memberships_toggle(tmp_path, project_xml, capsys, include_memberships, xml_has_memberships): + + xml_path = tmp_path / "project_with_members.xml" + shutil.copyfile(project_xml, xml_path) + + if xml_has_memberships: + add_memberships_to_xml( + str(xml_path), + members=[ + {"role": "owner", "user": {"username": "owner"}}, + {"role": "author", "user": {"username": "author"}}, + {"role": "guest", "user": {"username": "guest"}}, + ], + ) + + before = Project.objects.count() + + args = ["import_projects", "--files", str(xml_path)] + if include_memberships: + args.append("--include-memberships") + + call_command(*args) + out = capsys.readouterr().out + assert "→ Importing" in out + assert "imported successfully" in out + + assert Project.objects.count() == before + 1 + new_project = Project.objects.order_by("-pk").first() + + expected = {("owner", "owner"), ("author", "author"), ("guest", "guest")} + if include_memberships and xml_has_memberships: + actual = { + (m.user.username, m.role) + for m in Membership.objects.filter(project=new_project).select_related("user") + } + assert actual == expected + else: + assert not Membership.objects.filter(project=new_project).exists() + + +@pytest.mark.django_db +def test_import_projects_from_files_explicit(tmp_path, project_xml, capsys): + """Import a project via explicit --files path (no memberships).""" + xml_path = tmp_path / "project.xml" + shutil.copyfile(project_xml, xml_path) + + before = Project.objects.count() + call_command("import_projects", "--files", str(xml_path)) + + out = capsys.readouterr().out + assert "→ Importing" in out + assert "imported successfully" in out + assert Project.objects.count() == before + 1 + + +@pytest.mark.django_db +def test_import_projects_dir_scan_with_filter_and_missing(tmp_path, project_xml, capsys): + """ + Import via --dir with one valid numeric folder and one missing. + Expect a warning and a successful import of the existing one. + """ + good_id = 42 + (tmp_path / str(good_id)).mkdir(parents=True) + shutil.copyfile(project_xml, tmp_path / str(good_id) / "project.xml") + (tmp_path / "999999").mkdir() # empty folder + + before = Project.objects.count() + call_command("import_projects", "--dir", str(tmp_path), "--projects", str(good_id), "999999") + + out = capsys.readouterr().out + assert ("Some projects could not be found" in out) or ('No XML file found' in out) + assert "imported successfully" in out + assert Project.objects.count() == before + 1 + + +@pytest.mark.django_db +def test_import_projects_files_skips_non_xml(tmp_path, project_xml, capsys): + """Passing a non-XML alongside a valid XML: skip with warning, still import XML.""" + good_xml = tmp_path / "project.xml" + shutil.copyfile(project_xml, good_xml) + + junk = tmp_path / "notes.txt" + junk.write_text("hello") + + before = Project.objects.count() + call_command("import_projects", "--files", str(junk), str(good_xml)) + + out = capsys.readouterr().out + assert "Skipping non-XML file" in out + assert "imported successfully" in out + assert Project.objects.count() == before + 1 + + +@pytest.mark.django_db +@pytest.mark.parametrize("export_include_memberships", [True, False]) +@pytest.mark.parametrize("import_include_memberships", [True, False]) +def test_roundtrip_export_then_import_memberships_toggle( + tmp_path, capsys, export_include_memberships, import_include_memberships +): + """ + Round-trip: export project id=1 to XML, then import it back. + Memberships in the imported project should exist iff BOTH: + - export included memberships, and + - import requested memberships. + """ + project = Project.objects.get(id=1) + src_members = set( + Membership.objects.filter(project=project) + .values_list("user_id", "role") + ) + # --- export --- + export_args = [ + "export_projects", + "--projects", str(project.id), + "--export-mode", "project", + "--format", "xml", + "--path", str(tmp_path), + ] + if export_include_memberships: + assert src_members, "should not be empty here" + export_args.append("--include-memberships") + + call_command(*export_args) + + out = capsys.readouterr().out + assert "Exported 1 project(s) to" in out + + # --- import --- + import_args = ["import_projects", "--dir", str(tmp_path)] + if import_include_memberships: + import_args.append("--include-memberships") + + before_count = Project.objects.count() + call_command(*import_args) + + out = capsys.readouterr().out + assert "→ Importing" in out + assert "imported successfully" in out + + # a new project must have been created + assert Project.objects.count() == before_count + 1 + imported = Project.objects.exclude(pk=project.pk).order_by("-pk").first() + + imported_members = set( + Membership.objects.filter(project=imported) + .values_list("user_id", "role") + ) + + expect_members = export_include_memberships and import_include_memberships + + if expect_members: + # imported memberships should match the source memberships (set compare) + assert imported_members, "should not be empty here" + assert imported_members == src_members + else: + # no memberships should have been imported + assert imported_members == set() From de35635653414b9d908b4585157be21205a7e0d0 Mon Sep 17 00:00:00 2001 From: David Wallace Date: Fri, 16 Jan 2026 19:28:04 +0100 Subject: [PATCH 162/165] Clean up after rebase to 3.0.0 Signed-off-by: David Wallace --- rdmo/accounts/utils.py | 2 -- rdmo/projects/viewsets.py | 7 ++++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/rdmo/accounts/utils.py b/rdmo/accounts/utils.py index d453eef3b3..32fcfd27ee 100644 --- a/rdmo/accounts/utils.py +++ b/rdmo/accounts/utils.py @@ -7,8 +7,6 @@ from django.utils.crypto import get_random_string from django.utils.text import slugify - -from .models import Role from .settings import GROUPS log = logging.getLogger(__name__) diff --git a/rdmo/projects/viewsets.py b/rdmo/projects/viewsets.py index 687bd06866..b2c305aac4 100644 --- a/rdmo/projects/viewsets.py +++ b/rdmo/projects/viewsets.py @@ -9,7 +9,7 @@ from rest_framework import serializers, status from rest_framework.decorators import action -from rest_framework.exceptions import NotFound, ValidationError +from rest_framework.exceptions import NotFound from rest_framework.filters import SearchFilter from rest_framework.mixins import CreateModelMixin, ListModelMixin, RetrieveModelMixin, UpdateModelMixin from rest_framework.pagination import PageNumberPagination @@ -27,7 +27,7 @@ from rdmo.core.exports import XMLResponse from rdmo.core.permissions import HasModelPermission from rdmo.core.plugins import get_plugin -from rdmo.core.utils import human2bytes, is_truthy, return_file_response +from rdmo.core.utils import human2bytes, is_truthy, render_to_format, return_file_response from rdmo.options.models import OptionSet from rdmo.questions.models import Catalog, Page, Question, QuestionSet from rdmo.tasks.models import Task @@ -73,8 +73,8 @@ MembershipSerializer, ProjectAnswersSerializer, ProjectCopySerializer, - ProjectHierarchySerializer, ProjectFileUploadSerializer, + ProjectHierarchySerializer, ProjectImportConfirmSerializer, ProjectIntegrationSerializer, ProjectInviteCreateSerializer, @@ -106,6 +106,7 @@ copy_project, get_contact_message, get_upload_accept, + get_value_path, send_contact_message, send_invite_email, validate_and_prepare_import_plugin, From 1e16872978c101e725e737abd96d96d096ba02b5 Mon Sep 17 00:00:00 2001 From: David Wallace Date: Fri, 16 Jan 2026 21:51:48 +0100 Subject: [PATCH 163/165] Fix project import create Signed-off-by: David Wallace --- conftest.py | 6 ++ rdmo/core/exceptions.py | 12 +++ rdmo/core/tests/test_openapi.py | 2 +- rdmo/projects/serializers/v1/__init__.py | 58 ++++++++-- rdmo/projects/tests/conftest.py | 10 +- .../test_viewset_project_import_create.py | 44 ++++---- .../test_viewset_project_import_update.py | 46 ++++---- rdmo/projects/utils.py | 19 +++- rdmo/projects/viewsets.py | 100 ++++++++++++++---- 9 files changed, 218 insertions(+), 79 deletions(-) diff --git a/conftest.py b/conftest.py index 01fad1f47f..eab9ac0181 100644 --- a/conftest.py +++ b/conftest.py @@ -8,6 +8,8 @@ from django.contrib.auth.models import User from django.core.management import call_command +from rest_framework.test import APIClient + from rdmo.accounts.utils import set_group_permissions @@ -75,3 +77,7 @@ def delete_all(*models): for model in models: model.objects.all().delete() return delete_all + +@pytest.fixture +def api_client(): + return APIClient() diff --git a/rdmo/core/exceptions.py b/rdmo/core/exceptions.py index cde361b78b..2d282e97a4 100644 --- a/rdmo/core/exceptions.py +++ b/rdmo/core/exceptions.py @@ -1,2 +1,14 @@ +from django.core.exceptions import ValidationError + +from rest_framework import serializers + + class RDMOException(Exception): pass + +def raise_as_drf_validation_error(exc: ValidationError) -> None: + if hasattr(exc, "message_dict"): + raise serializers.ValidationError(exc.message_dict) from exc + + messages = getattr(exc, "messages", None) or [str(exc)] + raise serializers.ValidationError({"non_field_errors": messages}) from exc diff --git a/rdmo/core/tests/test_openapi.py b/rdmo/core/tests/test_openapi.py index fdb93e32d8..63c6f28f03 100644 --- a/rdmo/core/tests/test_openapi.py +++ b/rdmo/core/tests/test_openapi.py @@ -10,7 +10,7 @@ 'anonymous' ) -n_path = 137 +n_path = 142 @pytest.mark.parametrize('username', users) def test_openapi_schema(db, client, login, settings, username): diff --git a/rdmo/projects/serializers/v1/__init__.py b/rdmo/projects/serializers/v1/__init__.py index 2757441307..099a83a920 100644 --- a/rdmo/projects/serializers/v1/__init__.py +++ b/rdmo/projects/serializers/v1/__init__.py @@ -1,4 +1,6 @@ +import mimetypes from collections.abc import Mapping +from pathlib import Path from typing import Any from django.conf import settings @@ -276,13 +278,57 @@ class Meta: class ProjectFileUploadSerializer(FileUploadSerializer): - def validate(self, data): - file = data["file"] - _format = data["format"] + title = serializers.CharField(required=False, allow_blank=True) + catalog = serializers.PrimaryKeyRelatedField( + queryset=Catalog.objects.all(), + required=False, + allow_null=True + ) - accepted_formats = get_plugin_types_for_mimetype(file.content_type) - if not accepted_formats or _format not in accepted_formats: - raise serializers.ValidationError(f"File format not accepted for this MIME type: {file.content_type}.") + def validate(self, data): + file = data.get("file") + _format = data.get("format") + + errors = {} + if file is None: + errors["file"] = [_("This field is required.")] + if _format is None: + errors["format"] = [_("This field is required.")] + if errors: + raise serializers.ValidationError(errors) + + # Normalize format + _format = str(_format).lower().strip() + data["format"] = _format + + # 1) Try plugin types from MIME type (best case) + content_type = getattr(file, "content_type", None) + accepted_formats = set() + if content_type: + accepted_formats |= set(get_plugin_types_for_mimetype(content_type)) + + # 2) If nothing found, try guessing MIME from filename + if not accepted_formats: + guessed_type, _guessed_encoding = mimetypes.guess_type(getattr(file, "name", "")) + if guessed_type: + accepted_formats |= set(get_plugin_types_for_mimetype(guessed_type)) + + # 3) If still nothing, fall back to extension + if not accepted_formats: + suffix = Path(getattr(file, "name", "")).suffix.lstrip(".").lower() + if suffix: + accepted_formats.add(suffix) + + # 4) If we still couldn't determine formats, allow a small safe whitelist + # (because test uploads frequently arrive as octet-stream) + if not accepted_formats: + accepted_formats = {"xml", "json", "yaml", "yml"} + + if _format not in accepted_formats: + raise serializers.ValidationError({ + "format": [_("File format not accepted for this file type: %s") % (content_type or "unknown")] + + }) return data diff --git a/rdmo/projects/tests/conftest.py b/rdmo/projects/tests/conftest.py index 812c53bb93..a5b4f07400 100644 --- a/rdmo/projects/tests/conftest.py +++ b/rdmo/projects/tests/conftest.py @@ -17,7 +17,13 @@ def enable_project_tasks_sync(settings): apps.get_app_config('projects').ready() @pytest.fixture -def project_xml(settings): - xml_file = Path(settings.BASE_DIR) / 'xml/project.xml' +def xml_path_project(settings): + xml_file = Path(settings.BASE_DIR) / 'xml' / 'project.xml' + assert xml_file.exists(), f"Missing test XML at {xml_file}" + return xml_file + +@pytest.fixture +def xml_path_error(settings): + xml_file = Path(settings.BASE_DIR) / 'xml' / 'error.xml' assert xml_file.exists(), f"Missing test XML at {xml_file}" return xml_file diff --git a/rdmo/projects/tests/test_viewset_project_import_create.py b/rdmo/projects/tests/test_viewset_project_import_create.py index 7ab41b6c64..a24b6d6392 100644 --- a/rdmo/projects/tests/test_viewset_project_import_create.py +++ b/rdmo/projects/tests/test_viewset_project_import_create.py @@ -1,5 +1,4 @@ import json -import os import pytest @@ -16,18 +15,19 @@ @pytest.mark.parametrize('username,password', users) -def test_project_import_create_preview(db, client, settings, username, password): +def test_project_import_create_preview(db, api_client, settings, username, password, xml_path_project): if password: - client.login(username=username, password=password) + api_client.login(username=username, password=password) url = reverse(urlnames["import-create-preview"]) projects_count = Project.objects.all().count() - xml_path = os.path.join(settings.BASE_DIR, 'xml', 'project.xml') - with open(xml_path, 'rb') as xml_file: - response = client.post(url, {'file': xml_file, 'format': 'xml'}) + with open(xml_path_project, 'rb') as xml_file: + response = api_client.post(url, {'file': xml_file, 'format': 'xml'}, format="multipart") if password: + if response.status_code != 200: + print(response.json()) assert response.status_code == 200 data = json.loads(response.content.decode()) # Preview response should include at least one value and one snapshot @@ -59,34 +59,34 @@ def test_import_create_preview_missing_file(db, client): assert resp.status_code == 400 -def test_import_create_preview_invalid_xml(db, client, settings): +def test_import_create_preview_invalid_xml(db, client, settings, xml_path_error): username = password = 'user' client.login(username=username, password=password) url = reverse(urlnames["import-create-preview"]) - bad_xml = os.path.join(settings.BASE_DIR, "xml", "error.xml") - with open(bad_xml, "rb") as f: + + with open(xml_path_error, "rb") as f: resp = client.post(url, {"file": f, "format": "xml"}) assert resp.status_code == 400 + print(resp.json()) assert 'Parsing error' in ' '.join(resp.json()['file']) @pytest.mark.parametrize('username,password', users) -def test_project_import_create_confirm(db, client, settings, username, password): +def test_project_import_create_confirm(db, client, settings, username, password, xml_path_project): if password: client.login(username=username, password=password) preview_url = reverse(urlnames["import-create-preview"]) confirm_url = reverse(urlnames["import-create-confirm"]) projects_count = Project.objects.all().count() - xml_path = os.path.join(settings.BASE_DIR, 'xml', 'project.xml') if password: - with open(xml_path, 'rb') as xml_file: + with open(xml_path_project, 'rb') as xml_file: preview_response = client.post(preview_url, {'file': xml_file, 'format': 'xml'}) assert preview_response.status_code == 200 preview_data = json.loads(preview_response.content.decode()) # Prepare payload with all values and snapshots checked (selected) # Include the file again for the confirm step - with open(xml_path, 'rb') as xml_file: + with open(xml_path_project, 'rb') as xml_file: confirm_payload = { "file": xml_file, "format": "xml", @@ -98,15 +98,14 @@ def test_project_import_create_confirm(db, client, settings, username, password) assert confirm_response.status_code == 201 result = json.loads(confirm_response.content.decode()) assert 'id' in result - assert 'title' in result assert projects_count + 1 == Project.objects.all().count() else: - with open(xml_path, 'rb') as xml_file: + with open(xml_path_project, 'rb') as xml_file: confirm_response = client.post(confirm_url, {'file': xml_file}) assert confirm_response.status_code == 401 -def test_import_create_confirm_restricted(db, client, settings): +def test_import_create_confirm_restricted(db, client, settings, xml_path_project): settings.PROJECT_CREATE_RESTRICTED = True settings.PROJECT_CREATE_GROUPS = ['projects'] @@ -119,14 +118,13 @@ def test_import_create_confirm_restricted(db, client, settings): preview_url = reverse(urlnames["import-create-preview"]) confirm_url = reverse(urlnames["import-create-confirm"]) projects_count = Project.objects.all().count() - xml_path = os.path.join(settings.BASE_DIR, 'xml', 'project.xml') - with open(xml_path, 'rb') as xml: + with open(xml_path_project, 'rb') as xml: preview_resp = client.post(preview_url, {'file': xml, 'format': 'xml'}) assert preview_resp.status_code == 200 preview_data = preview_resp.json() - with open(xml_path, 'rb') as xml: + with open(xml_path_project, 'rb') as xml: confirm_resp = client.post(confirm_url, { 'file': xml, 'format': 'xml', @@ -136,11 +134,10 @@ def test_import_create_confirm_restricted(db, client, settings): assert confirm_resp.status_code == 201 result = confirm_resp.json() assert 'id' in result - assert 'title' in result assert projects_count + 1 == Project.objects.all().count() -def test_import_create_confirm_forbidden(db, client, settings): +def test_import_create_confirm_forbidden(db, client, settings, xml_path_project): settings.PROJECT_CREATE_RESTRICTED = True # no PROJECT_CREATE_GROUPS defined → no one is allowed @@ -148,13 +145,12 @@ def test_import_create_confirm_forbidden(db, client, settings): preview_url = reverse(urlnames["import-create-preview"]) confirm_url = reverse(urlnames["import-create-confirm"]) projects_count = Project.objects.all().count() - xml_path = os.path.join(settings.BASE_DIR, 'xml', 'project.xml') - with open(xml_path, 'rb') as xml: + with open(xml_path_project, 'rb') as xml: preview_resp = client.post(preview_url, {'file': xml, 'format': 'xml'}) assert preview_resp.status_code == 403 - with open(xml_path, 'rb') as xml: + with open(xml_path_project, 'rb') as xml: confirm_resp = client.post(confirm_url, { 'file': xml, 'format': 'xml', diff --git a/rdmo/projects/tests/test_viewset_project_import_update.py b/rdmo/projects/tests/test_viewset_project_import_update.py index 1b4b0d7424..87880f590d 100644 --- a/rdmo/projects/tests/test_viewset_project_import_update.py +++ b/rdmo/projects/tests/test_viewset_project_import_update.py @@ -1,5 +1,4 @@ import json -import os import pytest @@ -16,16 +15,15 @@ @pytest.mark.parametrize('username,password', users) @pytest.mark.parametrize('project_id', projects) -def test_project_import_update_preview(db, client, settings, username, password, project_id): +def test_project_import_update_preview(db, api_client, settings, username, password, project_id, xml_path_project): if password: - client.login(username=username, password=password) + api_client.login(username=username, password=password) url = reverse('v1-projects:project-import-update-preview', args=[project_id]) - xml_path = os.path.join(settings.BASE_DIR, 'xml', 'project.xml') projects_count = Project.objects.all().count() - with open(xml_path, 'rb') as xml_file: - response = client.post(url, {'file': xml_file, 'format': 'xml'}) + with open(xml_path_project, 'rb') as xml_file: + response = api_client.post(url, {'file': xml_file, 'format': 'xml'}, format="multipart") if project_id in view_project_permission_map.get(username, []): assert response.status_code == 200 @@ -42,51 +40,49 @@ def test_project_import_update_preview(db, client, settings, username, password, @pytest.mark.parametrize('action', ['preview','confirm']) -def test_import_update_preview_and_confirm_get_not_allowed(db, client, action): +def test_import_update_preview_and_confirm_get_not_allowed(db, api_client, action): username = password = 'owner' - client.login(username=username, password=password) + api_client.login(username=username, password=password) url = reverse(urlnames[f"import-update-{action}"], args=[1]) - response = client.get(url) + response = api_client.get(url) assert response.status_code == 405 -def test_import_update_preview_invalid_xml(db, client, settings): +def test_import_update_preview_invalid_xml(db, client, settings, xml_path_error): username = password = 'owner' client.login(username=username, password=password) url = reverse(urlnames["import-update-preview"], args=[1]) - bad_xml = os.path.join(settings.BASE_DIR, "xml", "error.xml") - with open(bad_xml, "rb") as f: - resp = client.post(url, {"file": f, "format": "xml"}) + with open(xml_path_error, "rb") as f: + resp = client.post(url, {"file": f, "format": "xml"}, format="multipart") assert resp.status_code == 400 - assert 'Parsing error' in ' '.join(resp.json()['file']) + assert 'Parsing error' in ' '.join(resp.json()['non_field_errors']) @pytest.mark.parametrize('username,password', users) @pytest.mark.parametrize('project_id', projects) -def test_project_import_update_confirm(db, client, settings, username, password, project_id): +def test_project_import_update_confirm(db, api_client, settings, username, password, project_id, xml_path_project): if password: - client.login(username=username, password=password) + api_client.login(username=username, password=password) preview_url = reverse('v1-projects:project-import-update-preview', args=[project_id]) confirm_url = reverse('v1-projects:project-import-update-confirm', args=[project_id]) - xml_path = os.path.join(settings.BASE_DIR, 'xml', 'project.xml') projects_count = Project.objects.all().count() if project_id in change_project_permission_map.get(username, []): - with open(xml_path, 'rb') as xml_file: - preview_response = client.post(preview_url, {'file': xml_file, 'format': 'xml'}) + with open(xml_path_project, 'rb') as xml_file: + preview_response = api_client.post(preview_url, {'file': xml_file, 'format': 'xml'}, format="multipart") assert preview_response.status_code == 200 preview_data = json.loads(preview_response.content.decode()) - with open(xml_path, 'rb') as xml_file: + with open(xml_path_project, 'rb') as xml_file: confirm_payload = { 'file': xml_file, 'format': 'xml', 'checked_values': [v["key"] for v in preview_data.get("values", [])], 'checked_snapshots': [s["index"] for s in preview_data.get("snapshots", [])], } - confirm_response = client.post(confirm_url, confirm_payload) + confirm_response = api_client.post(confirm_url, confirm_payload) assert confirm_response.status_code == 201 result = json.loads(confirm_response.content.decode()) @@ -94,8 +90,8 @@ def test_project_import_update_confirm(db, client, settings, username, password, assert 'title' in result assert projects_count == Project.objects.all().count() elif password: - with open(xml_path, 'rb') as xml_file: - confirm_response = client.post( + with open(xml_path_project, 'rb') as xml_file: + confirm_response = api_client.post( confirm_url, {'file': xml_file,'checked_values': [], 'checked_snapshots': []} ) @@ -105,8 +101,8 @@ def test_project_import_update_confirm(db, client, settings, username, password, else: assert confirm_response.status_code == 404 else: - with open(xml_path, 'rb') as xml_file: - confirm_response = client.post( + with open(xml_path_project, 'rb') as xml_file: + confirm_response = api_client.post( confirm_url, {'file': xml_file,'checked_values': [], 'checked_snapshots': []} ) diff --git a/rdmo/projects/utils.py b/rdmo/projects/utils.py index 3b984ff479..ef38ed7a33 100644 --- a/rdmo/projects/utils.py +++ b/rdmo/projects/utils.py @@ -12,6 +12,8 @@ from django.urls import reverse from django.utils.timezone import now +from rest_framework import serializers + from rdmo.accounts.utils import create_user_from_fields, find_user from rdmo.core.imports import store_temp_file from rdmo.core.mail import send_mail @@ -427,13 +429,24 @@ def import_memberships(project, records, create_users = False): def validate_and_prepare_import_plugin(request, file=None, file_format=None, current_project=None): - tmp_path = store_temp_file(file) + if not file: + raise serializers.ValidationError({"file": ["This field is required."]}) + + if not file_format: + raise serializers.ValidationError({"format": ["This field is required."]}) + + file_format = str(file_format).lower().strip() plugin = get_plugin("PROJECT_IMPORTS", file_format) if plugin is None: - raise ValidationError({"detail": f'Format "{file_format}" not configured.'}) + raise serializers.ValidationError({ + "format": [f'Format "{file_format}" not configured.'] + }) + + # ✅ Store upload in a temp file and give plugin a real path + tmp_path = store_temp_file(file) - plugin.file_name = tmp_path + plugin.file_name = str(tmp_path) plugin.request = request plugin.current_project = current_project plugin.raise_exception = True diff --git a/rdmo/projects/viewsets.py b/rdmo/projects/viewsets.py index b2c305aac4..6c82237229 100644 --- a/rdmo/projects/viewsets.py +++ b/rdmo/projects/viewsets.py @@ -24,6 +24,7 @@ from rdmo.conditions.models import Condition from rdmo.core.constants import VALUE_TYPE_FILE +from rdmo.core.exceptions import raise_as_drf_validation_error from rdmo.core.exports import XMLResponse from rdmo.core.permissions import HasModelPermission from rdmo.core.plugins import get_plugin @@ -76,6 +77,7 @@ ProjectFileUploadSerializer, ProjectHierarchySerializer, ProjectImportConfirmSerializer, + ProjectImportPreviewResponseSerializer, ProjectIntegrationSerializer, ProjectInviteCreateSerializer, ProjectInviteSerializer, @@ -584,11 +586,44 @@ def imports(self, request): permission_classes=[HasModelPermission | HasProjectsImportCreatePermission], serializer_class=ProjectFileUploadSerializer ) - def import_create_preview(self, request): - preview = self.handle_import_plugin_action( - plugin_call=lambda plugin, _: plugin.prepare_import(), + def import_create_preview(self, request, *args, **kwargs): + # 1) validate upload + optional overrides + upload = ProjectFileUploadSerializer(data=request.data, context={"request": request}) + upload.is_valid(raise_exception=True) + + file = upload.validated_data["file"] + file_format = upload.validated_data["format"] + + title_override = upload.validated_data.get("title") + catalog_override = upload.validated_data.get("catalog") + + # 2) let plugin parse the import file + plugin = validate_and_prepare_import_plugin( + request=request, + file=file, + file_format=file_format, + current_project=None, ) - return Response(preview, status=status.HTTP_200_OK) + + try: + prepared = plugin.prepare_import() + except ValidationError as exc: + raise_as_drf_validation_error(exc) + + # 3) apply override metadata (optional) + project_meta = prepared.get("project", {}) or {} + + if title_override: + project_meta["title"] = title_override + + if catalog_override is not None: + project_meta["catalog"] = catalog_override.pk + + prepared["project"] = project_meta + + # 4) return preview + out = ProjectImportPreviewResponseSerializer(prepared) + return Response(out.data, status=status.HTTP_200_OK) @action( detail=False, @@ -598,15 +633,46 @@ def import_create_preview(self, request): permission_classes=[HasModelPermission | HasProjectsImportCreatePermission], serializer_class=ProjectImportConfirmSerializer ) - def import_create_confirm(self, request): - project = self.handle_import_plugin_action( - plugin_call=lambda plugin, data: plugin.import_to_project( - checked_values=set(data["checked_values"]), - checked_snapshots=set(data["checked_snapshots"]) - ), + def import_create_confirm(self, request, *args, **kwargs): + confirm = ProjectImportConfirmSerializer(data=request.data, context={"request": request}) + confirm.is_valid(raise_exception=True) + + file = confirm.validated_data["file"] + file_format = confirm.validated_data["format"] + + title_override = confirm.validated_data.get("title") + catalog_override = confirm.validated_data.get("catalog") + + checked_values = confirm.validated_data.get("checked_values", []) + checked_snapshots = confirm.validated_data.get("checked_snapshots", []) + + plugin = validate_and_prepare_import_plugin( + request=request, + file=file, + file_format=file_format, + current_project=None, ) - serializer = ProjectSerializer(project, context={"request": request}) - return Response(serializer.data, status=status.HTTP_201_CREATED) + + # Import creates the project using the plugin parsed data + project = plugin.import_to_project( + checked_values=checked_values, + checked_snapshots=checked_snapshots, + ) + + # Apply overrides AFTER import if they were given + changed = False + if title_override: + project.title = title_override + changed = True + + if catalog_override is not None: + project.catalog = catalog_override + changed = True + + if changed: + project.save() + + return Response({"id": project.pk}, status=status.HTTP_201_CREATED) @action( detail=True, @@ -715,13 +781,11 @@ def handle_import_plugin_action( ) plugin_result = plugin_call(plugin, serializer.validated_data) except ValidationError as exc: - # exc.message_dict is a dict of field-names → list of messages, if available - if hasattr(exc, 'message_dict'): - detail = exc.message_dict + if hasattr(exc, "message_dict"): + raise serializers.ValidationError(exc.message_dict) from exc else: - # exc.messages is always a list of strings - detail = {'non_field_errors': exc.messages} - raise serializers.ValidationError(detail) from exc + messages = getattr(exc, "messages", None) or [str(exc)] + raise serializers.ValidationError({"non_field_errors": messages}) from exc else: return plugin_result From 0e4baa86eb99980f76014ec19d4633832c87000c Mon Sep 17 00:00:00 2001 From: David Wallace Date: Mon, 19 Jan 2026 10:30:34 +0100 Subject: [PATCH 164/165] Fix project import update and refactor Signed-off-by: David Wallace --- .../test_viewset_project_import_update.py | 13 +- rdmo/projects/viewsets.py | 208 ++++++++---------- 2 files changed, 96 insertions(+), 125 deletions(-) diff --git a/rdmo/projects/tests/test_viewset_project_import_update.py b/rdmo/projects/tests/test_viewset_project_import_update.py index 87880f590d..ad419b0667 100644 --- a/rdmo/projects/tests/test_viewset_project_import_update.py +++ b/rdmo/projects/tests/test_viewset_project_import_update.py @@ -56,7 +56,7 @@ def test_import_update_preview_invalid_xml(db, client, settings, xml_path_error) with open(xml_path_error, "rb") as f: resp = client.post(url, {"file": f, "format": "xml"}, format="multipart") assert resp.status_code == 400 - assert 'Parsing error' in ' '.join(resp.json()['non_field_errors']) + assert 'Parsing error' in ' '.join(resp.json()['file']) @pytest.mark.parametrize('username,password', users) @@ -82,18 +82,18 @@ def test_project_import_update_confirm(db, api_client, settings, username, passw 'checked_values': [v["key"] for v in preview_data.get("values", [])], 'checked_snapshots': [s["index"] for s in preview_data.get("snapshots", [])], } - confirm_response = api_client.post(confirm_url, confirm_payload) + confirm_response = api_client.post(confirm_url, confirm_payload, format="multipart") - assert confirm_response.status_code == 201 + assert confirm_response.status_code == 200 result = json.loads(confirm_response.content.decode()) assert 'id' in result - assert 'title' in result assert projects_count == Project.objects.all().count() elif password: with open(xml_path_project, 'rb') as xml_file: confirm_response = api_client.post( confirm_url, - {'file': xml_file,'checked_values': [], 'checked_snapshots': []} + {'file': xml_file, 'checked_values': [], 'checked_snapshots': []}, + format="multipart" ) if project_id in view_project_permission_map.get(username, []): @@ -104,6 +104,7 @@ def test_project_import_update_confirm(db, api_client, settings, username, passw with open(xml_path_project, 'rb') as xml_file: confirm_response = api_client.post( confirm_url, - {'file': xml_file,'checked_values': [], 'checked_snapshots': []} + {'file': xml_file, 'checked_values': [], 'checked_snapshots': []}, + format="multipart" ) assert confirm_response.status_code == 401 diff --git a/rdmo/projects/viewsets.py b/rdmo/projects/viewsets.py index 6c82237229..ed16f50454 100644 --- a/rdmo/projects/viewsets.py +++ b/rdmo/projects/viewsets.py @@ -587,43 +587,7 @@ def imports(self, request): serializer_class=ProjectFileUploadSerializer ) def import_create_preview(self, request, *args, **kwargs): - # 1) validate upload + optional overrides - upload = ProjectFileUploadSerializer(data=request.data, context={"request": request}) - upload.is_valid(raise_exception=True) - - file = upload.validated_data["file"] - file_format = upload.validated_data["format"] - - title_override = upload.validated_data.get("title") - catalog_override = upload.validated_data.get("catalog") - - # 2) let plugin parse the import file - plugin = validate_and_prepare_import_plugin( - request=request, - file=file, - file_format=file_format, - current_project=None, - ) - - try: - prepared = plugin.prepare_import() - except ValidationError as exc: - raise_as_drf_validation_error(exc) - - # 3) apply override metadata (optional) - project_meta = prepared.get("project", {}) or {} - - if title_override: - project_meta["title"] = title_override - - if catalog_override is not None: - project_meta["catalog"] = catalog_override.pk - - prepared["project"] = project_meta - - # 4) return preview - out = ProjectImportPreviewResponseSerializer(prepared) - return Response(out.data, status=status.HTTP_200_OK) + return self.get_import_preview(request, current_project=None) @action( detail=False, @@ -634,62 +598,19 @@ def import_create_preview(self, request, *args, **kwargs): serializer_class=ProjectImportConfirmSerializer ) def import_create_confirm(self, request, *args, **kwargs): - confirm = ProjectImportConfirmSerializer(data=request.data, context={"request": request}) - confirm.is_valid(raise_exception=True) - - file = confirm.validated_data["file"] - file_format = confirm.validated_data["format"] - - title_override = confirm.validated_data.get("title") - catalog_override = confirm.validated_data.get("catalog") - - checked_values = confirm.validated_data.get("checked_values", []) - checked_snapshots = confirm.validated_data.get("checked_snapshots", []) - - plugin = validate_and_prepare_import_plugin( - request=request, - file=file, - file_format=file_format, - current_project=None, - ) - - # Import creates the project using the plugin parsed data - project = plugin.import_to_project( - checked_values=checked_values, - checked_snapshots=checked_snapshots, - ) - - # Apply overrides AFTER import if they were given - changed = False - if title_override: - project.title = title_override - changed = True - - if catalog_override is not None: - project.catalog = catalog_override - changed = True - - if changed: - project.save() - - return Response({"id": project.pk}, status=status.HTTP_201_CREATED) + return self.get_import_confirm(request, current_project=None, status_code=status.HTTP_201_CREATED) @action( detail=True, methods=["post"], url_path="import-update-preview", parser_classes=[MultiPartParser, FormParser], - permission_classes=(HasModelPermission | HasProjectImportUpdatePermission,), + permission_classes=(HasModelPermission | HasProjectPermission,), serializer_class=ProjectFileUploadSerializer, ) def import_update_preview(self, request, pk=None): project = self.get_object() - - preview = self.handle_import_plugin_action( - plugin_kwargs={"current_project": project}, - plugin_call=lambda plugin, _: plugin.prepare_import(), - ) - return Response(preview, status=status.HTTP_200_OK) + return self.get_import_preview(request, current_project=project) @action( detail=True, @@ -702,22 +623,13 @@ def import_update_preview(self, request, pk=None): ) def import_update_confirm(self, request, pk=None): project = self.get_object() - - updated_project = self.handle_import_plugin_action( - plugin_kwargs={"current_project": project}, - plugin_call=lambda plugin, data: plugin.import_to_project( - checked_values=set(data["checked_values"]), - checked_snapshots=set(data["checked_snapshots"]) - ), - ) - serializer = ProjectSerializer(updated_project, context={"request": request}) - return Response(serializer.data, status=status.HTTP_201_CREATED) + return self.get_import_confirm(request, current_project=project, status_code=status.HTTP_200_OK) @action( - detail=True, - methods=["get"], - permission_classes=(HasModelPermission | HasProjectPermission,), - url_path="export(?:/(?P[a-z]+))?", + detail=True, + methods=["get"], + permission_classes=(HasModelPermission | HasProjectPermission,), + url_path="export(?:/(?P[a-z]+))?", ) def export(self, request, pk=None, export_format='xml'): project = self.get_object() @@ -763,31 +675,89 @@ def perform_create(self, serializer): for view in views: project.views.add(view) - def handle_import_plugin_action( - self, - plugin_kwargs=None, - plugin_call=None, - ): - serializer = self.get_serializer(data=self.request.data) - serializer.is_valid(raise_exception=True) + def apply_import_overrides(self, prepared: dict, title_override=None, catalog_override=None) -> dict: + project_meta = prepared.get("project", {}) or {} + + if title_override: + project_meta["title"] = title_override + + if catalog_override is not None: + project_meta["catalog"] = catalog_override.pk + + prepared["project"] = project_meta + return prepared + + def get_import_preview(self, request, *, current_project=None) -> Response: + upload = ProjectFileUploadSerializer(data=request.data, context={"request": request, "view": self}) + upload.is_valid(raise_exception=True) + + file = upload.validated_data["file"] + file_format = upload.validated_data["format"] + + title_override = upload.validated_data.get("title") + catalog_override = upload.validated_data.get("catalog") + + plugin = validate_and_prepare_import_plugin( + request=request, + file=file, + file_format=file_format, + current_project=current_project, + ) - plugin_kwargs = plugin_kwargs or {} try: - plugin = validate_and_prepare_import_plugin( - self.request, - file=serializer.validated_data["file"], - file_format=serializer.validated_data["format"], - **plugin_kwargs, - ) - plugin_result = plugin_call(plugin, serializer.validated_data) + prepared = plugin.prepare_import() except ValidationError as exc: - if hasattr(exc, "message_dict"): - raise serializers.ValidationError(exc.message_dict) from exc - else: - messages = getattr(exc, "messages", None) or [str(exc)] - raise serializers.ValidationError({"non_field_errors": messages}) from exc - else: - return plugin_result + raise_as_drf_validation_error(exc) + + prepared = self.apply_import_overrides( + prepared, + title_override=title_override, + catalog_override=catalog_override, + ) + + out = ProjectImportPreviewResponseSerializer(prepared) + return Response(out.data, status=status.HTTP_200_OK) + + def get_import_confirm(self, request, *, current_project=None, status_code=status.HTTP_201_CREATED) -> Response: + confirm = ProjectImportConfirmSerializer(data=request.data, context={"request": request, "view": self}) + confirm.is_valid(raise_exception=True) + + file = confirm.validated_data["file"] + file_format = confirm.validated_data["format"] + + checked_values = confirm.validated_data.get("checked_values", []) + checked_snapshots = confirm.validated_data.get("checked_snapshots", []) + + title_override = confirm.validated_data.get("title") + catalog_override = confirm.validated_data.get("catalog") + + plugin = validate_and_prepare_import_plugin( + request=request, + file=file, + file_format=file_format, + current_project=current_project, + ) + + updated_project = plugin.import_to_project( + checked_values=checked_values, + checked_snapshots=checked_snapshots, + ) + + # Optional overrides apply after import (explicit, predictable) + changed = False + if title_override: + updated_project.title = title_override + changed = True + + if catalog_override is not None: + updated_project.catalog = catalog_override + changed = True + + if changed: + updated_project.save() + + # keep response shape consistent and stable for tests + return Response({"id": updated_project.pk}, status=status_code) class ProjectNestedViewSetMixin(NestedViewSetMixin): From 0b062ce4a57e24a871ffc5c1393b2bcacc8ac225 Mon Sep 17 00:00:00 2001 From: David Wallace Date: Mon, 19 Jan 2026 12:28:36 +0100 Subject: [PATCH 165/165] Add snapshot export action Signed-off-by: David Wallace --- rdmo/core/tests/test_openapi.py | 2 +- rdmo/projects/exports.py | 4 +- rdmo/projects/serializers/export.py | 4 +- rdmo/projects/serializers/v1/__init__.py | 10 ++++ rdmo/projects/tests/test_viewset_project.py | 25 +++++++- rdmo/projects/tests/test_viewset_snapshot.py | 62 +++++++++++++++++++- rdmo/projects/viewsets.py | 50 +++++++++++++--- 7 files changed, 141 insertions(+), 16 deletions(-) diff --git a/rdmo/core/tests/test_openapi.py b/rdmo/core/tests/test_openapi.py index 63c6f28f03..5447105878 100644 --- a/rdmo/core/tests/test_openapi.py +++ b/rdmo/core/tests/test_openapi.py @@ -10,7 +10,7 @@ 'anonymous' ) -n_path = 142 +n_path = 143 @pytest.mark.parametrize('username', users) def test_openapi_schema(db, client, login, settings, username): diff --git a/rdmo/projects/exports.py b/rdmo/projects/exports.py index 34c58f58ab..51607065ee 100644 --- a/rdmo/projects/exports.py +++ b/rdmo/projects/exports.py @@ -165,7 +165,9 @@ def render(self): else: content_disposition = f'attachment; filename="{self.snapshot.title}.xml"' - serializer = SnapshotExportSerializer(self.snapshot) + serializer = SnapshotExportSerializer(self.snapshot, context={ + 'include_memberships': self.include_memberships + }) xmldata = XMLRenderer().render(serializer.data) response = HttpResponse(prettify_xml(xmldata), content_type="application/xml") diff --git a/rdmo/projects/serializers/export.py b/rdmo/projects/serializers/export.py index 3e7d293a0a..a8b716568b 100644 --- a/rdmo/projects/serializers/export.py +++ b/rdmo/projects/serializers/export.py @@ -49,7 +49,7 @@ class SnapshotSerializer(serializers.ModelSerializer): catalog = serializers.CharField(source='catalog.uri', default=None, read_only=True) tasks = serializers.SerializerMethodField() views = serializers.SerializerMethodField() - memberships = serializers.SerializerMethodField() # optional from context + memberships = serializers.SerializerMethodField() # optional, from context class Meta: model = Snapshot @@ -80,7 +80,7 @@ def get_views(self, obj): def get_memberships(self, obj): if not self.context.get("include_memberships"): return [] - qs = obj.memberships.select_related("user").all() + qs = obj.project.memberships.select_related("user").all() return MembershipForExportSerializer(qs, many=True, context=self.context).data diff --git a/rdmo/projects/serializers/v1/__init__.py b/rdmo/projects/serializers/v1/__init__.py index 099a83a920..eef70fa8f4 100644 --- a/rdmo/projects/serializers/v1/__init__.py +++ b/rdmo/projects/serializers/v1/__init__.py @@ -800,6 +800,8 @@ class Meta: class SnapshotSerializer(serializers.ModelSerializer): + catalog = serializers.CharField(source="project.catalog.id", read_only=True) + memberships = serializers.SerializerMethodField() # optional, enabled from context class Meta: model = Snapshot @@ -808,10 +810,18 @@ class Meta: 'project', 'title', 'description', + 'catalog', + 'memberships', # optional, enabled from context 'created', 'updated' ) + def get_memberships(self, obj): + if not self.context.get("include_memberships"): + return [] + qs = obj.project.memberships.select_related("user").all() + return ProjectMembershipSerializer(qs, many=True, context=self.context).data + class ValueSerializer(serializers.ModelSerializer): diff --git a/rdmo/projects/tests/test_viewset_project.py b/rdmo/projects/tests/test_viewset_project.py index e98efa5fcf..03891271cd 100644 --- a/rdmo/projects/tests/test_viewset_project.py +++ b/rdmo/projects/tests/test_viewset_project.py @@ -684,7 +684,8 @@ def test_imports(db, client, username, password): @pytest.mark.parametrize('username,password', users) @pytest.mark.parametrize('export_format', export_formats) -def test_export(db, client, username, password, export_format): +@pytest.mark.parametrize('include_memberships', [False, True]) +def test_export(db, client, username, password, export_format, include_memberships): client.login(username=username, password=password) if export_format: @@ -692,6 +693,10 @@ def test_export(db, client, username, password, export_format): else: url = reverse(urlnames["export"], kwargs={"pk": project_id}) + if include_memberships: + # This needs to match your viewset parameter name + url += "?include_memberships=1" + response = client.get(url) if project_id in view_project_permission_map.get(username, []): @@ -702,9 +707,23 @@ def test_export(db, client, username, password, export_format): if export_format in (None, "xml"): root = et.fromstring(response.content) assert root.tag == "project" + expected_tags = [ + "title", "description", "catalog", "tasks", "views", + "snapshots", "values", "memberships", "created", "updated" + ] + for child in root: - assert child.tag in ['title', 'description', 'catalog', 'tasks', 'views', - 'snapshots', 'values', 'created', 'updated'] + assert child.tag in expected_tags + + memberships_el = root.find("memberships") + if include_memberships: + assert memberships_el is not None + assert len(list(memberships_el)) > 0 + else: + if memberships_el is None: + pass + else: + assert len(list(memberships_el)) == 0 # JSON elif export_format == "json": diff --git a/rdmo/projects/tests/test_viewset_snapshot.py b/rdmo/projects/tests/test_viewset_snapshot.py index 833202b513..8b43d6843e 100644 --- a/rdmo/projects/tests/test_viewset_snapshot.py +++ b/rdmo/projects/tests/test_viewset_snapshot.py @@ -1,3 +1,5 @@ +import xml.etree.ElementTree as et + import pytest from django.urls import reverse @@ -22,12 +24,14 @@ 'guest': [1, 3, 5, 12], 'user': [12], 'api': [1, 2, 3, 4, 5, 12], - 'site': [1, 2, 3, 4, 5, 12] + 'site': [ + 1, 2, 3, 4, 5, 12] } urlnames = { 'list': 'v1-projects:snapshot-list', - 'detail': 'v1-projects:snapshot-detail' + 'detail': 'v1-projects:snapshot-detail', + 'export': 'v1-projects:project-snapshot-export', } snapshots = [ @@ -120,3 +124,57 @@ def test_delete(db, client, username, password, snapshot_id): assert response.status_code == 405 else: assert response.status_code == 401 + + + +@pytest.mark.parametrize("username,password", users) +@pytest.mark.parametrize("include_memberships", [False, True]) +def test_export(db, client, username, password, include_memberships): + snapshot_id = 1 + client.login(username=username, password=password) + + snapshot = Snapshot.objects.get(pk=snapshot_id) + + url = reverse( + urlnames["export"], + kwargs={ + "parent_lookup_project": snapshot.project_id, + "pk": snapshot.pk, + }, + ) + + if include_memberships: + url += "?include_memberships=1" + + response = client.get(url) + + if snapshot.project.id in view_snapshot_permission_map.get(username, []): + assert response.status_code == 200 + assert response.content + + root = et.fromstring(response.content) + assert root.tag == "project" + + expected_tags = [ + "title", "description", "catalog", "tasks", "views", + "snapshots", "values", "memberships", "created", "updated" + ] + + for child in root: + assert child.tag in expected_tags + + memberships_el = root.find("memberships") + if include_memberships: + assert memberships_el is not None + assert len(list(memberships_el)) > 0 + else: + if memberships_el is None: + pass + else: + assert len(list(memberships_el)) == 0 + + else: + if password: + assert response.status_code == 404 + else: + assert response.status_code == 404 diff --git a/rdmo/projects/viewsets.py b/rdmo/projects/viewsets.py index ed16f50454..096d5f0a18 100644 --- a/rdmo/projects/viewsets.py +++ b/rdmo/projects/viewsets.py @@ -634,10 +634,15 @@ def import_update_confirm(self, request, pk=None): def export(self, request, pk=None, export_format='xml'): project = self.get_object() project.catalog.prefetch_elements() - context = self.get_export_renderer_context(request) + + full = is_truthy(request.GET.get("full")) + context = { + "snapshots": full or is_truthy(request.GET.get("snapshots")), + "include_memberships": full or is_truthy(request.GET.get("include_memberships")), + } if export_format == 'xml': - serializer = ProjectExportSerializer(project) + serializer = ProjectExportSerializer(project, context=context) xml = XMLRenderer().render(serializer.data, context=context) return XMLResponse(xml, name=project.title) else: @@ -646,13 +651,9 @@ def export(self, request, pk=None, export_format='xml'): raise Http404 plugin.project = project plugin.snapshot = None + plugin.include_memberships = context['include_memberships'] return plugin.render() - def get_export_renderer_context(self, request): - full = is_truthy(request.GET.get('full')) - return { - 'snapshots': full or is_truthy(request.GET.get('snapshots', True)), - } def perform_create(self, serializer): project = serializer.save(site=get_current_site(self.request)) @@ -936,6 +937,41 @@ def rollback(self, request, parent_lookup_project, pk=None): snapshot.rollback() return Response(status=status.HTTP_204_NO_CONTENT) + @action( + detail=True, + methods=["get"], + permission_classes=(HasModelPermission | HasProjectPermission,), + url_path=r"export(?:/(?P[a-z]+))?", + ) + def export(self, request, pk=None, parent_lookup_project=None, export_format="xml"): + snapshot = self.get_object() + snapshot.project.catalog.prefetch_elements() + + # Build same context semantics as project export + full = is_truthy(request.GET.get("full")) + context = { + "include_memberships": full or is_truthy(request.GET.get("include_memberships")), + } + + # XML snapshot export + if export_format == "xml": + serializer = SnapshotSerializer(snapshot, context=context) + xml = XMLRenderer().render(serializer.data, context=context) + + # name is the filename stem in Content-Disposition + name = snapshot.title or snapshot.project.title + return XMLResponse(xml, name=name) + + # Plugin-based snapshot exports + plugin = get_plugin("SNAPSHOT_EXPORTS", export_format) + if plugin is None: + raise Http404 + + plugin.snapshot = snapshot + plugin.project = None + plugin.include_memberships = context['include_memberships'] + return plugin.render() + class ProjectValueViewSet(ProjectNestedViewSetMixin, ModelViewSet): permission_classes = (HasModelPermission | HasProjectPermission, )
{% full_name membership.user %} - {% include 'projects/project_detail_memberships_socialaccounts.html' %} + {% include 'projects/old/project_detail_memberships_socialaccounts.html' %} {{ membership.user.email }} diff --git a/rdmo/projects/templates/projects/project_detail_memberships_help.html b/rdmo/projects/templates/projects/old/project_detail_memberships_help.html similarity index 100% rename from rdmo/projects/templates/projects/project_detail_memberships_help.html rename to rdmo/projects/templates/projects/old/project_detail_memberships_help.html diff --git a/rdmo/projects/templates/projects/project_detail_memberships_socialaccounts.html b/rdmo/projects/templates/projects/old/project_detail_memberships_socialaccounts.html similarity index 100% rename from rdmo/projects/templates/projects/project_detail_memberships_socialaccounts.html rename to rdmo/projects/templates/projects/old/project_detail_memberships_socialaccounts.html diff --git a/rdmo/projects/templates/projects/project_detail_sidebar.html b/rdmo/projects/templates/projects/old/project_detail_sidebar.html similarity index 100% rename from rdmo/projects/templates/projects/project_detail_sidebar.html rename to rdmo/projects/templates/projects/old/project_detail_sidebar.html diff --git a/rdmo/projects/templates/projects/project_detail_sidebar_parent_import.html b/rdmo/projects/templates/projects/old/project_detail_sidebar_parent_import.html similarity index 100% rename from rdmo/projects/templates/projects/project_detail_sidebar_parent_import.html rename to rdmo/projects/templates/projects/old/project_detail_sidebar_parent_import.html diff --git a/rdmo/projects/templates/projects/project_detail_snapshots.html b/rdmo/projects/templates/projects/old/project_detail_snapshots.html similarity index 98% rename from rdmo/projects/templates/projects/project_detail_snapshots.html rename to rdmo/projects/templates/projects/old/project_detail_snapshots.html index c825276b97..c1353c4c3c 100644 --- a/rdmo/projects/templates/projects/project_detail_snapshots.html +++ b/rdmo/projects/templates/projects/old/project_detail_snapshots.html @@ -11,7 +11,7 @@

{% trans 'Snapshots' %}

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

{% trans 'Views' %}

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

+ 👍 +

+ +

+ +

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

Mit dem Datenmanagement starten

- -

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

- -

- Videos zur Einführung in RDMO: -

- -

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

Haben Sie weitere Fragen, Feedback oder brauchen Hilfe?

- -

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

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

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

-
-
-
-
-
- -
-
-

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

- -

- Impressum - Nutzungsbedingungen - Datenschutzerklärung -

- -

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

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

Lorem ipsum dolor sit amet

+ +

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

+ +

+ Lorem ipsum dolor sit amet: +

+ +

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

Lorem ipsum dolor sit amet, consetetur sadipscing elitr?

+ +

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

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

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

+
+
+
+
+
+ +
+
+

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

+ +

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

+ +

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

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

Lorem ipsum dolor sit amet

+ +

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

+ +

+ Lorem ipsum dolor sit amet: +

+ +

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

Lorem ipsum dolor sit amet, consetetur sadipscing elitr?

+ +

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

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

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

+
+
+
+
+
+ +
+
+

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

+ +

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

+ +

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

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

{{ image.attribution|markdown }}

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

{rolesString}

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

    +

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

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

    +

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

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

    +

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

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

    +

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

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

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

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

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

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

    From 81ddf60aee0670baf1e5d286f610875022b9d073 Mon Sep 17 00:00:00 2001 From: Jochen Klar Date: Thu, 9 Oct 2025 18:56:15 +0200 Subject: [PATCH 114/165] Add parent_title to ProjectSerializer --- rdmo/projects/serializers/v1/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/rdmo/projects/serializers/v1/__init__.py b/rdmo/projects/serializers/v1/__init__.py index 1b29cd1880..ddd2fcfdbd 100644 --- a/rdmo/projects/serializers/v1/__init__.py +++ b/rdmo/projects/serializers/v1/__init__.py @@ -126,7 +126,9 @@ def get_queryset(self): return Project.objects.filter_user(self.context['request'].user) catalog = CatalogField(required=True) + parent = ParentField(required=False, allow_null=True) + parent_title = serializers.CharField(source='parent.title', read_only=True) owners = ProjectUserSerializer(many=True, read_only=True) managers = ProjectUserSerializer(many=True, read_only=True) @@ -149,6 +151,7 @@ class Meta: 'catalog_uri', 'snapshots', 'parent', + 'parent_title', 'owners', 'managers', 'authors', From 412f48f44dcf54f74639f04fca0096b1de911a8f Mon Sep 17 00:00:00 2001 From: Jochen Klar Date: Thu, 9 Oct 2025 18:57:53 +0200 Subject: [PATCH 115/165] Refactor ProjectForm --- .../project/components/pages/ProjectForm.js | 23 ++++++------------- .../assets/js/project/utils/findById.js | 14 ----------- 2 files changed, 7 insertions(+), 30 deletions(-) delete mode 100644 rdmo/projects/assets/js/project/utils/findById.js diff --git a/rdmo/projects/assets/js/project/components/pages/ProjectForm.js b/rdmo/projects/assets/js/project/components/pages/ProjectForm.js index 8a9359e437..2656805ecc 100644 --- a/rdmo/projects/assets/js/project/components/pages/ProjectForm.js +++ b/rdmo/projects/assets/js/project/components/pages/ProjectForm.js @@ -1,8 +1,9 @@ -import React, { useEffect, useState } from 'react' +import React, { useState } from 'react' import PropTypes from 'prop-types' import { useDispatch, useSelector } from 'react-redux' import AsyncSelect from 'react-select/async' import { useDebouncedCallback } from 'use-debounce' +import { isEmpty } from 'lodash' import Html from 'rdmo/core/assets/js/components/Html' import Input from 'rdmo/core/assets/js/components/forms/Input' @@ -10,12 +11,11 @@ import Textarea from 'rdmo/core/assets/js/components/forms/Textarea' import { updateProject } from '../../actions/projectActions' import { useFieldErrors } from '../../hooks/useFieldErrors' -import { findById } from '../../utils/findById' import ProjectApi from '../../api/ProjectApi' const ProjectForm = ({ disabled }) => { - const { project, hierarchy, catalogs } = useSelector((state) => state.project.project) + const { project, catalogs } = useSelector((state) => state.project.project) const templates = useSelector((state) => state.templates) const dispatch = useDispatch() const errors = useFieldErrors() @@ -24,8 +24,6 @@ const ProjectForm = ({ disabled }) => { const [enableParent, setEnableParent] = useState(!!project.parent) const [parentOptions, setParentOptions] = useState([]) - const parentProject = project?.parent ? findById(hierarchy, project.parent) : null - const saveProject = (newFormData) => { dispatch(updateProject(newFormData)) } @@ -71,17 +69,7 @@ const ProjectForm = ({ disabled }) => { } } - useEffect(() => { - if (formData.parent && !parentOptions.some(p => p.value === formData.parent)) { - if (parentProject) { - const option = { value: parentProject.id, label: parentProject.title } - setParentOptions((prev) => [...prev, option]) - } - } - }, [formData.parent, parentOptions]) - return ( - // { noOptionsMessage={() => gettext('No projects matching your search.')} loadingMessage={() => gettext('Loading ...')} defaultOptions={parentOptions} - value={parentOptions.find(p => p.value === formData.parent) || null} + value={isEmpty(parentOptions) ? { + value: project.parent, + label: project.parent_title + } : parentOptions.find(p => p.value === formData.parent)} onChange={(option) => handleChange('parent', option ? option.value : null)} getOptionValue={(project) => project.value} getOptionLabel={(project) => project.label} diff --git a/rdmo/projects/assets/js/project/utils/findById.js b/rdmo/projects/assets/js/project/utils/findById.js deleted file mode 100644 index 9a5cfcbc52..0000000000 --- a/rdmo/projects/assets/js/project/utils/findById.js +++ /dev/null @@ -1,14 +0,0 @@ -export const findById = (node, id) => { - if (node.id === id) { - return node - } - - if (node.children && node.children.length > 0) { - for (const child of node.children) { - const found = findById(child, id) - if (found) return found - } - } - - return null -} From 8bf89885a19127a4475ae2bb3c51364e459fc40c Mon Sep 17 00:00:00 2001 From: Jochen Klar Date: Thu, 9 Oct 2025 19:02:08 +0200 Subject: [PATCH 116/165] Use isAdminOrSiteManager in projects --- .../js/projects/components/helper/ProjectFilters.js | 10 +++++----- .../assets/js/projects/components/main/Projects.js | 6 +++--- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/rdmo/projects/assets/js/projects/components/helper/ProjectFilters.js b/rdmo/projects/assets/js/projects/components/helper/ProjectFilters.js index 708942be03..7acb2c4078 100644 --- a/rdmo/projects/assets/js/projects/components/helper/ProjectFilters.js +++ b/rdmo/projects/assets/js/projects/components/helper/ProjectFilters.js @@ -9,7 +9,7 @@ import { Link, Select } from 'rdmo/core/assets/js/components' import useDatePicker from '../../hooks/useDatePicker' import { language } from 'rdmo/core/assets/js/utils' -const ProjectFilters = ({ catalogs, config, configActions, isManager, projectsActions }) => { +const ProjectFilters = ({ catalogs, config, configActions, isAdminOrSiteManager, projectsActions }) => { const { dateRange, dateFormat, @@ -35,7 +35,7 @@ const ProjectFilters = ({ catalogs, config, configActions, isManager, projectsAc projectsActions.fetchProjects() } - const catalogOptions = catalogs?.filter(catalog => isManager || catalog.available) + const catalogOptions = catalogs?.filter(catalog => isAdminOrSiteManager || catalog.available) .map(catalog => ({ value: catalog.id.toString(), label: ( @@ -78,7 +78,7 @@ const ProjectFilters = ({ catalogs, config, configActions, isManager, projectsAc
    -
    +