From a8a1314158df6ca0538963abd6f0704c97f5ed2a Mon Sep 17 00:00:00 2001 From: Muhammad Umair Date: Mon, 16 Sep 2024 09:58:16 +0200 Subject: [PATCH 1/8] Added DESK_REJECT --- api/app/outcome/api.py | 25 ++++++++++------- api/app/outcome/models.py | 1 + api/migrations/versions/fb6d5f943621_.py | 27 +++++++++++++++++++ webapp/src/pages/ResponsePage/ResponsePage.js | 13 ++++++++- 4 files changed, 56 insertions(+), 10 deletions(-) create mode 100644 api/migrations/versions/fb6d5f943621_.py diff --git a/api/app/outcome/api.py b/api/app/outcome/api.py index a782a49fc..986d247cc 100644 --- a/api/app/outcome/api.py +++ b/api/app/outcome/api.py @@ -106,15 +106,22 @@ def post(self, event_id): outcome_repository.add(outcome) db.session.commit() - if (status == Status.REJECTED or status == Status.WAITLIST): # Email will be sent with offer for accepted candidates - email_user( - 'outcome-rejected' if status == Status.REJECTED else 'outcome-waitlist', - template_parameters=dict( - host=misc.get_baobab_host() - ), - event=event, - user=user, - ) + if status in [Status.REJECTED, Status.WAITLIST, Status.DESK_REJECTED]: + email_template = { + Status.REJECTED: 'outcome-rejected', + Status.WAITLIST: 'outcome-waitlist', + Status.DESK_REJECTED: 'outcome-rejected' #TODO: add desk-rejected tempelate + }.get(status) + + if email_template: + email_user( + email_template, + template_parameters=dict( + host=misc.get_baobab_host() + ), + event=event, + user=user, + ) return outcome, 201 diff --git a/api/app/outcome/models.py b/api/app/outcome/models.py index 95dfe4184..96c8315f6 100644 --- a/api/app/outcome/models.py +++ b/api/app/outcome/models.py @@ -9,6 +9,7 @@ class Status(Enum): REVIEW = "in review" ACCEPT_W_REVISION = "accept with minor revision" REJECT_W_ENCOURAGEMENT = "reject with encouragement to resubmit" + DESK_REJECTED = "desk rejected" class Outcome(db.Model): id = db.Column(db.Integer(), primary_key = True, nullable = False) diff --git a/api/migrations/versions/fb6d5f943621_.py b/api/migrations/versions/fb6d5f943621_.py new file mode 100644 index 000000000..f44bd9518 --- /dev/null +++ b/api/migrations/versions/fb6d5f943621_.py @@ -0,0 +1,27 @@ +"""empty message + +Revision ID: fb6d5f943621 +Revises: d4f153e3cea2 +Create Date: 2024-09-16 07:53:51.262038 + +""" + +# revision identifiers, used by Alembic. +revision = 'fb6d5f943621' +down_revision = 'd4f153e3cea2' + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + op.execute("CREATE TYPE outcome_status_new AS ENUM ('ACCEPTED', 'REJECTED', 'WAITLIST', 'REVIEW', 'ACCEPT_W_REVISION', 'REJECT_W_ENCOURAGEMENT', 'DESK_REJECTED')") + op.execute("ALTER TABLE outcome ALTER COLUMN status TYPE outcome_status_new USING status::text::outcome_status_new") + op.execute("DROP TYPE outcome_status") + op.execute("ALTER TYPE outcome_status_new RENAME TO outcome_status") + +def downgrade(): + op.execute("CREATE TYPE outcome_status_old AS ENUM ('ACCEPTED', 'REJECTED', 'WAITLIST', 'REVIEW', 'ACCEPT_W_REVISION', 'REJECT_W_ENCOURAGEMENT')") + op.execute("ALTER TABLE outcome ALTER COLUMN status TYPE outcome_status_old USING status::text::outcome_status_old") + op.execute("DROP TYPE outcome_status") + op.execute("ALTER TYPE outcome_status_old RENAME TO outcome_status") diff --git a/webapp/src/pages/ResponsePage/ResponsePage.js b/webapp/src/pages/ResponsePage/ResponsePage.js index d91af1833..b97f937e0 100644 --- a/webapp/src/pages/ResponsePage/ResponsePage.js +++ b/webapp/src/pages/ResponsePage/ResponsePage.js @@ -200,6 +200,8 @@ class ResponsePage extends Component { return {this.state.outcome.status} {this.formatDate(this.state.outcome.timestamp)} } else if (this.state.outcome.status === 'REJECTED') { return {this.state.outcome.status} {this.formatDate(this.state.outcome.timestamp)} + } else if (this.state.outcome.status === 'DESK_REJECTED') { + return {this.state.outcome.status} {this.formatDate(this.state.outcome.timestamp)} } else { return {this.state.outcome.status} {this.formatDate(this.state.outcome.timestamp)} } @@ -242,7 +244,16 @@ class ResponsePage extends Component { onClick={(e) => this.submitOutcome('REJECTED')}> Reject - + +
+ +
} else if (this.state.event_type === 'CALL') { From b887a918c082ddf6a7be2c11cc0f809b4ca5444f Mon Sep 17 00:00:00 2001 From: Muhammad Umair Date: Mon, 16 Sep 2024 12:04:38 +0200 Subject: [PATCH 2/8] Desk reject email template + migrations updates --- api/app/outcome/api.py | 14 +- api/app/outcome/models.py | 8 +- api/app/utils/emailer.py | 5 +- api/migrations/versions/d2c160b0cbe5_.py | 26 ++++ api/migrations/versions/fb6d5f943621_.py | 1 + .../src/pages/ResponsePage/ResponsePage.css | 77 ++++++++++- webapp/src/pages/ResponsePage/ResponsePage.js | 120 +++++++++++++++--- .../components/ApplicationForm.js | 13 +- .../src/services/outcome/outcome.service.js | 42 +++--- 9 files changed, 259 insertions(+), 47 deletions(-) create mode 100644 api/migrations/versions/d2c160b0cbe5_.py diff --git a/api/app/outcome/api.py b/api/app/outcome/api.py index 986d247cc..1deac5cfc 100644 --- a/api/app/outcome/api.py +++ b/api/app/outcome/api.py @@ -26,6 +26,7 @@ def _extract_status(outcome): 'id': fields.Integer, 'status': fields.String(attribute=_extract_status), 'timestamp': fields.DateTime(dt_format='iso8601'), + 'reason': fields.String, } user_fields = { @@ -75,6 +76,7 @@ def post(self, event_id): req_parser = reqparse.RequestParser() req_parser.add_argument('user_id', type=int, required=True) req_parser.add_argument('outcome', type=str, required=True) + req_parser.add_argument('reason', type=str) args = req_parser.parse_args() event = event_repository.get_by_id(event_id) @@ -101,7 +103,9 @@ def post(self, event_id): event_id, args['user_id'], status, - g.current_user['id']) + g.current_user['id'], + reason=args['reason'] + ) outcome_repository.add(outcome) db.session.commit() @@ -110,17 +114,19 @@ def post(self, event_id): email_template = { Status.REJECTED: 'outcome-rejected', Status.WAITLIST: 'outcome-waitlist', - Status.DESK_REJECTED: 'outcome-rejected' #TODO: add desk-rejected tempelate + Status.DESK_REJECTED: 'outcome-desk-rejected' }.get(status) if email_template: email_user( email_template, template_parameters=dict( - host=misc.get_baobab_host() + host=misc.get_baobab_host(), + reason=args['reason'] ), event=event, - user=user, + user=user + ) return outcome, 201 diff --git a/api/app/outcome/models.py b/api/app/outcome/models.py index 96c8315f6..aa6419f90 100644 --- a/api/app/outcome/models.py +++ b/api/app/outcome/models.py @@ -19,7 +19,8 @@ class Outcome(db.Model): timestamp = db.Column(db.DateTime(), nullable = False) latest = db.Column(db.Boolean(), nullable = False) updated_by_user_id = db.Column(db.Integer(), db.ForeignKey('app_user.id'), nullable=False) - + reason = db.Column(db.String(255), nullable=True) + event = db.relationship('Event', foreign_keys=[event_id]) user = db.relationship('AppUser', foreign_keys=[user_id]) updated_by_user = db.relationship('AppUser', foreign_keys=[updated_by_user_id]) @@ -28,14 +29,15 @@ def __init__(self, event_id, user_id, status, - updated_by_user_id - ): + updated_by_user_id, + reason=None): self.event_id = event_id self.user_id = user_id self.status = status self.timestamp = datetime.now() self.latest = True self.updated_by_user_id = updated_by_user_id + self.reason = reason def reset_latest(self): self.latest = False \ No newline at end of file diff --git a/api/app/utils/emailer.py b/api/app/utils/emailer.py index f9d9d14a9..22134b52a 100644 --- a/api/app/utils/emailer.py +++ b/api/app/utils/emailer.py @@ -19,7 +19,8 @@ def email_user( event=None, subject_parameters=None, file_name='', - file_path='' + file_path='', + reason=None ): """Send an email to a specified user using an email template. Handles resolving the correct language.""" if user is None: @@ -46,6 +47,8 @@ def email_user( template_parameters['lastname'] = user.lastname if event is not None and 'event_name' not in template_parameters: template_parameters['event_name'] = event.get_name(language) if event.has_specific_translation(language) else event.get_name('en') + if reason is not None and reason not in template_parameters: + template_parameters['reason'] = reason body_text = email_template.template.format(**template_parameters) send_mail(recipient=user.email, subject=subject, body_text=body_text, file_name=file_name, file_path=file_path) diff --git a/api/migrations/versions/d2c160b0cbe5_.py b/api/migrations/versions/d2c160b0cbe5_.py new file mode 100644 index 000000000..5353c5ee7 --- /dev/null +++ b/api/migrations/versions/d2c160b0cbe5_.py @@ -0,0 +1,26 @@ +"""empty message + +Revision ID: d2c160b0cbe5 +Revises: fb6d5f943621 +Create Date: 2024-09-16 08:42:06.110593 + +""" + +# revision identifiers, used by Alembic. +revision = 'd2c160b0cbe5' +down_revision = 'fb6d5f943621' + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('outcome', sa.Column('reason', sa.String(length=255), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('outcome', 'reason') + # ### end Alembic commands ### diff --git a/api/migrations/versions/fb6d5f943621_.py b/api/migrations/versions/fb6d5f943621_.py index f44bd9518..734bb0807 100644 --- a/api/migrations/versions/fb6d5f943621_.py +++ b/api/migrations/versions/fb6d5f943621_.py @@ -21,6 +21,7 @@ def upgrade(): op.execute("ALTER TYPE outcome_status_new RENAME TO outcome_status") def downgrade(): + op.execute("UPDATE outcome SET status = 'REJECTED' WHERE status = 'DESK_REJECTED'") op.execute("CREATE TYPE outcome_status_old AS ENUM ('ACCEPTED', 'REJECTED', 'WAITLIST', 'REVIEW', 'ACCEPT_W_REVISION', 'REJECT_W_ENCOURAGEMENT')") op.execute("ALTER TABLE outcome ALTER COLUMN status TYPE outcome_status_old USING status::text::outcome_status_old") op.execute("DROP TYPE outcome_status") diff --git a/webapp/src/pages/ResponsePage/ResponsePage.css b/webapp/src/pages/ResponsePage/ResponsePage.css index a6617a2d6..d4a668a61 100644 --- a/webapp/src/pages/ResponsePage/ResponsePage.css +++ b/webapp/src/pages/ResponsePage/ResponsePage.css @@ -405,4 +405,79 @@ font-size: 15px; .question-answer-block .question-headline { color: #666; -} \ No newline at end of file +} + +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + } + + .modal-content { + background-color: white; + padding: 20px; + border-radius: 5px; + max-width: 500px; + width: 100%; + position: relative; + } + + .modal-close { + position: absolute; + top: 10px; + right: 10px; + border: none; + background: none; + font-size: 24px; + cursor: pointer; + } + + .desk-reject-modal h2 { + margin-bottom: 20px; + } + + .reason-list { + display: flex; + flex-direction: column; + gap: 10px; + margin-bottom: 20px; + } + + .reason-option { + display: flex; + align-items: center; + cursor: pointer; + } + + .reason-option input { + margin-right: 10px; + } + + .modal-actions { + display: flex; + justify-content: flex-end; + gap: 10px; + } + + .btn-cancel, .btn-confirm { + padding: 8px 16px; + border: none; + border-radius: 4px; + cursor: pointer; + } + + .btn-cancel { + background-color: #f0f0f0; + } + + .btn-confirm { + background-color: #007bff; + color: white; + } \ No newline at end of file diff --git a/webapp/src/pages/ResponsePage/ResponsePage.js b/webapp/src/pages/ResponsePage/ResponsePage.js index b97f937e0..bface1c77 100644 --- a/webapp/src/pages/ResponsePage/ResponsePage.js +++ b/webapp/src/pages/ResponsePage/ResponsePage.js @@ -14,6 +14,53 @@ import { ConfirmModal } from "react-bootstrap4-modal"; import moment from 'moment' import { getDownloadURL } from '../../utils/files'; import TagSelectorDialog from '../../components/TagSelectorDialog'; +import ApplicationForm from '../applicationForm/components/ApplicationForm'; + +const PopupModal = ({ isOpen, onClose, children }) => { + if (!isOpen) return null; + + return ( +
+
+ + {children} +
+
+ ); + }; + +const deskRejectReasons = [ + "Incomplete submission", + "Out of scope", + "Plagiarism detected", + "Formatting issues", + "Other" + ]; + +const DeskRejectModal = ({ isOpen, onClose, onConfirm, onReasonChange, t }) => ( + +
+

{t('Select Desk Rejection Reason')}

+
+ {deskRejectReasons.map((reason, index) => ( + + ))} +
+
+ + +
+
+
+); class ResponsePage extends Component { constructor(props) { @@ -35,6 +82,8 @@ class ResponsePage extends Component { assignableTagTypes: ["RESPONSE"], reviewResponses: [], outcome: {'status':null,'timestamp':null}, + deskRejectModalVisible: false, + deskRejectReason: '', } }; @@ -95,6 +144,25 @@ class ResponsePage extends Component { return moment(dateString).format('D MMM YYYY, H:mm:ss [(UTC)]'); } + showDeskRejectModal = () => { + this.setState({ deskRejectModalVisible: true }); + } + + hideDeskRejectModal = () => { + this.setState({ deskRejectModalVisible: false, deskRejectReason: '' }); + } + + handleDeskRejectReasonChange = (reason) => { + this.setState({ deskRejectReason: reason }); + } + + confirmDeskReject = () => { + if (this.state.deskRejectReason) { + this.submitOutcome('DESK_REJECTED', this.state.deskRejectReason); + this.hideDeskRejectModal(); + } + } + applicationStatus() { const data = this.state.applicationData; if (data) { @@ -156,6 +224,7 @@ class ResponsePage extends Component { const newOutcome = { timestamp: response.outcome.timestamp, status: response.outcome.status, + reason: response.outcome.reason, }; this.setState( { @@ -173,26 +242,28 @@ class ResponsePage extends Component { }); }; - submitOutcome(selectedOutcome) { - outcomeService.assignOutcome(this.state.applicationData.user_id, this.props.event.id, selectedOutcome).then(response => { - if (response.status === 201) { - const newOutcome = { - timestamp: response.outcome.timestamp, - status: response.outcome.status, - }; + submitOutcome = (selectedOutcome, reason = null) => { + outcomeService.assignOutcome(this.state.applicationData.user_id, this.props.event.id, selectedOutcome, reason) + .then(response => { + if (response.status === 201) { + const newOutcome = { + timestamp: response.outcome.timestamp, + status: response.outcome.status, + reason: response.outcome.reason, // Add this line + }; - this.setState({ - outcome: newOutcome - }); - } else { - this.setState({erorr: response.error}); - } - }); + this.setState({ + outcome: newOutcome + }); + } else { + this.setState({error: response.error}); + } + }); } outcomeStatus() { const data = this.state.applicationData; - + console.log("reason", this.state.outcome.reason); if (data) { if (this.state.outcome.status && this.state.outcome.status !== 'REVIEW') { @@ -201,7 +272,12 @@ class ResponsePage extends Component { } else if (this.state.outcome.status === 'REJECTED') { return {this.state.outcome.status} {this.formatDate(this.state.outcome.timestamp)} } else if (this.state.outcome.status === 'DESK_REJECTED') { - return {this.state.outcome.status} {this.formatDate(this.state.outcome.timestamp)} + return ( + {this.state.outcome.status} + {this.formatDate(this.state.outcome.timestamp)} + {this.state.outcome.reason &&
- Reason: {this.state.outcome.reason}
} +
+ ); } else { return {this.state.outcome.status} {this.formatDate(this.state.outcome.timestamp)} } @@ -250,7 +326,7 @@ class ResponsePage extends Component { type="button" className="btn btn-danger" id="desk_reject" - onClick={(e) => this.submitOutcome('DESK_REJECTED')}> + onClick={this.showDeskRejectModal}> Desk Reject @@ -664,6 +740,13 @@ class ResponsePage extends Component { } + {this.renderReviewerModal()} {this.renderDeleteTagModal()} {this.renderDeleteReviewerModal()} @@ -672,7 +755,8 @@ class ResponsePage extends Component { {applicationData &&
-

{applicationData.user_title} {applicationData.firstname} {applicationData.lastname}

+ {/*

{applicationData.user_title} {applicationData.firstname} {applicationData.lastname}

*/} +

User ID: {applicationData.id}

{t("Language")}: {applicationData.language}

{this.renderTags()} diff --git a/webapp/src/pages/applicationForm/components/ApplicationForm.js b/webapp/src/pages/applicationForm/components/ApplicationForm.js index c20ed56a2..72070ccde 100644 --- a/webapp/src/pages/applicationForm/components/ApplicationForm.js +++ b/webapp/src/pages/applicationForm/components/ApplicationForm.js @@ -753,6 +753,7 @@ class ApplicationFormInstanceComponent extends Component { startStep: 0, new_response: !props.response, outcome: props.response && props.response.outcome, + reason: props.response && props.response.reason, submitValidationErrors: [] }; } @@ -892,16 +893,22 @@ class ApplicationFormInstanceComponent extends Component { errorMessage, answers, isSubmitting, - outcome + outcome, + reason } = this.state; + + if (outcome && outcome === "DESK_REJECTED") { + console.log("Rejection reason:", reason); + } + if (isError) { return
{errorMessage}
; } - if (outcome === "ACCEPTED" || outcome === "REJECTED") { + if (outcome === "ACCEPTED" || outcome === "REJECTED" || outcome === "DESK_REJECTED") { return
{outcome === "ACCEPTED" &&

{this.props.t("You have already been accepted to this event.")}

@@ -909,7 +916,9 @@ class ApplicationFormInstanceComponent extends Component { {this.props.t("View Offer")}
} + {outcome === "REJECTED" &&

{this.props.t("Unfortunately your application to this event has been rejected, you are not able to apply again.")}

} + {outcome === "DESK_REJECTED" &&

{"Unfortunately your application to this event has been desk rejected for the following reason:"} {outcome.reason}

}
; } diff --git a/webapp/src/services/outcome/outcome.service.js b/webapp/src/services/outcome/outcome.service.js index 7c370de5f..e3d8f1ed3 100644 --- a/webapp/src/services/outcome/outcome.service.js +++ b/webapp/src/services/outcome/outcome.service.js @@ -31,29 +31,35 @@ function getOutcome(event_id, user_id){ }); } -function assignOutcome(user_id, event_id, outcome){ +function assignOutcome(user_id, event_id, outcome, reason = null) { + const params = { + outcome: outcome, + event_id: event_id, + user_id: user_id + }; + + if (reason) { + params.reason = reason; + } + return axios - .post(baseUrl + "/api/v1/outcome", event_id, { - "headers": authHeader(), - "params": { - outcome: outcome, - event_id: event_id, - user_id: user_id - } - }) - .then(function (response){ - return{ - status:response.status, + .post(`${baseUrl}/api/v1/outcome`, null, { + headers: authHeader(), + params: params + }) + .then(function (response) { + return { + status: response.status, outcome: response.data } }) .catch(function(error) { return { - message: null, - error: - error.response && error.response.data - ? error.response.data.message - : error.message + message: null, + error: + error.response && error.response.data + ? error.response.data.message + : error.message }; - }); + }); } \ No newline at end of file From 97dde8b28b7458fa015c533bdd0d5f106e47feef Mon Sep 17 00:00:00 2001 From: Muhammad Umair Date: Mon, 16 Sep 2024 14:48:56 +0200 Subject: [PATCH 3/8] Textbox added --- .../src/pages/ResponsePage/ResponsePage.css | 95 ++++++++++++++++++- webapp/src/pages/ResponsePage/ResponsePage.js | 56 ++++++++++- 2 files changed, 148 insertions(+), 3 deletions(-) diff --git a/webapp/src/pages/ResponsePage/ResponsePage.css b/webapp/src/pages/ResponsePage/ResponsePage.css index d4a668a61..0d4b74642 100644 --- a/webapp/src/pages/ResponsePage/ResponsePage.css +++ b/webapp/src/pages/ResponsePage/ResponsePage.css @@ -480,4 +480,97 @@ font-size: 15px; .btn-confirm { background-color: #007bff; color: white; - } \ No newline at end of file + } + + /* Existing styles ... */ + +/* Modal overlay */ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; + } + + /* Modal content */ + .modal-content { + background-color: white; + padding: 20px; + border-radius: 5px; + width: 90%; + max-width: 500px; + position: relative; + } + + /* Close button */ + .modal-close { + position: absolute; + top: 10px; + right: 10px; + background: none; + border: none; + font-size: 1.5rem; + cursor: pointer; + } + + /* Reject modal specific styles */ + .reject-modal { + display: flex; + flex-direction: column; + gap: 15px; + } + + .reject-modal h2 { + margin-bottom: 10px; + font-size: 1.5rem; + } + + .reject-reason-input { + width: 100%; + height: 100px; + padding: 10px; + border: 1px solid #ccc; + border-radius: 4px; + resize: vertical; + } + + .modal-actions { + display: flex; + justify-content: flex-end; + gap: 10px; + } + + .btn-cancel, + .btn-confirm { + padding: 8px 16px; + border: none; + border-radius: 4px; + cursor: pointer; + font-weight: bold; + } + + .btn-cancel { + background-color: #f0f0f0; + color: #333; + } + + .btn-confirm { + background-color: #dc3545; + color: white; + } + + .btn-cancel:hover { + background-color: #e0e0e0; + } + + .btn-confirm:hover { + background-color: #c82333; + } + + /* Existing styles ... */ \ No newline at end of file diff --git a/webapp/src/pages/ResponsePage/ResponsePage.js b/webapp/src/pages/ResponsePage/ResponsePage.js index bface1c77..092211d29 100644 --- a/webapp/src/pages/ResponsePage/ResponsePage.js +++ b/webapp/src/pages/ResponsePage/ResponsePage.js @@ -62,6 +62,23 @@ const DeskRejectModal = ({ isOpen, onClose, onConfirm, onReasonChange, t }) => ( ); +const RejectModal = ({ isOpen, onClose, onConfirm, onReasonChange, t }) => ( + +
+

{t('Enter Rejection Reason')}

+