From 6a7b0a0f5c409d89570d9a56ce9265a521897eff Mon Sep 17 00:00:00 2001
From: maltheism <16293415+maltheism@users.noreply.github.com>
Date: Thu, 27 Feb 2020 16:02:08 -0500
Subject: [PATCH] [issue_tracker] Add attachments to issues (#5394)
This adds the ability to add attachments to issues in the issue tracker.
---
CHANGELOG.md | 3 +
SQL/0000-00-00-schema.sql | 14 +
SQL/0000-00-03-ConfigTables.sql | 8 +-
...-10-29_adding_issues_attachments_table.sql | 25 ++
modules/issue_tracker/README.md | 13 +-
modules/issue_tracker/ajax/EditIssue.php | 26 +-
modules/issue_tracker/help/issue_tracker.md | 4 +-
modules/issue_tracker/jsx/CommentList.js | 19 +-
modules/issue_tracker/jsx/IssueForm.js | 63 +++++
.../jsx/attachments/attachmentsList.js | 241 ++++++++++++++++++
.../jsx/attachments/uploadForm.js | 154 +++++++++++
modules/issue_tracker/jsx/index.js | 22 +-
.../issue_tracker/jsx/issueTrackerIndex.js | 2 +-
.../issue_tracker/php/attachment.class.inc | 239 +++++++++++++++++
.../php/models/attachmentdto.class.inc | 52 ++++
.../attachmentprovisioner.class.inc | 58 +++++
.../issue_tracker/php/uploadhelper.class.inc | 239 +++++++++++++++++
src/Data/Provisioners/DBObjectProvisioner.php | 68 +++++
18 files changed, 1213 insertions(+), 37 deletions(-)
create mode 100644 SQL/New_patches/2019-10-29_adding_issues_attachments_table.sql
create mode 100644 modules/issue_tracker/jsx/attachments/attachmentsList.js
create mode 100644 modules/issue_tracker/jsx/attachments/uploadForm.js
create mode 100644 modules/issue_tracker/php/attachment.class.inc
create mode 100644 modules/issue_tracker/php/models/attachmentdto.class.inc
create mode 100644 modules/issue_tracker/php/provisioners/attachmentprovisioner.class.inc
create mode 100644 modules/issue_tracker/php/uploadhelper.class.inc
create mode 100644 src/Data/Provisioners/DBObjectProvisioner.php
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 612277f5537..0aff28dce54 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -19,6 +19,9 @@ documentation for file permissions has been added to the README.md file (PR #532
#### Modules
+##### Issue Tracker
+- The issue_tracker module now has the feature of uploading attachments to new or existing issues.
+
##### Battery Manager
- New module created to manage the entries in the test_battery table of the database.
This allows projects to modify their instrument battery without requiring backend access.
diff --git a/SQL/0000-00-00-schema.sql b/SQL/0000-00-00-schema.sql
index fc2e86666db..923755e1f40 100644
--- a/SQL/0000-00-00-schema.sql
+++ b/SQL/0000-00-00-schema.sql
@@ -2095,3 +2095,17 @@ CREATE TABLE `publication_users_edit_perm_rel` (
CONSTRAINT `FK_publication_users_edit_perm_rel_PublicationID` FOREIGN KEY (`PublicationID`) REFERENCES `publication` (`PublicationID`),
CONSTRAINT `FK_publication_users_edit_perm_rel_UserID` FOREIGN KEY (`UserID`) REFERENCES `users` (`ID`)
) ENGINE=InnoDB DEFAULT CHARSET='utf8';
+
+CREATE TABLE `issues_attachments` (
+ `ID` int NOT NULL AUTO_INCREMENT,
+ `issueID` int(11) NOT NULL,
+ `file_hash` varchar(64) NOT NULL,
+ `date_added` timestamp NOT NULL DEFAULT current_timestamp(),
+ `file_name` varchar(255) NOT NULL DEFAULT '',
+ `deleted` tinyint(1) NOT NULL DEFAULT 0,
+ `user` varchar(255) NOT NULL DEFAULT '',
+ `description` text DEFAULT NULL,
+ `file_size` int(20) DEFAULT NULL,
+ `mime_type` varchar(255) NOT NULL DEFAULT '',
+ PRIMARY KEY (`ID`)
+) DEFAULT CHARSET=utf8mb4;
diff --git a/SQL/0000-00-03-ConfigTables.sql b/SQL/0000-00-03-ConfigTables.sql
index 8fad544a686..3d436d9f406 100644
--- a/SQL/0000-00-03-ConfigTables.sql
+++ b/SQL/0000-00-03-ConfigTables.sql
@@ -25,8 +25,8 @@ CREATE TABLE `Config` (
KEY `fk_Config_1_idx` (`ConfigID`),
CONSTRAINT `fk_Config_1`
FOREIGN KEY (`ConfigID`)
- REFERENCES `ConfigSettings` (`ID`)
- ON DELETE CASCADE
+ REFERENCES `ConfigSettings` (`ID`)
+ ON DELETE CASCADE
ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
@@ -126,6 +126,9 @@ INSERT INTO ConfigSettings (Name, Description, Visible, AllowMultiple, DataType,
INSERT INTO ConfigSettings (Name, Description, Visible, AllowMultiple, DataType, Parent, Label, OrderNumber) SELECT 'reCAPTCHAPrivate', 'Private Key for Google reCAPTCHA', 1, 0, 'text', ID, 'reCAPTCHA Private Key', 2 FROM ConfigSettings WHERE Name="APIKeys";
INSERT INTO ConfigSettings (Name, Description, Visible, AllowMultiple, DataType, Parent, Label, OrderNumber) SELECT 'reCAPTCHAPublic', 'Public Key for Google reCaptcha', 1, 0, 'text', ID, 'reCAPTCHA Public Key', 3 FROM ConfigSettings WHERE Name="APIKeys";
+-- Issue_Tracker attachments for issues.
+INSERT INTO ConfigSettings (Name, Description, Visible, AllowMultiple, DataType, Parent, Label, OrderNumber) VALUES ('IssueTrackerDataPath', 'Path to Issue Tracker data files', 1, 0, 'web_path', 26, 'Issue Tracker Data Path', 8);
+
-- Loris-MRI/Imaging Pipeline options from the $profile (commonly "prod") file
INSERT INTO ConfigSettings (Name, Description, Visible, AllowMultiple, Label, OrderNumber) VALUES ('imaging_pipeline', 'Imaging Pipeline settings', 1, 0, 'Imaging Pipeline', 12);
INSERT INTO ConfigSettings (Name, Description, Visible, AllowMultiple, DataType, Parent, Label, OrderNumber) SELECT 'dataDirBasepath', 'Base Path to the data directory of Loris-MRI', 1, 0, 'text', ID, 'Loris-MRI Data Directory', 1 FROM ConfigSettings WHERE Name="imaging_pipeline";
@@ -258,3 +261,4 @@ INSERT INTO Config (ConfigID, Value) SELECT ID, 't2' FROM ConfigSettings WHER
INSERT INTO Config (ConfigID, Value) SELECT ID, 'pd' FROM ConfigSettings WHERE Name="modalities_to_deface";
INSERT INTO Config (ConfigID, Value) SELECT ID, 'false' FROM ConfigSettings WHERE Name="usePwnedPasswordsAPI";
INSERT INTO Config (ConfigID, Value) SELECT ID, 'Y-m-d H:i:s' FROM ConfigSettings WHERE Name="dateDisplayFormat";
+INSERT INTO Config (ConfigID, Value) SELECT ID, '/data/issue_tracker/' FROM ConfigSettings WHERE Name="IssueTrackerDataPath";
diff --git a/SQL/New_patches/2019-10-29_adding_issues_attachments_table.sql b/SQL/New_patches/2019-10-29_adding_issues_attachments_table.sql
new file mode 100644
index 00000000000..6b3d73a3937
--- /dev/null
+++ b/SQL/New_patches/2019-10-29_adding_issues_attachments_table.sql
@@ -0,0 +1,25 @@
+CREATE TABLE `issues_attachments` (
+ `ID` int NOT NULL AUTO_INCREMENT,
+ `issueID` int(11) NOT NULL,
+ `file_hash` varchar(64) NOT NULL,
+ `date_added` timestamp NOT NULL DEFAULT current_timestamp(),
+ `file_name` varchar(255) NOT NULL DEFAULT '',
+ `deleted` tinyint(1) NOT NULL DEFAULT 0,
+ `user` varchar(255) NOT NULL DEFAULT '',
+ `description` text DEFAULT NULL,
+ `file_size` int(20) DEFAULT NULL,
+ `mime_type` varchar(255) NOT NULL DEFAULT '',
+ PRIMARY KEY (`ID`)
+) DEFAULT CHARSET=utf8mb4;
+
+INSERT INTO ConfigSettings (Name, Description, Visible, AllowMultiple, DataType, Parent, Label, OrderNumber)
+VALUES('IssueTrackerDataPath', 'Path to Issue Tracker data files', 1, 0, 'web_path', 26, 'Issue Tracker Data Path', 8);
+
+INSERT INTO Config (ConfigID, Value)
+SELECT
+ ID,
+ '/data/issue_tracker/'
+FROM
+ ConfigSettings
+WHERE
+ Name = "IssueTrackerDataPath";
diff --git a/modules/issue_tracker/README.md b/modules/issue_tracker/README.md
index 57ad222cc4b..970afad7cbb 100644
--- a/modules/issue_tracker/README.md
+++ b/modules/issue_tracker/README.md
@@ -1,12 +1,17 @@
# Issue Tracker
## Purpose
-The Issues Module allows users to track issues they have with data, or with their LORIS instance itself. A form with pre-defined fields is provided for users to submit issues, and a filter-form gives a sortable and filterable table view of issues viewable by the user.
+The Issues Module allows users to track issues they have with data, or with their LORIS instance itself. A form with pre-defined fields is provided for users to submit issues, upload attachments and a filter-form gives a sortable and filterable table view of issues viewable by the user.
## Permissions
-- `issue_tracker_reporter` permission allows adding an issue, editing issues created by the user, and commenting on all issues for the site.
-- `issue_tracker_developer` permission allows to do the same, as well as closing an issue or editing any field of a submitted issue for the site.
-- `view_all_sites` permission allows a user to view issues relevant to data from other sites.
+- `issue_tracker_reporter` permission allows adding an issue, editing issues created by the user, download issue attachments and commenting on all issues for the site.
+- `issue_tracker_developer` permission allows to do the same, as well as closing an issue or editing any field of a submitted issue for the site.
+- Permissions are in accordance with the data permissions granted to that user - if a user can not see data outside of their site, they will only be able to view issues relevant to their site.
+- If a user has DCC permission they will be able to see issues relevant to all sites.
+- Additionally, users can be designated reporters or developers.
+- Reporters can add issues, edit their own issues, and comment on all issues.
+- Developers can additionally edit all issues, and mark them as resolved.
+- Most users of Loris should be designated as reporters.
Most of the permissions are controlled in `IssueForm.js`, dependent on values returned in `editIssue.php`.
diff --git a/modules/issue_tracker/ajax/EditIssue.php b/modules/issue_tracker/ajax/EditIssue.php
index 13d4b87fa8e..1f166442587 100644
--- a/modules/issue_tracker/ajax/EditIssue.php
+++ b/modules/issue_tracker/ajax/EditIssue.php
@@ -28,6 +28,8 @@
*/
require_once "Email.class.inc";
+use LORIS\issue_tracker\Provisioners\AttachmentProvisioner;
+
//TODO: or split it into two files... :P
if ($_SERVER['REQUEST_METHOD'] === "GET") {
echo json_encode(getIssueFields());
@@ -112,6 +114,19 @@ function editIssue()
updateHistory($historyValues, $issueID);
updateComments($_POST['comment'], $issueID);
+ // Attachment for new issue.
+ if (isset($_FILES['file'])) {
+ $attachment = new \LORIS\issue_tracker\UploadHelper();
+ $attachment->setupUploading(
+ $user,
+ $_FILES,
+ array(
+ 'fileDescription' => '',
+ 'issueID' => $issueID,
+ )
+ );
+ }
+
// Adding new assignee to watching
if (isset($issueValues['assignee'])) {
$nowWatching = array(
@@ -662,12 +677,18 @@ function getIssueFields()
$issueID = $_GET['issueID'];
$issueData = getIssueData($issueID);
- $desc = $db->pselect(
+ $desc = $db->pselect(
"SELECT issueComment
FROM issues_comments WHERE issueID=:i
ORDER BY dateAdded LIMIT 1",
array('i' => $issueID)
);
+
+ $provisioner = (new AttachmentProvisioner($issueID));
+ $attachments = (new \LORIS\Data\Table())
+ ->withDataFrom($provisioner)
+ ->toArray($user);
+
$isWatching = $db->pselectOne(
"SELECT userID, issueID FROM issues_watching
WHERE issueID=:issueID AND userID=:userID",
@@ -681,7 +702,10 @@ function getIssueFields()
} else {
$issueData['watching'] = "Yes";
}
+ $username = $user->getUsername();
$issueData['commentHistory'] = getComments($issueID);
+ $issueData['attachments'] = $attachments;
+ $issueData['whoami'] = $username;
$issueData['othersWatching'] = getWatching($issueID);
$issueData['desc'] = $desc[0]['issueComment'] ?? '';
}
diff --git a/modules/issue_tracker/help/issue_tracker.md b/modules/issue_tracker/help/issue_tracker.md
index 970e6d151c1..820e9cde65c 100644
--- a/modules/issue_tracker/help/issue_tracker.md
+++ b/modules/issue_tracker/help/issue_tracker.md
@@ -1,11 +1,11 @@
# Issue Tracker
-This module allows you to submit, edit, view, and track issues.
+This module allows you to submit, edit, view, upload and track issues.
Use the *Selection Filter* section to search for specific issues, if desired. In the data table, you can navigate between different tabs—**All Issues**, **Active Issues**, **Closed Issues**, and **My Issues**—to further narrow your results.
In the table, in the *Title* column, you can click on any issue name to edit its details.
-Click **New Issue** if you wish to add a new issue. Then, populate the fields and click **Submit Issue**.
+Click **New Issue** if you wish to add a new issue. Then, populate the fields and click **Submit Issue**. You may also upload an attachment for the issue.
Note that you can add users to the "Watching" list - this will send email notifications to the selected users when the issue is created and updated. If you are the author of an issue, and you add yourself as Watching, you won't be sent notifications about your own updates.
diff --git a/modules/issue_tracker/jsx/CommentList.js b/modules/issue_tracker/jsx/CommentList.js
index 3580818050b..5e00dc5f18a 100644
--- a/modules/issue_tracker/jsx/CommentList.js
+++ b/modules/issue_tracker/jsx/CommentList.js
@@ -19,10 +19,6 @@ class CommentList extends Component {
}
render() {
- const btnCommentsLabel = (this.state.collapsed ?
- 'Show Comment History' :
- 'Hide Comment History');
-
const changes = this.props.commentHistory.reduce(function(carry, item) {
let label = item.dateAdded.concat(' - ', item.addedBy);
if (!carry[label]) {
@@ -59,18 +55,9 @@ class CommentList extends Component {
}, this);
return (
-
-
- {btnCommentsLabel}
-
-
+
);
}
diff --git a/modules/issue_tracker/jsx/IssueForm.js b/modules/issue_tracker/jsx/IssueForm.js
index 56de70d8400..cfb524bc151 100644
--- a/modules/issue_tracker/jsx/IssueForm.js
+++ b/modules/issue_tracker/jsx/IssueForm.js
@@ -1,5 +1,8 @@
import Loader from 'Loader';
+import Modal from 'jsx/Modal';
import CommentList from './CommentList';
+import IssueUploadAttachmentForm from './attachments/uploadForm';
+import AttachmentsList from './attachments/attachmentsList';
/**
* Issue add/edit form
@@ -25,6 +28,7 @@ class IssueForm extends Component {
isLoaded: false,
isNewIssue: false,
issueID: 0,
+ showAttachmentUploadModal: false,
};
// Bind component instance to custom methods
@@ -33,12 +37,30 @@ class IssueForm extends Component {
this.setFormData = this.setFormData.bind(this);
this.isValidForm = this.isValidForm.bind(this);
this.showAlertMessage = this.showAlertMessage.bind(this);
+ this.closeAttachmentUploadModal = this.closeAttachmentUploadModal.bind(this);
+ this.openAttachmentUploadModal = this.openAttachmentUploadModal.bind(this);
}
componentDidMount() {
this.getFormData();
}
+ openAttachmentUploadModal(e) {
+ e.preventDefault();
+ this.setState({showAttachmentUploadModal: true});
+ }
+ closeAttachmentUploadModal() {
+ this.setState({
+ upload: {
+ formData: {
+ fileType: '',
+ fileDescription: '',
+ },
+ },
+ showAttachmentUploadModal: false,
+ });
+ }
+
render() {
// If error occurs, return a message.
// XXX: Replace this with a UI component for 500 errors.
@@ -64,6 +86,8 @@ class IssueForm extends Component {
let submitButtonValue;
let commentLabel;
let isWatching = this.state.issueData.watching;
+ let attachmentUploadBtn = null;
+ let attachmentFileElement = null;
if (this.state.isNewIssue) {
headerText = 'Create New Issue';
@@ -72,6 +96,15 @@ class IssueForm extends Component {
dateCreated = 'Sometime Soon!';
submitButtonValue = 'Submit Issue';
commentLabel = 'Description';
+ attachmentFileElement = (
+
+ );
} else {
headerText = 'Edit Issue #' + this.state.issueData.issueID;
lastUpdateValue = this.state.issueData.lastUpdate;
@@ -79,8 +112,22 @@ class IssueForm extends Component {
dateCreated = this.state.issueData.dateCreated;
submitButtonValue = 'Update Issue';
commentLabel = 'New Comment';
+ attachmentUploadBtn = (
+
+ );
}
+ const fileCollection = this.state.isNewIssue || (
+
+ );
+
const commentHistory = this.state.isNewIssue || (
);
@@ -137,6 +184,16 @@ class IssueForm extends Component {
return (
+
+
+
+ {attachmentFileElement}
+ {attachmentUploadBtn}
+ {fileCollection}
{commentHistory}
);
@@ -438,7 +498,10 @@ class IssueForm extends Component {
IssueForm.propTypes = {
DataURL: PropTypes.string.isRequired,
+ baseURL: PropTypes.string.isRequired,
action: PropTypes.string.isRequired,
+ issue: PropTypes.string.isRequired,
+ whoami: PropTypes.string.isRequired,
};
export default IssueForm;
diff --git a/modules/issue_tracker/jsx/attachments/attachmentsList.js b/modules/issue_tracker/jsx/attachments/attachmentsList.js
new file mode 100644
index 00000000000..74783a8b4d8
--- /dev/null
+++ b/modules/issue_tracker/jsx/attachments/attachmentsList.js
@@ -0,0 +1,241 @@
+/**
+ * The following file handles displaying attachments
+ * for the issue being viewed in issue_tracker.
+ *
+ * @author Alizée Wickenheiser
+ * @license http://www.gnu.org/licenses/gpl-3.0.txt GPLv3
+ * @link https://github.com/aces/Loris
+ */
+
+import React, {Component, Fragment} from 'react';
+import PropTypes from 'prop-types';
+import Modal from 'jsx/Modal';
+
+/**
+ * React component used to display
+ * issue_tracker attachments list.
+ */
+class AttachmentsList extends Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ attachments: this.props.attachments,
+ showModalAttachmentDelete: false,
+ deleteItem: {
+ ID: '',
+ file_name: '',
+ file_hash: '',
+ },
+ };
+ this.deleteAttachment = this.deleteAttachment.bind(this);
+ this.openModalAttachmentDelete = this.openModalAttachmentDelete.bind(this);
+ this.closeModalAttachmentDelete = this.closeModalAttachmentDelete.bind(this);
+ this.displayAttachmentOptions = this.displayAttachmentOptions.bind(this);
+ }
+
+ /**
+ * Sends DELETE request to "soft" delete
+ * the attachment selected from the user.
+ */
+ deleteAttachment() {
+ const state = Object.assign({}, this.state);
+ const url = this.props.baseURL +
+ '/issue_tracker/Attachment' +
+ '?ID=' + state.deleteItem.ID;
+ fetch(url,
+ {
+ credentials: 'same-origin',
+ method: 'DELETE',
+ }).then((resp) => {
+ return resp.json();
+ })
+ .then((data) => {
+ if (data.success) {
+ window.location.href = this.props.baseURL
+ + '/issue_tracker/issue/'
+ + this.props.issue;
+ } else {
+ swal('Permission denied', '', 'error');
+ }
+ }).catch((error) => {
+ console.error(error);
+ }
+ );
+ }
+
+ /**
+ * Confirm with the user about deleting the file.
+ * @param {object} event - name of the form element
+ */
+ openModalAttachmentDelete(event) {
+ event.preventDefault();
+ const json = JSON.parse(event.target.getAttribute('value'));
+ this.setState({
+ showModalAttachmentDelete: true,
+ deleteItem: json,
+ });
+ }
+
+ /**
+ * Used to signal closing the attachment
+ * delete confirmation Modal.
+ */
+ closeModalAttachmentDelete() {
+ this.setState({
+ showModalAttachmentDelete: false,
+ });
+ }
+
+ /**
+ * Populates the attachment options.
+ * @param {string} deleteData - name of the element if deleted.
+ * @param {object} item - info of attachment.
+ * @return {DOMRect} row - to display.
+ */
+ displayAttachmentOptions(deleteData, item) {
+ if (this.props.userHasPermission
+ || this.state.attachments.whoami === item.user) {
+ return (
+
+
+
Attachment options:
+
+
+
+ );
+ }
+ return (
+
+
+
Attachment options:
+
+
+
+ );
+ }
+
+ render() {
+ const footerCSS = {
+ float: 'right',
+ paddingRight: '100px',
+ };
+ const overflowCSS = {
+ fontSize: '15pt',
+ whiteSpace: 'nowrap',
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ };
+ const modalConfirmationDeleteAttachment = (
+
+
+ Please confirm the request to delete the
+ "{this.state.deleteItem.file_name}" attachment.
+
+
+
+
+
+ );
+
+ let attachmentsRows = [];
+ for (const key in this.state.attachments) {
+ if (this.state.attachments.hasOwnProperty(key)) {
+ const item = this.state.attachments[key];
+ const deleteData = JSON.stringify(item);
+ // Hide "soft" deleted attachments
+ if (parseInt(item.deleted) === 1) {
+ continue;
+ }
+ attachmentsRows.unshift(
+
+
+
+
+
Date of attachment:
+
{item.date_added}
+
+
+
File:
+
{item.file_name}
+
+
+
+
+
+ {item.description ? (
+ <>
+
Description:
+
{item.description}
+ >
+ ) : null}
+
+
+ {this.displayAttachmentOptions(deleteData, item)}
+
+ );
+ }
+ }
+ const issueAttachments = attachmentsRows.length > 0 ? (
+ <>
+ Attachment History
+ {attachmentsRows}
+ >
+ ) : null;
+ return (
+
+ {modalConfirmationDeleteAttachment}
+ {issueAttachments}
+
+ );
+ }
+}
+
+AttachmentsList.propTypes = {
+ issue: PropTypes.string.isRequired,
+ baseURL: PropTypes.string.isRequired,
+ attachments: PropTypes.array,
+};
+AttachmentsList.defaultProps = {
+ attachments: [],
+};
+
+export default AttachmentsList;
diff --git a/modules/issue_tracker/jsx/attachments/uploadForm.js b/modules/issue_tracker/jsx/attachments/uploadForm.js
new file mode 100644
index 00000000000..ae6fb927cc9
--- /dev/null
+++ b/modules/issue_tracker/jsx/attachments/uploadForm.js
@@ -0,0 +1,154 @@
+import React, {Component} from 'react';
+import PropTypes from 'prop-types';
+
+import ProgressBar from 'ProgressBar';
+
+/**
+ * Issue Upload Attachment Form
+ *
+ * Displays a form allowing for uploading
+ * attachment in the issue_tracker.
+ *
+ * @author Alizée Wickenheiser
+ * @version 1.0.0
+ *
+ * */
+class IssueUploadAttachmentForm extends Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ formData: {
+ file: '',
+ fileDescription: '',
+ },
+ uploadResult: null,
+ errorMessage: null,
+ isLoaded: false,
+ loadedData: 0,
+ uploadProgress: -1,
+ };
+ this.uploadFile = this.uploadFile.bind(this);
+ this.handleSubmit = this.handleSubmit.bind(this);
+ this.setFileUploadFormData = this.setFileUploadFormData.bind(this);
+ }
+
+ /**
+ * Store the value of the element in this.state.upload.formData
+ *
+ * @param {string} formElement - name of the form element
+ * @param {string} value - value of the form element
+ */
+ setFileUploadFormData(formElement, value) {
+ const state = Object.assign({}, this.state);
+ state.formData[formElement] = value;
+ this.setState(state);
+ }
+
+ /**
+ * Handle form submission
+ * @param {object} e - Form submission event
+ */
+ handleSubmit(e) {
+ e.preventDefault();
+ this.uploadFile();
+ }
+
+ /*
+ * Uploads the file to the server
+ */
+ uploadFile() {
+ // Set form data and upload the media file
+ const state = Object.assign({}, this.state);
+ let formObj = new FormData();
+ for (let key in state.formData) {
+ if (state.formData.hasOwnProperty(key)) {
+ formObj.append(key, state.formData[key]);
+ }
+ }
+ formObj.append('issueID', this.props.issue);
+ const url = this.props.baseURL +
+ '/issue_tracker/Attachment' +
+ '?issueID=' + this.props.issue +
+ '&fileDescription=' + state.formData.fileDescription;
+ fetch(url,
+ {
+ credentials: 'same-origin',
+ method: 'POST',
+ body: formObj,
+ }).then((resp) => {
+ return resp.json();
+ })
+ .then((data) => {
+ // reset form data after successful file upload
+ if (data.success) {
+ this.setState({
+ formData: {
+ file: '',
+ fileDescription: '',
+ },
+ uploadProgress: -1,
+ });
+ swal('Upload Successful!', '', 'success');
+ window.location.href = this.props.baseURL
+ + '/issue_tracker/issue/'
+ + this.props.issue;
+ } else if (data.error) {
+ swal(data.error, '', 'error');
+ } else {
+ swal('Permission denied', '', 'error');
+ }
+ }).catch((error) => {
+ console.error(error);
+ const msg = error.responseJSON ?
+ error.responseJSON.message
+ : 'Upload error!';
+ this.setState({
+ errorMessage: msg,
+ uploadProgress: -1,
+ });
+ swal(msg, '', 'error');
+ });
+ }
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+IssueUploadAttachmentForm.propTypes = {
+ issue: PropTypes.string.isRequired,
+ baseURL: PropTypes.string.isRequired,
+};
+
+export default IssueUploadAttachmentForm;
diff --git a/modules/issue_tracker/jsx/index.js b/modules/issue_tracker/jsx/index.js
index c7d168720d1..e035bc903c9 100644
--- a/modules/issue_tracker/jsx/index.js
+++ b/modules/issue_tracker/jsx/index.js
@@ -3,17 +3,17 @@ import IssueForm from './IssueForm';
/**
* Render IssueForm on page load
*/
-$(function() {
+window.addEventListener('load', () => {
const id = location.href.split('/issue/')[1];
- const issueTracker = (
-
-
-
+ ReactDOM.render(
+ ,
+ document.getElementById('lorisworkspace')
);
-
- ReactDOM.render(issueTracker, document.getElementById('lorisworkspace'));
});
diff --git a/modules/issue_tracker/jsx/issueTrackerIndex.js b/modules/issue_tracker/jsx/issueTrackerIndex.js
index a2fecd82c82..2a738308b2f 100644
--- a/modules/issue_tracker/jsx/issueTrackerIndex.js
+++ b/modules/issue_tracker/jsx/issueTrackerIndex.js
@@ -66,7 +66,7 @@ class IssueTrackerIndex extends Component {
case 'Issue ID':
link = (
{cell}
diff --git a/modules/issue_tracker/php/attachment.class.inc b/modules/issue_tracker/php/attachment.class.inc
new file mode 100644
index 00000000000..dd743acd5f5
--- /dev/null
+++ b/modules/issue_tracker/php/attachment.class.inc
@@ -0,0 +1,239 @@
+
+ * @license http://www.gnu.org/licenses/gpl-3.0.txt GPLv3
+ * @link https://www.github.com/aces/Loris/
+ */
+class Attachment extends \NDB_Page implements ETagCalculator
+{
+ /**
+ * Return a json response for the specific attachment request
+ * or thrown an error.
+ *
+ * @param ServerRequestInterface $request The incoming PSR7 request
+ *
+ * @return ResponseInterface The outgoing PSR7 response
+ */
+ public function handle(ServerRequestInterface $request) : ResponseInterface
+ {
+ // Ensure GET, POST, or DELETE request.
+ switch ($request->getMethod()) {
+ case 'GET':
+ return $this->_handleGET($request);
+ case 'POST':
+ return $this->_handlePOST($request);
+ case 'DELETE':
+ return $this->_handleDELETE($request);
+ default:
+ return new \LORIS\Http\Response\JSON\MethodNotAllowed(
+ $this->allowedMethods()
+ );
+ }
+ }
+
+ /**
+ * Handle GET requests for attachment.
+ *
+ * @param ServerRequestInterface $request The incoming PSR7 request.
+ *
+ * @return ResponseInterface The outgoing PSR7 response
+ */
+ private function _handleGET(ServerRequestInterface $request) : ResponseInterface
+ {
+ // Parse GET query params.
+ $values = $request->getQueryParams();
+ // DB Query begins.
+ $ID = intval($values['ID']);
+ $factory = \NDB_Factory::singleton();
+ $DB = $factory->database();
+ $query = 'SELECT
+ file_hash,
+ file_name,
+ mime_type
+ FROM
+ issues_attachments
+ WHERE
+ ID = :ID';
+ $result = $DB->pselectRow($query, array('ID' => $ID));
+
+ $config = \NDB_Config::singleton();
+ $attachment_data_dir = rtrim(
+ $config->getSetting('IssueTrackerDataPath'),
+ '/'
+ );
+
+ $fileToDownload = trim($attachment_data_dir) .
+ '/attachments/' .
+ $result['file_hash'];
+
+ if (!is_readable($fileToDownload)) {
+ throw new \LorisException('Forbidden Access');
+ }
+
+ $name = rawurldecode($result['file_name']);
+
+ return (new \LORIS\Http\Response())
+ ->withHeader('Content-Type', $result['mime_type'])
+ ->withHeader(
+ 'Content-Disposition',
+ 'attachment; filename=' . basename($name)
+ )
+ ->withStatus(200)
+ ->withBody(new \LORIS\Http\FileStream($fileToDownload));
+ }
+
+ /**
+ * Handle POST requests for attachment
+ * and used for uploading attachment file.
+ *
+ * @param ServerRequestInterface $request The incoming PSR7 request.
+ *
+ * @return ResponseInterface The outgoing PSR7 response
+ */
+ private function _handlePOST(ServerRequestInterface $request) : ResponseInterface
+ {
+ // Parse POST request body.
+ $values = $request->getQueryParams();
+ $user = \NDB_Factory::singleton()->user();
+ $attachment = new \LORIS\issue_tracker\UploadHelper();
+ $response = $attachment->setupUploading(
+ $user,
+ $_FILES,
+ $values
+ );
+ return new \LORIS\Http\Response\JsonResponse(
+ $response
+ );
+ }
+
+ /**
+ * Handle DELETE requests for attachment
+ * and marks attachment as deleted from issue_tracker.
+ *
+ * @param ServerRequestInterface $request The incoming PSR7 request.
+ *
+ * @return ResponseInterface The outgoing PSR7 response
+ */
+ private function _handleDELETE(ServerRequestInterface $request)
+ : ResponseInterface
+ {
+ // Verify User can delete file.
+ if ($this->_deletePermission($request)) {
+ // Parse request body.
+ $values = $request->getQueryParams();
+ $ID = $values['ID'];
+ $factory = \NDB_Factory::singleton();
+ $DB = $factory->database();
+ $DB->update(
+ 'issues_attachments',
+ array('deleted' => 1),
+ array('ID' => $ID)
+ );
+ return new \LORIS\Http\Response\JsonResponse(
+ array('success' => true)
+ );
+ }
+ return new \LORIS\Http\Response\JSON\Forbidden(
+ 'Invalid Permissions'
+ );
+ }
+
+ /**
+ * Check if user is able to delete the attachment
+ * from the issue_tracker module.
+ *
+ * @param ServerRequestInterface $request The incoming PSR7 request.
+ *
+ * @return bool The permission to delete attachment.
+ */
+ private function _deletePermission(ServerRequestInterface $request)
+ : bool
+ {
+ // Parse request body.
+ $values = $request->getQueryParams();
+ $user = \NDB_Factory::singleton()->user();
+ $username = $user->getUsername();
+ $ID = $values['ID'];
+ $DB = \NDB_Factory::singleton()->database();
+ $query = 'SELECT
+ USER
+ FROM
+ issues_attachments
+ WHERE
+ ID = :ID
+ AND USER = :user;';
+ $result = $DB->pselectOne(
+ $query,
+ array('ID' => $ID, 'user' => $username)
+ );
+ // Check if User shouldn't be able to Delete.
+ if (empty($result)
+ && !$user->hasPermission(
+ 'issue_tracker_developer'
+ )
+ ) {
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * An ETagCalculator provides the ability to calculate an ETag for
+ * an incoming HTTP request.
+ *
+ * @param ServerRequestInterface $request The incoming PSR7 request.
+ *
+ * @return string The value to use for the ETag header.
+ */
+ public function ETag(ServerRequestInterface $request): string
+ {
+ if ($request->getMethod() === 'GET') {
+ return $_GET['file_hash'];
+ }
+ return '';
+ }
+
+ /**
+ * Return an array of valid HTTP methods for this endpoint.
+ *
+ * @return string[] Valid versions
+ */
+ protected function allowedMethods(): array
+ {
+ return array(
+ 'GET',
+ 'POST',
+ 'DELETE'
+ );
+ }
+
+ /**
+ * Returns true if the user has permission to access
+ * the issue_tracker module.
+ *
+ * @param \User $user The user whose access is being checked
+ *
+ * @return bool true if user has permission
+ */
+ function _hasAccess(\User $user) : bool
+ {
+ return $user->hasAnyPermission(
+ array(
+ 'issue_tracker_reporter',
+ 'issue_tracker_developer'
+ )
+ );
+ }
+
+}
diff --git a/modules/issue_tracker/php/models/attachmentdto.class.inc b/modules/issue_tracker/php/models/attachmentdto.class.inc
new file mode 100644
index 00000000000..c0fca436643
--- /dev/null
+++ b/modules/issue_tracker/php/models/attachmentdto.class.inc
@@ -0,0 +1,52 @@
+
+ * @license http://www.gnu.org/licenses/gpl-3.0.txt GPLv3
+ * @link https://github.com/aces/Loris-Trunk
+ */
+
+namespace LORIS\issue_tracker\Models;
+
+/**
+ * AttachmentDTO represents a attachment in the Issue Tracker module.
+ * It is doing the mapping between the issues_attachments table columns
+ * and the JSON object properties returned to the frontend.
+ *
+ * Additionally, it implements the DataInstance interface
+ * so it can be used by a Database Provisioner.
+ *
+ * @category Issue_Tracker
+ * @package Loris
+ * @author Alizée Wickenheiser
+ * @license http://www.gnu.org/licenses/gpl-3.0.txt GPLv3
+ * @link https://github.com/aces/Loris-Trunk
+ */
+class AttachmentDTO implements \LORIS\Data\DataInstance
+{
+ /**
+ * Implements \LORIS\Data\DataInstance interface for this row.
+ *
+ * @return string
+ */
+ public function toJSON(): string
+ {
+ return json_encode(
+ array(
+ 'ID' => $this->ID,
+ 'issueID' => $this->issueID,
+ 'file_hash' => $this->file_hash,
+ 'date_added' => $this->date_added,
+ 'file_name' => $this->file_name,
+ 'deleted' => $this->deleted,
+ 'user' => $this->user,
+ 'description' => $this->description,
+ 'file_size' => $this->file_size,
+ 'mime_type' => $this->mime_type,
+ )
+ );
+ }
+}
diff --git a/modules/issue_tracker/php/provisioners/attachmentprovisioner.class.inc b/modules/issue_tracker/php/provisioners/attachmentprovisioner.class.inc
new file mode 100644
index 00000000000..4745590f5d1
--- /dev/null
+++ b/modules/issue_tracker/php/provisioners/attachmentprovisioner.class.inc
@@ -0,0 +1,58 @@
+
+ * @license http://www.gnu.org/licenses/gpl-3.0.txt GPLv3
+ * @link https://github.com/aces/Loris-Trunk
+ */
+
+namespace LORIS\issue_tracker\Provisioners;
+
+/**
+ * The AttachmentProvisioner class
+ *
+ * PHP version 7
+ *
+ * @category Issue_Tracker
+ * @package Loris
+ * @author Alizée Wickenheiser
+ * @license http://www.gnu.org/licenses/gpl-3.0.txt GPLv3
+ * @link https://github.com/aces/Loris-Trunk
+ */
+class AttachmentProvisioner extends \LORIS\Data\Provisioners\DBObjectProvisioner
+{
+ /**
+ * Create a RowProvisioner
+ *
+ * @param string $issueID the issue id of the attachment.
+ */
+ function __construct($issueID)
+ {
+ parent::__construct(
+ "
+ SELECT
+ ID,
+ issueID,
+ file_hash,
+ date_added,
+ file_name,
+ deleted,
+ user,
+ description,
+ file_size,
+ mime_type
+ FROM
+ issues_attachments
+ WHERE
+ issueID = :issueID
+ ORDER BY
+ date_added
+ ",
+ array('issueID' => $issueID),
+ '\LORIS\issue_tracker\Models\AttachmentDTO'
+ );
+ }
+}
diff --git a/modules/issue_tracker/php/uploadhelper.class.inc b/modules/issue_tracker/php/uploadhelper.class.inc
new file mode 100644
index 00000000000..529fd1aa239
--- /dev/null
+++ b/modules/issue_tracker/php/uploadhelper.class.inc
@@ -0,0 +1,239 @@
+
+ * @license http://www.gnu.org/licenses/gpl-3.0.txt GPLv3
+ * @link https://www.github.com/aces/Loris/
+ */
+namespace LORIS\issue_tracker;
+
+/**
+ * The UploadHelper Class.
+ *
+ * This class provides the code to process uploading
+ * a user file from the issue_tracker for saving attachments.
+ *
+ * @category Loris
+ * @package Issue_Tracker
+ * @author Alizée Wickenheiser
+ * @license http://www.gnu.org/licenses/gpl-3.0.txt GPLv3
+ * @link https://www.github.com/aces/Loris/
+ */
+class UploadHelper
+{
+ /**
+ * The file uploaded from the user.
+ */
+ private $_fileToUpload;
+
+ /**
+ * The bytes of the file.
+ */
+ private $_bytes;
+
+ /**
+ * The user uploading the file.
+ */
+ private $_user;
+
+ /**
+ * The 'post message' array content.
+ */
+ private $_values;
+
+ /**
+ * Setup uploading configurations.
+ *
+ * @param object $user The user uploading.
+ * @param array $files The $_FILES array content.
+ * @param array $values The post message array content.
+ *
+ * @return array (success|error) with a message for the frontend.
+ */
+ function setupUploading(\User $user, array $files, array $values) : array
+ {
+ // Check if post.ini max_post configured for the file_size.
+ if (!isset($files['file']['tmp_name'])
+ || empty($files['file']['tmp_name'])
+ ) {
+ return array(
+ 'error' =>
+ 'The server is not configured to receive a file this large.'
+ );
+ }
+ $this->_user = $user;
+ $this->_bytes = $files['file']['size'];
+ $this->_values = $values;
+
+ $this->_fileToUpload
+ = (object) array(
+ 'file' => $files['file']['name'],
+ 'file_hash' => $this->generateHash($files['file']['tmp_name']),
+ 'mime_type' => $this->getFileMimeType($files['file']['name']),
+ 'tmp_name' => $files['file']['tmp_name'],
+ 'size' => $this->_bytes,
+ 'inserted_by' => $this->_user->getData('UserID'),
+ 'description' => $this->_values['fileDescription'] ?? '',
+ );
+
+ $this->setFullPath($this->_fileToUpload);
+
+ $DB = \NDB_Factory::singleton()->database();
+ $DB->beginTransaction();
+ $this->registerFile($this->_fileToUpload);
+ $this->endWithSuccess();
+
+ return array('success' => true);
+ }
+
+ /**
+ * Sets $fileToUpload->full_path to be the full path
+ * of where the attachment file will be stored.
+ *
+ * @param object $fileToUpload The file to upload
+ *
+ * @return void
+ * @throws \LorisException
+ */
+ function setFullPath(Object &$fileToUpload) : void
+ {
+ $config = \NDB_Config::singleton();
+ $attachment_data_dir = trim(
+ rtrim(
+ $config->getSetting('IssueTrackerDataPath'),
+ '/'
+ )
+ );
+
+ if (is_writable($attachment_data_dir)) {
+ $fileToUpload->full_path = $attachment_data_dir .
+ '/attachments';
+ if (!is_dir($fileToUpload->full_path)) {
+ mkdir($fileToUpload->full_path, 0770, true);
+ }
+ } else {
+ throw new \LorisException(
+ 'Issue_Tracker attachments directory not writable.'
+ );
+ }
+
+ }
+
+ /**
+ * This insert a record in the issues_attachments table.
+ *
+ * @param object $fileToUpload The object containing
+ * the $_FILES and the $_POST values.
+ *
+ * @return void
+ */
+ function registerFile(Object &$fileToUpload) : void
+ {
+ $DB = \NDB_Factory::singleton()->database();
+ $values = array(
+ 'issueID' => $this->_values['issueID'],
+ 'file_hash' => $fileToUpload->file_hash,
+ 'date_added' => date('Y-m-d h:i:s', time()),
+ 'file_name' => $fileToUpload->file,
+ 'deleted' => 0,
+ 'user' => $fileToUpload->inserted_by,
+ 'description' => $fileToUpload->description,
+ 'file_size' => $fileToUpload->size,
+ 'mime_type' => $fileToUpload->mime_type
+ );
+ try {
+ $DB->insert('issues_attachments', $values);
+ // Move file from tmp to issue_tracker attachment directory.
+ $path = $fileToUpload->full_path . '/' . $fileToUpload->file_hash;
+ move_uploaded_file($_FILES['file']['tmp_name'], $path);
+ } catch (\DatabaseException $e) {
+ $this->endWithFailure();
+ error_log($e->getMessage());
+ throw new \LorisException(
+ 'Could not write to the Database'
+ );
+ }
+ }
+
+ /**
+ * Get mime_type details for the attachment file.
+ *
+ * @param string $file the file name with extension.
+ *
+ * @return string $mime_type
+ */
+ function getFileMimeType(string $file) : string
+ {
+ $known_mime_types = array(
+ 'htm' => 'text/html',
+ 'csv' => 'text/csv',
+ 'exe' => 'application/octet-stream',
+ 'zip' => 'application/zip',
+ 'doc' => 'application/msword',
+ 'jpg' => 'image/jpg',
+ 'php' => 'text/plain',
+ 'xls' => 'application/vnd.ms-excel',
+ 'ppt' => 'application/vnd.ms-powerpoint',
+ 'gif' => 'image/gif',
+ 'pdf' => 'application/pdf',
+ 'txt' => 'text/plain',
+ 'html' => 'text/html',
+ 'png' => 'image/png',
+ 'jpeg' => 'image/jpg',
+ );
+ if (strpos($file, '.') !== false) {
+ $file_extension = strtolower(substr(strrchr($file, '.'), 1));
+ if (array_key_exists($file_extension, $known_mime_types)) {
+ $mime_type = $known_mime_types[$file_extension];
+ } else {
+ $mime_type = 'application/force-download';
+ }
+ } else {
+ $mime_type = 'application/force-download';
+ }
+ return $mime_type;
+ }
+
+ /**
+ * Create a sha256 string from file_content.
+ *
+ * @param string $filePath the path to the file.
+ *
+ * @return string
+ */
+ function generateHash(string $filePath) : string
+ {
+ return hash_file('sha256', $filePath);
+ }
+
+ /**
+ * Ends the transaction for the file attachment
+ * upload with failure.
+ *
+ * @return void
+ */
+ function endWithFailure() : void
+ {
+ $DB = \Database::singleton();
+ $DB->rollBack();
+ }
+
+ /**
+ * Ends the transaction for the file attachment
+ * upload with success.
+ *
+ * @return void
+ */
+ function endWithSuccess() : void
+ {
+ $DB = \Database::singleton();
+ $DB->commit();
+ }
+}
diff --git a/src/Data/Provisioners/DBObjectProvisioner.php b/src/Data/Provisioners/DBObjectProvisioner.php
new file mode 100644
index 00000000000..0928454a7f5
--- /dev/null
+++ b/src/Data/Provisioners/DBObjectProvisioner.php
@@ -0,0 +1,68 @@
+
+ * @license http://www.gnu.org/licenses/gpl-3.0.txt GPLv3
+ * @link https://www.github.com/aces/Loris/
+ */
+
+namespace LORIS\Data\Provisioners;
+
+/**
+ * A DBObjectProvisioner is an instance of ProvisionerInstance which
+ * takes an SQL query and the bind parameters to go with it, and
+ * executes it against the LORIS database.
+ *
+ * It also sets the fetch mode to PDO:FETCH_CLASS makes the statement
+ * return an instance of a given class name for each row.
+ *
+ * @category Data
+ * @package Main
+ * @subpackage Data
+ * @author Xavier Lecours
+ * @license http://www.gnu.org/licenses/gpl-3.0.txt GPLv3
+ * @link https://www.github.com/aces/Loris/
+ */
+abstract class DBObjectProvisioner extends \LORIS\Data\ProvisionerInstance
+{
+ private $query;
+ private $params;
+ private $classname;
+
+ /**
+ * Constructor
+ *
+ * @param string $query The SQL query to prepare and run
+ * @param array $params The prepared statement bind parameters
+ * @param string $classname The class name of the returned objects
+ */
+ public function __construct(string $query, array $params, string $classname)
+ {
+ $this->query = $query;
+ $this->params = $params;
+ if (!class_exists($classname)) {
+ throw new \NotFound($classname . ' not found.');
+ }
+ $this->classname = $classname;
+ }
+
+ /**
+ * GetAllInstances implements the abstract method from
+ * ProvisionerInstance by executing the query with PDO Fetch class
+ * option.
+ *
+ * @return \Traversable
+ */
+ public function getAllInstances() : \Traversable
+ {
+ $DB = (\NDB_Factory::singleton())->database();
+ $stmt = $DB->prepare($this->query);
+ $stmt->setFetchMode(\PDO::FETCH_CLASS, $this->classname);
+ $stmt->execute($this->params);
+ return $stmt;
+ }
+}