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 ( -
+ return ( + <> + {definition.link && this.props.submissionLink ? (
- - {definition.submissionShortText} - +
+ +
+
+ + {definition.shortText} + +
-
- - {definition.shortText} - + ) : ( + + {definition.shortText} + + )} + + +
+

+ > {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?")} +

-
- ); - } else { - return ( - - {definition.shortText} - - ); - } + + + ); }; + render() { const definition = this.mapStatus(this.props.event); if (this.props.longForm) { diff --git a/webapp/src/pages/ResponseDetails/ResponseDetails.css b/webapp/src/pages/ResponseDetails/ResponseDetails.css new file mode 100644 index 000000000..ffa1bed71 --- /dev/null +++ b/webapp/src/pages/ResponseDetails/ResponseDetails.css @@ -0,0 +1,59 @@ +.application-list { + padding: 20px; + background-color: #f9f9f9; + border-radius: 8px; + } + + .application-list h3 { + margin-bottom: 15px; + font-size: 1.5em; + color: #333; + } + + .application-list_items { + list-style-type: none; + padding: 0; + margin: 0; + } + + .application-list_item { + margin-bottom: 10px; + padding: 10px; + background-color: #ffffff; + border-radius: 6px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); + transition: all 0.3s ease; + } + + .application-list_item:hover { + transform: translateY(-3px); + } + + .application-list_link { + text-decoration: none; + color: #333; + font-weight: bold; + display: block; + transition: color 0.3s ease; + } + + .application-list_link:hover { + color: #007BFF; + } + + .shift_button{ + margin-left: 30px; + } + + .buttons-container { + display: flex; + flex-direction: column; + justify-content: flex-end; + gap: 10px; + align-items: flex-end; + } + .buttons-container button { + width: 100%; + min-width: 200px; + box-sizing: border-box; + } \ No newline at end of file diff --git a/webapp/src/pages/ResponseDetails/ResponseDetails.js b/webapp/src/pages/ResponseDetails/ResponseDetails.js new file mode 100644 index 000000000..924ea8d93 --- /dev/null +++ b/webapp/src/pages/ResponseDetails/ResponseDetails.js @@ -0,0 +1,317 @@ +import React, { Component } from "react"; +import { withRouter } from "react-router"; +import { applicationFormService } from "../../services/applicationForm"; +import Loading from "../../components/Loading"; +import { withTranslation } from "react-i18next"; +import AnswerValue from "../../components/answerValue"; +import { eventService } from "../../services/events"; +import moment from "moment"; +import "./ResponseDetails.css"; + +class ReponseDetails extends Component { + constructor(props) { + super(props); + this.state = { + applicationData: null, + applicationForm: null, + all_responses: null, + isLoading: true, + outcome: null, + error: false, + }; + } + + componentDidMount() { + const eventId = this.props.event ? this.props.event.id : 0; + Promise.all([ + applicationFormService.getForEvent(eventId), + applicationFormService.getResponse(eventId), + eventService.getEvent(eventId), + ]).then((responses) => { + console.log("Responses:", responses); + + this.setState({ + applicationForm: responses[0].formSpec, + all_responses: responses[1].response, + applicationData: responses[1].response.find( + (item) => item.id === parseInt(this.props.match.params.id) + ), + isLoading: false, + error: responses[0].error || responses[1].error || responses[2].error, + + }); + }); + + } + + + renderSections() { + const { applicationForm, applicationData } = this.state; + + if (applicationForm && applicationData) { + return applicationForm.sections.map((section) => ( +
+
+

{section.name}

+
+
{this.renderResponses(section)}
+
+ )); + } + + return null; +} + + renderResponses(section) { + const applicationData = this.state.applicationData; + const questions = section.questions.map((q) => { + const a = applicationData.answers.find((a) => a.question_id === q.id); + if (q.type === "information") { + return

{q.headline}

; + } + return ( +
+

+ {q.headline} + {q.description && ( + +
+ {q.description} +
+ )} +

+
+ +
+
+ ); + }); + return questions; + } + goBack() { + window.location.href = `/${this.props.event.key}/apply/view`; + } + + formatDate = (dateString) => { + return moment(dateString).format("D MMM YYYY, H:mm:ss [(UTC)]"); + }; + + applicationStatus() { + const data = this.state.applicationData; + if (data) { + const unsubmitted = !data.is_submitted && !data.is_withdrawn; + const submitted = data.is_submitted; + const withdrawn = data.is_withdrawn; + + if (unsubmitted) { + return ( + + {this.props.t('Unsubmitted')}{this.formatDate(data.started_timestamp)} + + ); + } + if (submitted) { + return ( + + {this.props.t('Submitted')}{this.formatDate(data.submitted_timestamp)} + + ); + } + if (withdrawn) { + return ( + + {this.props.t('Withdrawn')}{this.formatDate(data.started_timestamp)} + + ); + } + } + } + + + outcomeStatus= (response) => { + if (response.outcome) { + const badgeClass = + response.outcome === "ACCEPTED" + ? "badge-success" + : response.outcome === "REJECTED" + ? "badge-danger" + : "badge-warning"; + const outcome= response.outcome==='ACCEPTED'?this.props.t("ACCEPTED"):response.outcome==='REJECTED'? + this.props.t("REJECTED"):response.outcome==='ACCEPT_W_REVISION'? + this.props.t("ACCEPTED WITH REVISION"):response.outcome==='REJECT_W_ENCOURAGEMENT'? + this.props.t("REJECTED WITH ENCOURAGEMENT TO RESUMIT"):this.props.t("REVIEWING"); + return ( + {outcome} + ); + } + return ( + + {this.props.t("PENDING")} + + ); + }; + + getChainById = (data, id) => { + const elementMap = data.reduce((map, element) => { + map[element.id] = element; + return map; + }, {}); + + if (!elementMap[id]) return []; + + function getParentChain(element, chain = []) { + if (!element || chain.some(e => e.id === element.id)) return chain; + chain.unshift(element); + if (element.parent_id !== null && elementMap[element.parent_id]) { + return getParentChain(elementMap[element.parent_id], chain); + } + return chain; + } + + + function getChildChain(element, visited = new Set(), chain = []) { + if (!element || visited.has(element.id)) return; + visited.add(element.id); + chain.push(element); + + data + .filter(child => child.parent_id === element.id) + .sort((a, b) => a.id - b.id) + .forEach(child => getChildChain(child, visited, chain)); + } + + + const parentChain = getParentChain(elementMap[id]); + const childChain = []; + const visited = new Set(); + + getChildChain(elementMap[id], visited, childChain); + + + let finalChain = [...parentChain, ...childChain.slice(1)]; + + finalChain = finalChain.map((element, index) => ({ + ...element, + chain_number: index + 1 + })); + + return finalChain.sort((a, b) => a.id - b.id); + }; + + getSubmissionList = (applications) => { + + if (!applications || applications.length === 0) { + return

No submissions available.

; + } + return ( +
+

{this.props.t("Related Submissions")}

+ +
+ ); + }; + + getLastResponse(response) { + const lastResponse = response.at(-1);; + if (lastResponse.outcome!=null && lastResponse.outcome !== 'ACCEPTED'&& lastResponse.outcome !== 'REJECTED') { + return true; + } + return false; +} + + + render() { + const { applicationData, isLoading, error, all_responses } = this.state; + console.log("Application Data:", applicationData); + + + if (isLoading) { + return ; + } + + const chain_responses = this.getChainById(all_responses, this.props.match.params.id); + + return ( +
+ {error && ( +
+

{JSON.stringify(error)}

+
+ )} + +
+
+ {" "} +

{this.applicationStatus()}

+ {" "} +

{this.outcomeStatus(applicationData)}

+
+ +
+ + + + +
+
+
+
+
+

{this.props.t('Review Summaries')}

+
+
+ +
{applicationData.review_summary || this.props.t("No available")}
+
+
+
+ + {applicationData && ( +
+ {this.renderSections()} +
+ +
+ )} + +
+ {this.getSubmissionList( + chain_responses.filter( + (element) => element.id !== parseInt(this.props.match.params.id) + ) + )} +
+
+ ); + } +} + +export default withRouter(withTranslation()(ReponseDetails)); \ No newline at end of file diff --git a/webapp/src/pages/ResponsePage/ResponsePage.css b/webapp/src/pages/ResponsePage/ResponsePage.css index a6617a2d6..a03998ef3 100644 --- a/webapp/src/pages/ResponsePage/ResponsePage.css +++ b/webapp/src/pages/ResponsePage/ResponsePage.css @@ -329,8 +329,6 @@ font-size: 15px; color: #e8a64a; } - -/* Response Details Section and Sub-Sections */ .response-details { margin-top: 20px; width: 100%; @@ -353,7 +351,6 @@ font-size: 15px; margin-bottom: 0.75em; } -/* Q&A Sections */ .Q-A { display: flex; flex-wrap: wrap; @@ -390,7 +387,6 @@ font-size: 15px; } -/* Response Details Type Face */ .response-details h3 { color: hsl(213deg 16% 49%); margin: 0; @@ -405,4 +401,288 @@ font-size: 15px; .question-answer-block .question-headline { color: #666; -} \ No newline at end of file +} + +.submission-container { + text-align: center; + padding: 20px; + background: white; + border-radius: 10px; + width: 200px; +} + +.progress-label { + display: block; + font-size: 14px; + color: #666; +} + +.progress-value { + font-size: 16px; + font-weight: bold; +} + +.upload-box { + border: 2px dashed #aaa; + padding: 20px; + border-radius: 5px; + cursor: pointer; + margin-bottom: 15px; +} + + +.file-input { + display: none; +} + +.comment-box { + width: 100%; + height: 60px; + padding: 10px; + margin-bottom: 15px; + border-radius: 5px; + border: 1px solid #ccc; + color: #212529 !important; + font-size: 17px; +} + +.letter { + background-color: #ffffff; + border: 1px solid #ddd; + border-radius: 8px; + max-width: 800px; + width: 90%; + margin: 20px auto; + padding: 20px 30px; + font-family: 'Arial', sans-serif; + color: #333; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + max-height: 80vh; + overflow-y: auto; + + + scrollbar-width: thin; + scrollbar-color: #aaa #f9f9f9; +} + +.letter::-webkit-scrollbar { + width: 6px; +} + +.letter::-webkit-scrollbar-track { + background: #f9f9f9; +} + +.letter::-webkit-scrollbar-thumb { + background-color: #aaa; + border-radius: 3px; +} + +.letter-header { + text-align: center; + margin-bottom: 20px; + border-bottom: 1px solid #e3e3e3; + padding-bottom: 15px; +} + +.letter-header h2 { + margin: 0; + font-size: 26px; + font-weight: bold; + color: #2c3e50; +} + +.letter-header .date, +.letter-header .recipient { + margin: 5px 0; + font-size: 14px; + color: #7f8c8d; +} + +.letter-body { + margin-top: 20px; + line-height: 1.6; +} + +.letter-body .greeting { + font-size: 18px; + margin-bottom: 15px; +} + +.application-sections, +.review-sections { + margin: 20px 0; + padding: 15px; + border: 1px solid #e3e3e3; + border-radius: 4px; + background-color: #f9f9f9; +} + +.application-sections .section, +.review-sections .section, +.review-container { + margin-bottom: 15px; +} + +.section h3, +.section h5 { + margin: 10px 0; + color: #34495e; + font-size: 20px; +} + +.review-container { + padding: 10px; + border: 1px solid #ccc; + border-radius: 4px; + background-color: #fff; +} + +.review-container h4 { + margin-bottom: 10px; + font-size: 18px; + color: #2c3e50; +} + +.letter-footer { + text-align: center; + margin-top: 30px; + border-top: 1px solid #e3e3e3; + padding-top: 15px; + font-size: 16px; + color: #2c3e50; +} + +.comment-box { + width: 100%; + height: 200px; + margin-bottom: 15px; + padding: 10px; + border: 1px solid #ccc; + border-radius: 4px; + resize: vertical; + background-color: #f2f2f2; + color: #212529 !important; +} + +.letter { + width: 95%; + max-width: 1100px; + max-height: 80vh; + padding: 30px; + border-radius: 10px; + background: #fff; + box-shadow: 0px 5px 15px rgba(0, 0, 0, 0.15); + overflow-y: auto; + margin: auto; + scrollbar-width: thin; + scrollbar-color: #bbb transparent; + } + + .letter::-webkit-scrollbar { + width: 6px; + } + + .letter::-webkit-scrollbar-thumb { + background: #bbb; + border-radius: 4px; + } + + .letter::-webkit-scrollbar-track { + background: transparent; + } + + .letter-header { + text-align: center; + margin-bottom: 20px; + } + + .letter-header h2 { + font-size: 2.2rem; + font-weight: bold; + color: #333; + } + + .letter-header .date { + font-size: 1.1rem; + color: #666; + } + + + .letter-body { + font-size: 1.3rem; + line-height: 1.8; + text-align: justify; + padding-right: 14px; + } + + + .application-sections, .review-sections { + background: #f8f8f8; + padding: 20px; + border-radius: 8px; + margin-bottom: 20px; + } + + .application-sections h4, .review-sections h4 { + font-size: 1.5rem; + color: #007bff; + margin-bottom: 15px; + } + + + .letter-footer { + text-align: left; + margin-top: 20px; + font-weight: bold; + color: #333; + } + + + @media (max-width: 768px) { + .letter { + width: 98%; + max-height: 70vh; + } + + .letter-header h2 { + font-size: 1.7rem; + } + + .letter-body { + font-size: 1.1rem; + } + } + + .application-status { + background-color: #f8f9fa; + padding: 15px; + border-radius: 8px; + margin-top: 20px; + border: 1px solid #ddd; + } + + .application-status h5 { + font-size: 18px; + font-weight: bold; + color: #333; + margin-bottom: 10px; + } + + .application-status p { + font-size: 16px; + color: #555; + margin-bottom: 5px; + } + + .application-status .status { + font-size: 18px; + font-weight: bold; + color: #007bff; + } + + + .empty-comment { + border: 1px solid red; + } + \ No newline at end of file diff --git a/webapp/src/pages/ResponsePage/ResponsePage.js b/webapp/src/pages/ResponsePage/ResponsePage.js index a0ab23619..7b834ac08 100644 --- a/webapp/src/pages/ResponsePage/ResponsePage.js +++ b/webapp/src/pages/ResponsePage/ResponsePage.js @@ -11,9 +11,24 @@ import { tagsService } from '../../services/tags/tags.service'; import { responsesService } from '../../services/responses/responses.service'; import AnswerValue from "../../components/answerValue"; import { ConfirmModal } from "react-bootstrap4-modal"; +import Modal from 'react-bootstrap4-modal'; +import _ from "lodash"; + import moment from 'moment' import { getDownloadURL } from '../../utils/files'; import TagSelectorDialog from '../../components/TagSelectorDialog'; +import Loading from "../../components/Loading"; + +const answerByQuestionKey = (key, allQuestions, answers) => { + let question = allQuestions.find(q => q.key === key); + if (question) { + let answer = answers.find(a => a.question_id === question.id); + if (answer) { + return answer.value; + } + } + return null; + } class ResponsePage extends Component { constructor(props) { @@ -34,7 +49,12 @@ class ResponsePage extends Component { tagList: [], assignableTagTypes: ["RESPONSE"], reviewResponses: [], - outcome: {'status':null,'timestamp':null}, + outcome: {'status':null,'timestamp':null, 'review_summary':null}, + confirmModalVisible: false, + pendingOutcome: "", + confirmationMessage: "", + review_summary: "", + isCommentEmpty: false, isOutcomeDropdownOpen: false, isStatusDropdownOpen: false, } @@ -143,6 +163,7 @@ class ResponsePage extends Component { renderCompleteReviews() { + if (this.state.reviewResponses.length) { const reviews = this.state.reviewResponses.map(val => { @@ -166,13 +187,39 @@ class ResponsePage extends Component { return

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) => ( +
+

+ {t("Reviewer ") + (index + 1)} +

+ {reviewForm.review_sections.map(section => ( +
+
{section.headline}
+ {this.renderReviewResponse(val, section)} +
+ ))} +
+ )); + } + getOutcome() { - outcomeService.getOutcome(this.props.event.id, this.state.applicationData.user_id).then(response => { + outcomeService.getOutcome(this.props.event.id, this.state.applicationData.user_id,this.props.match.params.id).then(response => { if (response.status === 200) { + const newOutcome = { timestamp: response.outcome.timestamp, status: response.outcome.status, + review_summary: response.outcome.review_summary }; this.setState( { @@ -213,6 +260,60 @@ class ResponsePage extends Component { }); } + handleConfirmation = (outcome, message) => { + this.setState({ + confirmModalVisible: true, + pendingOutcome: outcome, + confirmationMessage: message, + }); + }; + + handleConfirmationOK = (event) => { + this.setState({ + confirmModalVisible: false, + }); + this.submitOutcome(this.state.pendingOutcome,this.state.review_summary); + + }; + + handleConfirmationCancel = (event) => { + this.setState({ + confirmModalVisible: false, + }); + }; + + + handleConfirmationClick = (outcome, message) => { + const { review_summary } = this.state; + if (this.state.event_type==='JOURNAL') { + + if (!review_summary || !review_summary.trim()) { + this.setState({ isCommentEmpty: true }); + alert(this.props.t("The review summary field is required.")); + return; + } + + this.setState({ isCommentEmpty: false }); + } + this.handleConfirmation(outcome, message); + }; + + renderConfirmationButton(outcome, label, className, message) { + return ( + + ); + } + + + + outcomeStatus() { const data = this.state.applicationData; @@ -222,15 +323,7 @@ class ResponsePage extends Component { if (data) { - if (this.state.outcome.status && this.state.outcome.status !== 'REVIEW') { - if (this.state.outcome.status === 'ACCEPTED') { - 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 { - return {this.state.outcome.status} {this.formatDate(this.state.outcome.timestamp)} - } - }; + const name = data.user_title + " " + data.firstname + " " + data.lastname; if (this.state.event_type === 'JOURNAL') { return ( @@ -285,8 +378,13 @@ class ResponsePage extends Component {
) } - }; - }; + + + + ); + } + } + updateResponseStatus = (is_submitted, is_withdrawn) => { this.setState({ isStatusDropdownOpen: false }, () => { @@ -493,7 +591,7 @@ class ResponsePage extends Component { responsesService.removeTag(applicationData.id, tagToRemove, this.props.event.id) .then(resp => { - console.log(resp); + if (resp.status === 200) { this.setState({ removeTagModalVisible: false, @@ -645,7 +743,7 @@ class ResponsePage extends Component { if (!this.state.reviewResponses || !this.state.applicationData) { return
} - return < ReviewModal + return this.postReviewerService(data)} response={this.state.applicationData} reviewers={this.state.availableReviewers.filter(r => !this.state.applicationData.reviewers.some(rr => rr.reviewer_user_id === r.reviewer_user_id))} @@ -670,7 +768,6 @@ class ResponsePage extends Component { }; addTag = (response) => { - console.log(response); const tagIds = response.tags.map(t=>t.id); this.setState({ tagSelectorVisible: true, @@ -684,6 +781,31 @@ class ResponsePage extends Component { }) } + handleCommentChange = (event) => { + this.setState({ review_summary: event.target.value ,isCommentEmpty: false}); + + }; + + + + renderComment = () => { + return ( +