diff --git a/api/app/outcome/api.py b/api/app/outcome/api.py index a782a49fc..451f3fec2 100644 --- a/api/app/outcome/api.py +++ b/api/app/outcome/api.py @@ -4,10 +4,12 @@ from flask import g, request from sqlalchemy.exc import SQLAlchemyError +from app.events.models import EventType from app.outcome.models import Outcome, Status from app.outcome.repository import OutcomeRepository as outcome_repository from app.events.repository import EventRepository as event_repository from app.users.repository import UserRepository as user_repository +from app.responses.repository import ResponseRepository as response_repository from app.utils.emailer import email_user from app.utils.auth import auth_required, event_admin_required @@ -26,6 +28,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 +78,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) @@ -85,8 +89,13 @@ def post(self, event_id): if not user: return errors.USER_NOT_FOUND + response = response_repository.get_submitted_by_user_id_for_event(args['user_id'], event_id) + if not response: + return errors.RESPONSE_NOT_FOUND + try: status = Status[args['outcome']] + except KeyError: return errors.OUTCOME_STATUS_NOT_VALID @@ -101,20 +110,31 @@ 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() - - 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' if not event.event_type == EventType.JOURNAL else 'outcome-journal-rejected', + Status.WAITLIST: 'outcome-waitlist', + Status.DESK_REJECTED: 'outcome-desk-rejected' if not event.event_type == EventType.JOURNAL else 'outcome-journal-desk-rejected' + }.get(status) + + if email_template: + email_user( + email_template, + template_parameters=dict( + host=misc.get_baobab_host(), + reason=args['reason'], + response_id=response.id + ), + event=event, + user=user + + ) return outcome, 201 diff --git a/api/app/outcome/models.py b/api/app/outcome/models.py index 95dfe4184..aa6419f90 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) @@ -18,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]) @@ -27,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..ea0fb3f1d 100644 --- a/api/app/utils/emailer.py +++ b/api/app/utils/emailer.py @@ -19,9 +19,13 @@ def email_user( event=None, subject_parameters=None, file_name='', - file_path='' + file_path='', + reason=None, + response_id=None ): """Send an email to a specified user using an email template. Handles resolving the correct language.""" + + if user is None: raise ValueError('You must specify a user!') @@ -46,8 +50,13 @@ 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 + if response_id is not None and response_id not in template_parameters: + template_parameters['response_id'] = response_id 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/3447b1bb8210_.py b/api/migrations/versions/3447b1bb8210_.py new file mode 100644 index 000000000..32e034e53 --- /dev/null +++ b/api/migrations/versions/3447b1bb8210_.py @@ -0,0 +1,57 @@ +"""empty message + +Revision ID: 3447b1bb8210 +Revises: d2c160b0cbe5 +Create Date: 2024-09-21 11:30:45.546768 + +""" + +# revision identifiers, used by Alembic. +revision = '3447b1bb8210' +down_revision = 'd2c160b0cbe5' + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + + op.execute("""INSERT INTO email_template (id, key, template, language, subject) +VALUES (71, 'outcome-desk-rejected', 'Dear {title} {firstname} {lastname}, + +Thank you very much for submitting the application, with submission number {response_id}, to the {event_name}. + +We regret to inform you that your submission is desk rejected, with the following motivation by the editors: + +{reason} + +Best wishes, +The {event_name} Editors', 'en', '{event_name} Application Status Update')""") + + op.execute("""INSERT INTO email_template (id, key, template, language, subject) +VALUES (72, 'outcome-journal-desk-rejected', 'Dear {title} {firstname} {lastname}, + +Thank you very much for submitting your paper with submission number {response_id} to the {event_name}. + +We regret to inform you that your submission is desk rejected, with the following motivation by the editors: + +{reason} + +Best wishes, +The {event_name} Editors', 'en', '{event_name} Application Status Update')""") + + op.execute("""INSERT INTO email_template (id, key, template, language, subject) +VALUES (73, 'outcome-journal-rejected', 'Dear {title} {firstname} {lastname}, + +Thank you very much for submitting your paper with submission number {response_id} to the {event_name}. + +We regret to inform you that your submission is rejected, with the following motivation by the editors: + +{reason} + +Best wishes, +The {event_name} Editors', 'en', '{event_name} Application Status Update')""") + +def downgrade(): + # Remove the added email template rows + op.execute("DELETE FROM email_template WHERE key IN ('outcome-journal-rejected', 'outcome-journal-desk-rejected', 'outcome-desk-rejected')") 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 new file mode 100644 index 000000000..734bb0807 --- /dev/null +++ b/api/migrations/versions/fb6d5f943621_.py @@ -0,0 +1,28 @@ +"""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("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") + op.execute("ALTER TYPE outcome_status_old RENAME TO outcome_status") diff --git a/webapp/src/pages/ResponsePage/ResponsePage.css b/webapp/src/pages/ResponsePage/ResponsePage.css index a6617a2d6..0d4b74642 100644 --- a/webapp/src/pages/ResponsePage/ResponsePage.css +++ b/webapp/src/pages/ResponsePage/ResponsePage.css @@ -405,4 +405,172 @@ 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; + } + + /* 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 d91af1833..b1e10d21f 100644 --- a/webapp/src/pages/ResponsePage/ResponsePage.js +++ b/webapp/src/pages/ResponsePage/ResponsePage.js @@ -14,6 +14,70 @@ 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) => ( + + ))} +
+
+ + +
+
+
+); + +const RejectModal = ({ isOpen, onClose, onConfirm, onReasonChange, t }) => ( + +
+

{t('Enter Rejection Reason')}

+