diff --git a/client/.npmrc b/client/.npmrc index cafe685..43c97e7 100644 --- a/client/.npmrc +++ b/client/.npmrc @@ -1 +1 @@ -package-lock=true +package-lock=false diff --git a/client/extensions/tga-sign-off/src/components/SignOffListItem.tsx b/client/extensions/tga-sign-off/src/components/SignOffListItem.tsx new file mode 100644 index 0000000..77d7d2b --- /dev/null +++ b/client/extensions/tga-sign-off/src/components/SignOffListItem.tsx @@ -0,0 +1,111 @@ +import * as React from 'react'; + +import {IUser} from 'superdesk-api'; +import {superdesk} from '../superdesk'; + +import {ButtonGroup, Button, ContentDivider} from 'superdesk-ui-framework/react'; +import {UserDetail} from './UserDetail'; + +interface IPropsBase { + state: 'approved' | 'pending' | 'not_sent'; + user: IUser; + readOnly?: boolean; + appendContentDivider?: boolean; + buttonProps?: Array>; +} + +interface IPropsApproved extends IPropsBase { + state: 'approved'; + email: string; + date: string; +} + +interface IPropsPendingOrExpired extends IPropsBase { + state: 'pending'; + date: string; +} + +interface IPropsNotSend extends IPropsBase { + state: 'not_sent'; +} + +type IProps = IPropsApproved | IPropsPendingOrExpired | IPropsNotSend; + +export function SignOffListItem(props: IProps) { + const {formatDateTime, gettext} = superdesk.localization; + + return ( + +
+ + {props.state === 'not_sent' ? null : ( +
+ + {formatDateTime(new Date(props.date))} +
+ )} + + {props.buttonProps == null ? null : ( + + {props.buttonProps.length === 1 ? ( + +
+ )} +
+ )} + + {props.state !== 'approved' ? null : ( + +
+ + {props.email.trim()} +
+
+ )} + + {props.state !== 'pending' ? null : ( +
+ + {new Date(props.date) <= new Date() ? ( + {gettext('Expired')} + ): ( + {gettext('Pending')} + )} +
+ )} + {props.state !== 'not_sent' ? null : ( +
+ + {gettext('Not Sent')} +
+ )} + + {props.appendContentDivider !== true ? null : ( + + )} + + ); +} diff --git a/client/extensions/tga-sign-off/src/components/SignOffRequestDetails.tsx b/client/extensions/tga-sign-off/src/components/SignOffRequestDetails.tsx new file mode 100644 index 0000000..d541aa6 --- /dev/null +++ b/client/extensions/tga-sign-off/src/components/SignOffRequestDetails.tsx @@ -0,0 +1,24 @@ +import * as React from 'react'; + +import {IUser} from 'superdesk-api'; +import {IPublishSignOff} from '../interfaces'; +import {superdesk} from '../superdesk'; + +interface IProps { + publishSignOff: IPublishSignOff; + user: IUser; +} + +export function SignOffRequestDetails(props: IProps) { + const {gettext, formatDateTime, longFormatDateTime} = superdesk.localization; + + return ( +
+ {gettext('Request last sent')}  + +  {gettext('by')} {props.user.display_name} +
+ ); +} diff --git a/client/extensions/tga-sign-off/src/components/UserDetail.tsx b/client/extensions/tga-sign-off/src/components/UserDetail.tsx new file mode 100644 index 0000000..1cfbc5f --- /dev/null +++ b/client/extensions/tga-sign-off/src/components/UserDetail.tsx @@ -0,0 +1,26 @@ +import * as React from 'react'; + +import {IUser} from 'superdesk-api'; +import {superdesk} from "../superdesk"; + + +interface IProps { + user: IUser; + label: string; +} + +export function UserDetail(props: IProps) { + const {UserAvatar} = superdesk.components; + + return ( + +
+ +
+
+ + {props.user.display_name} +
+
+ ); +} diff --git a/client/extensions/tga-sign-off/src/components/details.tsx b/client/extensions/tga-sign-off/src/components/details.tsx deleted file mode 100644 index b71fae0..0000000 --- a/client/extensions/tga-sign-off/src/components/details.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import * as React from 'react'; - -import {IUser} from 'superdesk-api'; -import {IUserSignOff} from '../interfaces'; -import {superdesk} from '../superdesk'; - -interface IProps { - signOff?: IUserSignOff | null; -} - -interface IState { - user?: IUser; -} - -export class SignOffDetails extends React.Component { - constructor(props: IProps) { - super(props); - - this.state = {user: undefined}; - } - - componentDidMount() { - this.loadUser(); - } - - componentDidUpdate(prevProps: Readonly) { - if (prevProps.signOff?.user_id !== this.props.signOff?.user_id) { - this.loadUser(); - } - } - - loadUser() { - if (this.props.signOff?.user_id == null) { - this.setState({user: undefined}); - } else { - const {getUsersByIds} = superdesk.entities.users; - - getUsersByIds([this.props.signOff.user_id]).then((users) => { - this.setState({user: users[0]}); - }) - } - } - - render() { - const {UserAvatar} = superdesk.components; - const {formatDateTime} = superdesk.localization; - - return this.state.user == null ? null : ( - -
- -
-
- - {this.state.user.display_name} -
- {this.props.signOff?.sign_date == null ? null : ( -
- - {formatDateTime(new Date(this.props.signOff.sign_date))} -
- )} -
- ); - } -} diff --git a/client/extensions/tga-sign-off/src/components/fields/editor.tsx b/client/extensions/tga-sign-off/src/components/fields/editor.tsx index 6de18b2..8b7fb7a 100644 --- a/client/extensions/tga-sign-off/src/components/fields/editor.tsx +++ b/client/extensions/tga-sign-off/src/components/fields/editor.tsx @@ -1,117 +1,238 @@ import * as React from 'react'; -import {IEditorProps} from '../../interfaces'; +import {IUser} from 'superdesk-api'; +import {IEditorProps, IAuthorSignOffData, IPublishSignOff} from '../../interfaces'; import {superdesk} from '../../superdesk'; -import {hasUserSignedOff} from '../../utils'; +import { + hasUserSignedOff, + getListAuthorIds, + loadUsersFromPublishSignOff, + getSignOffDetails, + viewSignOffApprovalForm, +} from '../../utils'; -import {Button, ButtonGroup} from 'superdesk-ui-framework/react'; -import {getUserSignOffModal} from '../modal'; -import {SignOffDetails} from '../details'; +import {Button, ToggleBox} from 'superdesk-ui-framework/react'; +import {SignOffListItem} from '../SignOffListItem'; +import {SignOffRequestDetails} from '../SignOffRequestDetails'; interface IState { - showModal: boolean; + users: {[userId: string]: IUser}; } export class UserSignOffField extends React.Component { constructor(props: IEditorProps) { super(props); - this.showModal = this.showModal.bind(this); - this.removeSignOff = this.removeSignOff.bind(this); + this.state = {users: {}}; - this.state = {showModal: false}; + this.removeSignOff = this.removeSignOff.bind(this); } componentDidMount() { - if (this.props.value?.user_id == null) { - this.props.setValue({ - consent_publish: false, - consent_disclosure: false, - }); - } + this.reloadUsers(); } - showModal() { - const {showModal} = superdesk.ui; - const {getCurrentUser} = superdesk.session; + componentDidUpdate() { + const {signOffIds, unsentAuthorIds} = getSignOffDetails(this.props.item, this.state.users); + const userIds = signOffIds.concat(unsentAuthorIds); + let reloadUsers = false; + + for (let i = 0; i < userIds.length; i++) { + if (this.state.users[userIds[i]] == null) { + reloadUsers = true; + break; + } + } - getCurrentUser().then((currentUser) => { - const Modal = getUserSignOffModal(this.props, currentUser); - showModal(Modal); - }) + if (reloadUsers) { + this.reloadUsers(); + } } - removeSignOff() { - const {confirm} = superdesk.ui; - - confirm('Are you sure you want to remove this publishing sign off?', 'Remove publishing sign off') - .then((response) => { - if (response) { - this.props.setValue({ - consent_publish: false, - consent_disclosure: false, - user_id: null, - funding_source: null, - affiliation: null, - sign_date: null, - }); - } - }) + reloadUsers() { + loadUsersFromPublishSignOff(this.props.item).then((users) => { + this.setState({users: users}); + }); } - render() { + sendSignOff(authorIds?: Array) { + const {notify} = superdesk.ui; + const {gettext} = superdesk.localization; + const {signOffIds} = getSignOffDetails(this.props.item, this.state.users); + + if (authorIds == null) { + authorIds = getListAuthorIds(this.props.item) + .filter((authorId) => !signOffIds.includes(authorId)); + } + + if (authorIds.length === 0) { + notify.error( + signOffIds.length === 0 ? + gettext('Unable to send email(s), list of authors is empty!') : + gettext('All authors have already signed off.') + ); + return; + } + + superdesk.httpRequestJsonLocal({ + method: 'POST', + path: '/sign_off_request', + payload: { + item_id: this.props.item._id, + authors: authorIds, + } + }).then(() => { + notify.success(gettext('Sign off request email(s) sent')); + }, (error) => { + notify.error(gettext('Failed to send sign off request email(s). {{ error }}', error)); + }); + } + + removeSignOff(signOffData: IAuthorSignOffData) { + const {confirm, notify} = superdesk.ui; const {gettext} = superdesk.localization; - const {getCurrentUserId} = superdesk.session; + const publishSignOff: IPublishSignOff | undefined = this.props.item.extra?.publish_sign_off; - const isSameUser = getCurrentUserId() === this.props.value?.user_id; + if (publishSignOff == null) { + notify.error(gettext('Unable to remove sign off, no sign offs found')); + return; + } + + confirm( + gettext('Are you sure you want to remove this publishing sign off?'), + gettext('Remove publishing sign off') + ).then((response) => { + if (response) { + superdesk.httpRequestVoidLocal({ + method: 'DELETE', + path: `/sign_off_request/${this.props.item._id}/${signOffData.user_id}`, + }).then(() => { + notify.success(gettext('Publishing sign off removed')); + }).catch((error: string) => { + notify.error(gettext('Failed to remove sign off. {{ error }}', {error})) + }); + } + }); + } + + render() { + const {gettext,} = superdesk.localization; + const { + publishSignOff, + unsentAuthorIds, + pendingReviews, + requestUser, + } = getSignOffDetails(this.props.item, this.state.users); return (
-
- - {!hasUserSignedOff(this.props.value) ? ( + {hasUserSignedOff(this.props.item) === true || this.props.readOnly ? null : ( +
- {!this.props.value?.funding_source?.length ? null : ( -
- - {this.props.value.funding_source}
)} - {!this.props.value?.affiliation?.length ? null : ( -
- - {this.props.value.affiliation} -
+ {requestUser == null || publishSignOff?.request_sent == null ? null : ( + + )} + + {publishSignOff?.sign_offs == null || publishSignOff.sign_offs.length === 0 ? null : ( + + {publishSignOff.sign_offs.map((signOffData, index) => ( + this.state.users[signOffData.user_id] == null ? null : ( + + ) + ))} + + )} + + {(pendingReviews.length + unsentAuthorIds.length) === 0 ? null : ( + + {pendingReviews.map((pendingReview) => ( + this.state.users[pendingReview.user_id] == null ? null : ( + + ) + ))} + + {unsentAuthorIds.map((authorId) => ( + this.state.users[authorId] == null ? null : ( + + ) + ))} + )}
); diff --git a/client/extensions/tga-sign-off/src/components/fields/preview.tsx b/client/extensions/tga-sign-off/src/components/fields/preview.tsx index 3dbaf2f..ca24055 100644 --- a/client/extensions/tga-sign-off/src/components/fields/preview.tsx +++ b/client/extensions/tga-sign-off/src/components/fields/preview.tsx @@ -1,50 +1,141 @@ import * as React from 'react'; -import {IPreviewComponentProps} from 'superdesk-api'; -import {IUserSignOff} from '../../interfaces'; +import {IPreviewComponentProps, IUser} from 'superdesk-api'; +import {IAuthorSignOffData} from '../../interfaces'; import {superdesk} from '../../superdesk'; -import {hasUserSignedOff} from '../../utils'; +import {loadUsersFromPublishSignOff, getSignOffDetails, viewSignOffApprovalForm} from '../../utils'; import {IconLabel, ToggleBox} from 'superdesk-ui-framework/react'; -import {SignOffDetails} from '../details'; +import {SignOffListItem} from '../SignOffListItem'; +import {SignOffRequestDetails} from '../SignOffRequestDetails'; -type IProps = IPreviewComponentProps; +type IProps = IPreviewComponentProps; +type ISignOffState = 'completed' | 'partially' | 'none'; + +function getSignOffStateLabel(state: ISignOffState): string { + const {gettext} = superdesk.localization; + + switch (state) { + case 'completed': + return gettext('Signed Off'); + case 'partially': + return gettext('Partially Signed Off'); + case 'none': + return gettext('Not Signed Off'); + } +} + +interface IState { + users: {[userId: string]: IUser}; +} + +export class UserSignOffPreview extends React.PureComponent { + constructor(props: IProps) { + super(props); + + this.state = {users: {}}; + } + + componentDidMount() { + loadUsersFromPublishSignOff(this.props.item).then((users) => { + this.setState({users: users}); + }); + } -export class UserSignOffPreview extends React.PureComponent { render() { const {gettext} = superdesk.localization; - const isSignedOff = hasUserSignedOff(this.props.value); + const { + publishSignOff, + unsentAuthorIds, + pendingReviews, + requestUser, + } = getSignOffDetails(this.props.item, this.state.users); + + let signOffState: ISignOffState = 'none'; + + if (publishSignOff != null) { + signOffState = (unsentAuthorIds.length + pendingReviews.length) === 0 ? 'completed' : 'partially'; + } return (
- - + + {requestUser == null || publishSignOff?.request_sent == null ? null : ( + -
- -
- {!this.props.value?.funding_source?.length ? null : ( -
- - {this.props.value.funding_source} -
- )} - {!this.props.value?.affiliation?.length ? null : ( -
- - {this.props.value.affiliation} -
- )} -
+ )} + + {publishSignOff?.sign_offs == null || publishSignOff.sign_offs.length === 0 ? null : ( + + {publishSignOff.sign_offs.map((signOffData, index) => ( + this.state.users[signOffData.user_id] == null ? null : ( + + ) + ))} + + )} + + {(pendingReviews.length + unsentAuthorIds.length) === 0 ? null : ( + + {pendingReviews.map((pendingReview) => ( + this.state.users[pendingReview.user_id] == null ? null : ( + + ) + ))} + {unsentAuthorIds.map((authorId) => ( + this.state.users[authorId] == null ? null : ( + + ) + ))} + + )}
); } diff --git a/client/extensions/tga-sign-off/src/components/fields/template.tsx b/client/extensions/tga-sign-off/src/components/fields/template.tsx index 39c236c..e1b7a90 100644 --- a/client/extensions/tga-sign-off/src/components/fields/template.tsx +++ b/client/extensions/tga-sign-off/src/components/fields/template.tsx @@ -4,7 +4,6 @@ import {IEditorProps} from '../../interfaces'; import {superdesk} from '../../superdesk'; import {Button} from 'superdesk-ui-framework/react'; -import {SignOffDetails} from '../details'; export class UserSignOffTemplate extends React.PureComponent { render() { @@ -13,11 +12,10 @@ export class UserSignOffTemplate extends React.PureComponent { return (
-
- - - ); - } - } -} diff --git a/client/extensions/tga-sign-off/src/interfaces.ts b/client/extensions/tga-sign-off/src/interfaces.ts index b313fb6..9ab2b13 100644 --- a/client/extensions/tga-sign-off/src/interfaces.ts +++ b/client/extensions/tga-sign-off/src/interfaces.ts @@ -1,14 +1,46 @@ -import {IEditorComponentProps} from 'superdesk-api'; +import {IEditorComponentProps, IUser} from 'superdesk-api'; -export interface IUserSignOff { - user_id?: string | null; - sign_date?: string | null; +export interface IAuthorSignOffData { + user_id: IUser['_id']; + sign_date: string; + version_signed: number; - funding_source?: string | null; - affiliation?: string | null; + article_name: string; + funding_source: string; + affiliation: string; + copyright_terms: string; - consent_publish: boolean; - consent_disclosure: boolean; + author: { + name: string; + title: string; + institute: string; + email: string; + country: string; + orcid_id?: string; + }; + + warrants: { + no_copyright_infringements: boolean; + indemnify_360_against_loss: boolean; + ready_for_publishing: boolean; + }; + consent: { + signature: string; + contact: boolean; + personal_information: boolean; + multimedia_usage: boolean; + }; +} + +export interface IPublishSignOff { + requester_id: IUser['_id']; + request_sent: string; // datetime string + pending_reviews: Array<{ + user_id: IUser['_id']; + sent: string; // datetime string + expires: string; // datetime string + }>; + sign_offs: Array; } -export type IEditorProps = IEditorComponentProps; +export type IEditorProps = IEditorComponentProps; diff --git a/client/extensions/tga-sign-off/src/utils.ts b/client/extensions/tga-sign-off/src/utils.ts index 7b0c520..de2107b 100644 --- a/client/extensions/tga-sign-off/src/utils.ts +++ b/client/extensions/tga-sign-off/src/utils.ts @@ -1,10 +1,115 @@ -import {IUserSignOff} from "./interfaces"; - -export function hasUserSignedOff(signOff: IUserSignOff | null): boolean { - return signOff != null && - signOff.user_id != null && - signOff.consent_publish && - signOff.consent_disclosure && - (signOff.funding_source ?? '').trim().length > 0 && - (signOff.affiliation ?? '').trim().length > 0; +import {IArticle, IUser} from 'superdesk-api'; +import {IPublishSignOff} from './interfaces'; +import {superdesk} from './superdesk'; + +export function hasUserSignedOff(item: IArticle): boolean { + const publishSignOff: IPublishSignOff | undefined = item.extra?.publish_sign_off; + + if (publishSignOff == null || (publishSignOff?.sign_offs ?? []).length === 0) { + return false; + } + + const authorIds = getListAuthorIds(item); + const signOffIds = publishSignOff.sign_offs.map((signOff) => signOff.user_id); + + if (signOffIds.length > 0) { + // Make sure that all Author IDs have been signed off + for (let i = 0; i < authorIds.length; i++) { + if (!signOffIds.includes(authorIds[i])) { + return false; + } + } + + for (let i = 0; i < publishSignOff.sign_offs.length; i++) { + const signOff = publishSignOff.sign_offs[i]; + const {warrants, consent} = signOff; + + if (warrants.no_copyright_infringements !== true || + warrants.indemnify_360_against_loss !== true || + warrants.ready_for_publishing !== true + ) { + return false; + } else if (consent.contact !== true || + consent.personal_information !== true || + consent.multimedia_usage != true + ) { + return false; + } else if (signOff.funding_source.trim().length === 0 || signOff.affiliation.trim().length === 0) { + return false; + } + } + + return true; + } + + return false; +} + +export function getListAuthorIds(item: IArticle): Array { + // @ts-ignore + return (item.authors ?? []) + .map((author) => author.parent) + .filter((authorId) => authorId != null); +} + +export function loadUsersFromPublishSignOff(item: IArticle): Promise<{[userId: string]: IUser}> { + const publishSignOff: IPublishSignOff | undefined = item.extra?.publish_sign_off; + const userIds = getListAuthorIds(item).concat( + (publishSignOff?.sign_offs ?? []).map((signOff) => signOff.user_id) + ).concat( + (publishSignOff?.pending_reviews ?? []).map((review) => review.user_id) + ); + + if (userIds.length === 0) { + return Promise.resolve({}); + } + + return superdesk.entities.users.getUsersByIds(userIds).then((userArray) => { + return userArray.reduce<{[userId: string]: IUser}>((users, user) => { + users[user._id] = user; + + return users; + }, {}); + }); +} + +interface ISignOffUserDetails { + publishSignOff?: IPublishSignOff; + signOffIds: Array; + unsentAuthorIds: Array; + pendingReviews: IPublishSignOff['pending_reviews']; + requestUser?: IUser; +} + +export function getSignOffDetails(item: IArticle, users: {[userId: string]: IUser}): ISignOffUserDetails { + const publishSignOff: IPublishSignOff | undefined = item.extra?.publish_sign_off; + const signOffIds: Array = ( + (publishSignOff?.sign_offs ?? []).map((signOff) => signOff.user_id) + ).concat( + (publishSignOff?.pending_reviews ?? []).map((pendingReview) => pendingReview.user_id) + ); + const unsentAuthorIds = getListAuthorIds(item) + .filter((authorId) => !signOffIds.includes(authorId)); + const pendingReviews = (publishSignOff?.pending_reviews ?? []); + const requestUser = publishSignOff?.requester_id == null ? + undefined : + users[publishSignOff.requester_id] + + return { + publishSignOff, + signOffIds, + unsentAuthorIds, + pendingReviews, + requestUser, + }; +} + +export function viewSignOffApprovalForm(itemId: IArticle['_id'], userId: IUser['_id']) { + const baseUrl = superdesk.instance.config.server.url; + + window.open( + `${baseUrl}/sign_off_requests/${itemId}/${userId}/view`, + 'signOffForm', + 'popup' + ); } diff --git a/client/package-lock.json b/client/package-lock.json index fab21fb..3a8f5f6 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -4,11 +4,11 @@ "lockfileVersion": 1, "dependencies": { "@babel/code-frame": { - "version": "7.22.13", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.13.tgz", - "integrity": "sha512-XktuhWlJ5g+3TJXc5upd9Ks1HutSArik6jf2eAjYFyIOf4ej3RN+184cZbzDvbPnuTJIUhPKKJE3cIsYTiAT3w==", + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.23.4.tgz", + "integrity": "sha512-r1IONyb6Ia+jYR2vvIDhdWdlTGhqbBoFqLTQidzZ4kepUFH15ejXvFHxCVbtl7BOXIudsIubf4E81xeA3h3IXA==", "requires": { - "@babel/highlight": "^7.22.13", + "@babel/highlight": "^7.23.4", "chalk": "^2.4.2" }, "dependencies": { @@ -46,9 +46,9 @@ "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==" }, "@babel/highlight": { - "version": "7.22.20", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.22.20.tgz", - "integrity": "sha512-dkdMCN3py0+ksCgYmGG8jKeGA/8Tk+gJwSYYlFGxG5lmhfKNoAy004YpLxpS1W2J8m/EK2Ew+yOs9pVRwO89mg==", + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.23.4.tgz", + "integrity": "sha512-acGdbYSfp2WheJoJm/EBBBLh/ID8KDc64ISZ9DYtBmC8/Q204PZJLHyzeB5qMzJ5trcOkybd78M4x2KWsUq++A==", "requires": { "@babel/helper-validator-identifier": "^7.22.20", "chalk": "^2.4.2", @@ -84,14 +84,14 @@ } }, "@babel/parser": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.0.tgz", - "integrity": "sha512-vvPKKdMemU85V9WE/l5wZEmImpCtLqbnTvqDS2U1fJ96KrxoW7KrXhNsNCblQlg8Ck4b85yxdTyelsMUgFUXiw==" + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.4.tgz", + "integrity": "sha512-vf3Xna6UEprW+7t6EtOmFpHNAuxw3xqPZghy+brsnusscJRW5BMUzzHZc5ICjULee81WeUV2jjakG09MDglJXQ==" }, "@babel/runtime": { - "version": "7.23.2", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.2.tgz", - "integrity": "sha512-mM8eg4yl5D6i3lu2QKPuPH4FArvJ8KhTofbE7jwMUv9KX5mBvwPAqnV3MlyBNqdp9RyRKP6Yck8TrfYrPvX3bg==", + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.4.tgz", + "integrity": "sha512-2Yv65nlWnWlSpe3fXEyX5i7fx5kIKo4Qbcj+hMO0odwaneFjfXw5fdum+4yL20O0QiaHpia0cYQ9xpNMqrBwHg==", "requires": { "regenerator-runtime": "^0.14.0" }, @@ -320,9 +320,9 @@ "integrity": "sha512-D3KB0PdaxdwtA44yOpK+NtptTscKWgUzXmf8fiLaaVxnX+b7QQ+dUMsyeVDweCQ6VX4PMwkd6x2hJ0X+ISIsoQ==" }, "@types/cheerio": { - "version": "0.22.33", - "resolved": "https://registry.npmjs.org/@types/cheerio/-/cheerio-0.22.33.tgz", - "integrity": "sha512-XUlu2BK4q3xJsccRLK69m/cABZd7m60o+cDEPUTG6jTpuG2vqN35UioeF99MQ/HoSOEPq0Bgil8g3jtzE0oH9A==", + "version": "0.22.35", + "resolved": "https://registry.npmjs.org/@types/cheerio/-/cheerio-0.22.35.tgz", + "integrity": "sha512-yD57BchKRvTV+JD53UZ6PD8KWY5g5rvvMLRnZR3EQBCZXiDT/HR+pKpMzFGlWNhFrXlo7VPZXtKvIEwZkAWOIA==", "requires": { "@types/node": "*" } @@ -346,9 +346,9 @@ } }, "@types/enzyme-adapter-react-16": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/enzyme-adapter-react-16/-/enzyme-adapter-react-16-1.0.8.tgz", - "integrity": "sha512-nzYumRvxSh2Vbk7mkseYbInmpMbVZRc1EJQVeQmXhNCNVmLg2VOKAJujc7YJ/gDdPW+X03wYrDGa1Bj0Q/NTaw==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/enzyme-adapter-react-16/-/enzyme-adapter-react-16-1.0.9.tgz", + "integrity": "sha512-z24MMxGtUL8HhXdye3tWzjp+19QTsABqLaX2oOZpxMPHRJgLfahQmOeTTrEBQd9ogW20+UmPBXD9j+XOasFHvw==", "requires": { "@types/enzyme": "*" } @@ -369,18 +369,18 @@ } }, "@types/hoist-non-react-statics": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.4.tgz", - "integrity": "sha512-ZchYkbieA+7tnxwX/SCBySx9WwvWR8TaP5tb2jRAzwvLb/rWchGw3v0w3pqUbUvj0GCwW2Xz/AVPSk6kUGctXQ==", + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.5.tgz", + "integrity": "sha512-SbcrWzkKBw2cdwRTwQAswfpB9g9LJWfjtUeW/jvNwbhC8cpmmNYVePa+ncbUe0rGTQ7G3Ff6mYUN2VMfLVr+Sg==", "requires": { "@types/react": "*", "hoist-non-react-statics": "^3.3.0" } }, "@types/json-schema": { - "version": "7.0.14", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.14.tgz", - "integrity": "sha512-U3PUjAudAdJBeC2pgN8uTIKgxrb4nlDF3SF0++EldXQvQBGkpFZMSnwQiIoDU77tv45VgNkl/L4ouD+rEomujw==" + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==" }, "@types/lodash": { "version": "4.14.117", @@ -394,9 +394,9 @@ "dev": true }, "@types/node": { - "version": "20.8.9", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.8.9.tgz", - "integrity": "sha512-UzykFsT3FhHb1h7yD4CA4YhBHq545JC0YnEz41xkipN88eKQtL6rSgocL5tbAP6Ola9Izm/Aw4Ora8He4x0BHg==", + "version": "20.10.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.10.0.tgz", + "integrity": "sha512-D0WfRmU9TQ8I9PFx9Yc+EBHw+vSpIub4IDvQivcp26PtPrdMGAq5SDcpXEo/epqa/DXotVpekHiLNTg3iaKXBQ==", "requires": { "undici-types": "~5.26.4" } @@ -408,9 +408,9 @@ "dev": true }, "@types/prop-types": { - "version": "15.7.9", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.9.tgz", - "integrity": "sha512-n1yyPsugYNSmHgxDFjicaI2+gCNjsBck8UX9kuofAKlc0h1bL+20oSF72KeNaW2DUlesbEVCFgyV2dPGTiY42g==" + "version": "15.7.11", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.11.tgz", + "integrity": "sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==" }, "@types/react": { "version": "16.8.23", @@ -1446,16 +1446,16 @@ }, "dependencies": { "deep-equal": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.1.tgz", - "integrity": "sha512-yd9c5AdiqVcR+JjcwUQb9DkhJc8ngNr0MahEBGvDiJw8puWab2yZlh+nkasOnZP+EGTAP6rRp2JzJhJZzvNF8g==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.2.tgz", + "integrity": "sha512-5tdhKF6DbU7iIzrIOa1AOUt39ZRm13cmL1cGEh//aqR8x9+tNfbywRf0n5FD/18OKMdo7DNEtrX2t22ZAkI+eg==", "requires": { - "is-arguments": "^1.0.4", - "is-date-object": "^1.0.1", - "is-regex": "^1.0.4", - "object-is": "^1.0.1", + "is-arguments": "^1.1.1", + "is-date-object": "^1.0.5", + "is-regex": "^1.1.4", + "object-is": "^1.1.5", "object-keys": "^1.1.1", - "regexp.prototype.flags": "^1.2.0" + "regexp.prototype.flags": "^1.5.1" } } } @@ -1720,9 +1720,9 @@ } }, "caniuse-db": { - "version": "1.0.30001554", - "resolved": "https://registry.npmjs.org/caniuse-db/-/caniuse-db-1.0.30001554.tgz", - "integrity": "sha512-dngZL2eSJEoJsxJzZslGQoiXhGiGb94GrfxBFpFHXuTROhvmQYSpQefF8W8bQzfmW27YUj2XMMXukF18fuQcMw==" + "version": "1.0.30001564", + "resolved": "https://registry.npmjs.org/caniuse-db/-/caniuse-db-1.0.30001564.tgz", + "integrity": "sha512-nNf2MZGDyVVTiy8Hrzg3kaqlZ4JIaNLQi66C8zHA0QNfkMA1XJXq9FWDReiVqOts5fXIt3aCilsmyLlFPgTTJQ==" }, "caseless": { "version": "0.12.0", @@ -2228,9 +2228,9 @@ "integrity": "sha512-J2wnb6TKniXNOtoHS8TSrG9IOQluPrsmyAJ8oCUJOBmv+uLBCyPYAZkD2jFvw2DCzIXNnISIM01NIvr35TkBMQ==" }, "component-emitter": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz", - "integrity": "sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==" + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==" }, "compressible": { "version": "2.0.18", @@ -2799,14 +2799,14 @@ "integrity": "sha512-yVn6RZmHiGnxRKR9sJb3iVV2XTF1Ghh2DiWRZ3dMnGc43yUdWWF/kX6lQyk3+P84iprfWKU/8zFTrlkvtFm1ug==" }, "deep-equal": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.2.tgz", - "integrity": "sha512-xjVyBf0w5vH0I42jdAZzOKVldmPgSulmiyPRywoyq7HXC9qdgo17kxJE+rdnif5Tz6+pIrpJI8dCpMNLIGkUiA==", + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz", + "integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==", "requires": { "array-buffer-byte-length": "^1.0.0", - "call-bind": "^1.0.2", + "call-bind": "^1.0.5", "es-get-iterator": "^1.1.3", - "get-intrinsic": "^1.2.1", + "get-intrinsic": "^1.2.2", "is-arguments": "^1.1.1", "is-array-buffer": "^3.0.2", "is-date-object": "^1.0.5", @@ -2816,11 +2816,11 @@ "object-is": "^1.1.5", "object-keys": "^1.1.1", "object.assign": "^4.1.4", - "regexp.prototype.flags": "^1.5.0", + "regexp.prototype.flags": "^1.5.1", "side-channel": "^1.0.4", "which-boxed-primitive": "^1.0.2", "which-collection": "^1.0.1", - "which-typed-array": "^1.1.9" + "which-typed-array": "^1.1.13" }, "dependencies": { "isarray": { @@ -3180,9 +3180,9 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" }, "electron-to-chromium": { - "version": "1.4.568", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.568.tgz", - "integrity": "sha512-3TCOv8+BY6Ltpt1/CmGBMups2IdKOyfEmz4J8yIS4xLSeMm0Rf+psSaxLuswG9qMKt+XbNbmADybtXGpTFlbDg==" + "version": "1.4.594", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.594.tgz", + "integrity": "sha512-xT1HVAu5xFn7bDfkjGQi9dNpMqGchUkebwf1GL7cZN32NSwwlHRPMSDJ1KN6HkS0bWUtndbSQZqvpQftKG2uFQ==" }, "elliptic": { "version": "6.5.4", @@ -7500,16 +7500,16 @@ }, "dependencies": { "deep-equal": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.1.tgz", - "integrity": "sha512-yd9c5AdiqVcR+JjcwUQb9DkhJc8ngNr0MahEBGvDiJw8puWab2yZlh+nkasOnZP+EGTAP6rRp2JzJhJZzvNF8g==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.2.tgz", + "integrity": "sha512-5tdhKF6DbU7iIzrIOa1AOUt39ZRm13cmL1cGEh//aqR8x9+tNfbywRf0n5FD/18OKMdo7DNEtrX2t22ZAkI+eg==", "requires": { - "is-arguments": "^1.0.4", - "is-date-object": "^1.0.1", - "is-regex": "^1.0.4", - "object-is": "^1.0.1", + "is-arguments": "^1.1.1", + "is-date-object": "^1.0.5", + "is-regex": "^1.1.4", + "object-is": "^1.1.5", "object-keys": "^1.1.1", - "regexp.prototype.flags": "^1.2.0" + "regexp.prototype.flags": "^1.5.1" } } } @@ -10622,9 +10622,9 @@ } }, "punycode": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz", - "integrity": "sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==" + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==" }, "q": { "version": "1.5.1", @@ -12994,9 +12994,9 @@ } }, "superdesk-ui-framework": { - "version": "3.0.62", - "resolved": "https://registry.npmjs.org/superdesk-ui-framework/-/superdesk-ui-framework-3.0.62.tgz", - "integrity": "sha512-UzEnpfpL2YoK+FpJlsXKt8ENlR02BhnBPyuaXEDebqXLK0H4WlrUMseJk9XKx6de0kFHCiCGVusP1tYyql0d3Q==", + "version": "3.0.64", + "resolved": "https://registry.npmjs.org/superdesk-ui-framework/-/superdesk-ui-framework-3.0.64.tgz", + "integrity": "sha512-pRfl3YKV6KS/RJW6RfV6Yt9dzhZj91OLpM9yWUqDNVam9pdgmWt3wut093V29otPXkfNl59xIPIvonFpjLQ//Q==", "requires": { "@material-ui/lab": "^4.0.0-alpha.56", "@popperjs/core": "^2.4.0", @@ -13581,9 +13581,9 @@ "integrity": "sha512-BLbiRkiBzAwsjut4x/dsibSTB6yWpwT5qWmC2OfuCg3GgVQCSgMs4vEctYPhsaGtd0AeuuHMkjZ2h2WG8MSzRw==" }, "ua-parser-js": { - "version": "0.7.36", - "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.36.tgz", - "integrity": "sha512-CPPLoCts2p7D8VbybttE3P2ylv0OBZEAy7a12DsulIEcAiMtWJy+PBgMXgWDI80D5UwqE8oQPHYnk13tm38M2Q==" + "version": "0.7.37", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.37.tgz", + "integrity": "sha512-xV8kqRKM+jhMvcHWUKthV9fNebIzrNy//2O9ZwWcfiBFR5f25XVZPLlEajk/sf3Ra15V92isyQqnIEXRDaZWEA==" }, "uglify-js": { "version": "2.4.6", diff --git a/server/requirements.in b/server/requirements.in index 291c0e4..d3cd072 100755 --- a/server/requirements.in +++ b/server/requirements.in @@ -1,6 +1,8 @@ gunicorn>=20.0.4,<20.1 honcho==1.0.1 slackclient==1.0.9 +WTForms==3.0.1 +Flask-WTF==1.2.1 git+https://github.com/superdesk/superdesk-core.git@v2.6.5#egg=superdesk-core git+https://github.com/superdesk/superdesk-planning.git@v2.6.3-rc1#egg=superdesk-planning diff --git a/server/requirements.txt b/server/requirements.txt index 88bba76..de001fc 100644 --- a/server/requirements.txt +++ b/server/requirements.txt @@ -107,6 +107,7 @@ flask==1.1.2 # flask-mail # flask-oidc-ex # flask-script + # flask-wtf # raven # superdesk-core flask-babel==1.0.0 @@ -117,6 +118,8 @@ flask-oidc-ex==0.5.5 # via superdesk-core flask-script==2.0.6 # via superdesk-core +flask-wtf==1.2.1 + # via -r requirements.in future==0.18.3 # via python-twitter gunicorn==20.0.4 @@ -137,6 +140,7 @@ itsdangerous==1.1.0 # via # flask # flask-oidc-ex + # flask-wtf # superdesk-core jinja2==2.11.3 # via @@ -165,6 +169,7 @@ markupsafe==2.0.1 # via # jinja2 # superdesk-core + # wtforms mongolock==1.3.4 # via superdesk-core natsort==8.4.0 @@ -294,6 +299,10 @@ wrapt==1.14.1 # via # deprecated # elastic-apm +wtforms==3.0.1 + # via + # -r requirements.in + # flask-wtf # The following packages are considered to be unsafe in a requirements file: # setuptools diff --git a/server/settings.py b/server/settings.py index a3e893a..2e58678 100644 --- a/server/settings.py +++ b/server/settings.py @@ -39,6 +39,7 @@ "analytics", "tga.signal_hooks", "tga.publish", + "tga.sign_off", ] MACROS_MODULE = env("MACROS_MODULE", "macros") @@ -196,3 +197,6 @@ "tga.content_api.items", "tga.content_api.author_profiles", ] + +SIGN_OFF_REQUESTS_SHARED_SECRET = env("SIGN_OFF_REQUESTS_SHARED_SECRET", env("AUTH_SERVER_SHARED_SECRET")) +SIGN_OFF_REQUESTS_EXPIRATION = 86400 # 1 day in seconds diff --git a/server/templates/email_sign_off_copy.html b/server/templates/email_sign_off_copy.html new file mode 100644 index 0000000..4730dbf --- /dev/null +++ b/server/templates/email_sign_off_copy.html @@ -0,0 +1,218 @@ +{% extends "email_layout.html" %} + +{% block content %} +

Hi there,

+

+

Thank you for completing the publishing approval request for the following item:

+

Slugline: {{ item.slugline }}

+

Headline: {{ item.headline }}

+

+

See below a copy of the answers you provided:

+
+ +

+ Thank you for authoring an article for 360info. We require you to agree to the following + terms and provide the information requested before we can publish your article. +

+

+ Please add the name of the article
+ {{ form.article_name.data }} +

+ +

About the article

+

+ Please provide the following information regarding the above-mentioned article. + The information you provide may be published with the article. +

+ +

Funding

+

+ Any relevant funding contributions must be disclosed, i.e. "The research was undertaken with + financial assistance from the Asia Development Bank". +

+

+ Was your research funded by an outside organisation? If so, please provide details. If not, please state 'No'.
+ {{ form.funding_source.data }} +

+ +

Conflict of Interest

+

+ Conflicts of interest arise when you have interests that are not fully apparent and may have + influenced or have the appearance of influencing the article. These may include a range of + competing interests be they financial (such as through funding, employment or shares in a + company), personal, political etc, and must be disclosed when publishing the article, i.e. "The + researcher is a member of the board for company x, who are one of the funding sources for the + project". +

+

+ + Do you have a financial, personal or political affiliation or other that may be perceived + as a conflict in connection with your contribution? If not, then state 'No’. +
+ {{ form.affiliation.data }} +

+ +

Copyright

+

+ If you provide any third-party material such as graphics, photographs or videos to include with your + article, consents, releases or permissions must be obtained by you. +

+

+ The article will be subject to the + + Creative Commons Attributions 4.0 International Licence. + + Accordingly, all permission for use by you of Third Party material must allow for republications. +

+

+ We also require you to comply with all provisions of any agreement that are material to the article + including, without limitation, any agreement that relates to: +

+
+
    +
  1. use of Third-Party rights; or
  2. +
  3. the production of the Article
  4. +
+
+

+ You acknowledge that 360info is not responsible for meeting any expenses incurred in relation to + consents, releases, or permissions required for any materials supplied by you. +

+

+ You grant 360info an exclusive licence to publish and distribute the article in digital form worldwide. +

+

+ + Are there any copyright terms relating to your contribution that must be disclosed
+ when publishing, if so, what are they? If not, then state N/A. +

+ {{ form.copyright_terms.data }} +

+ +

Author Details

+

Name: {{ form.author_name.data }}

+

Position held / title: {{ form.author_title.data }}

+

University / Institute: {{ form.author_institute.data }}

+

Email: {{ form.author_email.data }}

+

Country: {{ form.author_country.data }}

+

ORCID ID (if known): {{ form.author_orcid_id.data }}

+ +

The Author Warrants that

+
    +
  • + The source of any third party copyright materials in the article has been acknowledged and + the article does not infringe any copyright or other rights held by third parties, nor does it + breach any duty of confidentiality or obligation of privacy and does not contain any material + which is defamatory, obscene or otherwise of an unlawful nature. Any materials contained + in the article (including illustrations, tables, or other material) from third-party sources will + be identified as such through citation, indicating the source. If the Author becomes aware of + anything to the contrary, the Author will advise 360info immediately and understands that + 360info reserves the right to refuse to publish the article or any part of it if in 360info's + opinion, it does not meet the warranty above. +
    + {{ 'YES' if form.warrants_no_copyright_infringements.data else 'NO' }} +
  • +
  • + I hereby indemnify 360info against all damages, costs, expenses, loss or damage which they + may incur or sustain from actions, proceedings, claims and demands whatsoever which may + be brought or made against them by any person in respect of or by reason of or arising out + of the publication of the Article, or from the breach or any of the warranties contained in + this declaration. +
    + {{ 'YES' if form.warrants_indemnify_360_against_loss.data else 'NO' }} +
  • +
  • + I hereby acknowledge that the article I have written for 360info is ready for publishing and + grant 360info the exclusive right to publish the article on the 360info website and that it + complies with the Editorial Policies and the declaration above. In doing so I acknowledge + that this article may be further edited and republished by other organisations under the + + Creative Commons Attributions 4.0 International Licence. + +
    + {{ 'YES' if form.warrants_ready_for_publishing.data else 'NO' }} +
  • +
+ +

Author Consent

+

+ I agree to abide by these terms
+ {{ form.consent_signature.data }} +

+ +

Privacy Collection Statement

+

+ 360info is a wholly owned entity of Monash University. Monash University is responsible for the + handling of all personal data that is collected, used, stored and otherwise processed for the delivery + of the 360info service including: +

+ +
    +
  • + To contact you regarding your engagement with 360info +
  • +
  • + To identify you, your university / institute and country as contributing to the 360info service + for analytics and reporting purposes only. These analytics and reports can be shared with + you and with third parties including current and future partners of 360info. +
  • +
  • + To use photographs, video recordings and audio recordings relating to your engagement + with 360info and to reproduce, publish, communicate or broadcast those in any form as part + of the 360info service and marketing and promotional purposes. +
  • +
+ +

+ 360info values the privacy of every individual’s personal information and is committed to the + protection of that information from unauthorised use and disclosure except where permitted by law. + For more information about Data Protection and Privacy at Monash University please see our + + Data Protection and Privacy Procedure. + + If you have any questions about how Monash University is + collecting and handling your personal information, please contact our Data Protection and Privacy + Office at + + dataprotectionofficer@monash.edu. + +

+ +

+ I consent to be contacted regarding my engagement with 360info
+ {{ 'YES' if form.consent_contact.data else 'NO' }} +

+

+ + I consent to the personal information that I provide in this form being used for analytics + and reporting purposes and shared with third parties including current and future partners of + 360info. +
+ {{ 'YES' if form.consent_personal_information.data else 'NO' }} +

+

+ + I consent to photographs, video recordings and audio recordings relating to my + engagement with 360info to be reproduced, published, communicated or broadcast in any form as + part of the 360info service and marketing and promotional purposes. +
+ {{ 'YES' if form.consent_multimedia_usage else 'NO' }} +

+ +

+ If you do not provide your consent for the above purposes, 360info will not be able to share insights + and analytics relating to your publication with you or others. +

+

+ We thank you for your time. +

+

+ End of survey +

+ +
+{% endblock %} diff --git a/server/templates/email_sign_off_copy.txt b/server/templates/email_sign_off_copy.txt new file mode 100644 index 0000000..768bd69 --- /dev/null +++ b/server/templates/email_sign_off_copy.txt @@ -0,0 +1,139 @@ +{% extends "email_layout.txt" %} + +{% block content %} +Hi there, + +Thank you for completing the publishing approval request for the following item: +Slugline: {{ item.slugline }} +Headline: {{ item.headline }} + +See below a copy of the answers you provided: +---------------------------------------------------------------------- +Thank you for authoring an article for 360info. We require you to agree to the following +terms and provide the information requested before we can publish your article. + +*Please add the name of the article:* +{{ form.article_name.data }} + +About the article +Please provide the following information regarding the above-mentioned article. +The information you provide may be published with the article. + +Funding +Any relevant funding contributions must be disclosed, i.e. "The research was undertaken with +financial assistance from the Asia Development Bank". + +*Was your research funded by an outside organisation? If so, please provide details. If not, please state 'No'.* +{{ form.funding_source.data }} + +Conflict of Interest +Conflicts of interest arise when you have interests that are not fully apparent and may have +influenced or have the appearance of influencing the article. These may include a range of +competing interests be they financial (such as through funding, employment or shares in a +company), personal, political etc, and must be disclosed when publishing the article, i.e. "The +researcher is a member of the board for company x, who are one of the funding sources for the +project". + +*Do you have a financial, personal or political affiliation or other that may be perceived +as a conflict in connection with your contribution? If not, then state 'No’.* +{{ form.affiliation.data }} + +Copyright +If you provide any third-party material such as graphics, photographs or videos to include with your +article, consents, releases or permissions must be obtained by you. + +The article will be subject to the Creative Commons Attributions 4.0 International +Licence (https://creativecommons.org/licenses/by/4.0/). Accordingly, all permission for use by you of +Third Party material must allow for republications. + +We also require you to comply with all provisions of any agreement that are material to the article +including, without limitation, any agreement that relates to: +a) use of Third-Party rights; or +b) the production of the Article + +You acknowledge that 360info is not responsible for meeting any expenses incurred in relation to +consents, releases, or permissions required for any materials supplied by you. + +You grant 360info an exclusive licence to publish and distribute the article in digital form worldwide. + +*Are there any copyright terms relating to your contribution that must be disclosed
+when publishing, if so, what are they? If not, then state N/A.* +{{ form.copyright_terms.data }} + +Author Details +*Name*: {{ form.author_name.data }} +*Position held / title*: {{ form.author_title.data }} +*University / Institute*: {{ form.author_institute.data }} +*Email*: {{ form.author_email.data }} +*Country*: {{ form.author_country.data }} +*ORCID ID (if known)*: {{ form.author_orcid_id.data }} + +The Author Warrants that +- The source of any third party copyright materials in the article has been acknowledged and + the article does not infringe any copyright or other rights held by third parties, nor does it + breach any duty of confidentiality or obligation of privacy and does not contain any material + which is defamatory, obscene or otherwise of an unlawful nature. Any materials contained + in the article (including illustrations, tables, or other material) from third-party sources will + be identified as such through citation, indicating the source. If the Author becomes aware of + anything to the contrary, the Author will advise 360info immediately and understands that + 360info reserves the right to refuse to publish the article or any part of it if in 360info's + opinion, it does not meet the warranty above. + {{ 'YES' if form.warrants_no_copyright_infringements.data else 'NO' }} +- I hereby indemnify 360info against all damages, costs, expenses, loss or damage which they + may incur or sustain from actions, proceedings, claims and demands whatsoever which may + be brought or made against them by any person in respect of or by reason of or arising out + of the publication of the Article, or from the breach or any of the warranties contained in + this declaration. + {{ 'YES' if form.warrants_indemnify_360_against_loss.data else 'NO' }} +- I hereby acknowledge that the article I have written for 360info is ready for publishing and + grant 360info the exclusive right to publish the article on the 360info website and that it + complies with the Editorial Policies and the declaration above. In doing so I acknowledge + that this article may be further edited and republished by other organisations under the + Creative Commons Attributions 4.0 International Licence (https://creativecommons.org/licenses/by/4.0/). + {{ 'YES' if form.warrants_ready_for_publishing.data else 'NO' }} + +Author Consent +*I agree to abide by these terms* +{{ form.consent_signature.data }} + +Privacy Collection Statement +360info is a wholly owned entity of Monash University. Monash University is responsible for the +handling of all personal data that is collected, used, stored and otherwise processed for the delivery +of the 360info service including: +- To contact you regarding your engagement with 360info +- To identify you, your university / institute and country as contributing to the 360info service + for analytics and reporting purposes only. These analytics and reports can be shared with + you and with third parties including current and future partners of 360info. +- To use photographs, video recordings and audio recordings relating to your engagement + with 360info and to reproduce, publish, communicate or broadcast those in any form as part + of the 360info service and marketing and promotional purposes. + +360info values the privacy of every individual’s personal information and is committed to the +protection of that information from unauthorised use and disclosure except where permitted by law. +For more information about Data Protection and Privacy at Monash University please see our +Data Protection and Privacy Procedure (https://publicpolicydms.monash.edu/Monash/documents/1909233). +If you have any questions about how Monash University is +collecting and handling your personal information, please contact our Data Protection and Privacy +Office at dataprotectionofficer@monash.edu. + +*I consent to be contacted regarding my engagement with 360info* +{{ 'YES' if form.consent_contact.data else 'NO' }} + +*I consent to the personal information that I provide in this form being used for analytics +and reporting purposes and shared with third parties including current and future partners of +360info.* +{{ 'YES' if form.consent_personal_information.data else 'NO' }} + +*I consent to photographs, video recordings and audio recordings relating to my +engagement with 360info to be reproduced, published, communicated or broadcast in any form as +part of the 360info service and marketing and promotional purposes.* +{{ 'YES' if form.consent_multimedia_usage else 'NO' }} + +If you do not provide your consent for the above purposes, 360info will not be able to share insights +and analytics relating to your publication with you or others. + +We thank you for your time. + +End of survey +---------------------------------------------------------------------- +{% endblock %} diff --git a/server/templates/email_sign_off_request.html b/server/templates/email_sign_off_request.html new file mode 100644 index 0000000..c658f6d --- /dev/null +++ b/server/templates/email_sign_off_request.html @@ -0,0 +1,12 @@ +{% extends "email_layout.html" %} + +{% block content %} +

Hi there,

+

+

A request has been sent for your approval for the following item:

+

Slugline: {{ item.slugline }}

+

Headline: {{ item.headline }}

+

+

Please go to {{ approval_url }} and review the content and provide your approval.

+

This link expires after {{ expires_in }} hours.

+{% endblock %} diff --git a/server/templates/email_sign_off_request.txt b/server/templates/email_sign_off_request.txt new file mode 100644 index 0000000..b1297dc --- /dev/null +++ b/server/templates/email_sign_off_request.txt @@ -0,0 +1,12 @@ +{% extends "email_layout.txt" %} + +{% block content %} +Hi there, + +A request has been sent for your approval for the following item: +Slugline: {{ item.slugline }} +Headline: {{ item.headline }} + +Please go to {{ approval_url }} and review the content and provide your approval. +This link expires after {{ expires_in }} hours. +{% endblock %} diff --git a/server/templates/sign_off_approval.html b/server/templates/sign_off_approval.html new file mode 100644 index 0000000..b468706 --- /dev/null +++ b/server/templates/sign_off_approval.html @@ -0,0 +1,75 @@ + + + + + Superdesk - Author Approval + + + + + + + + +
+
+ +

Author Approval

+
+
+
+ +
+ + {% if token_error %} +
+

An error has occurred.

+ {% if token_error == "no_token" %} +

Token not provided, invalid URL

+ {% elif token_error == "expired" %} +

Token has expired. Please contact your administrator.

+ {% elif token_error == "invalid" %} +

Invalid token. Please make sure you have the correct URL.

+ {% else %} +

Failed to process token: {{ token_error }}

+ {% endif %} +
+ {% else %} + {% include "sign_off_approval_content_preview.html" %} + {% endif %} + +
+
+ {% if not token_error %} + {% include "sign_off_approval_modal.html" %} + {% endif %} +
+ + diff --git a/server/templates/sign_off_approval_content_preview.html b/server/templates/sign_off_approval_content_preview.html new file mode 100644 index 0000000..8c30f27 --- /dev/null +++ b/server/templates/sign_off_approval_content_preview.html @@ -0,0 +1,125 @@ +
+
+
+
+ +
+
+
+
+
+
+
First Created
+
+ +
+
+
+
Last Modified
+
+ +
+
+
+
Version
+
{{ item._current_version }}
+
+
+
+
+ + + +
+
+
+
+
+
diff --git a/server/templates/sign_off_approval_modal.html b/server/templates/sign_off_approval_modal.html new file mode 100644 index 0000000..cd6e6de --- /dev/null +++ b/server/templates/sign_off_approval_modal.html @@ -0,0 +1,83 @@ + + + diff --git a/server/templates/sign_off_approval_modal_form.html b/server/templates/sign_off_approval_modal_form.html new file mode 100644 index 0000000..c1122a6 --- /dev/null +++ b/server/templates/sign_off_approval_modal_form.html @@ -0,0 +1,408 @@ +
+ {% if not readonly %} + {{ form.csrf_token }} + {{ form.item_id(value=item._id) }} + {{ form.user_id(value=data.author_id) }} + {{ form.version_signed(value=item._current_version) }} + {% else %} +

Approval Details

+
User ID: {{ form.user_id.data }}
+
Item ID: {{ form.item_id.data }}
+
Item Version: {{ form.version_signed.data }}
+
Date Signed: {{ form.sign_date.data | format_datetime }}
+
+ {% endif %} + +

+ Thank you for authoring an article for 360info. We require you to agree to the following + terms and provide the information requested before we can publish your article. +

+ +
+
+ +
+ {{ form.article_name( + class="sd-input__input", + id="article_name", + required="true", + readonly=readonly + ) }} +
+
+
+ +

About the article

+

+ Please provide the following information regarding the above-mentioned article. + The information you provide may be published with the article. +

+ +

Funding

+

+ Any relevant funding contributions must be disclosed, i.e. "The research was undertaken with + financial assistance from the Asia Development Bank". +

+
+
+ +
+ {{ form.funding_source( + class="sd-input__input", + id="funding_source", + required="true", + readonly=readonly + ) }} +
+
+
+ +

Conflict of Interest

+

+ Conflicts of interest arise when you have interests that are not fully apparent and may have + influenced or have the appearance of influencing the article. These may include a range of + competing interests be they financial (such as through funding, employment or shares in a + company), personal, political etc, and must be disclosed when publishing the article, i.e. "The + researcher is a member of the board for company x, who are one of the funding sources for the + project". +

+
+
+ +
+ {{ form.affiliation( + class="sd-input__input", + id="affiliation", + required="true", + readonly=readonly + ) }} +
+
+
+ +

Copyright

+

+ If you provide any third-party material such as graphics, photographs or videos to include with your + article, consents, releases or permissions must be obtained by you. +

+

+ The article will be subject to the + + Creative Commons Attributions 4.0 International Licence. + + Accordingly, all permission for use by you of Third Party material must allow for republications. +

+

+ We also require you to comply with all provisions of any agreement that are material to the article + including, without limitation, any agreement that relates to: +

+
+
    +
  1. use of Third-Party rights; or
  2. +
  3. the production of the Article
  4. +
+
+

+ You acknowledge that 360info is not responsible for meeting any expenses incurred in relation to + consents, releases, or permissions required for any materials supplied by you. +

+

+ You grant 360info an exclusive licence to publish and distribute the article in digital form worldwide. +

+
+
+ +
+ {{ form.copyright_terms( + class="sd-input__input", + id="copyright_terms", + required="true", + readonly=readonly + ) }} +
+
+
+ +

Author Details

+
+
+ +
+ {{ form.author_name( + class="sd-input__input", + id="author_name", + required="true", + readonly=readonly + ) }} +
+
+
+
+
+ +
+ {{ form.author_title( + class="sd-input__input", + id="author_title", + required="true", + readonly=readonly + ) }} +
+
+
+
+
+ +
+ {{ form.author_institute( + class="sd-input__input", + id="author_institute", + required="true", + readonly=readonly + ) }} +
+
+
+
+
+ +
+ {{ form.author_email( + class="sd-input__input", + id="author_email", + readonly=readonly + ) }} +
+
+
+
+
+ +
+ {{ form.author_country( + class="sd-input__input", + id="author_country", + required="true", + readonly=readonly + ) }} +
+
+
+
+
+ +
+ {{ form.author_orcid_id( + class="sd-input__input", + id="author_orcid_id", + readonly=readonly + ) }} +
+
+
+ +

The Author Warrants that

+
    +
  • + The source of any third party copyright materials in the article has been acknowledged and + the article does not infringe any copyright or other rights held by third parties, nor does it + breach any duty of confidentiality or obligation of privacy and does not contain any material + which is defamatory, obscene or otherwise of an unlawful nature. Any materials contained + in the article (including illustrations, tables, or other material) from third-party sources will + be identified as such through citation, indicating the source. If the Author becomes aware of + anything to the contrary, the Author will advise 360info immediately and understands that + 360info reserves the right to refuse to publish the article or any part of it if in 360info's + opinion, it does not meet the warranty above. + + {{ form.warrants_no_copyright_infringements( + class="sd-check-new__input", + id="warrants_no_copyright_infringements", + type="checkbox", + required="true", + disabled=readonly + ) }} + + +
  • +
  • + I hereby indemnify 360info against all damages, costs, expenses, loss or damage which they + may incur or sustain from actions, proceedings, claims and demands whatsoever which may + be brought or made against them by any person in respect of or by reason of or arising out + of the publication of the Article, or from the breach or any of the warranties contained in + this declaration. + + {{ form.warrants_indemnify_360_against_loss( + class="sd-check-new__input", + id="warrants_indemnify_360_against_loss", + type="checkbox", + required="true", + disabled=readonly + ) }} + + +
  • +
  • + I hereby acknowledge that the article I have written for 360info is ready for publishing and + grant 360info the exclusive right to publish the article on the 360info website and that it + complies with the Editorial Policies and the declaration above. In doing so I acknowledge + that this article may be further edited and republished by other organisations under the + + Creative Commons Attributions 4.0 International Licence. + + + {{ form.warrants_ready_for_publishing( + class="sd-check-new__input", + id="warrants_ready_for_publishing", + type="checkbox", + required="true", + disabled=readonly + ) }} + + +
  • +
+ +

Author Consent

+
+
+ +
+ {{ form.consent_signature( + class="sd-input__input", + id="consent_signature", + required="true", + readonly=readonly, + style="font-family: 'Cedarville Cursive', cursive; font-size: 22px;" + ) }} +
+
+
+ +

Privacy Collection Statement

+

+ 360info is a wholly owned entity of Monash University. Monash University is responsible for the + handling of all personal data that is collected, used, stored and otherwise processed for the delivery + of the 360info service including: +

+
    +
  • + To contact you regarding your engagement with 360info +
  • +
  • + To identify you, your university / institute and country as contributing to the 360info service + for analytics and reporting purposes only. These analytics and reports can be shared with + you and with third parties including current and future partners of 360info. +
  • +
  • + To use photographs, video recordings and audio recordings relating to your engagement + with 360info and to reproduce, publish, communicate or broadcast those in any form as part + of the 360info service and marketing and promotional purposes. +
  • +
+

+ 360info values the privacy of every individual’s personal information and is committed to the + protection of that information from unauthorised use and disclosure except where permitted by law. + For more information about Data Protection and Privacy at Monash University please see our + + Data Protection and Privacy Procedure. + + If you have any questions about how Monash University is + collecting and handling your personal information, please contact our Data Protection and Privacy + Office at + + dataprotectionofficer@monash.edu. + +

+
+
+ + {{ form.consent_contact( + class="sd-check-new__input", + id="consent_contact", + type="checkbox", + required="true", + disabled=readonly + ) }} + + + +
+
+
+
+ + {{ form.consent_personal_information( + class="sd-check-new__input", + id="consent_personal_information", + type="checkbox", + required="true", + disabled=readonly + ) }} + + + +
+
+
+
+ + {{ form.consent_multimedia_usage( + class="sd-check-new__input", + id="consent_multimedia_usage", + type="checkbox", + required="true", + disabled=readonly + ) }} + + + +
+
+

+ If you do not provide your consent for the above purposes, 360info will not be able to share insights + and analytics relating to your publication with you or others. +

+

+ We thank you for your time. +

+

+ End of survey +

+
diff --git a/server/templates/sign_off_approval_submitted.html b/server/templates/sign_off_approval_submitted.html new file mode 100644 index 0000000..9281afc --- /dev/null +++ b/server/templates/sign_off_approval_submitted.html @@ -0,0 +1,37 @@ + + + + + Superdesk - Author Approval + + + + + + +
+
+ +

Author Approval

+
+
+
+ +
+
+

Congratulations

+

Your approval has been sent.

+
+
+
+
+ + diff --git a/server/templates/sign_off_approval_view_form.html b/server/templates/sign_off_approval_view_form.html new file mode 100644 index 0000000..06ec1bb --- /dev/null +++ b/server/templates/sign_off_approval_view_form.html @@ -0,0 +1,15 @@ + + + + + Superdesk - Author Approval + + + + + + + + {% include "sign_off_approval_modal_form.html" %} + + diff --git a/server/tests/sign_off_request_test.py b/server/tests/sign_off_request_test.py new file mode 100644 index 0000000..8eddb46 --- /dev/null +++ b/server/tests/sign_off_request_test.py @@ -0,0 +1,96 @@ +from flask import json + +from superdesk.tests import TestCase +from superdesk.utc import utcnow + + +class SignOffRequestTestCase(TestCase): + def setUp(self): + super().setUp() + self.app.config["SIGN_OFF_REQUESTS_SHARED_SECRET"] = "secret123" + + def test_sign_off_request_email_workflow(self): + user_ids = self.app.data.insert( + "users", + [ + {"username": "foo", "user_type": "administrator", "email": "foo@foobar.org"}, + {"username": "bar", "user_type": "user", "email": "bar@foobar.org"}, + ], + ) + self.app.data.insert( + "auth", + [ + { + "user": user_ids[0], + "_updated": utcnow(), + "token": "foo", + } + ], + ) + item_id = self.app.data.insert( + "archive", + [ + { + "_id": "tag:example.com,0000:newsml_BRE9A605", + "guid": "tag:example.com,0000:newsml_BRE9A605", + "slugline": "slugger-info", + "headline": "Header Of The Informational Article", + "body_html": "

The story so far.

", + "genre": [{"name": "Article", "qcode": "Article"}], + "anpa_category": [{"qcode": "i", "name": "International News"}], + "subject": [{"qcode": "17004000", "name": "Statistics"}, {"qcode": "04001002", "name": "Weather"}], + } + ], + )[0] + + data = json.dumps( + { + "item_id": item_id, + "authors": [user_ids[0], user_ids[1]], + } + ) + + # Construct test client, so we have a user logged in + client = self.app.test_client() + with client.session_transaction() as sess: + sess["session_token"] = "foo" + + # Post the request, which in tern sends the emails to authors + with self.app.mail.record_messages() as outbox: + response = client.post("/api/sign_off_request", content_type="application/json", data=data) + assert response.status_code == 201 + assert len(outbox) == 2 + + # Pop the session_token, effectively logging us out of Superdesk + with client.session_transaction() as sess: + sess.pop("session_token", None) + + # Iterate over the emails that were sent, extract the link, and test the HTML response from that link + for email_sent in outbox: + assert "'Header Of The Informational Article'" in email_sent.subject + + approval_url = "https://localhost/api/sign_off_requests/approve?token=" + assert approval_url in email_sent.body + assert "link expires after 24 hours" in email_sent.body + + # Extract the URL from the email + url_index = email_sent.body.index(approval_url) + len(approval_url) + token = email_sent.body[url_index:].split(" ", 1)[0] + + # Get the HTML page from the link in the email + response = client.get(f"/api/sign_off_requests/approve?token={token}") + assert response.status_code == 200 + response_text = response.get_data(as_text=True) + + assert "Header Of The Informational Article" in response_text + current_user_id = 0 if "foo@foobar.org" in email_sent.recipients else 1 + assert str(user_ids[current_user_id]) in response_text + + def test_validate_sign_off_request_approval(self): + client = self.app.test_client() + + response = client.get("/api/sign_off_requests/approve") + assert "Token not provided, invalid URL" in response.get_data(as_text=True) + + response = client.get("/api/sign_off_requests/approve?token=some_invalid_token") + assert "Invalid token" in response.get_data(as_text=True) diff --git a/server/tga/author_profiles.py b/server/tga/author_profiles.py index 95a57bd..83f4d68 100644 --- a/server/tga/author_profiles.py +++ b/server/tga/author_profiles.py @@ -1,4 +1,8 @@ -from typing import List, Dict, Any +from typing import List, Dict, Any, Optional + +from bson import ObjectId +from flask import current_app as app + from superdesk import get_resource_service @@ -119,3 +123,27 @@ def _add_cv_item_on_update(updates: Dict[str, Any], original: Dict[str, Any], cu } ) cv_service.patch(cv_id, cv_updates) + + +def get_author_profiles_by_user_id(user_ids: List[ObjectId]) -> Dict[ObjectId, Dict[str, Any]]: + urn_domain = app.config["URN_DOMAIN"] + query = { + "query": { + "bool": { + "must": [ + {"terms": {"authors.uri": [f"urn:{urn_domain}:user:{user_id}" for user_id in user_ids]}}, + {"term": {"authors.role": AUTHOR_PROFILE_ROLE}}, + ], + }, + }, + } + + try: + response = get_resource_service("author_profiles").search(query) + except KeyError: + response = get_resource_service("items").search(query) + + if response.count(): + return {ObjectId(user["extra"]["profile_id"]): user for user in response} + + return {} diff --git a/server/tga/sign_off/__init__.py b/server/tga/sign_off/__init__.py new file mode 100644 index 0000000..edae1eb --- /dev/null +++ b/server/tga/sign_off/__init__.py @@ -0,0 +1,41 @@ +from superdesk.factory.app import SuperdeskEve + +from .sign_off_requests import sign_off_request_bp +from .template_globals import ( + render_text_input, + render_content_text, + render_html_content, + render_tag_list, + render_cv_items, + render_featuremedia_image, +) +from .utils import ( + fix_item_publish_sign_off_format, + fix_resource_publish_sign_off_formats, + fix_archive_lock_sign_off_formats, + fix_item_on_archive_update, +) + + +def init_app(app: SuperdeskEve): + app.register_blueprint(sign_off_request_bp) + app.add_template_global(render_text_input) + app.add_template_global(render_content_text) + app.add_template_global(render_html_content) + app.add_template_global(render_tag_list) + app.add_template_global(render_cv_items) + app.add_template_global(render_featuremedia_image) + + # Make sure publish sign off format is correct + # This makes the sign_off extension backwards compatible, so the format coming from + # the server is the newer format, so the front-end shouldn't need backwards compatability + app.on_fetched_item_archive += fix_item_publish_sign_off_format + app.on_fetched_item_published += fix_item_publish_sign_off_format + + app.on_fetched_resource_archive += fix_resource_publish_sign_off_formats + app.on_fetched_resource_published += fix_resource_publish_sign_off_formats + + app.on_inserted_archive_lock += fix_archive_lock_sign_off_formats + + # Make sure ``extra.publish_sign_off`` use ``ObjectId`` for User IDs + app.on_update_archive += fix_item_on_archive_update diff --git a/server/tga/sign_off/form.py b/server/tga/sign_off/form.py new file mode 100644 index 0000000..05a33a4 --- /dev/null +++ b/server/tga/sign_off/form.py @@ -0,0 +1,31 @@ +from flask_wtf import FlaskForm +from wtforms import StringField, BooleanField, HiddenField +from wtforms.validators import DataRequired + + +class UserSignOffForm(FlaskForm): + item_id = HiddenField("Item Id") + user_id = HiddenField("User Id") + version_signed = HiddenField("Item Version Signed") + sign_date = HiddenField("Signed Date") + + article_name = StringField("Article Name", validators=[DataRequired()]) + funding_source = StringField("Funding Source", validators=[DataRequired()]) + affiliation = StringField("Affiliation", validators=[DataRequired()]) + copyright_terms = StringField("Copyright Terms", validators=[DataRequired()]) + + author_name = StringField("Name", validators=[DataRequired()]) + author_title = StringField("Position held / title", validators=[DataRequired()]) + author_institute = StringField("University / Institute", validators=[DataRequired()]) + author_email = StringField("Email", validators=[DataRequired()]) # This should be disabled + author_country = StringField("Country", validators=[DataRequired()]) + author_orcid_id = StringField("ORCID ID (if known)", validators=[]) + + warrants_no_copyright_infringements = BooleanField("No Copyright Infringements", validators=[DataRequired()]) + warrants_indemnify_360_against_loss = BooleanField("Indemnify 360info Against Loss", validators=[DataRequired()]) + warrants_ready_for_publishing = BooleanField("Ready For Publishing", validators=[DataRequired()]) + + consent_signature = StringField("Agree To Terms", validators=[DataRequired()]) + consent_contact = BooleanField("Consent Contact", validators=[DataRequired()]) + consent_personal_information = BooleanField("Consent Personal Information", validators=[DataRequired()]) + consent_multimedia_usage = BooleanField("Consent Multimedia Usage", validators=[DataRequired()]) diff --git a/server/tga/sign_off/sign_off_requests.py b/server/tga/sign_off/sign_off_requests.py new file mode 100644 index 0000000..1d24717 --- /dev/null +++ b/server/tga/sign_off/sign_off_requests.py @@ -0,0 +1,232 @@ +from typing import Dict, Any, Optional, List +from time import time +import logging + +from authlib.jose import JsonWebToken +from authlib.jose.errors import ExpiredTokenError, DecodeError +from bson import ObjectId +from bson.errors import InvalidId +from flask import Blueprint, request, render_template, current_app as app, url_for, jsonify, make_response + +from superdesk.errors import SuperdeskApiError +from superdesk.auth.decorator import blueprint_auth +from superdesk.emails import send_email +from superdesk.upload import generate_response_for_file + +from apps.auth import get_user_id + +from .form import UserSignOffForm +from .utils import ( + get_css_filename, + JWT_ALGORITHM, + get_item_from_token_data, + get_users_from_token_data, + modify_asset_urls, + remove_sign_off_from_item, + update_item_publish_approval, + update_item_with_request_details, + gen_jwt_for_approval_request, + get_author_profiles_by_user_id, + get_publish_sign_off_data, +) + + +logger = logging.getLogger(__name__) +sign_off_request_bp = Blueprint("sign_off_requests", __name__) + + +def make_cors_response(methods, *args): + response = make_response(*args) + response.headers.add("Access-Control-Allow-Origin", app.config["CLIENT_URL"]) + response.headers.add("Access-Control-Allow-Headers", ",".join(app.config["X_HEADERS"])) + response.headers.add("Access-Control-Allow-Methods", methods) + response.headers.set("Access-Control-Allow-Credentials", "true") + return response + + +@sign_off_request_bp.route("/api/sign_off_requests/approve", methods=["GET", "OPTIONS", "POST"]) +def sign_off_approval(): + def render_html_page( + token_error: Optional[str] = None, + form_errors: Optional[Dict[str, List[str]]] = None, + data: Optional[Dict[str, Any]] = None, + form: Optional[UserSignOffForm] = None, + item: Optional[Dict[str, Any]] = None, + ): + return render_template( + "sign_off_approval.html", + token_error=token_error, + form_errors=form_errors, + data=data, + form=form, + item=item, + css_file_path=get_css_filename(), + readonly=False, + ) + + token = request.args.get("token") + if not token: + return render_html_page(token_error="no_token") + + try: + token_data = JsonWebToken([JWT_ALGORITHM]).decode(token, app.config["SIGN_OFF_REQUESTS_SHARED_SECRET"]) + token_data.validate_exp(now=time(), leeway=0) + except ExpiredTokenError as err: + logger.exception(err) + logger.error("Token expired") + return render_html_page(token_error="expired") + except DecodeError as err: + logger.exception(err) + logger.error("Failed to decode token") + return render_html_page(token_error="invalid") + except Exception as err: + logger.exception(err) + logger.error("Failed to process token") + return render_html_page(token_error=str(err)) + + item = get_item_from_token_data(token_data) + modify_asset_urls(item, token_data["author_id"]) + user_id = ObjectId(token_data["author_id"]) + author_profiles = get_author_profiles_by_user_id([user_id]) + profile = (author_profiles.get(user_id) or {}).get("extra") or {} + form = UserSignOffForm(author_email=profile.get("profile_email")) + + if request.method == "POST": + if form.validate_on_submit(): + update_item_publish_approval(item, form) + return render_template("sign_off_approval_submitted.html", css_file_path=get_css_filename()) + else: + return render_html_page(data=token_data, form=form, item=item, form_errors=form.errors) + + return render_html_page(data=token_data, form=form, item=item) + + +@sign_off_request_bp.route("/api/sign_off_requests///view", methods=["GET", "OPTIONS"]) +@blueprint_auth() +def view_sign_off_request(item_id, user_id_str): + if request.method == "OPTIONS": + return make_cors_response("GET") + + try: + user_id = ObjectId(user_id_str) + except InvalidId: + raise SuperdeskApiError.badRequestError("Invalid User ID provided") + + item = get_item_from_token_data({"item_id": item_id}) + publish_sign_off = get_publish_sign_off_data(item) + + if not publish_sign_off: + raise SuperdeskApiError.badRequestError("Item doesnt contain any publish sign off") + + author_sign_off = next( + (sign_off for sign_off in publish_sign_off["sign_offs"] if sign_off["user_id"] == user_id), None + ) + + if not author_sign_off: + raise SuperdeskApiError.badRequestError("Item doesnt contain sign off for user") + + form = UserSignOffForm( + item_id=item_id, + user_id=user_id_str, + version_signed=author_sign_off["version_signed"], + sign_date=author_sign_off["sign_date"], + article_name=author_sign_off["article_name"], + funding_source=author_sign_off["funding_source"], + affiliation=author_sign_off["affiliation"], + copyright_terms=author_sign_off["copyright_terms"], + author_name=author_sign_off["author"]["name"], + author_title=author_sign_off["author"]["title"], + author_institute=author_sign_off["author"]["institute"], + author_email=author_sign_off["author"]["email"], + author_country=author_sign_off["author"]["country"], + author_orcid_id=author_sign_off["author"]["orcid_id"], + warrants_no_copyright_infringements=author_sign_off["warrants"]["no_copyright_infringements"], + warrants_indemnify_360_against_loss=author_sign_off["warrants"]["indemnify_360_against_loss"], + warrants_ready_for_publishing=author_sign_off["warrants"]["ready_for_publishing"], + consent_signature=author_sign_off["consent"]["signature"], + consent_contact=author_sign_off["consent"]["contact"], + consent_personal_information=author_sign_off["consent"]["personal_information"], + consent_multimedia_usage=author_sign_off["consent"]["multimedia_usage"], + ) + + return render_template( + "sign_off_approval_view_form.html", + css_file_path=get_css_filename(), + form=form, + readonly=True, + ) + + +@sign_off_request_bp.route("/api/sign_off_requests/upload-raw/", methods=["GET", "OPTIONS"]) +def get_upload_as_data_uri(media_token): + if request.method == "OPTIONS": + return make_cors_response("GET") + + token_data = JsonWebToken([JWT_ALGORITHM]).decode(media_token, app.config["SIGN_OFF_REQUESTS_SHARED_SECRET"]) + token_data.validate_exp(now=time(), leeway=0) + media_id = token_data["item_id"] + + if not request.args.get("resource"): + media_file = app.media.get_by_filename(media_id) + else: + media_file = app.media.get(media_id, request.args["resource"]) + if media_file: + return generate_response_for_file(media_file) + + raise SuperdeskApiError.notFoundError("File not found on media storage.") + + +@sign_off_request_bp.route("/api/sign_off_request//", methods=["DELETE", "OPTIONS"]) +@blueprint_auth() +def remove_author_sign_off(item_id, user_id_str): + if request.method == "OPTIONS": + return make_cors_response("DELETE") + + try: + user_id = ObjectId(user_id_str) + except InvalidId: + raise SuperdeskApiError.badRequestError("Invalid User ID provided") + + item = get_item_from_token_data({"item_id": item_id}) + remove_sign_off_from_item(item, user_id) + + return make_cors_response("DELETE", "", 204) + + +@sign_off_request_bp.route("/api/sign_off_request", methods=["POST", "OPTIONS"]) +@blueprint_auth() +def send_sign_off_request_emails(): + if request.method == "OPTIONS": + return make_cors_response("POST") + + data = request.json + item = get_item_from_token_data(data) + user_ids: List[ObjectId] = [] + + for user in get_users_from_token_data(data): + token = gen_jwt_for_approval_request(item["_id"], user["_id"], "approval_request") + admins = app.config["ADMINS"] + base_url = url_for("sign_off_requests.sign_off_approval", _external=True) + + data = dict( + app_name=app.config["APPLICATION_NAME"], + approval_url=f"{base_url}?token={token}", + expires_in=int(app.config["SIGN_OFF_REQUESTS_EXPIRATION"] / 3600), # hours + item=item, + ) + text_body = render_template("email_sign_off_request.txt", **data) + html_body = render_template("email_sign_off_request.html", **data) + item_name = item.get("headline") or item.get("slugline") + + send_email.delay( + subject=f"Author Approval Request for '{item_name}'", + sender=admins[0], + recipients=[user["email"]], + text_body=text_body, + html_body=html_body, + ) + user_ids.append(ObjectId(user["_id"])) + + update_item_with_request_details(item, get_user_id(True), user_ids) + + return make_cors_response("POST", jsonify({"_status": "OK"}), 201) diff --git a/server/tga/sign_off/template_globals.py b/server/tga/sign_off/template_globals.py new file mode 100644 index 0000000..a7e73dc --- /dev/null +++ b/server/tga/sign_off/template_globals.py @@ -0,0 +1,107 @@ +from typing import List +from typing_extensions import TypedDict + + +def get_input_id_from_label(label: str) -> str: + return "input_" + label.replace(" ", "_").lower() + + +def render_text_input(label: str, value: str): + input_id = get_input_id_from_label(label) + + return f""" +
+
+ +
+ +
+
+
+""" + + +def render_content_text(label: str, value: str): + input_id = get_input_id_from_label(label) + + return f""" +
+ +
+ +
+
+""" + + +def render_html_content(label: str, value: str): + input_id = get_input_id_from_label(label) + + return f""" +
+ +
+
+ {value} +
+
+
+""" + + +class CVItem(TypedDict): + name: str + + +def render_tag_list(label: str, values: List[str]): + input_id = get_input_id_from_label(label) + value_list = "".join( + [ + f"""
  • {value}
  • """ + for value in values + ] + ) + chip_classes = "p-chips p-component p-inputwrapper tags-input--multi-select sd-input__input p-inputwrapper-filled" + + return f""" +
    +
    + +
    +
    +
      + {value_list} +
    +
    +
    +
    +
    +""" + + +def render_featuremedia_image(item): + featuremedia = item["associations"]["featuremedia"] + image_url = featuremedia["renditions"]["viewImage"]["href"] + alt_text = featuremedia.get("alt_text") or featuremedia.get("description_text") or featuremedia.get("caption") + + return f""" +
    + +
    +
    + {alt_text} +
    {alt_text}
    +
    +
    +
    +""" + + +def render_cv_items(label: str, values: List[CVItem]): + return render_tag_list(label, [value["name"] for value in values]) diff --git a/server/tga/sign_off/utils.py b/server/tga/sign_off/utils.py new file mode 100644 index 0000000..490145e --- /dev/null +++ b/server/tga/sign_off/utils.py @@ -0,0 +1,325 @@ +from typing import Dict, List, Optional, Any +import pathlib +from copy import deepcopy +import re +from datetime import timedelta +from time import time + +from authlib.jose import JsonWebToken +from bson import ObjectId +from bson.errors import InvalidId +from eve.utils import config +from flask import g, current_app as app, render_template +from flask_babel import _ + +from superdesk import get_resource_service +from superdesk.errors import SuperdeskApiError +from superdesk.utc import utcnow +from superdesk.notification import push_notification +from superdesk.emails import send_email + +from tga.types import ( + SignOffAuthor, + SignOffWarrants, + SignOffConsent, + AuthorSignOffData, + AuthorSignOffRequest, + PublishSignOffData, +) +from tga.author_profiles import get_author_profiles_by_user_id +from .form import UserSignOffForm + + +JWT_ALGORITHM = "HS256" + + +def get_publish_sign_off_data(item: Dict[str, Any]) -> Optional[PublishSignOffData]: + if (item.get("extra") or {}).get("publish_sign_off"): + if item["extra"]["publish_sign_off"].get("requester_id"): + # This is the newer format + # Make sure all User IDs are an instance of ``ObjectId`` + publish_sign_off: PublishSignOffData = item["extra"]["publish_sign_off"] + + if publish_sign_off.get("requester_id"): + publish_sign_off["requester_id"] = ObjectId(publish_sign_off["requester_id"]) + + for review in publish_sign_off["pending_reviews"]: + review["user_id"] = ObjectId(review["user_id"]) + + for sign_off in publish_sign_off["sign_offs"]: + sign_off["user_id"] = ObjectId(sign_off["user_id"]) + + return publish_sign_off + elif item["extra"]["publish_sign_off"].get("user_id"): + # This is the legacy format (AuthorSignOffData), change it to PublishSignOffData + legacy_sign_off = item["extra"]["publish_sign_off"] + user_id = ObjectId(legacy_sign_off["user_id"]) + + profiles = get_author_profiles_by_user_id([user_id]) + author_profile = (profiles.get(user_id) or {}).get("extra") or {} + + author_sign_off = AuthorSignOffData( + user_id=user_id, + sign_date=legacy_sign_off["sign_date"], + version_signed=item.get("version"), + article_name="", + funding_source=legacy_sign_off.get("funding_source"), + affiliation=legacy_sign_off.get("affiliation"), + copyright_terms="", + author=SignOffAuthor( + name=author_profile.get("profile_name") or "", + title=author_profile.get("profile_title") or "", + institute=author_profile.get("profile_institute") or "", + email=author_profile.get("profile_email") or "", + country=author_profile.get("profile_country") or "", + orcid_id=author_profile.get("profile_orcid_id") or "", + ), + warrants=SignOffWarrants( + no_copyright_infringements=True, + indemnify_360_against_loss=True, + ready_for_publishing=True, + ), + consent=SignOffConsent( + signature="", + contact=True, + personal_information=True, + multimedia_usage=True, + ), + ) + + return PublishSignOffData( + requester_id=author_sign_off["user_id"], + request_sent=author_sign_off["sign_date"], + pending_reviews=[], + sign_offs=[author_sign_off], + ) + + return None + + +def fix_item_publish_sign_off_format(item: Dict[str, Any]): + publish_sign_off = get_publish_sign_off_data(item) + if publish_sign_off is not None: + item["extra"]["publish_sign_off"] = publish_sign_off + + +def fix_resource_publish_sign_off_formats(docs): + for item in docs.get(config.ITEMS, []): + fix_item_publish_sign_off_format(item) + + +def fix_archive_lock_sign_off_formats(items): + for item in items: + fix_item_publish_sign_off_format(item) + + +def fix_item_on_archive_update(updates: Dict[str, Any], original: Dict[str, Any]): + publish_sign_off = get_publish_sign_off_data(updates) or get_publish_sign_off_data(original) + if publish_sign_off is not None: + updates.setdefault("extra", deepcopy(original.get("extra") or {})) + updates["extra"]["publish_sign_off"] = publish_sign_off + + +def get_css_filename(): + try: + dist_path = pathlib.Path(pathlib.Path(__file__).parent.parent.parent.parent, "client/dist") + css_filename = [f.name for f in dist_path.glob("app.bundle.*.css")][0] + return f"/{css_filename}" + except (IndexError, ValueError): + return "/app.bundle.css" + + +def modify_asset_urls(item, author_id: ObjectId): + pattern = re.compile('\/api\/upload-raw\/(.*?)"') + new_body_html = item["body_html"] + url_prefix = "/api/upload-raw/" + url_prefix_len = len(url_prefix) + + for asset_filename in re.findall(pattern, item["body_html"]): + asset_token = gen_jwt_for_approval_request(asset_filename, author_id, "upload-raw", token_expiration=3600) + new_body_html = new_body_html.replace( + url_prefix + asset_filename, f"/api/sign_off_requests/upload-raw/{asset_token}" + ) + + item["body_html"] = new_body_html + + for key, association in (item.get("associations") or {}).items(): + if association is None: + continue + for size_name, rendition in (association.get("renditions") or {}).items(): + rendition_href = rendition["href"] + asset_filename = rendition_href[rendition_href.index(url_prefix) + url_prefix_len :] + asset_token = gen_jwt_for_approval_request(asset_filename, author_id, "upload-raw", token_expiration=3600) + rendition["href"] = rendition_href.replace( + url_prefix + asset_filename, f"/api/sign_off_requests/upload-raw/{asset_token}" + ) + + +def remove_sign_off_from_item(item: Dict[str, Any], user_id: ObjectId): + publish_sign_off = get_publish_sign_off_data(item) + + if publish_sign_off is None: + raise SuperdeskApiError.badRequestError(_("No sign offs found on the item")) + + publish_sign_off["sign_offs"] = [ + sign_off for sign_off in publish_sign_off["sign_offs"] if sign_off["user_id"] != user_id + ] + _update_publish_sign_off(item, publish_sign_off) + + +def update_item_publish_approval(item: Dict[str, Any], form: UserSignOffForm): + user_id = ObjectId(form.user_id.data) + publish_sign_off = get_publish_sign_off_data(item) or PublishSignOffData( + requester_id=user_id, request_sent=utcnow(), pending_reviews=[], sign_offs=[] + ) + + # Remove any previous sign off from this user, and add the new sign off + publish_sign_off["sign_offs"] = [ + sign_off for sign_off in publish_sign_off["sign_offs"] if sign_off["user_id"] != user_id + ] + [ + AuthorSignOffData( + user_id=user_id, + sign_date=utcnow(), + version_signed=form.version_signed.data, + article_name=form.article_name.data, + funding_source=form.funding_source.data, + affiliation=form.affiliation.data, + copyright_terms=form.copyright_terms.data, + author=SignOffAuthor( + name=form.author_name.data, + title=form.author_title.data, + institute=form.author_institute.data, + email=form.author_email.data, + country=form.author_country.data, + orcid_id=form.author_orcid_id.data, + ), + warrants=SignOffWarrants( + no_copyright_infringements=form.warrants_no_copyright_infringements.data, + indemnify_360_against_loss=form.warrants_indemnify_360_against_loss.data, + ready_for_publishing=form.warrants_ready_for_publishing.data, + ), + consent=SignOffConsent( + signature=form.consent_signature.data, + contact=form.consent_contact.data, + personal_information=form.consent_personal_information.data, + multimedia_usage=form.consent_multimedia_usage.data, + ), + ) + ] + + # Remove user from ``pending_reviews`` + publish_sign_off["pending_reviews"] = [ + review for review in publish_sign_off["pending_reviews"] if review["user_id"] != user_id + ] + + g.user = get_resource_service("users").find_one(req=None, _id=user_id) + _update_publish_sign_off(item, publish_sign_off) + + data = dict( + app_name=app.config["APPLICATION_NAME"], + item=item, + form=form, + ) + text_body = render_template("email_sign_off_copy.txt", **data) + html_body = render_template("email_sign_off_copy.html", **data) + admins = app.config["ADMINS"] + item_name = item.get("headline") or item.get("slugline") + + send_email.delay( + subject=f"Completed: Author Approval Request for '{item_name}'", + sender=admins[0], + recipients=[form.author_email.data], + text_body=text_body, + html_body=html_body, + ) + + del g.user + + +def _update_publish_sign_off(original: Dict[str, Any], publish_sign_off: PublishSignOffData): + extra = deepcopy(original.get("extra") or {}) + extra["publish_sign_off"] = publish_sign_off + updates = {"extra": extra} + get_resource_service("archive").system_update(original["_id"], updates, original) + get_resource_service("archive_history").on_item_updated(updates, original, "author_approval") + push_notification( + "author_approval:updated", extension="tga-sign-off", item_id=original["_id"], new_sign_off=publish_sign_off + ) + + +def update_item_with_request_details(item: Dict[str, Any], current_user_id: ObjectId, user_ids: List[ObjectId]): + publish_sign_off = get_publish_sign_off_data(item) + if publish_sign_off is None: + publish_sign_off = PublishSignOffData( + requester_id=current_user_id, request_sent=utcnow(), pending_reviews=[], sign_offs=[] + ) + else: + publish_sign_off["requester_id"] = current_user_id + publish_sign_off["request_sent"] = utcnow() + + publish_sign_off["pending_reviews"] = [ + review for review in publish_sign_off["pending_reviews"] if review["user_id"] not in user_ids + ] + [ + AuthorSignOffRequest( + user_id=user_id, + sent=utcnow(), + expires=utcnow() + timedelta(seconds=app.config["SIGN_OFF_REQUESTS_EXPIRATION"]), + ) + for user_id in user_ids + ] + + _update_publish_sign_off(item, publish_sign_off) + + +def get_item_from_token_data(data): + archive_service = get_resource_service("archive") + item_id: str = data.get("item_id") + if not item_id: + raise SuperdeskApiError.badRequestError(_("item_id field is required")) + item = archive_service.find_one(req=None, _id=item_id) + if not item: + raise SuperdeskApiError.notFoundError(_("Content not found")) + + return item + + +def get_users_from_token_data(data) -> List[Dict[str, Any]]: + users_service = get_resource_service("users") + try: + authors: List[ObjectId] = [ObjectId(authorId) for authorId in data.get("authors") or []] + except InvalidId: + raise SuperdeskApiError.badRequestError(_("authors field must be a list of ObjectIds")) + if not len(authors): + raise SuperdeskApiError.badRequestError(_("authors field is required")) + + users = [] + profiles = get_author_profiles_by_user_id(authors) + for user_id in authors: + user = users_service.find_one(req=None, _id=user_id) + if not user: + raise SuperdeskApiError.notFoundError(_("User not found")) + + try: + user["email"] = profiles[user_id]["extra"]["profile_email"] + except KeyError: + pass + + users.append(user) + + return users + + +def gen_jwt_for_approval_request(item_id: str, author_id: ObjectId, scope: str, token_expiration: Optional[int] = None): + header = {"alg": JWT_ALGORITHM} + payload = { + "iss": "Superdesk Author Approvals", + "iat": int(time()), + "exp": int(time() + (token_expiration or app.config["SIGN_OFF_REQUESTS_EXPIRATION"])), + "scope": scope, + "author_id": str(author_id), + "item_id": item_id, + } + + token = JsonWebToken([JWT_ALGORITHM]).encode(header, payload, app.config["SIGN_OFF_REQUESTS_SHARED_SECRET"]) + + return token.decode("UTF-8") diff --git a/server/tga/types.py b/server/tga/types.py new file mode 100644 index 0000000..889ce54 --- /dev/null +++ b/server/tga/types.py @@ -0,0 +1,55 @@ +from typing import List +from typing_extensions import TypedDict +from datetime import datetime + +from bson import ObjectId + + +class SignOffAuthor(TypedDict): + name: str + title: str + institute: str + email: str + country: str + orcid_id: str + + +class SignOffWarrants(TypedDict): + no_copyright_infringements: bool + indemnify_360_against_loss: bool + ready_for_publishing: bool + + +class SignOffConsent(TypedDict): + signature: str + contact: bool + personal_information: bool + multimedia_usage: bool + + +class AuthorSignOffData(TypedDict): + user_id: ObjectId + sign_date: datetime + version_signed: int + + article_name: str + funding_source: str + affiliation: str + copyright_terms: str + + author: SignOffAuthor + warrants: SignOffWarrants + consent: SignOffConsent + + +class AuthorSignOffRequest(TypedDict): + user_id: ObjectId + sent: datetime + expires: datetime + + +class PublishSignOffData(TypedDict): + requester_id: ObjectId + request_sent: datetime + pending_reviews: List[AuthorSignOffRequest] + sign_offs: List[AuthorSignOffData]