diff --git a/client/actions/planning/api.js b/client/actions/planning/api.js index bd9ba168e..e9b9101fb 100644 --- a/client/actions/planning/api.js +++ b/client/actions/planning/api.js @@ -960,15 +960,16 @@ const fetchPlanningFiles = (planning) => ( return Promise.resolve(); } + const filesToFetch = planningUtils.getPlanningFiles(planning); const filesInStore = selectors.general.files(getState()); - if (every(planning.files, (f) => f in filesInStore)) { + if (every(filesToFetch, (f) => f in filesInStore)) { return Promise.resolve(); } return api('planning_files').query( { - where: {$and: [{_id: {$in: planning.files}}]}, + where: {$and: [{_id: {$in: filesToFetch}}]}, } ) .then((data) => { diff --git a/client/components/Assignments/AssignmentPreviewContainer/AssignmentPreview.jsx b/client/components/Assignments/AssignmentPreviewContainer/AssignmentPreview.jsx index 63598fe0d..1c5e40d92 100644 --- a/client/components/Assignments/AssignmentPreviewContainer/AssignmentPreview.jsx +++ b/client/components/Assignments/AssignmentPreviewContainer/AssignmentPreview.jsx @@ -7,6 +7,7 @@ import {gettext, stringUtils, assignmentUtils} from '../../../utils'; import {InternalNoteLabel} from '../../'; import {ContactsPreviewList} from '../../Contacts/index'; import {Row} from '../../UI/Preview'; +import {FileReadOnlyList} from '../../UI'; // eslint-disable-next-line complexity export const AssignmentPreview = ({ @@ -15,6 +16,8 @@ export const AssignmentPreview = ({ coverageFormProfile, planningFormProfile, planningItem, + files, + createLink, }) => { const planning = get(assignment, 'planning', {}); @@ -86,11 +89,25 @@ export const AssignmentPreview = ({

{stringUtils.convertNewlineToBreak(planning.internal_note || '-')}

+ + + + + + ); }; @@ -101,4 +118,6 @@ AssignmentPreview.propTypes = { coverageFormProfile: PropTypes.object, planningFormProfile: PropTypes.object, planningItem: PropTypes.object, + files: PropTypes.array, + createLink: PropTypes.func, }; diff --git a/client/components/Assignments/AssignmentPreviewContainer/EventPreview.jsx b/client/components/Assignments/AssignmentPreviewContainer/EventPreview.jsx index 17189c540..5a163588d 100644 --- a/client/components/Assignments/AssignmentPreviewContainer/EventPreview.jsx +++ b/client/components/Assignments/AssignmentPreviewContainer/EventPreview.jsx @@ -1,18 +1,16 @@ import React from 'react'; import PropTypes from 'prop-types'; -import {connect} from 'react-redux'; import {get} from 'lodash'; -import * as selectors from '../../../selectors'; import {gettext, stringUtils, timeUtils} from '../../../utils'; - import {Datetime} from '../../'; import {Location} from '../../Location'; +import {FileReadOnlyList} from '../../UI'; import {Row} from '../../UI/Preview'; -import {FileInput, LinkInput} from '../../UI/Form'; +import {LinkInput} from '../../UI/Form'; import {ContactsPreviewList} from '../../Contacts'; -export const EventPreviewComponent = ({item, formProfile, createLink, streetMapUrl, files}) => { +export const EventPreview = ({item, formProfile, createLink, streetMapUrl, files}) => { if (!item) { return null; } @@ -115,22 +113,12 @@ export const EventPreviewComponent = ({item, formProfile, createLink, streetMapU enabled={get(formProfile, 'editor.files.enabled')} label={gettext('Attachments')} > - {get(item, 'files.length', 0) > 0 ? ( - - ) : ( -

{gettext('No attached files added.')}

- )} + ({ - createLink: (f) => (selectors.config.getServerUrl(state) + '/upload/' + f.filemeta.media_id + '/raw'), - streetMapUrl: selectors.config.getStreetMapUrl(state), - files: selectors.general.files(state), -}); - - -export const EventPreview = connect(mapStateToProps)(EventPreviewComponent); \ No newline at end of file diff --git a/client/components/Assignments/AssignmentPreviewContainer/index.jsx b/client/components/Assignments/AssignmentPreviewContainer/index.jsx index 73dcdd114..cce27106c 100644 --- a/client/components/Assignments/AssignmentPreviewContainer/index.jsx +++ b/client/components/Assignments/AssignmentPreviewContainer/index.jsx @@ -5,7 +5,7 @@ import {get} from 'lodash'; import * as selectors from '../../../selectors'; import * as actions from '../../../actions'; -import {assignmentUtils, gettext, eventUtils} from '../../../utils'; +import {assignmentUtils, gettext, eventUtils, planningUtils} from '../../../utils'; import {ASSIGNMENTS, WORKSPACE} from '../../../constants'; import {AssignmentPreviewHeader} from './AssignmentPreviewHeader'; @@ -20,6 +20,10 @@ class AssignmentPreviewContainerComponent extends React.Component { if (eventUtils.shouldFetchFilesForEvent(this.props.eventItem)) { this.props.fetchEventFiles(this.props.eventItem); } + + if (planningUtils.shouldFetchFilesForPlanning(this.props.planningItem)) { + this.props.fetchPlanningFiles(this.props.planningItem); + } } getItemActions() { @@ -82,6 +86,9 @@ class AssignmentPreviewContainerComponent extends React.Component { contentTypes, session, privileges, + createLink, + streetMapUrl, + files, } = this.props; if (!assignment) { @@ -129,6 +136,8 @@ class AssignmentPreviewContainerComponent extends React.Component { coverageFormProfile={formProfile.coverage} planningFormProfile={formProfile.planning} planningItem={planningItem} + createLink={createLink} + files={files} /> @@ -161,6 +170,9 @@ class AssignmentPreviewContainerComponent extends React.Component { @@ -202,6 +214,10 @@ AssignmentPreviewContainerComponent.propTypes = { fetchEventFiles: PropTypes.func, currentWorkspace: PropTypes.string, contentTypes: PropTypes.array, + fetchPlanningFiles: PropTypes.func, + createLink: PropTypes.func, + streetMapUrl: PropTypes.string, + files: PropTypes.array, }; const mapStateToProps = (state) => ({ @@ -223,6 +239,9 @@ const mapStateToProps = (state) => ({ agendas: selectors.general.agendas(state), currentWorkspace: selectors.general.currentWorkspace(state), contentTypes: selectors.general.contentTypes(state), + createLink: (f) => (selectors.config.getServerUrl(state) + '/upload/' + f.filemeta.media_id + '/raw'), + streetMapUrl: selectors.config.getStreetMapUrl(state), + files: selectors.general.files(state), }); const mapDispatchToProps = (dispatch) => ({ @@ -235,6 +254,7 @@ const mapDispatchToProps = (dispatch) => ({ removeAssignment: (assignment) => dispatch(actions.assignments.ui.showRemoveAssignmentModal(assignment)), openArchivePreview: (assignment) => dispatch(actions.assignments.ui.openArchivePreview(assignment)), fetchEventFiles: (event) => dispatch(actions.events.api.fetchEventFiles(event)), + fetchPlanningFiles: (planning) => dispatch(actions.planning.api.fetchPlanningFiles(planning)), }); export const AssignmentPreviewContainer = connect( diff --git a/client/components/Coverages/CoverageEditor/CoverageForm.jsx b/client/components/Coverages/CoverageEditor/CoverageForm.jsx index 7212bee89..3cfc72adf 100644 --- a/client/components/Coverages/CoverageEditor/CoverageForm.jsx +++ b/client/components/Coverages/CoverageEditor/CoverageForm.jsx @@ -4,8 +4,8 @@ import {get} from 'lodash'; import {getItemInArrayById, gettext, planningUtils, generateTempId, assignmentUtils} from '../../../utils'; import moment from 'moment'; import {WORKFLOW_STATE, DEFAULT_DATE_FORMAT, DEFAULT_TIME_FORMAT, TO_BE_CONFIRMED_FIELD} from '../../../constants'; -import {Button} from '../../UI'; -import {Row, Label, LineInput} from '../../UI/Form'; +import {Button, ToggleBox} from '../../UI'; +import {Row, Label, LineInput, FileInput} from '../../UI/Form'; import {ScheduledUpdate} from '../ScheduledUpdate'; @@ -32,11 +32,18 @@ export class CoverageForm extends React.Component { this.onRemoveScheduledUpdate = this.onRemoveScheduledUpdate.bind(this); this.onScheduledUpdateClose = this.onScheduledUpdateClose.bind(this); this.onScheduledUpdateOpen = this.onScheduledUpdateOpen.bind(this); + this.onAddFiles = this.onAddFiles.bind(this); + this.onRemoveFile = this.onRemoveFile.bind(this); this.dom = { contentType: null, popupContainer: null, }; - this.state = {openScheduledUpdates: []}; + this.state = { + openScheduledUpdates: [], + uploading: false, + }; + this.filePath = 'value.planning.files'; + this.fullFilePath = `coverages[${this.props.index}].planning.files`; } componentDidUpdate(prevProps) { @@ -142,6 +149,34 @@ export class CoverageForm extends React.Component { )}); } + onAddFiles(fileList) { + const files = Array.from(fileList).map((f) => [f]); + + this.setState({uploading: true}); + this.props.uploadFiles(files) + .then((newFiles) => { + this.props.onChange(this.fullFilePath, + [ + ...get(this.props, this.filePath, []), + ...newFiles.map((f) => f._id), + ]); + this.setState({uploading: false}); + }, () => { + this.props.notifyValidationErrors(['Failed to upload files']); + this.setState({uploading: false}); + }); + } + + onRemoveFile(file) { + const promise = !get(this.props, this.filePath, []).includes(file._id) ? + this.props.removeFile(file) : Promise.resolve(); + + promise.then(() => + this.props.onChange(this.fullFilePath, + get(this.props, this.filePath, []).filter((f) => f !== file._id)) + ); + } + render() { const { field, @@ -170,6 +205,8 @@ export class CoverageForm extends React.Component { planningAllowScheduledUpdates, onRemoveAssignment, setCoverageDefaultDesk, + createUploadLink, + files, ...props } = this.props; @@ -315,6 +352,29 @@ export class CoverageForm extends React.Component { {...fieldProps} /> + {get(formProfile, 'editor.files.enabled') && + +
+ {!this.state.uploading && } +
+
} + { const coverageStatus = get(coverage, 'news_coverage_status.qcode', '') === PLANNING.NEWS_COVERAGE_CANCELLED_STATUS.qcode ? PLANNING.NEWS_COVERAGE_CANCELLED_STATUS : @@ -146,6 +148,14 @@ export const CoveragePreview = ({ /> } + {get(formProfile, 'editor.files.enabled') && + + } + { - this.notifyValidationErrors('Failed to upload files'); + this.props.notifyValidationErrors(['Failed to upload files']); this.setState({uploading: false}); }); } @@ -518,6 +518,7 @@ EventEditorComponent.propTypes = { onPopupClose: PropTypes.func, itemManager: PropTypes.object, original: PropTypes.object, + notifyValidationErrors: PropTypes.func, }; EventEditorComponent.defaultProps = { diff --git a/client/components/Events/EventPreviewContent.jsx b/client/components/Events/EventPreviewContent.jsx index 45e8effb3..93755b7c6 100644 --- a/client/components/Events/EventPreviewContent.jsx +++ b/client/components/Events/EventPreviewContent.jsx @@ -12,9 +12,9 @@ import { StateLabel, } from '../index'; import {EventScheduleSummary} from './'; -import {ToggleBox} from '../UI'; +import {ToggleBox, FileReadOnlyList} from '../UI'; import {ContentBlock} from '../UI/SidePanel'; -import {LinkInput, FileInput} from '../UI/Form'; +import {LinkInput} from '../UI/Form'; import {Location} from '../Location'; import * as actions from '../../actions'; import {ContactsPreviewList} from '../Contacts/index'; @@ -180,26 +180,13 @@ export class EventPreviewContentComponent extends React.Component { value={stringUtils.convertNewlineToBreak(item.ednote || '-')} /> - {get(formProfile, 'editor.files.enabled') && - 0 ? item.files.length : null}> - {get(item, 'files.length') > 0 ? -
    - {get(item, 'files', []).map((file, index) => ( -
  • - -
  • - ))} -
: - {gettext('No attached files added.')}} -
- } + + + {get(formProfile, 'editor.links.enabled') && { - this.notifyValidationErrors('Failed to upload files'); + this.props.notifyValidationErrors('Failed to upload files'); this.setState({uploading: false}); }); } @@ -761,6 +761,10 @@ export class PlanningEditorComponent extends React.Component { planningAllowScheduledUpdates={planningAllowScheduledUpdates} coverageAddAdvancedMode={this.props.coverageAddAdvancedMode} setCoverageAddAdvancedMode={this.props.setCoverageAddAdvancedMode} + files={files} + createUploadLink={createUploadLink} + uploadFiles={this.props.uploadFiles} + notifyValidationErrors={this.props.notifyValidationErrors} /> ); @@ -823,6 +827,7 @@ PlanningEditorComponent.propTypes = { planningAllowScheduledUpdates: PropTypes.bool, coverageAddAdvancedMode: PropTypes.bool, setCoverageAddAdvancedMode: PropTypes.func, + notifyValidationErrors: PropTypes.func, }; PlanningEditorComponent.defaultProps = { diff --git a/client/components/Planning/PlanningPreviewContent.jsx b/client/components/Planning/PlanningPreviewContent.jsx index 6992fb6ba..b00f1f2d9 100644 --- a/client/components/Planning/PlanningPreviewContent.jsx +++ b/client/components/Planning/PlanningPreviewContent.jsx @@ -240,6 +240,8 @@ export class PlanningPreviewContentComponent extends React.Component { timeFormat={timeFormat} formProfile={formProfile.coverage} inner={inner} + files={files} + createLink={createUploadLink} planningAllowScheduledUpdates={planningAllowScheduledUpdates} />) ) } diff --git a/client/components/UI/FileReadOnlyList.jsx b/client/components/UI/FileReadOnlyList.jsx new file mode 100644 index 000000000..ca2e9249e --- /dev/null +++ b/client/components/UI/FileReadOnlyList.jsx @@ -0,0 +1,56 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import {get} from 'lodash'; + +import {FileInput} from './Form'; +import {ToggleBox} from './index'; + +/** + * @ngdoc react + * @name FileReadOnlyList + * @description Generic read-only file list + */ +const FileReadOnlyList = ({formProfile, item, field, createLink, files, noToggle}) => { + if (!get(formProfile, 'editor.files.enabled')) { + return null; + } + + const fileList = get(item, `${field}.length`, 0) > 0 ? + (
    + {item[field].map((file, index) => ( +
  • + +
  • + ))} +
) : + (
{gettext('No attached files added.')}
); + + if (noToggle) { + return fileList; + } + + return ( + 0 ? item[field].length : null}> + {fileList} + ); +}; + +FileReadOnlyList.propTypes = { + formProfile: PropTypes.object, + item: PropTypes.object, + createLink: PropTypes.func, + files: PropTypes.array, + field: PropTypes.string, + noToggle: PropTypes.bool, +}; + +FileReadOnlyList.defaultProps = {field: 'files'}; + +export default FileReadOnlyList; diff --git a/client/components/UI/index.jsx b/client/components/UI/index.jsx index 469cc8da9..b0bc291d0 100644 --- a/client/components/UI/index.jsx +++ b/client/components/UI/index.jsx @@ -21,6 +21,7 @@ import ButtonList from './ButtonList'; import IconButton from './IconButton'; import Icon from './Icon'; import IconMix from './IconMix'; +import FileReadOnlyList from './FileReadOnlyList'; export { List, @@ -46,4 +47,5 @@ export { IconButton, Icon, IconMix, + FileReadOnlyList, }; diff --git a/client/utils/planning.js b/client/utils/planning.js index 70e4930ed..271b4399d 100644 --- a/client/utils/planning.js +++ b/client/utils/planning.js @@ -637,6 +637,7 @@ const getCoverageReadOnlyFields = ( newsCoverageStatus: true, scheduled: readOnly || get(addNewsItemToPlanning, 'state') === 'published', flags: scheduledUpdatesExist, + files: true, }; } @@ -661,6 +662,7 @@ const getCoverageReadOnlyFields = ( newsCoverageStatus: true, scheduled: readOnly, flags: true, + files: readOnly, }; case ASSIGNMENTS.WORKFLOW_STATE.IN_PROGRESS: case ASSIGNMENTS.WORKFLOW_STATE.SUBMITTED: @@ -674,6 +676,7 @@ const getCoverageReadOnlyFields = ( newsCoverageStatus: true, scheduled: readOnly, flags: true, + files: readOnly, }; case ASSIGNMENTS.WORKFLOW_STATE.COMPLETED: return { @@ -686,6 +689,7 @@ const getCoverageReadOnlyFields = ( newsCoverageStatus: true, scheduled: readOnly, flags: true, + files: readOnly, }; case ASSIGNMENTS.WORKFLOW_STATE.CANCELLED: return { @@ -698,6 +702,7 @@ const getCoverageReadOnlyFields = ( newsCoverageStatus: true, scheduled: true, flags: true, + files: readOnly, }; case null: default: @@ -711,6 +716,7 @@ const getCoverageReadOnlyFields = ( newsCoverageStatus: readOnly, scheduled: readOnly, flags: scheduledUpdatesExist, + files: readOnly, }; } }; @@ -988,7 +994,7 @@ const isFeaturedPlanningUpdatedAfterPosting = (item) => { }; const shouldFetchFilesForPlanning = (planning) => ( - get(planning, 'files', []).filter((f) => typeof (f) === 'string' + self.getPlanningFiles(planning).filter((f) => typeof (f) === 'string' || f instanceof String).length > 0 ); @@ -1035,6 +1041,21 @@ const getActiveCoverage = (updatedCoverage, newsCoverageStatus) => { return coverage; }; +const getPlanningFiles = (planning) => { + let filesToFetch = get(planning, 'files') || []; + + (get(planning, 'coverages') || []).forEach((c) => { + if ((c.planning.files || []).length) { + filesToFetch = [ + ...filesToFetch, + ...c.planning.files, + ]; + } + }); + + return filesToFetch; +}; + // eslint-disable-next-line consistent-this const self = { canSpikePlanning, @@ -1086,6 +1107,7 @@ const self = { getActiveCoverage, canAddScheduledUpdateToWorkflow, getDefaultCoverageStatus, + getPlanningFiles, }; export default self; diff --git a/server/features/planning_files.feature b/server/features/planning_files.feature new file mode 100644 index 000000000..cf4736019 --- /dev/null +++ b/server/features/planning_files.feature @@ -0,0 +1,95 @@ +Feature: Planning Files + + @auth + Scenario: Delete fails if a file is used by planning item + When we upload a file "bike.jpg" to "/planning_files" + Then we get an event file reference + When we get "planning_files/" + Then we get list with 1 items + """ + {"_items": [{ "_id": "#planning_files._id#" }]} + """ + When we post to "planning" + """ + { + "slugline": "file test", + "planning_date": "2016-01-02", + "type": "planning", + "files": ["#planning_files._id#"] + } + """ + Then we get OK response + When we get "/planning/#planning._id#" + Then we get existing resource + """ + { + "_id": "#planning._id#", + "slugline": "file test", + "type": "planning", + "files": ["#planning_files._id#"] + } + """ + When we delete "planning_files/#planning_files._id#" + Then we get error 403 + + @auth + Scenario: Delete fails if a file is used by a coverage + When we upload a file "bike.jpg" to "/planning_files" + Then we get an event file reference + When we get "planning_files/" + Then we get list with 1 items + """ + {"_items": [{ "_id": "#planning_files._id#" }]} + """ + When we post to "planning" + """ + { + "slugline": "file test", + "planning_date": "2016-01-02", + "type": "planning", + "files": ["#planning_files._id#"], + "coverages": [ + { + "workflow_status": "draft", + "news_coverage_status": { + "qcode": "ncostat:int" + }, + "planning": { + "ednote": "test coverage, I want 250 words", + "headline": "test headline", + "slugline": "test slugline", + "files": ["#planning_files._id#"] + } + } + ] + } + """ + Then we get OK response + When we get "/planning/#planning._id#" + Then we get existing resource + """ + { + "_id": "#planning._id#", + "slugline": "file test", + "type": "planning", + "files": ["#planning_files._id#"], + "coverages": [ + { + "workflow_status": "draft", + "news_coverage_status": { + "qcode": "ncostat:int" + }, + "planning": { + "ednote": "test coverage, I want 250 words", + "headline": "test headline", + "slugline": "test slugline", + "files": ["#planning_files._id#"] + } + } + ] + + } + """ + When we delete "planning_files/#planning_files._id#" + Then we get error 403 + diff --git a/server/planning/output_formatters/json_planning.py b/server/planning/output_formatters/json_planning.py index b917cc9e8..8d08c82ca 100644 --- a/server/planning/output_formatters/json_planning.py +++ b/server/planning/output_formatters/json_planning.py @@ -38,6 +38,7 @@ def __init__(self): # fields to be removed from coverage remove_coverage_fields = ('original_creator', 'version_creator', 'assigned_to', 'flags') + remove_coverage_planning_fields = ('contact_info', 'files') def can_format(self, format_type, article): if article.get('flags', {}).get('marked_for_not_publication', False): @@ -63,8 +64,9 @@ def _format_item(self, item): for f in self.remove_coverage_fields: coverage.pop(f, None) - # Remove contacts field in coverage - coverage.get('planning').pop('contact_info', None) + for key in (coverage.get('planning') or {}).keys(): + if key in self.remove_coverage_planning_fields: + coverage['planning'].pop(key, None) output_item['agendas'] = self._expand_agendas(item) return output_item diff --git a/server/planning/planning/planning.py b/server/planning/planning/planning.py index 59beb5d65..cf7939579 100644 --- a/server/planning/planning/planning.py +++ b/server/planning/planning/planning.py @@ -1067,6 +1067,12 @@ def on_event_converted_to_recurring(self, updates, original): 'item_class': {'type': 'string', 'mapping': not_analyzed}, 'item_count': {'type': 'string', 'mapping': not_analyzed}, 'scheduled': {'type': 'datetime'}, + 'files': { + 'type': 'list', + 'nullable': True, + 'schema': Resource.rel('planning_files'), + 'mapping': not_analyzed, + }, 'service': { 'type': 'list', 'mapping': { diff --git a/server/planning/planning/planning_files.py b/server/planning/planning/planning_files.py index 51d210acf..0d4a0614a 100644 --- a/server/planning/planning/planning_files.py +++ b/server/planning/planning/planning_files.py @@ -53,6 +53,10 @@ def on_created(self, docs): doc['media']['name'] = doc['media']['name'].split('/')[1] def on_delete(self, doc): - plannings_using_file = get_resource_service("planning").find(where={'files': doc.get("_id")}) + find_clause = { + '$or': [{'files': doc.get("_id")}, + {'coverages.planning.files': doc.get("_id")}], + } + plannings_using_file = get_resource_service("planning").find(where=find_clause) if plannings_using_file.count() > 0: - raise SuperdeskApiError.forbiddenError('Delete failed. File still used by other events.') + raise SuperdeskApiError.forbiddenError('Delete failed. File still used by other planning items.') diff --git a/server/planning/planning/planning_types.py b/server/planning/planning/planning_types.py index 28dbecc0a..118bf52fe 100644 --- a/server/planning/planning/planning_types.py +++ b/server/planning/planning/planning_types.py @@ -198,6 +198,7 @@ class CoverageSchema(BaseSchema): 'news_coverage_status': {'enabled': True}, 'contact_info': {'enabled': False}, 'flags': {'enabled': True}, + 'files': {'enabled': True}, }, 'schema': dict(CoverageSchema) }, { diff --git a/server/planning/planning_notifications.py b/server/planning/planning_notifications.py index b59d54e4f..e19f937cb 100644 --- a/server/planning/planning_notifications.py +++ b/server/planning/planning_notifications.py @@ -269,6 +269,14 @@ def _send_user_email(user_id, contact_id, text_message, html_message, data): fp = media.read() attachments.append(Attachment(filename=media.name, content_type=media.content_type, data=fp)) + if data.get('assignment') and (data['assignment'].get('planning', {})).get('files'): + for file_id in data['assignment']['planning']['files']: + assignment_file = superdesk.get_resource_service('planning_files').find_one(req=None, _id=file_id) + if assignment_file: + media = app.media.get(assignment_file['media'], resource='planning_files') + fp = media.read() + attachments.append(Attachment(filename=media.name, content_type=media.content_type, data=fp)) + send_email(subject='Superdesk assignment' + ': {}'.format(data.get('slugline') if data.get('slugline') else ''), sender=admins[0], recipients=[email_address],