diff --git a/api/app/events/repository.py b/api/app/events/repository.py index 4983bf67c..0fc73a2f7 100644 --- a/api/app/events/repository.py +++ b/api/app/events/repository.py @@ -36,6 +36,17 @@ def get_event_by_response_id(response_id): .join(Event, Event.id == ApplicationForm.event_id)\ .first() return result.Event if result else None + + @staticmethod + def get_event_by_application_form_id(application_form_id): + result = ( + db.session.query(ApplicationForm.event_id, Event) + .filter(ApplicationForm.id == application_form_id) + .join(Event, Event.id == ApplicationForm.event_id) + .first() + ) + return result.Event if result else None + @staticmethod def get_upcoming_for_organisation(organisation_id): diff --git a/api/app/outcome/api.py b/api/app/outcome/api.py index a782a49fc..102fb720b 100644 --- a/api/app/outcome/api.py +++ b/api/app/outcome/api.py @@ -4,17 +4,24 @@ from flask import g, request from sqlalchemy.exc import SQLAlchemyError + 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.utils.emailer import email_user +from app.responses.repository import ResponseRepository as response_repository +from app.utils import errors, strings +from app.reviews.repository import ReviewRepository as review_repository +from app.events.models import EventType + from app.utils.auth import auth_required, event_admin_required from app import LOGGER from app import db from app.utils import errors from app.utils import misc +from app.reviews.api import ReviewResponseDetailListAPI def _extract_status(outcome): @@ -26,6 +33,7 @@ def _extract_status(outcome): 'id': fields.Integer, 'status': fields.String(attribute=_extract_status), 'timestamp': fields.DateTime(dt_format='iso8601'), + 'review_summary': fields.String, } user_fields = { @@ -36,27 +44,50 @@ def _extract_status(outcome): 'user_title': fields.String } + +answer_fields = { + 'id': fields.Integer, + 'question_id': fields.Integer, + 'question': fields.String(attribute='question.headline'), + 'value': fields.String(attribute='value_display'), + 'question_type': fields.String(attribute='question.type') +} + +response_fields = { + 'id': fields.Integer, + 'application_form_id': fields.Integer, + 'user_id': fields.Integer, + 'is_submitted': fields.Boolean, + 'submitted_timestamp': fields.DateTime(dt_format='iso8601'), + 'is_withdrawn': fields.Boolean, + 'withdrawn_timestamp': fields.DateTime(dt_format='iso8601'), + 'started_timestamp': fields.DateTime(dt_format='iso8601'), + 'answers': fields.List(fields.Nested(answer_fields)) +} + outcome_list_fields = { 'id': fields.Integer, 'status': fields.String(attribute=_extract_status), 'timestamp': fields.DateTime(dt_format='iso8601'), 'user': fields.Nested(user_fields), - 'updated_by_user': fields.Nested(user_fields) + 'updated_by_user': fields.Nested(user_fields), + 'response': fields.Nested(response_fields) } - class OutcomeAPI(restful.Resource): @event_admin_required @marshal_with(outcome_fields) def get(self, event_id): req_parser = reqparse.RequestParser() req_parser.add_argument('user_id', type=int, required=True) + req_parser.add_argument('response_id', type=int, required=True) args = req_parser.parse_args() user_id = args['user_id'] + response_id=args['response_id'] try: - outcome = outcome_repository.get_latest_by_user_for_event(user_id, event_id) + outcome = outcome_repository.get_latest_by_user_for_event(user_id, event_id, response_id) if not outcome: return errors.OUTCOME_NOT_FOUND @@ -68,6 +99,8 @@ def get(self, event_id): except: LOGGER.error("Encountered unknown error: {}".format(traceback.format_exc())) return errors.DB_NOT_AVAILABLE + + @event_admin_required @marshal_with(outcome_fields) @@ -75,15 +108,19 @@ 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('response_id', type=int, required=True) + req_parser.add_argument('review_summary', type=str, required=False) args = req_parser.parse_args() event = event_repository.get_by_id(event_id) + if not event: return errors.EVENT_NOT_FOUND user = user_repository.get_by_id(args['user_id']) if not user: return errors.USER_NOT_FOUND + try: status = Status[args['outcome']] @@ -92,7 +129,7 @@ def post(self, event_id): try: # Set existing outcomes to no longer be the latest outcome - existing_outcomes = outcome_repository.get_all_by_user_for_event(args['user_id'], event_id) + existing_outcomes = outcome_repository.get_all_by_user_for_event(args['user_id'], event_id, args['response_id']) for existing_outcome in existing_outcomes: existing_outcome.reset_latest() @@ -101,21 +138,58 @@ def post(self, event_id): event_id, args['user_id'], status, - g.current_user['id']) + g.current_user['id'], + args['response_id'], + args.get('review_summary')) 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 (event.event_type==EventType.JOURNAL): + response = response_repository.get_by_id_and_user_id(outcome.response_id, outcome.user_id) + submission_title = response_repository.get_answer_by_question_key_and_response_id('submission_title', response.id) + + if not submission_title: + raise errors.SUBMISSION_TITLE_NOT_FOUND + + review_form = review_repository.get_review_form(outcome.event_id) + + if review_form is not None: + response_reviews = review_repository.get_all_review_responses_by_response(review_form.id, outcome.response_id) + else: + + raise errors.REVIEW_FORM_NOT_FOUND + + serialized_reviews = [ReviewResponseDetailListAPI._serialise_review_response(response, user.user_primaryLanguage) for response in response_reviews] + question_answer_summary = strings.build_review_email_body(serialized_reviews, user.user_primaryLanguage, review_form) + email_user( + 'response-journal', + template_parameters=dict( + summary=outcome.review_summary, + outcome=outcome.status.value, + submission_title=submission_title, + reviewers_contents=question_answer_summary, + ), + subject_parameters=dict( + submission_title=submission_title, + ), + event=event, + user=user, + ) + + else: + 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, + ) + + return outcome, 201 except SQLAlchemyError as e: @@ -124,6 +198,7 @@ def post(self, event_id): except: LOGGER.error("Encountered unknown error: {}".format(traceback.format_exc())) return errors.DB_NOT_AVAILABLE + class OutcomeListAPI(restful.Resource): diff --git a/api/app/outcome/models.py b/api/app/outcome/models.py index 95dfe4184..79fc5b9e7 100644 --- a/api/app/outcome/models.py +++ b/api/app/outcome/models.py @@ -23,11 +23,19 @@ class Outcome(db.Model): user = db.relationship('AppUser', foreign_keys=[user_id]) updated_by_user = db.relationship('AppUser', foreign_keys=[updated_by_user_id]) + response_id = db.Column(db.Integer(), db.ForeignKey('response.id'), nullable=True) + response = db.relationship('Response', foreign_keys=[response_id]) + + review_summary = db.Column(db.String(), nullable=True) + + def __init__(self, event_id, user_id, status, - updated_by_user_id + updated_by_user_id, + response_id, + review_summary ): self.event_id = event_id self.user_id = user_id @@ -35,6 +43,8 @@ def __init__(self, self.timestamp = datetime.now() self.latest = True self.updated_by_user_id = updated_by_user_id + self.response_id = response_id + self.review_summary = review_summary def reset_latest(self): self.latest = False \ No newline at end of file diff --git a/api/app/outcome/repository.py b/api/app/outcome/repository.py index e673cc6d3..4f9308fc0 100644 --- a/api/app/outcome/repository.py +++ b/api/app/outcome/repository.py @@ -5,16 +5,16 @@ class OutcomeRepository(): @staticmethod - def get_latest_by_user_for_event(user_id, event_id): + def get_latest_by_user_for_event(user_id, event_id, response_id=None): outcome = (db.session.query(Outcome) - .filter_by(user_id=user_id, event_id=event_id, latest=True) + .filter_by(user_id=user_id, event_id=event_id, latest=True, response_id=response_id) .first()) return outcome @staticmethod - def get_all_by_user_for_event(user_id, event_id): + def get_all_by_user_for_event(user_id, event_id, response_id=None): outcomes = (db.session.query(Outcome) - .filter_by(user_id=user_id, event_id=event_id) + .filter_by(user_id=user_id, event_id=event_id, response_id=response_id) .all()) return outcomes diff --git a/api/app/responses/api.py b/api/app/responses/api.py index da3ee2d7e..122fec86f 100644 --- a/api/app/responses/api.py +++ b/api/app/responses/api.py @@ -29,6 +29,11 @@ def _extract_outcome_status(response): return None return response.outcome.status.name +def _extract_review_summary(response): + if not hasattr(response, "review_summary") or not isinstance(response.outcome, Outcome): + return None + return response.outcome.review_summary + class ResponseAPI(ResponseMixin, restful.Resource): @@ -50,7 +55,8 @@ class ResponseAPI(ResponseMixin, restful.Resource): 'answers': fields.List(fields.Nested(answer_fields)), 'language': fields.String, 'parent_id': fields.Integer(default=None), - 'outcome': fields.String(attribute=_extract_outcome_status) + 'outcome': fields.String(attribute=_extract_outcome_status), + 'review_summary': fields.String(attribute=_extract_review_summary) } def find_answer(self, question_id: int, answers: Sequence[Answer]) -> Optional[Answer]: @@ -122,10 +128,11 @@ def get(self): form = event.get_application_form() responses = response_repository.get_all_for_user_application(current_user_id, form.id) - # TODO: Link outcomes to responses rather than events to cater for multiple submissions. - outcome = outcome_repository.get_latest_by_user_for_event(current_user_id, event_id) for response in responses: + outcome = outcome_repository.get_latest_by_user_for_event(current_user_id, event_id, response.id) + print('outcome', outcome) response.outcome = outcome + response.review_summary = outcome return marshal(responses, ResponseAPI.response_fields), 200 @@ -136,20 +143,32 @@ def post(self): is_submitted = args['is_submitted'] application_form_id = args['application_form_id'] language = args['language'] + parent_id = args.get('parent_id') + + if len(language) != 2: language = 'en' # Fallback to English if language doesn't look like an ISO 639-1 code application_form = application_form_repository.get_by_id(application_form_id) + if application_form is None: return errors.FORM_NOT_FOUND_BY_ID - - user = user_repository.get_by_id(user_id) - responses = response_repository.get_all_for_user_application(user_id, application_form_id) - if not application_form.nominations and len(responses) > 0: - return errors.RESPONSE_ALREADY_SUBMITTED + event = event_repository.get_event_by_application_form_id(application_form_id) + if event is None: + return errors.EVENT_NOT_FOUND + + allow_multiple_submissions = False + if event.event_type == EventType.JOURNAL: + allow_multiple_submissions = True + + if not allow_multiple_submissions: + responses = response_repository.get_all_for_user_application(user_id, application_form_id) + if len(responses) > 0: + return errors.RESPONSE_ALREADY_SUBMITTED + - response = Response(application_form_id, user_id, language) + response = Response(application_form_id, user_id, language, parent_id) response_repository.save(response) answers = [] @@ -300,16 +319,37 @@ def send_confirmation(self, user, response): event_description = event.get_description(user.user_primaryLanguage) else: event_description = event.get_description('en') + + if (event.event_type==EventType.JOURNAL): + submission_title= response_repository.get_answer_by_question_key_and_response_id('submission_title', response.id) + if not submission_title: + raise errors.SUBMISSION_TITLE_NOT_FOUND + + emailer.email_user( + 'submitting-article-journal', + template_parameters=dict( + event_description=event_description, + question_answer_summary=question_answer_summary, + ), + subject_parameters=dict( + submission_title=submission_title.value, + ), + event=event, + user=user + ) + - emailer.email_user( - 'confirmation-response-call' if event.event_type == EventType.CALL else 'confirmation-response', - template_parameters=dict( - event_description=event_description, - question_answer_summary=question_answer_summary, - ), - event=event, - user=user - ) + else: + + emailer.email_user( + 'confirmation-response-call' if event.event_type == EventType.CALL else 'confirmation-response', + template_parameters=dict( + event_description=event_description, + question_answer_summary=question_answer_summary, + ), + event=event, + user=user + ) except Exception as e: LOGGER.error('Could not send confirmation email for response with id : {response_id} due to: {e}'.format(response_id=response.id, e=e)) diff --git a/api/app/responses/mixins.py b/api/app/responses/mixins.py index 6da7eca5f..dd5c50347 100644 --- a/api/app/responses/mixins.py +++ b/api/app/responses/mixins.py @@ -9,6 +9,7 @@ class ResponseMixin(object): post_req_parser.add_argument('application_form_id', type=int, required=True) post_req_parser.add_argument('answers', type=list, required=True, location='json') post_req_parser.add_argument('language', type=str, required=True) + post_req_parser.add_argument('parent_id', type=int, required=True) put_req_parser = reqparse.RequestParser() put_req_parser.add_argument('id', type=int, required=True) diff --git a/api/app/responses/models.py b/api/app/responses/models.py index 231449b92..489d20789 100644 --- a/api/app/responses/models.py +++ b/api/app/responses/models.py @@ -51,7 +51,7 @@ def __init__(self, application_form_id, user_id, language, parent_id=None): self.submitted_timestamp = None self.is_withdrawn = False self.withdrawn_timestamp = None - self.started_timestamp = date.today() + self.started_timestamp = datetime.now() self.language = language self.parent_id = parent_id diff --git a/api/app/reviews/api.py b/api/app/reviews/api.py index a0fa570e1..270eea897 100644 --- a/api/app/reviews/api.py +++ b/api/app/reviews/api.py @@ -682,7 +682,7 @@ def add_reviewer_role(self, user_id, event_id): def get_eligible_response_ids(self, event_id, reviewer_user_id, num_reviews, reviews_required, tags): candidate_responses = review_repository.get_candidate_responses(event_id, reviewer_user_id, reviews_required) - # Filter by tags + # Filter by tags if tags: filtered_candidates_responses = [] for response in candidate_responses: diff --git a/api/app/utils/errors.py b/api/app/utils/errors.py index 7d49ea944..a95d58d65 100644 --- a/api/app/utils/errors.py +++ b/api/app/utils/errors.py @@ -151,6 +151,7 @@ EVENT_FEE_REQUIRED = ({'message': "Event fee id is required for when payment is required."}, 400) DELETE_INVITED_GUEST_FAILED = ({'message': "Failed to delete invited guest."}, 500) EVENT_ROLE_NOT_FOUND = ({'message': "Event role not found."}, 404) -EVENT_ROLE_ALREADY_EXISTS = ({'message': 'This user already has this role for this event.'}, 409) INVALID_INPUT_MALFORMED_PAGINATION = ({'message': "Invalid input: malformed pagination."}, 400) -NOT_EDITABLE = ({'message': "This response cannot be edited."}, 400) \ No newline at end of file +SUBMISSION_TITLE_NOT_FOUND = ({'message': "The submission title could not be found in the application form."}, 400) +EVENT_ROLE_ALREADY_EXISTS = ({'message': 'This user already has this role for this event.'}, 409) +NOT_EDITABLE = ({'message': "This response cannot be edited."}, 400) diff --git a/api/app/utils/strings.py b/api/app/utils/strings.py index 1a61b4319..49a684f8b 100644 --- a/api/app/utils/strings.py +++ b/api/app/utils/strings.py @@ -83,6 +83,7 @@ def build_response_email_body(answers, language, application_form): return stringified_summary + def build_response_html_app_info(response, language): """ Stringifying the application information, for output in a html file, with the response_id and applicant name contact info as @@ -125,3 +126,42 @@ def build_response_html_answers(answers, language, application_form): return stringified_answers + +def build_review_email_body(review_responses, language, review_form): + """ Builds a summary of review responses for an email body. + Each reviewer's responses are grouped by section and question.""" + summary_str = "" + for idx, review_response in enumerate(review_responses, start=1): + summary_str += f"Reviewer {idx}\n\n" + + for section in review_form.review_sections: + if not section.review_questions: + continue + + section_translation = section.get_translation(language) + if not section_translation: + section_translation = section.get_translation('en') + section_title = section_translation.headline + summary_str += section_title + "\n" + ("-" * 20) + "\n\n" + + for question in section.review_questions: + question_translation = question.get_translation(language) + if not question_translation: + question_translation = question.get_translation('en') + + found_score = next( + (score for score in review_response["scores"] + if score["review_question_id"] == question.id), + None + ) + if found_score: + answer_value = found_score.get("value", "") + summary_str += ( + f"{question_translation.headline}\n" + f"{answer_value}\n\n" + ) + + summary_str += "\n" + + return summary_str + diff --git a/api/migrations/versions/0a7b0ee0693a_add_template_email_journal.py b/api/migrations/versions/0a7b0ee0693a_add_template_email_journal.py new file mode 100644 index 000000000..49a41d162 --- /dev/null +++ b/api/migrations/versions/0a7b0ee0693a_add_template_email_journal.py @@ -0,0 +1,100 @@ +"""add_template email journal + +Revision ID: 0a7b0ee0693a +Revises: 4b881c1c6dc2 +Create Date: 2025-03-03 09:12:51.876903 + +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy import orm +from sqlalchemy.ext.declarative import declarative_base +from app import db + +depends_on = "4b881c1c6dc2" + +# Revision identifiers, used by Alembic. +revision = "0a7b0ee0693a" +down_revision = "4b881c1c6dc2" + +Base = declarative_base() + +class EmailTemplate(Base): + __tablename__ = "email_template" + __table_args__ = {"extend_existing": True} + + id = db.Column(db.Integer(), primary_key=True) + key = db.Column(db.String(50), nullable=False) + event_id = db.Column(db.Integer(), nullable=True) + language = db.Column(db.String(2), nullable=False) + template = db.Column(db.String(), nullable=False) + subject = db.Column(db.String(), nullable=False) + +def upgrade(): + Base.metadata.bind = op.get_bind() + session = orm.Session(bind=Base.metadata.bind) + + template = """Dear {title} {firstname} {lastname}, + + Thank you for your submission of **{submission_title}** to {event_name}. + Please find below a meta review summary, followed by the reviewer(s)’s feedback. + + **Meta-review** + + {summary} + + **Decision** + + {outcome} + + **Review(s)** + + {reviewers_contents} + + Kind regards, + The {event_name} editorial board""" + + session.add( + EmailTemplate( + key="response-journal", + event_id=None, + subject="{submission_title} Decision", + template=template, + language="en" + ) + ) + + template = """Cher/Cher(e) {title} {firstname} {lastname}, + + Merci pour votre soumission de {submission_title} au {event_name}. + Vous trouverez ci-dessous un résumé de la méta-évaluation, suivi des commentaires des réviseurs. + + **Méta-évaluation** + {summary} + + **Décision** + {outcome} + + **Commentaires des relecteurs** + {reviewers_contents} + + Cordialement, + Le comité de redaction du {event_name}""" + + session.add( + EmailTemplate( + key="response-journal", + event_id=None, + subject="{submission_title} Decision", + template=template, + language="fr" + ) + ) + session.commit() + +def downgrade(): + op.execute("""DELETE FROM email_template WHERE key = 'response-journal'""") + + + diff --git a/api/migrations/versions/0b6fae140f61_add_review_summary_outcome.py b/api/migrations/versions/0b6fae140f61_add_review_summary_outcome.py new file mode 100644 index 000000000..2717ada14 --- /dev/null +++ b/api/migrations/versions/0b6fae140f61_add_review_summary_outcome.py @@ -0,0 +1,27 @@ +"""add_review_summary_outcome + + + +Revision ID: 0b6fae140f61 +Revises: 3fe861d70b76 +Create Date: 2025-02-19 19:52:27.330452 + +""" + +# revision identifiers, used by Alembic. +revision: str = '0b6fae140f61' +down_revision: str ='3fe861d70b76' + +from alembic import op +import sqlalchemy as sa + +def upgrade(): + op.add_column( + 'outcome', + sa.Column('review_summary', sa.String(), nullable=True) + ) + +def downgrade(): + op.drop_column('outcome', 'review_summary') + + diff --git a/api/migrations/versions/3fe861d70b76_add_foreign_key_response_id_to_outcome.py b/api/migrations/versions/3fe861d70b76_add_foreign_key_response_id_to_outcome.py new file mode 100644 index 000000000..c76cba0eb --- /dev/null +++ b/api/migrations/versions/3fe861d70b76_add_foreign_key_response_id_to_outcome.py @@ -0,0 +1,39 @@ +"""Add foreign key response_id to outcome + +Revision ID: 3fe861d70b76 +Revises: 48d559146efd +Create Date: 2025-01-29 00:54:20.105588 + +""" +# revision identifiers, used by Alembic. +revision = '3fe861d70b76' +down_revision = '48d559146efd' + +from alembic import op +import sqlalchemy as sa + +def upgrade(): + op.add_column('outcome', sa.Column('response_id', sa.Integer(), nullable=True)) + + op.create_foreign_key( + 'fk_outcome_response_id', + 'outcome', + 'response', + ['response_id'], + ['id'] + ) + + op.execute(""" + UPDATE outcome + SET response_id = r.id + FROM response r, application_form app_form + WHERE outcome.user_id = r.user_id AND app_form.id = r.application_form_id AND app_form.event_id = outcome.event_id + """) + + op.alter_column('outcome', 'response_id', nullable=False) + + +def downgrade(): + op.drop_constraint('fk_outcome_response_id', 'outcome', type_='foreignkey') + + op.drop_column('outcome', 'response_id') diff --git a/api/migrations/versions/40ed07fdb75a_add_template_email_journal_submission.py b/api/migrations/versions/40ed07fdb75a_add_template_email_journal_submission.py new file mode 100644 index 000000000..b3491da50 --- /dev/null +++ b/api/migrations/versions/40ed07fdb75a_add_template_email_journal_submission.py @@ -0,0 +1,82 @@ +"""add_template_email_journal_submission + +Revision ID: 40ed07fdb75a +Revises: 0a7b0ee0693a +Create Date: 2025-03-06 00:05:43.764947 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy import orm +from sqlalchemy.ext.declarative import declarative_base +from app import db + +# revision identifiers, used by Alembic. +revision = '40ed07fdb75a' +down_revision = '0a7b0ee0693a' + + +Base = declarative_base() + +class EmailTemplate(Base): + __tablename__ = "email_template" + __table_args__ = {"extend_existing": True} + + id = db.Column(db.Integer(), primary_key=True) + key = db.Column(db.String(50), nullable=False) + event_id = db.Column(db.Integer(), nullable=True) + language = db.Column(db.String(2), nullable=False) + template = db.Column(db.String(), nullable=False) + subject = db.Column(db.String(), nullable=False) + +def upgrade(): + Base.metadata.bind = op.get_bind() + session = orm.Session(bind=Base.metadata.bind) + + template = """Dear {title} {firstname} {lastname}, + +Thank you for submitting to {event_name}. Your article is being reviewed by our committee and we will get back to you as soon as possible. Included below is a copy of your responses. + +{question_answer_summary} + +Kind Regards, +The {event_name} editorial board""" + + + session.add( + EmailTemplate( + key="submitting-article-journal", + event_id=None, + subject="{submission_title} Submission", + template=template, + language="en" + ) + ) + + template = """Cher/Cher(e) {title} {firstname} {lastname}, + + Merci d'avoir soumis votre article à {event_name}. Votre article est actuellement en cours d’examen par notre comité, et nous reviendrons vers vous dès que possible. Vous trouverez ci-dessous un récapitulatif de vos réponses. + + {question_answer_summary} + + Cordialement, + Le comité de redaction du {event_name} + """ + + + session.add( + EmailTemplate( + key="submitting-article-journal", + event_id=None, + subject="{submission_title} Submission", + template=template, + language="fr" + ) + ) + session.commit() + +def downgrade(): + Base.metadata.bind = op.get_bind() + session = orm.Session(bind=Base.metadata.bind) + session.query(EmailTemplate).filter_by(key='submitting-article-journal').delete() + session.commit() diff --git a/api/migrations/versions/48d559146efd_add_new_outcomes.py b/api/migrations/versions/48d559146efd_add_new_outcomes.py new file mode 100644 index 000000000..2d5f9648b --- /dev/null +++ b/api/migrations/versions/48d559146efd_add_new_outcomes.py @@ -0,0 +1,34 @@ +"""Add new outcomes + +Revision ID: 48d559146efd +Revises: a4662031beca +Create Date: 2025-01-28 17:11:21.400338 + +""" +# revision identifiers, used by Alembic. +revision = '48d559146efd' +down_revision = 'a4662031beca' + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + op.execute("ALTER TYPE outcome_status ADD VALUE 'REVIEW';") + op.execute("ALTER TYPE outcome_status ADD VALUE 'ACCEPT_W_REVISION';") + op.execute("ALTER TYPE outcome_status ADD VALUE 'REJECT_W_ENCOURAGEMENT';") + + +def downgrade(): + op.execute(""" + DO $$ + BEGIN + ALTER TYPE outcome_status RENAME TO outcome_status_old; + CREATE TYPE outcome_status AS ENUM('ACCEPTED', 'REJECTED', 'WAITLIST'); + ALTER TABLE outcome + ALTER COLUMN status + TYPE outcome_status + USING status::text::outcome_status; + DROP TYPE outcome_status_old; + END $$; + """) \ No newline at end of file diff --git a/webapp/public/locales/en/translation.json b/webapp/public/locales/en/translation.json index 360c505c4..7ae15af80 100644 --- a/webapp/public/locales/en/translation.json +++ b/webapp/public/locales/en/translation.json @@ -600,6 +600,37 @@ "Applications Opening Soon": "Applications Opening Soon", "Invitation letter is not yet available, please check again at a later date.": "Invitation letter is not yet available, please check again at a later date.", "Confirmed": "Confirmed", + "Resubmit an article": "Resubmit an article", + "Start a new submission": "Start a new submission", + "REJECTED WITH ENCOURAGEMENT TO RESUMIT": "REJECTED WITH ENCOURAGEMENT TO RESUMIT", + "ACCEPTED": "ACCEPTED", + "REJECTED": "REJECTED", + "PENDING": "PENDING", + "ACCEPTED WITH REVISION": "ACCEPTED WITH REVISION", + "Submissions": "Submissions", + "Related Submissions": "Related Submissions", + "Review Summaries": "Review Summaries", + "The review summary field is required.": "The review summary field is required.", + "No reviews available": "No reviews available", + "You are about to submit a ":"You are about to submit a ", + "completely new article.": "completely new article.", + "If you are making a ":"If you are making a ", + "resubmission to an article ":"resubmission to an article ", + "under review, please do so using the ":"under review, please do so using the ", + "Resubmit button.":"Resubmit button.", + " next to the latest version of the article you submitted.":" next to the latest version of the article you submitted.", + "The ":"The ", + "Resubmit button ":"Resubmit button ", + "will only be available after the article has been reviewed.":"will only be available after the article has been reviewed.", + "Review(s)":"Review(s)", + "Decision": "Decision", + "The JAISD editorial board":"The JAISD editorial board", + " to The Journal of Artificial Intelligence for Sustainable Development. Please find below a meta review summary, followed by the reviewer(s)’s feedback.":" to The Journal of Artificial Intelligence for Sustainable Development. Please find below a meta review summary, followed by the reviewer(s)’s feedback.", + "Thank you for your submission of ":"Thank you for your submission of ", + "Kind Regards":"Kind Regards", + "No - Don't confirm": "No - Don't confirm", + "Yes - Confirm": "Yes - Confirm", + "No available": "No available", "All Tags": "All Tags", "Allow candidates to edit their applications after submitting": "Allow candidates to edit their applications after submitting", "Editable forms with reviews open before application close are not recommended": "Editable forms with reviews open before application close are not recommended", diff --git a/webapp/public/locales/fr/translation.json b/webapp/public/locales/fr/translation.json index 5eb04e3c0..cbde61529 100644 --- a/webapp/public/locales/fr/translation.json +++ b/webapp/public/locales/fr/translation.json @@ -597,6 +597,37 @@ "Please check back later to apply": "Veuillez revenir plus tard pour postuler", "Applications will be opened on": "Les candidatures seront ouvertes le", "Applications Opening Soon":"Ouverture prochaine des candidatures", + "Resubmit an article":"Ressoumettre un article", + "Start a new submission":"Commencer un nouvelle soumission", + "REJECTED WITH ENCOURAGEMENT TO RESUMIT":"REJETÉ AVEC ENCOURAGEMENT À RESSOUMETTRE", + "ACCEPTED": "ACCEPTÉ", + "REJECTED": "REJETÉ", + "PENDING": "EN ATTENTE", + "Submissions": "Submissions", + "ACCEPTED WITH REVISION": "ACCEPTÉ AVEC RÉVISION", + "Related Submissions": "Soumissions connexes", + "Review Summaries": "Résumé des revues", + "The review summary field is required.": "Le champ de résumé des revues est obligatoire.", + "No reviews available": "Aucune revue disponible", + "You are about to submit a ":"Vous êtes sur le point de soumettre un ", + "completely new article.": "article complètement nouveau.", + "If you are making a ":"Si vous faites une ", + "resubmission to an article ":"ressoumission d'un article ", + "under review, please do so using the ":"en cours de révision, veuillez le faire en utilisant le ", + "Resubmit button.":"bouton Ressoumettre.", + " next to the latest version of the article you submitted.":" à côté de la dernière version de l'article que vous avez soumis.", + "The ":"Le ", + "Resubmit button ":"bouton Ressoumettre ", + "will only be available after the article has been reviewed.":"ne sera disponible qu'après que l'article ait été examiné.", + "Review(s)":"Revue(s)", + "Decision": "Décision", + "The JAISD editorial board":"Le comité de redaction du JAISD", + " to The Journal of Artificial Intelligence for Sustainable Development. Please find below a meta review summary, followed by the reviewer(s)’s feedback.":" au Journal de l'intelligence artificielle pour le développement durable. Veuillez trouver ci-dessous un résumé de la méta-revue, suivi des commentaires du ou des évaluateur(s).", + "Thank you for your submission of ":"Merci pour votre soumission de ", + "Kind Regards":"Cordialement", + "No - Don't confirm": "Non - Ne pas confirmer", + "Yes - Confirm": "Oui - Confirmer", + "No available": "Non disponible", "Invitation letter is not yet available, please check again at a later date.": "Lettre d'invitation n'est pas encore disponible, veuillez vérifier à nouveau plus tard.", "Confirmed": "Confirmé", "All Tags": "Toutes les balises", diff --git a/webapp/src/components/EventStatus.js b/webapp/src/components/EventStatus.js index 841135984..f893b4773 100644 --- a/webapp/src/components/EventStatus.js +++ b/webapp/src/components/EventStatus.js @@ -1,8 +1,15 @@ import React, { Component } from "react"; import { withRouter } from "react-router"; import { withTranslation } from "react-i18next"; - +import { ConfirmModal } from "react-bootstrap4-modal"; class EventStatus extends Component { + constructor(props) { + super(props); + this.state = { + open: false, + currentLink: "", + }; + } unknownStatus = (status_name, status) => { return { title: "ERROR", @@ -72,6 +79,8 @@ class EventStatus extends Component { applicationStatus = (event) => { const applyLink = `${event.key}/apply`; const submissionLink = `${event.key}/apply/new`; + const viewLink = `${event.key}/apply/view`; + if (event.status.application_status === "Submitted") { if (event.event_type === "JOURNAL") { return { @@ -79,7 +88,7 @@ class EventStatus extends Component { longText: this.props.t("You have submitted your article."), shortText: this.props.t("View Submission(s)"), linkClass: "btn-secondary", - link: applyLink, + link: viewLink, submissionShortText: this.props.t("New submission"), submissionLinkClass: "btn-primary", submissionLink: submissionLink, @@ -97,30 +106,56 @@ class EventStatus extends Component { }; } } else if (event.status.application_status === "Withdrawn") { - return { - title: this.props.t("Application Withdrawn"), - titleClass: "text-danger", - longText: - this.props.t( - "Your application has been withdrawn, you will not be considered for" - ) + - ` ${event.name} ` + - this.props.t("unless you re-submit by the deadline."), - shortText: this.props.t("Re-apply"), - linkClass: "btn-warning", - link: applyLink, - }; + if (event.event_type === "JOURNAL") { + return { + titleClass: "text-success", + longText: this.props.t("You have submitted your article."), + shortText: this.props.t("View Submission(s)"), + linkClass: "btn-secondary", + link: viewLink, + submissionShortText: this.props.t("New submission"), + submissionLinkClass: "btn-primary", + submissionLink: submissionLink, + }; + } else { + return { + title: this.props.t("Application Withdrawn"), + titleClass: "text-danger", + longText: + this.props.t( + "Your application has been withdrawn, you will not be considered for" + ) + + ` ${event.name} ` + + this.props.t("unless you re-submit by the deadline."), + shortText: this.props.t("Re-apply"), + linkClass: "btn-warning", + link: applyLink, + }; + } } else if (event.status.application_status === "Not Submitted") { - return { - title: this.props.t("In Progress"), - titleClass: "text-warning", - longText: this.props.t( - `You have not yet submitted your application, you must do so before the deadline to be considered` - ), - shortText: this.props.t("Continue"), - linkClass: "btn-primary", - link: applyLink, - }; + if (event.event_type === "JOURNAL") { + return { + titleClass: "text-success", + longText: this.props.t("You have submitted your article."), + shortText: this.props.t("View Submission(s)"), + linkClass: "btn-secondary", + link: viewLink, + submissionShortText: this.props.t("New submission"), + submissionLinkClass: "btn-primary", + submissionLink: submissionLink, + }; + } else { + return { + title: this.props.t("In Progress"), + titleClass: "text-warning", + longText: this.props.t( + `You have not yet submitted your application, you must do so before the deadline to be considered` + ), + shortText: this.props.t("Continue"), + linkClass: "btn-primary", + link: applyLink, + }; + } } else { if (event.event_type === "JOURNAL") { return { @@ -295,7 +330,9 @@ class EventStatus extends Component { this.props.t("will open on") + ` ${event.application_open_date}. ` + this.props.t("Please check back later to apply"), - shortText: this.props.t(`Applications will be opened on ${event.application_open_date}`), + shortText: this.props.t( + `Applications will be opened on ${event.application_open_date}` + ), }; }; @@ -433,33 +470,87 @@ class EventStatus extends Component { return this.applicationClosedStatus(event); }; + handleOpenModal = (link) => { + this.setState({ currentLink: link, open: true }); + }; + handleCloseModal = () => { + this.setState({ open: false }); + window.location.href = `/${this.props.event.key}/apply/view`; + }; + + handleConfirm = () => { + window.location.href = this.state.currentLink; + }; + renderButton = (definition) => { - if (definition.link && this.props.submissionLink) { - return ( -
+ > {this.props.t("You are about to submit a ")} + {this.props.t("completely new article.")} +
++ > {this.props.t("If you are making a ")} + {this.props.t("resubmission to an article ")} + {this.props.t("under review, please do so using the ")} + {this.props.t("Resubmit button ")} + {this.props.t( + "next to the latest version of the article you submitted." + )} +
+ ++ > {this.props.t("The ")} + {this.props.t("Resubmit button ")} + {this.props.t( + "will only be available after the article has been reviewed." + )} +
++ > {this.props.t("Would you like to proceed?")} +
+ {q.headline}
+ {q.description && (
+
+
+ {q.description}
+
+ )}
+
No submissions available.
; + } + return ( +{JSON.stringify(error)}
+{this.applicationStatus()}
+ {" "} +{this.outcomeStatus(applicationData)}
+No reviews available
; } + + renderJournalReviews() { + const { reviewResponses, reviewForm } = this.state; + const { t } = this.props; + + if (!reviewResponses.length) { + return{t("No reviews available")}
; + } + + return reviewResponses.map((val, index) => ( +