From 922abc9aa1b6628769222e6c2fbdc6823b75f034 Mon Sep 17 00:00:00 2001 From: Yvan CARRE Date: Wed, 29 Jan 2025 01:08:34 +0200 Subject: [PATCH 01/47] add new migrations --- alembic.ini | 116 ++++++++++++++++++ alembic/README | 1 + alembic/env.py | 78 ++++++++++++ alembic/script.py.mako | 22 ++++ ..._add_foreign_key_response_id_to_outcome.py | 40 ++++++ .../versions/48d559146efd_add_new_outcomes.py | 35 ++++++ 6 files changed, 292 insertions(+) create mode 100644 alembic.ini create mode 100644 alembic/README create mode 100644 alembic/env.py create mode 100644 alembic/script.py.mako create mode 100644 api/migrations/versions/3fe861d70b76_add_foreign_key_response_id_to_outcome.py create mode 100644 api/migrations/versions/48d559146efd_add_new_outcomes.py diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 000000000..c10d4ca0a --- /dev/null +++ b/alembic.ini @@ -0,0 +1,116 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +script_location = alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python>=3.9 or backports.zoneinfo library. +# Any required deps can installed by adding `alembic[tz]` to the pip requirements +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the +# "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to alembic/versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "version_path_separator" below. +# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions + +# version path separator; As mentioned above, this is the character used to split +# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. +# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. +# Valid values for version_path_separator are: +# +# version_path_separator = : +# version_path_separator = ; +# version_path_separator = space +version_path_separator = os # Use os.pathsep. Default configuration used for new projects. + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +sqlalchemy.url = driver://user:pass@localhost/dbname + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the exec runner, execute a binary +# hooks = ruff +# ruff.type = exec +# ruff.executable = %(here)s/.venv/bin/ruff +# ruff.options = --fix REVISION_SCRIPT_FILENAME + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/alembic/README b/alembic/README new file mode 100644 index 000000000..98e4f9c44 --- /dev/null +++ b/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 000000000..36112a3c6 --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,78 @@ +from logging.config import fileConfig + +from sqlalchemy import engine_from_config +from sqlalchemy import pool + +from alembic import context + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +target_metadata = None + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, target_metadata=target_metadata + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 000000000..95702017e --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,22 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision} +Create Date: ${create_date} + +""" + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} 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..7e38f8f8d --- /dev/null +++ b/api/migrations/versions/3fe861d70b76_add_foreign_key_response_id_to_outcome.py @@ -0,0 +1,40 @@ +"""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'] + ) + + +def downgrade(): + op.drop_constraint( + 'fk_outcome_response_id', + 'outcome', + type_='foreignkey' + ) + + op.drop_column('outcome', 'response_id') + 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..f85a7c95a --- /dev/null +++ b/api/migrations/versions/48d559146efd_add_new_outcomes.py @@ -0,0 +1,35 @@ +"""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 From 561ef22e06ac546a49eca05c32e45d3c7dc75ebb Mon Sep 17 00:00:00 2001 From: Yvan CARRE Date: Wed, 29 Jan 2025 01:10:21 +0200 Subject: [PATCH 02/47] add new migrations --- alembic.ini | 116 ------------------ alembic/README | 1 - alembic/env.py | 78 ------------ alembic/script.py.mako | 22 ---- ..._add_foreign_key_response_id_to_outcome.py | 1 - .../versions/48d559146efd_add_new_outcomes.py | 1 - 6 files changed, 219 deletions(-) delete mode 100644 alembic.ini delete mode 100644 alembic/README delete mode 100644 alembic/env.py delete mode 100644 alembic/script.py.mako diff --git a/alembic.ini b/alembic.ini deleted file mode 100644 index c10d4ca0a..000000000 --- a/alembic.ini +++ /dev/null @@ -1,116 +0,0 @@ -# A generic, single database configuration. - -[alembic] -# path to migration scripts -script_location = alembic - -# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s -# Uncomment the line below if you want the files to be prepended with date and time -# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file -# for all available tokens -# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s - -# sys.path path, will be prepended to sys.path if present. -# defaults to the current working directory. -prepend_sys_path = . - -# timezone to use when rendering the date within the migration file -# as well as the filename. -# If specified, requires the python>=3.9 or backports.zoneinfo library. -# Any required deps can installed by adding `alembic[tz]` to the pip requirements -# string value is passed to ZoneInfo() -# leave blank for localtime -# timezone = - -# max length of characters to apply to the -# "slug" field -# truncate_slug_length = 40 - -# set to 'true' to run the environment during -# the 'revision' command, regardless of autogenerate -# revision_environment = false - -# set to 'true' to allow .pyc and .pyo files without -# a source .py file to be detected as revisions in the -# versions/ directory -# sourceless = false - -# version location specification; This defaults -# to alembic/versions. When using multiple version -# directories, initial revisions must be specified with --version-path. -# The path separator used here should be the separator specified by "version_path_separator" below. -# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions - -# version path separator; As mentioned above, this is the character used to split -# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. -# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. -# Valid values for version_path_separator are: -# -# version_path_separator = : -# version_path_separator = ; -# version_path_separator = space -version_path_separator = os # Use os.pathsep. Default configuration used for new projects. - -# set to 'true' to search source files recursively -# in each "version_locations" directory -# new in Alembic version 1.10 -# recursive_version_locations = false - -# the output encoding used when revision files -# are written from script.py.mako -# output_encoding = utf-8 - -sqlalchemy.url = driver://user:pass@localhost/dbname - - -[post_write_hooks] -# post_write_hooks defines scripts or Python functions that are run -# on newly generated revision scripts. See the documentation for further -# detail and examples - -# format using "black" - use the console_scripts runner, against the "black" entrypoint -# hooks = black -# black.type = console_scripts -# black.entrypoint = black -# black.options = -l 79 REVISION_SCRIPT_FILENAME - -# lint with attempts to fix using "ruff" - use the exec runner, execute a binary -# hooks = ruff -# ruff.type = exec -# ruff.executable = %(here)s/.venv/bin/ruff -# ruff.options = --fix REVISION_SCRIPT_FILENAME - -# Logging configuration -[loggers] -keys = root,sqlalchemy,alembic - -[handlers] -keys = console - -[formatters] -keys = generic - -[logger_root] -level = WARN -handlers = console -qualname = - -[logger_sqlalchemy] -level = WARN -handlers = -qualname = sqlalchemy.engine - -[logger_alembic] -level = INFO -handlers = -qualname = alembic - -[handler_console] -class = StreamHandler -args = (sys.stderr,) -level = NOTSET -formatter = generic - -[formatter_generic] -format = %(levelname)-5.5s [%(name)s] %(message)s -datefmt = %H:%M:%S diff --git a/alembic/README b/alembic/README deleted file mode 100644 index 98e4f9c44..000000000 --- a/alembic/README +++ /dev/null @@ -1 +0,0 @@ -Generic single-database configuration. \ No newline at end of file diff --git a/alembic/env.py b/alembic/env.py deleted file mode 100644 index 36112a3c6..000000000 --- a/alembic/env.py +++ /dev/null @@ -1,78 +0,0 @@ -from logging.config import fileConfig - -from sqlalchemy import engine_from_config -from sqlalchemy import pool - -from alembic import context - -# this is the Alembic Config object, which provides -# access to the values within the .ini file in use. -config = context.config - -# Interpret the config file for Python logging. -# This line sets up loggers basically. -if config.config_file_name is not None: - fileConfig(config.config_file_name) - -# add your model's MetaData object here -# for 'autogenerate' support -# from myapp import mymodel -# target_metadata = mymodel.Base.metadata -target_metadata = None - -# other values from the config, defined by the needs of env.py, -# can be acquired: -# my_important_option = config.get_main_option("my_important_option") -# ... etc. - - -def run_migrations_offline() -> None: - """Run migrations in 'offline' mode. - - This configures the context with just a URL - and not an Engine, though an Engine is acceptable - here as well. By skipping the Engine creation - we don't even need a DBAPI to be available. - - Calls to context.execute() here emit the given string to the - script output. - - """ - url = config.get_main_option("sqlalchemy.url") - context.configure( - url=url, - target_metadata=target_metadata, - literal_binds=True, - dialect_opts={"paramstyle": "named"}, - ) - - with context.begin_transaction(): - context.run_migrations() - - -def run_migrations_online() -> None: - """Run migrations in 'online' mode. - - In this scenario we need to create an Engine - and associate a connection with the context. - - """ - connectable = engine_from_config( - config.get_section(config.config_ini_section, {}), - prefix="sqlalchemy.", - poolclass=pool.NullPool, - ) - - with connectable.connect() as connection: - context.configure( - connection=connection, target_metadata=target_metadata - ) - - with context.begin_transaction(): - context.run_migrations() - - -if context.is_offline_mode(): - run_migrations_offline() -else: - run_migrations_online() diff --git a/alembic/script.py.mako b/alembic/script.py.mako deleted file mode 100644 index 95702017e..000000000 --- a/alembic/script.py.mako +++ /dev/null @@ -1,22 +0,0 @@ -"""${message} - -Revision ID: ${up_revision} -Revises: ${down_revision} -Create Date: ${create_date} - -""" - -# revision identifiers, used by Alembic. -revision = ${repr(up_revision)} -down_revision = ${repr(down_revision)} - -from alembic import op -import sqlalchemy as sa -${imports if imports else ""} - -def upgrade(): - ${upgrades if upgrades else "pass"} - - -def downgrade(): - ${downgrades if downgrades else "pass"} 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 index 7e38f8f8d..0e813024d 100644 --- 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 @@ -5,7 +5,6 @@ Create Date: 2025-01-29 00:54:20.105588 """ - # revision identifiers, used by Alembic. revision = '3fe861d70b76' down_revision = '48d559146efd' diff --git a/api/migrations/versions/48d559146efd_add_new_outcomes.py b/api/migrations/versions/48d559146efd_add_new_outcomes.py index f85a7c95a..2d5f9648b 100644 --- a/api/migrations/versions/48d559146efd_add_new_outcomes.py +++ b/api/migrations/versions/48d559146efd_add_new_outcomes.py @@ -5,7 +5,6 @@ Create Date: 2025-01-28 17:11:21.400338 """ - # revision identifiers, used by Alembic. revision = '48d559146efd' down_revision = 'a4662031beca' From c4851df46f47fc46244cb907481583cd501e0718 Mon Sep 17 00:00:00 2001 From: Yvan CARRE Date: Wed, 29 Jan 2025 01:31:48 +0200 Subject: [PATCH 03/47] Outcome model adjustment --- api/app/outcome/models.py | 7 ++++++- api/app/outcome/repository.py | 8 ++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/api/app/outcome/models.py b/api/app/outcome/models.py index 95dfe4184..62779edf9 100644 --- a/api/app/outcome/models.py +++ b/api/app/outcome/models.py @@ -23,11 +23,15 @@ 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]) + def __init__(self, event_id, user_id, status, - updated_by_user_id + updated_by_user_id, + response_id ): self.event_id = event_id self.user_id = user_id @@ -35,6 +39,7 @@ def __init__(self, self.timestamp = datetime.now() self.latest = True self.updated_by_user_id = updated_by_user_id + self.response_id = response_id 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..3fab5992d 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 From 25ac768c8095ee57595e8217b65182de1e23de7d Mon Sep 17 00:00:00 2001 From: Yvan CARRE Date: Wed, 29 Jan 2025 01:45:24 +0200 Subject: [PATCH 04/47] Outcome model adjustment --- api/app/outcome/api.py | 40 +++++++++++++++++++++++++++++++++++----- 1 file changed, 35 insertions(+), 5 deletions(-) diff --git a/api/app/outcome/api.py b/api/app/outcome/api.py index a782a49fc..e428906a3 100644 --- a/api/app/outcome/api.py +++ b/api/app/outcome/api.py @@ -15,6 +15,7 @@ from app import db from app.utils import errors from app.utils import misc +from app.responses.repository import ResponseRepository as response_repository def _extract_status(outcome): @@ -36,27 +37,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 @@ -75,6 +99,7 @@ def post(self, event_id): req_parser = reqparse.RequestParser() req_parser.add_argument('user_id', type=int, required=True) req_parser.add_argument('outcome', type=str, required=True) + req_parser.add_argument('response_id', type=int, required=True) args = req_parser.parse_args() event = event_repository.get_by_id(event_id) @@ -84,6 +109,10 @@ def post(self, event_id): user = user_repository.get_by_id(args['user_id']) if not user: return errors.USER_NOT_FOUND + + response = response_repository.get_by_id_and_user_id(args['response_id'],args['user_id']) + if not response: + return errors.RESPONSE_NOT_FOUND try: status = Status[args['outcome']] @@ -92,7 +121,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,7 +130,8 @@ def post(self, event_id): event_id, args['user_id'], status, - g.current_user['id']) + g.current_user['id'], + args['response_id']) outcome_repository.add(outcome) db.session.commit() From 5531c1a456508929b845bf6f2fa18db9c498ff52 Mon Sep 17 00:00:00 2001 From: Yvan CARRE Date: Wed, 29 Jan 2025 01:54:27 +0200 Subject: [PATCH 05/47] Outcome model adjustment --- api/app/outcome/api.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/api/app/outcome/api.py b/api/app/outcome/api.py index e428906a3..4a3f5711b 100644 --- a/api/app/outcome/api.py +++ b/api/app/outcome/api.py @@ -15,7 +15,6 @@ from app import db from app.utils import errors from app.utils import misc -from app.responses.repository import ResponseRepository as response_repository def _extract_status(outcome): @@ -110,9 +109,6 @@ def post(self, event_id): if not user: return errors.USER_NOT_FOUND - response = response_repository.get_by_id_and_user_id(args['response_id'],args['user_id']) - if not response: - return errors.RESPONSE_NOT_FOUND try: status = Status[args['outcome']] From 503b56949eadad3c7632c7964cc57916c41ae689 Mon Sep 17 00:00:00 2001 From: Yvan CARRE Date: Wed, 29 Jan 2025 19:58:12 +0200 Subject: [PATCH 06/47] Response model adjustment --- api/app/responses/api.py | 10 +++++++--- api/app/responses/mixins.py | 2 ++ api/app/responses/models.py | 2 +- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/api/app/responses/api.py b/api/app/responses/api.py index b628b9a7f..d87170524 100644 --- a/api/app/responses/api.py +++ b/api/app/responses/api.py @@ -123,8 +123,8 @@ def get(self): 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) response.outcome = outcome return marshal(responses, ResponseAPI.response_fields), 200 @@ -136,6 +136,10 @@ def post(self): is_submitted = args['is_submitted'] application_form_id = args['application_form_id'] language = args['language'] + parent_id = args.get('parent_id', None) + allow_multiple_submissions = args['multiple_submission'] + + if len(language) != 2: language = 'en' # Fallback to English if language doesn't look like an ISO 639-1 code @@ -146,10 +150,10 @@ def post(self): 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: + if not allow_multiple_submissions and 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 = [] diff --git a/api/app/responses/mixins.py b/api/app/responses/mixins.py index 6da7eca5f..77d03971a 100644 --- a/api/app/responses/mixins.py +++ b/api/app/responses/mixins.py @@ -9,6 +9,8 @@ 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) + post_req_parser.add_argument('multiple_submission', type=bool, 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 a8dc7e0ce..abbbb368c 100644 --- a/api/app/responses/models.py +++ b/api/app/responses/models.py @@ -46,7 +46,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 From 5dc41a25d83b8d9775bd4b13702c2313b3195353 Mon Sep 17 00:00:00 2001 From: Yvan CARRE Date: Wed, 29 Jan 2025 20:07:30 +0200 Subject: [PATCH 07/47] Adjustment of service outcome and applicationForm --- .../services/applicationForm/applicationForm.service.js | 6 ++++-- webapp/src/services/outcome/outcome.service.js | 9 +++++---- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/webapp/src/services/applicationForm/applicationForm.service.js b/webapp/src/services/applicationForm/applicationForm.service.js index 4170a3bff..9d6f77cb2 100644 --- a/webapp/src/services/applicationForm/applicationForm.service.js +++ b/webapp/src/services/applicationForm/applicationForm.service.js @@ -76,11 +76,13 @@ function getResponse(eventId) { }); } -function submit(applicationFormId, isSubmitted, answers) { +function submit(applicationFormId, isSubmitted, answers, parent_id, allow_multiple_submission) { let response = { "application_form_id": applicationFormId, "is_submitted": isSubmitted, - "answers": answers + "answers": answers, + "parent_id": parent_id, + "multiple_submission": allow_multiple_submission } return axios.post(baseUrl + `/api/v1/response`, response, {headers: authHeader()}) diff --git a/webapp/src/services/outcome/outcome.service.js b/webapp/src/services/outcome/outcome.service.js index 7c370de5f..9e9eea3f4 100644 --- a/webapp/src/services/outcome/outcome.service.js +++ b/webapp/src/services/outcome/outcome.service.js @@ -8,9 +8,9 @@ export const outcomeService = { assignOutcome, } -function getOutcome(event_id, user_id){ +function getOutcome(event_id, user_id,response_id){ return axios.get(baseUrl + "/api/v1/outcome?event_id=" +event_id + - "&user_id=" +user_id ,{ + "&user_id=" +user_id + "&response_id=" +response_id,{ headers: authHeader() }) .then(function(response){ @@ -31,14 +31,15 @@ function getOutcome(event_id, user_id){ }); } -function assignOutcome(user_id, event_id, outcome){ +function assignOutcome(user_id, event_id, outcome, response_id){ return axios .post(baseUrl + "/api/v1/outcome", event_id, { "headers": authHeader(), "params": { outcome: outcome, event_id: event_id, - user_id: user_id + user_id: user_id, + response_id: response_id } }) .then(function (response){ From fb33cb1a5eb47d961cafe30e3757408184afb353 Mon Sep 17 00:00:00 2001 From: Yvan CARRE Date: Wed, 29 Jan 2025 20:22:19 +0200 Subject: [PATCH 08/47] creation of page ResponseDetails and modification of Eventstatus page --- webapp/src/components/EventStatus.js | 19 +- .../pages/ResponseDetails/ResponseDetails.css | 46 +++ .../pages/ResponseDetails/ResponseDetails.js | 283 ++++++++++++++++++ .../src/pages/review/components/ReviewForm.js | 1 - 4 files changed, 346 insertions(+), 3 deletions(-) create mode 100644 webapp/src/pages/ResponseDetails/ResponseDetails.css create mode 100644 webapp/src/pages/ResponseDetails/ResponseDetails.js diff --git a/webapp/src/components/EventStatus.js b/webapp/src/components/EventStatus.js index 11b10c571..51cca223d 100644 --- a/webapp/src/components/EventStatus.js +++ b/webapp/src/components/EventStatus.js @@ -72,6 +72,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 +81,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,6 +99,19 @@ class EventStatus extends Component { }; } } else if (event.status.application_status === "Withdrawn") { + 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", @@ -109,7 +124,7 @@ class EventStatus extends Component { 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"), diff --git a/webapp/src/pages/ResponseDetails/ResponseDetails.css b/webapp/src/pages/ResponseDetails/ResponseDetails.css new file mode 100644 index 000000000..9951f8f57 --- /dev/null +++ b/webapp/src/pages/ResponseDetails/ResponseDetails.css @@ -0,0 +1,46 @@ +.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; + } \ 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..ac26eff13 --- /dev/null +++ b/webapp/src/pages/ResponseDetails/ResponseDetails.js @@ -0,0 +1,283 @@ +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) => { + 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 = this.state.applicationForm; + const applicationData = this.state.applicationData; + let html = []; + + if (applicationForm && applicationData) { + applicationForm.sections.forEach((section) => { + html.push( +
+
+

{section.name}

+
+
{this.renderResponses(section)}
+
+ ); + }); + } + + return html; + } + + 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; + }, {}); + + function getChain(element, visited = new Set()) { + if (!element || visited.has(element.id)) return []; + visited.add(element.id); + + const chain = [element]; + if (element.parent_id !== null && elementMap[element.parent_id]) { + chain.push(...getChain(elementMap[element.parent_id], visited)); + } + data.forEach((child) => { + if (child.parent_id === element.id) { + chain.push(...getChain(child, visited)); + } + }); + + return chain; + } + + if (!elementMap[id]) { + return []; + } + + const result = getChain(elementMap[id]); + result.sort((a, b) => b.id - a.id); + return result; + }; + + getSubmissionList = (applications) => { + if (!applications || applications.length === 0) { + return

No submissions available.

; + } + return ( +
+

Related Submissions

+ +
+ ); + }; + + getLastResponse(response) { + const lastResponse = response[0]; + if (lastResponse.outcome!=null && lastResponse.outcome !== 'ACCEPTED') { + return true; + } + return false; +} + + + render() { + const { applicationData, isLoading, error, all_responses } = this.state; + 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)}

+
+
+ + {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/review/components/ReviewForm.js b/webapp/src/pages/review/components/ReviewForm.js index bb81b02a8..ff9b00048 100644 --- a/webapp/src/pages/review/components/ReviewForm.js +++ b/webapp/src/pages/review/components/ReviewForm.js @@ -247,7 +247,6 @@ class ReviewForm extends Component { } if (response.form) { - console.log("Response.form:", response.form); questionModels = response.form.review_form.review_sections.map(s => { return { headline: s.headline, From 965f85c533d215c41a6f9d95c272b632e75c37f3 Mon Sep 17 00:00:00 2001 From: Yvan CARRE Date: Wed, 29 Jan 2025 20:34:17 +0200 Subject: [PATCH 09/47] add new routes --- webapp/src/pages/eventHome/EventHome.js | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/webapp/src/pages/eventHome/EventHome.js b/webapp/src/pages/eventHome/EventHome.js index a63fec540..02135dedf 100755 --- a/webapp/src/pages/eventHome/EventHome.js +++ b/webapp/src/pages/eventHome/EventHome.js @@ -28,6 +28,7 @@ import ResponseList from "../ResponseList/ResponseList"; import ResponsePage from "../ResponsePage/ResponsePage"; import ReviewDashboard from "../reviewDashboard"; import { Attendance, Indemnity } from '../attendance'; +import ReponseDetails from "../ReponseDetails/ReponseDetails"; class EventInfo extends Component { constructor(props) { @@ -277,6 +278,30 @@ class EventHome extends Component { path={`${match.path}/offerAdmin`} render={(props) => } /> + + } + /> + + } + /> + + } + /> + + } + /> ); From e0a9e811a5afc299dd73e23bd0fc2357f9695a5c Mon Sep 17 00:00:00 2001 From: Yvan CARRE Date: Wed, 29 Jan 2025 20:40:28 +0200 Subject: [PATCH 10/47] insert css for application.js --- .../src/pages/applicationForm/Application.css | 133 ++++++++++++++++++ 1 file changed, 133 insertions(+) diff --git a/webapp/src/pages/applicationForm/Application.css b/webapp/src/pages/applicationForm/Application.css index 69e268e93..fbea2fbf4 100644 --- a/webapp/src/pages/applicationForm/Application.css +++ b/webapp/src/pages/applicationForm/Application.css @@ -222,3 +222,136 @@ ol.progtrckr li.progtrckr-done:hover:before { display: inline; } } + + +.shift_button { + margin-left: 50px; +} + +.response-chains-container { + background-color: #f8f9fa; + padding: 20px; + border-radius: 10px; +} + +.response-chains-title { + text-align: center; + margin-bottom: 20px; + font-size: 28px; + color: #333; + font-weight: bold; +} + +.response-chains-table { + width: 100%; + border-collapse: collapse; + background-color: #fff; + border-radius: 10px; + overflow: hidden; +} + +.response-chains-header { + padding: 15px; + font-size: 20px; + background-color: #007bff; + color: #fff; + text-align: left; +} + +.chain-header { + cursor: pointer; + transition: background-color 0.3s ease; +} + +.chain-header-title { + padding: 15px 10px; + font-weight: bold; + font-size: 20px; + border-bottom: 2px solid #007bff; +} + +.chain-toggle-icon { + float: right; + font-size: 18px; +} + +.response-row { + background-color: #ffffff; + border-bottom: 1px solid #dcdcdc; + transition: background-color 0.3s ease; +} + +.resubmit-button-container { + text-align: center; + padding: 15px; + background-color: #f9f9f9; +} + +.btn.btn-primary { + padding: 10px 20px; + font-size: 16px; + border-radius: 5px; +} + +.custom-button { + width: 100px; + height: 40px; + font-size: 14px; +} + + +.resubmit-container { + display: flex; + align-items: center; + gap: 40px; +} + +.resubmit-container i { + font-size: 16px; +} + +.resubmit-container button { + padding: 8px 16px; + font-size: 14px; + border: none; + border-radius: 4px; + background-color: #007bff; + color: #fff; + cursor: pointer; + transition: background-color 0.3s ease; +} + +.resubmit-container button:hover { + background-color: #0056b3; +} + +table { + width: 100%; + table-layout: auto; + border-collapse: collapse; +} + +td, th { + padding: 8px 12px; + text-align: left; + vertical-align: middle; +} + +.response-row td:nth-child(1) { + width: 30%; +} + +.response-row td:nth-child(2) { + width: 15%; + text-align: center; +} + +.response-row td:nth-child(3) { + width: 40%; + text-align: center; +} + +.response-row td:nth-child(4) { + width: 15%; + text-align: center; +} \ No newline at end of file From 6b2587ba3b24fe0151f75907a67ab01ed617cdcd Mon Sep 17 00:00:00 2001 From: Yvan CARRE Date: Wed, 29 Jan 2025 21:22:20 +0200 Subject: [PATCH 11/47] improving applicationForm --- .../components/ApplicationForm.js | 432 +++++++++++++++--- 1 file changed, 379 insertions(+), 53 deletions(-) diff --git a/webapp/src/pages/applicationForm/components/ApplicationForm.js b/webapp/src/pages/applicationForm/components/ApplicationForm.js index c20ed56a2..5ba2b0b9f 100644 --- a/webapp/src/pages/applicationForm/components/ApplicationForm.js +++ b/webapp/src/pages/applicationForm/components/ApplicationForm.js @@ -1,4 +1,4 @@ -import React, { Component } from "react"; +import React, { Component, Fragment } from "react"; import { withRouter } from "react-router"; import { Link } from "react-router-dom"; import { applicationFormService } from "../../../services/applicationForm"; @@ -16,13 +16,15 @@ import { fileService } from "../../../services/file/file.service"; import FormMultiCheckbox from "../../../components/form/FormMultiCheckbox"; import FormReferenceRequest from "./ReferenceRequest"; import Loading from "../../../components/Loading"; -import _ from "lodash"; +import _ , { chain } from "lodash"; import { withTranslation } from 'react-i18next'; import AnswerValue from '../../../components/answerValue' import FormSelectOther from "../../../components/form/FormSelectOther"; import FormMultiCheckboxOther from "../../../components/form/FormMultiCheckboxOther"; import FormCheckbox from "../../../components/form/FormCheckbox"; import { eventService } from "../../../services/events"; +import moment from "moment"; +import { Redirect } from 'react-router-dom'; const baseUrl = process.env.REACT_APP_API_URL; @@ -658,6 +660,10 @@ class SubmittedComponent extends React.Component { }); }; + viewsubmission = ()=> { + window.location.href = `/${this.props.event.key}/apply/view`; + } + render() { const t = this.props.t; const initialText = this.props.event && this.props.event.event_type === "CALL" @@ -698,12 +704,16 @@ class SubmittedComponent extends React.Component { {this.props.event.event_type === "JOURNAL" - ? undefined + ? : } + + } { if (this.state.new_response) { applicationFormService - .submit(this.props.formSpec.id, false, this.state.answers) + .submit(this.props.formSpec.id, false, this.state.answers, this.state.parent_id, this.state.allow_multiple_submission) .then(resp => { let submitError = resp.response_id === null; this.setState({ @@ -1040,38 +1052,256 @@ class ApplicationListComponent extends Component { getAction = (response) => { if (response.is_submitted) { - return + return ( + + ); + } else { + return ( + + ); } - else { - return + } + + formatDate = (dateString) => { + return moment(dateString).format("D MMM YYYY, H:mm:ss [(UTC)]"); + }; + + getSubmission = (response) => { + return this.props.t("Submission") + " - " + (response.is_submitted==true?this.formatDate(response.submitted_timestamp):this.formatDate(response.started_timestamp)) + }; + + renderSections() { + const applicationForm = this.state.applicationForm; + const applicationData = this.state.applicationData; + let html = []; + + if (applicationForm && applicationData) { + applicationForm.sections.forEach((section) => { + html.push( +
+
+

{section.name}

+
+
{this.renderResponses(section)}
+
+ ); + }); } + return html; } + 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; + } + + getOutcome = (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")} + + ); + }; + + newSubmission = (id) => { + window.location.href = `/${this.props.event.key}/apply/new/${id}`; + }; + + getLastResponse = (response) => { + if (response.children.length === 0) return response; + return this.getLastResponse(response.children[response.children.length - 1]); + }; + + buildResponseChains = (responses) => { + const responseMap = responses.reduce((map, response) => { + map[response.id] = { ...response, children: [] }; + return map; + }, {}); + + const chains = []; + + responses.forEach((response) => { + if (response.parent_id !== null && responseMap[response.parent_id]) { + responseMap[response.parent_id].children.push(responseMap[response.id]); + } else { + chains.push(responseMap[response.id]); + } + }); + + return chains.sort((a, b) => this.getLastResponse(b).id - this.getLastResponse(a).id); + }; + + toggleChain = (chainId) => { + this.setState((prevState) => { + const expandedChains = prevState.expandedChains ? { ...prevState.expandedChains } : {}; + expandedChains[chainId] = !expandedChains[chainId]; + return { expandedChains }; + }); + }; + + renderResubmitButton = (chain) => { + const lastResponse = this.getLastResponse(chain); + + if (lastResponse && lastResponse.outcome && lastResponse.outcome !== 'ACCEPTED') { + return ( +
+ +
+ ); + } + + return null; + }; + + + renderEntireChain = (response) => { + const children = response.children.slice().reverse(); + return ( + + {children.map((childResponse) => this.renderEntireChain(childResponse))} + + {this.getSubmission(response)} + {this.getStatus(response)} + {this.getOutcome(response)} + {this.getAction(response)} + + + ); + }; + + renderResponseChain = (chain) => { + return ( + + this.toggleChain(chain.id)} className="chain-header"> + + + {this.state.expandedChains && this.state.expandedChains[chain.id] ? ( +
+ {this.renderResubmitButton(chain)} + +
+ ) + : ( +
+ {this.renderResubmitButton(chain)} + +
+ ) + } + +
+ {this.props.t("Submission")} {this.formatDate(chain.started_timestamp)} + + + {this.state.expandedChains && this.state.expandedChains[chain.id] && + this.renderEntireChain(chain) + } +
+ ); + + }; + + render() { let allQuestions = _.flatMap(this.props.formSpec.sections, s => s.questions); const title = this.props.event.event_type ==='JOURNAL' ? this.props.t("Your Submissions") : this.props.t("Your Nominations"); - let firstColumn = this.props.event.event_type ==='JOURNAL' ? this.props.t("Submission") : this.props.t("Nominee"); + let firstColumn = this.props.event.event_type ==='JOURNAL' ? this.props.t("Submission") : this.props.t("Nominee");\ + + if (this.props.event.event_type =='JOURNAL') { + const responseChains = this.buildResponseChains(this.props.responses); + return ( +
+

{title}

+ + + + + + + + {responseChains.map((chain, index) => this.renderResponseChain(chain, index))} + +
{firstColumn}
+
+ ); + } return
-

{title}

- - - - - - +

{title}

+
{firstColumn}{this.props.t("Status")}
+ + + + + + + + + {this.props.responses.map(response => { + return + + + - - - {this.props.responses.map(response => { - return - - - - - })} - -
{firstColumn}{this.props.t("Status")}
{this.getCandidate(allQuestions, response)}{this.getStatus(response)}{this.getAction(response)}
{this.getCandidate(allQuestions, response)}{this.getStatus(response)}{this.getAction(response)}
-
+ })} + + + ; } } @@ -1089,7 +1319,11 @@ class ApplicationForm extends Component { formSpec: null, responses: [], selectedResponse: null, - journalSubmissionFlag: false + journalSubmissionFlag: false, + listselectedResponse: null, + continue: false, + view: false, + chain: false, } } @@ -1129,7 +1363,10 @@ class ApplicationForm extends Component { }); } - + newSubmission = () => { + window.location.href = `/${this.props.event.key}/apply/new`; + }; + render() { const { isLoading, @@ -1138,7 +1375,8 @@ class ApplicationForm extends Component { formSpec, responses, selectedResponse, - responseSelected} = this.state; + responseSelected, + listselectedResponse,} = this.state; if (isLoading) { return (); @@ -1148,24 +1386,112 @@ class ApplicationForm extends Component { return
{errorMessage}
; } - if (this.props.event.event_type === 'JOURNAL' && this.props.journalSubmissionFlag) { - return - } - else if (formSpec.nominations && responses.length > 0 && !responseSelected) { - let newForm = this.state.event.event_type ==='JOURNAL' ? this.props.t("New Submission") + " " : this.props.t("New Nomination") + " "; - return
-
- -
- } - else { - return + if (this.props.event.event_type === "JOURNAL") { + if (this.props.journalSubmissionFlag) { + return ( + + ); + } + if (this.props.view) { + + return ( +
+ {/*
*/} +
+ +
+ + +
+ ); + } + if (this.props.chain) { + const isIdAndNotParent=(listResponse, id) =>{ + const isIdPresent = listResponse.some(entry => entry.id === id); + const isNotAParent = !listResponse.some(entry => entry.parent_id === id); + return isIdPresent && isNotAParent; + } + if (!isIdAndNotParent(responses, parseInt(this.props.match.params.id))) { + return + } + return ( + + ); + } + if (this.props.continue) { + const response = listselectedResponse.find( + (item) => item.id === parseInt(this.props.match.params.id)); + if (response == null || response.outcome != null) { + return + } + return ( + + ); + } + return ( + + ); + } else if ( + formSpec.nominations && + responses.length > 0 && + !responseSelected + ) { + let newForm = + this.state.event.event_type === "JOURNAL" + ? this.props.t("New Submission") + " " + : this.props.t("New Nomination") + " "; + return ( +
+ +
+ + +
+ ); + } else { + return ( + + ); } } } From 60242b1b4ae4074520c69d63652bc5cc75cf24de Mon Sep 17 00:00:00 2001 From: Yvan CARRE Date: Thu, 30 Jan 2025 23:04:33 +0200 Subject: [PATCH 12/47] Submission displaying using chain mecanism --- api/migrations/versions/61ecb542488f_.py | 22 ++ api/migrations/versions/afd6a07ccc79_.py | 22 ++ webapp/public/locales/en/translation.json | 4 +- webapp/public/locales/fr/translation.json | 4 +- .../pages/ResponseDetails/ResponseDetails.js | 122 ++++++--- webapp/src/pages/ResponsePage/ResponsePage.js | 237 ++++++++++-------- .../src/pages/applicationForm/Application.css | 4 +- .../components/ApplicationForm.js | 13 +- webapp/src/pages/eventHome/EventHome.js | 2 +- 9 files changed, 287 insertions(+), 143 deletions(-) create mode 100644 api/migrations/versions/61ecb542488f_.py create mode 100644 api/migrations/versions/afd6a07ccc79_.py diff --git a/api/migrations/versions/61ecb542488f_.py b/api/migrations/versions/61ecb542488f_.py new file mode 100644 index 000000000..3cfcf1053 --- /dev/null +++ b/api/migrations/versions/61ecb542488f_.py @@ -0,0 +1,22 @@ +"""empty message + +Revision ID: 61ecb542488f +Revises: afd6a07ccc79 +Create Date: 2025-01-30 00:40:08.868204 + +""" + +# revision identifiers, used by Alembic. +revision = '61ecb542488f' +down_revision = 'afd6a07ccc79' + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + pass + + +def downgrade(): + pass diff --git a/api/migrations/versions/afd6a07ccc79_.py b/api/migrations/versions/afd6a07ccc79_.py new file mode 100644 index 000000000..542d61c7b --- /dev/null +++ b/api/migrations/versions/afd6a07ccc79_.py @@ -0,0 +1,22 @@ +"""empty message + +Revision ID: afd6a07ccc79 +Revises: ('3fe861d70b76', 'e8eafcfe7800') +Create Date: 2025-01-30 00:11:31.161040 + +""" + +# revision identifiers, used by Alembic. +revision = 'afd6a07ccc79' +down_revision = ('3fe861d70b76', 'e8eafcfe7800') + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + pass + + +def downgrade(): + pass diff --git a/webapp/public/locales/en/translation.json b/webapp/public/locales/en/translation.json index 50167e962..a6141644d 100644 --- a/webapp/public/locales/en/translation.json +++ b/webapp/public/locales/en/translation.json @@ -588,5 +588,7 @@ "will close on": "will close on", "Please check back later to apply": "Please check back later to apply", "Applications will be opened on": "Applications will be opened on", - "Applications Opening Soon": "Applications Opening Soon" + "Applications Opening Soon": "Applications Opening Soon", + "Resubmit an article": "Resubmit an article", + "Start a new submission": "Start a new submission" } diff --git a/webapp/public/locales/fr/translation.json b/webapp/public/locales/fr/translation.json index a47a086c5..76703bac4 100644 --- a/webapp/public/locales/fr/translation.json +++ b/webapp/public/locales/fr/translation.json @@ -587,6 +587,8 @@ "will close on": "fermeront le ", "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" + "Applications Opening Soon":"Ouverture prochaine des candidatures", + "Resubmit an article":"Ressoumettre un article", + "Start a new submission":"Commencer un nouvelle soumission" } diff --git a/webapp/src/pages/ResponseDetails/ResponseDetails.js b/webapp/src/pages/ResponseDetails/ResponseDetails.js index ac26eff13..f767b6c73 100644 --- a/webapp/src/pages/ResponseDetails/ResponseDetails.js +++ b/webapp/src/pages/ResponseDetails/ResponseDetails.js @@ -152,39 +152,89 @@ class ReponseDetails extends Component { ); }; - getChainById = (data, id) => { - const elementMap = data.reduce((map, element) => { - map[element.id] = element; - return map; - }, {}); + getChainById = (data, id) => { + const elementMap = data.reduce((map, element) => { + map[element.id] = element; + return map; + }, {}); + + if (!elementMap[id]) return []; - function getChain(element, visited = new Set()) { - if (!element || visited.has(element.id)) return []; - visited.add(element.id); - - const chain = [element]; - if (element.parent_id !== null && elementMap[element.parent_id]) { - chain.push(...getChain(elementMap[element.parent_id], visited)); - } - data.forEach((child) => { - if (child.parent_id === element.id) { - chain.push(...getChain(child, visited)); + 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; } - }); - - return chain; - } - - if (!elementMap[id]) { - return []; - } - - const result = getChain(elementMap[id]); - result.sort((a, b) => b.id - a.id); - return result; - }; + + + 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)); + } + + + let parentChain = getParentChain(elementMap[id]); + let childChain = []; + let visited = new Set(); + + getChildChain(elementMap[id], visited, childChain); + + + let finalChain = [...parentChain, ...childChain.slice(1)]; + + finalChain = finalChain.map((element, index) => ({ + ...element, + chain_number: index + 1 + })); + + // Trier par ordre décroissant de l'id + return finalChain.sort((a, b) => a.id - b.id); + }; + + // getChainById = (data, id) => { + // const elementMap = data.reduce((map, element) => { + // map[element.id] = element; + // return map; + // }, {}); + + // function getChain(element, visited = new Set()) { + // if (!element || visited.has(element.id)) return []; + // visited.add(element.id); + + // const chain = [element]; + // if (element.parent_id !== null && elementMap[element.parent_id]) { + // chain.push(...getChain(elementMap[element.parent_id], visited)); + // } + // data.forEach((child) => { + // if (child.parent_id === element.id) { + // chain.push(...getChain(child, visited)); + // } + // }); + + // return chain; + // } + + // if (!elementMap[id]) { + // return []; + // } + + // const result = getChain(elementMap[id]); + // result.sort((a, b) => b.id - a.id); + // return result; + // }; + - getSubmissionList = (applications) => { + getSubmissionList = (applications) => { + if (!applications || applications.length === 0) { return

No submissions available.

; } @@ -198,7 +248,9 @@ class ReponseDetails extends Component { href={`/${this.props.event.key}/responseDetails/${application.id}`} className="application-list_link" > - {this.props.t(`Submission`) + " " + this.formatDate(application.submitted_timestamp)} + {/* {this.props.t(`Submission`) + " " + this.formatDate(application.submitted_timestamp)} */} + ({application.chain_number}) {application.answers[0].value} + ))} @@ -207,8 +259,8 @@ class ReponseDetails extends Component { ); }; - getLastResponse(response) { - const lastResponse = response[0]; + getLastResponse(response) { + const lastResponse = response.at(-1);; if (lastResponse.outcome!=null && lastResponse.outcome !== 'ACCEPTED') { return true; } @@ -222,6 +274,8 @@ class ReponseDetails extends Component { return ; } const chain_responses = this.getChainById(all_responses, this.props.match.params.id); + + return (
@@ -264,7 +318,7 @@ class ReponseDetails extends Component { } disabled={!this.getLastResponse(chain_responses)} > - {this.props.t("New submission")} + {this.props.t("Resubmit an article")} }
diff --git a/webapp/src/pages/ResponsePage/ResponsePage.js b/webapp/src/pages/ResponsePage/ResponsePage.js index c9433b744..a1a7f375c 100644 --- a/webapp/src/pages/ResponsePage/ResponsePage.js +++ b/webapp/src/pages/ResponsePage/ResponsePage.js @@ -14,6 +14,7 @@ import { ConfirmModal } from "react-bootstrap4-modal"; import moment from 'moment' import { getDownloadURL } from '../../utils/files'; import TagSelectorDialog from '../../components/TagSelectorDialog'; +import Loading from "../../components/Loading"; class ResponsePage extends Component { constructor(props) { @@ -35,6 +36,9 @@ class ResponsePage extends Component { assignableTagTypes: ["RESPONSE"], reviewResponses: [], outcome: {'status':null,'timestamp':null}, + confirmModalVisible: false, + pendingOutcome: "", + confirmationMessage: "", } }; @@ -166,7 +170,7 @@ class ResponsePage extends Component { } 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, @@ -189,7 +193,7 @@ class ResponsePage extends Component { }; submitOutcome(selectedOutcome) { - outcomeService.assignOutcome(this.state.applicationData.user_id, this.props.event.id, selectedOutcome).then(response => { + outcomeService.assignOutcome(this.state.applicationData.user_id, this.props.event.id, selectedOutcome, this.props.match.params.id).then(response => { if (response.status === 201) { const newOutcome = { timestamp: response.outcome.timestamp, @@ -197,7 +201,8 @@ class ResponsePage extends Component { }; this.setState({ - outcome: newOutcome + outcome: newOutcome, + confirmModalVisible: false, }); } else { this.setState({erorr: response.error}); @@ -205,107 +210,143 @@ class ResponsePage extends Component { }); } - outcomeStatus() { - const data = this.state.applicationData; + handleConfirmation = (outcome, message) => { + this.setState({ + confirmModalVisible: true, + pendingOutcome: outcome, + confirmationMessage: message, + }); + }; + + handleConfirmationOK = (event) => { + this.setState({ + confirmModalVisible: false, + }); + this.submitOutcome(this.state.pendingOutcome); - 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)} - } - }; + handleConfirmationCancel = (event) => { + this.setState({ + confirmModalVisible: false, + }); + }; - if (this.state.event_type === 'JOURNAL') { - return
-
- -
-
- -
-
- -
-
- -
-
+ renderConfirmationButton(outcome, label, className, message) { + return ( + - -
- -
- + > + {this.props.t(label)} + + ); + } + outcomeStatus() { + const data = this.state.applicationData; + if (data) { + if (this.state.outcome.status && this.state.outcome.status !== "REVIEW") { + const badgeClass = this.state.outcome.status === "ACCEPTED" + ? "badge-success" + : this.state.outcome.status === "REJECTED" + ? "badge-danger" + : "badge-warning"; + + const outcome= this.state.outcome.status ==='ACCEPTED'?this.props.t("ACCEPTED"):this.state.outcome.status ==='REJECTED'? + this.props.t("REJECTED"):this.state.outcome.status ==='ACCEPT_W_REVISION'? + this.props.t("ACCEPTED WITH REVISION"):this.state.outcome.status ==='REJECT_W_ENCOURAGEMENT'? + this.props.t("REJECTED WITH ENCOURAGEMENT TO RESUMIT"):this.props.t("REVIEWING"); + + return ( + + + {outcome} + {" "} + {this.formatDate(this.state.outcome.timestamp)} + + ); + } + + const { event_type } = this.state; + const buttons = []; + + if (event_type === "JOURNAL" || event_type === "CALL" || event_type === "EVENT") { + buttons.push( + this.renderConfirmationButton( + "ACCEPTED", + "Accept", + "btn-success", + "Are you sure you want to ACCEPT this submission?" + ) + ); + + if (event_type === "JOURNAL") { + buttons.push( + this.renderConfirmationButton( + "ACCEPT_W_REVISION", + "Accept with Minor Revision", + "btn-warning", + "Are you sure you want to ACCEPT WITH MINOR REVISION?" + ) + ); + buttons.push( + this.renderConfirmationButton( + "REJECT_W_ENCOURAGEMENT", + "Reject with Encouragement to Resubmit", + "btn-warning", + "Are you sure you want to REJECT WITH ENCOURAGEMENT TO RESUBMIT?" + ) + ); + } - else if (this.state.event_type === 'EVENT') { - return
-
- -
-
- -
-
+ + if (event_type === "EVENT") { + buttons.push( + this.renderConfirmationButton( + "WAITLIST", + "Waitlist", + "btn-warning", + "Are you sure you want to WAITLIST this submission?" + ) + ); } - }; - }; + + buttons.push( + this.renderConfirmationButton( + "REJECTED", + "Reject", + "btn-danger", + "Are you sure you want to REJECT this submission?" + ) + ); + + } + + return ( +
+ {buttons.map((button, index) => ( +
+ {button} +
+ ))} + +

{this.props.t(this.state.confirmationMessage)}

+
+
+ ); + } + } + // Render Sections renderSections() { diff --git a/webapp/src/pages/applicationForm/Application.css b/webapp/src/pages/applicationForm/Application.css index fbea2fbf4..dd0bdbb31 100644 --- a/webapp/src/pages/applicationForm/Application.css +++ b/webapp/src/pages/applicationForm/Application.css @@ -338,7 +338,7 @@ td, th { } .response-row td:nth-child(1) { - width: 30%; + width: 40%; } .response-row td:nth-child(2) { @@ -347,7 +347,7 @@ td, th { } .response-row td:nth-child(3) { - width: 40%; + width: 30%; text-align: center; } diff --git a/webapp/src/pages/applicationForm/components/ApplicationForm.js b/webapp/src/pages/applicationForm/components/ApplicationForm.js index 5ba2b0b9f..9e2b9ca54 100644 --- a/webapp/src/pages/applicationForm/components/ApplicationForm.js +++ b/webapp/src/pages/applicationForm/components/ApplicationForm.js @@ -1081,7 +1081,7 @@ class ApplicationListComponent extends Component { }; getSubmission = (response) => { - return this.props.t("Submission") + " - " + (response.is_submitted==true?this.formatDate(response.submitted_timestamp):this.formatDate(response.started_timestamp)) + return response.answers[0].value }; renderSections() { @@ -1200,7 +1200,7 @@ class ApplicationListComponent extends Component { onClick={() => this.newSubmission(lastResponse.id)} className="btn btn-primary resubmit-button" > - {this.props.t("Resubmit")} + {this.props.t("Resubmit an article")} ); @@ -1216,7 +1216,7 @@ class ApplicationListComponent extends Component { {children.map((childResponse) => this.renderEntireChain(childResponse))} - {this.getSubmission(response)} + {this.getSubmission(response)} {this.getStatus(response)} {this.getOutcome(response)} {this.getAction(response)} @@ -1226,6 +1226,7 @@ class ApplicationListComponent extends Component { }; renderResponseChain = (chain) => { + return ( this.toggleChain(chain.id)} className="chain-header"> @@ -1246,7 +1247,7 @@ class ApplicationListComponent extends Component { } - {this.props.t("Submission")} {this.formatDate(chain.started_timestamp)} + {chain.answers[0].value} {this.state.expandedChains && this.state.expandedChains[chain.id] && @@ -1261,7 +1262,7 @@ class ApplicationListComponent extends Component { render() { let allQuestions = _.flatMap(this.props.formSpec.sections, s => s.questions); const title = this.props.event.event_type ==='JOURNAL' ? this.props.t("Your Submissions") : this.props.t("Your Nominations"); - let firstColumn = this.props.event.event_type ==='JOURNAL' ? this.props.t("Submission") : this.props.t("Nominee");\ + let firstColumn = this.props.event.event_type ==='JOURNAL' ? this.props.t("Submission") : this.props.t("Nominee"); if (this.props.event.event_type =='JOURNAL') { const responseChains = this.buildResponseChains(this.props.responses); @@ -1406,7 +1407,7 @@ class ApplicationForm extends Component { className="btn btn-primary" onClick={() => this.newSubmission()} > - {this.props.t("New Submission")} > + {this.props.t("Start a new submission")} >
Date: Thu, 30 Jan 2025 23:11:14 +0200 Subject: [PATCH 13/47] Submission displaying using chain mecanism --- webapp/src/pages/ResponseDetails/ResponseDetails.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webapp/src/pages/ResponseDetails/ResponseDetails.js b/webapp/src/pages/ResponseDetails/ResponseDetails.js index f767b6c73..8f688fb86 100644 --- a/webapp/src/pages/ResponseDetails/ResponseDetails.js +++ b/webapp/src/pages/ResponseDetails/ResponseDetails.js @@ -248,7 +248,7 @@ class ReponseDetails extends Component { href={`/${this.props.event.key}/responseDetails/${application.id}`} className="application-list_link" > - {/* {this.props.t(`Submission`) + " " + this.formatDate(application.submitted_timestamp)} */} + ({application.chain_number}) {application.answers[0].value} From 9012c5f850a38d36e5aa3f48f284fe158b518aab Mon Sep 17 00:00:00 2001 From: Yvan CARRE Date: Fri, 31 Jan 2025 00:27:29 +0200 Subject: [PATCH 14/47] ResponseDetails clean code --- .../pages/ResponseDetails/ResponseDetails.js | 69 +++++++++---------- 1 file changed, 33 insertions(+), 36 deletions(-) diff --git a/webapp/src/pages/ResponseDetails/ResponseDetails.js b/webapp/src/pages/ResponseDetails/ResponseDetails.js index 8f688fb86..abf72e4c0 100644 --- a/webapp/src/pages/ResponseDetails/ResponseDetails.js +++ b/webapp/src/pages/ResponseDetails/ResponseDetails.js @@ -7,6 +7,7 @@ import AnswerValue from "../../components/answerValue"; import { eventService } from "../../services/events"; import moment from "moment"; import "./ResponseDetails.css"; +import { reviewService } from '../../services/reviews/review.service'; class ReponseDetails extends Component { constructor(props) { @@ -27,6 +28,7 @@ class ReponseDetails extends Component { applicationFormService.getForEvent(eventId), applicationFormService.getResponse(eventId), eventService.getEvent(eventId), + reviewService.getReviewAssignments(this.props.event.id) ]).then((responses) => { this.setState({ applicationForm: responses[0].formSpec, @@ -41,8 +43,36 @@ class ReponseDetails extends Component { }); } + getReviewResponses(applicationData) { + + + reviewService.getResponseReviewAdmin(applicationData.id, this.props.event.id) + .then(resp => { + if (resp.error) { + this.setState({ + error: resp.error + }); + } + if(resp.form) { + this.setState( { + reviewResponses: resp.form.review_responses, + reviewForm: resp.form.review_form, + isLoading: false, + + error: resp.error + }); + } + }); + } renderSections() { + console.log('details'); + + console.log(this.state.reviewResponses); + console.log('-------'); + console.log(this.state.applicationData); + + const applicationForm = this.state.applicationForm; const applicationData = this.state.applicationData; let html = []; @@ -196,42 +226,9 @@ class ReponseDetails extends Component { chain_number: index + 1 })); - // Trier par ordre décroissant de l'id return finalChain.sort((a, b) => a.id - b.id); }; - - // getChainById = (data, id) => { - // const elementMap = data.reduce((map, element) => { - // map[element.id] = element; - // return map; - // }, {}); - - // function getChain(element, visited = new Set()) { - // if (!element || visited.has(element.id)) return []; - // visited.add(element.id); - - // const chain = [element]; - // if (element.parent_id !== null && elementMap[element.parent_id]) { - // chain.push(...getChain(elementMap[element.parent_id], visited)); - // } - // data.forEach((child) => { - // if (child.parent_id === element.id) { - // chain.push(...getChain(child, visited)); - // } - // }); - - // return chain; - // } - - // if (!elementMap[id]) { - // return []; - // } - - // const result = getChain(elementMap[id]); - // result.sort((a, b) => b.id - a.id); - // return result; - // }; - + getSubmissionList = (applications) => { @@ -301,7 +298,7 @@ class ReponseDetails extends Component { )} -
+ {/*
} -
+
*/}
{this.getSubmissionList( chain_responses.filter( From 2c360cf9bc3e82bf91f7090541ee3954591b4ca1 Mon Sep 17 00:00:00 2001 From: Yvan CARRE Date: Wed, 5 Feb 2025 19:55:08 +0200 Subject: [PATCH 15/47] Modal modification and improvement --- api/app/outcome/models.py | 1 + .../4b881c1c6dc2_add_email_templates.py | 2 +- .../pages/ResponseDetails/ResponseDetails.js | 69 ++-- .../src/pages/ResponsePage/ResponsePage.css | 294 +++++++++++++++++- webapp/src/pages/ResponsePage/ResponsePage.js | 138 +++++++- 5 files changed, 460 insertions(+), 44 deletions(-) diff --git a/api/app/outcome/models.py b/api/app/outcome/models.py index 62779edf9..1f5e13a4d 100644 --- a/api/app/outcome/models.py +++ b/api/app/outcome/models.py @@ -25,6 +25,7 @@ class Outcome(db.Model): response_id = db.Column(db.Integer(), db.ForeignKey('response.id'), nullable=True) response = db.relationship('Response', foreign_keys=[response_id]) + def __init__(self, event_id, diff --git a/api/migrations/versions/4b881c1c6dc2_add_email_templates.py b/api/migrations/versions/4b881c1c6dc2_add_email_templates.py index 44ded1c65..e22db063b 100644 --- a/api/migrations/versions/4b881c1c6dc2_add_email_templates.py +++ b/api/migrations/versions/4b881c1c6dc2_add_email_templates.py @@ -192,7 +192,7 @@ def upgrade(): Please follow the link below to see details and accept your offer: {host}/offer You have up until {expiry_date} to accept the offer, otherwise we will automatically allocate your spot to someone else. -If you are unable to accept the offer for any reason, please do let us know by visiting {host}/offer, clicking "Reject" and filling in the reason. +If you are unable to accept the offer for any reason, please do let us know by visiting {host}/offer, clicking "Reject" and filling in the reason. We will read all of these and if there is anything we can do to accommodate you, we may extend you a new offer in a subsequent round. If you have any queries, please contact us at {event_email_from} diff --git a/webapp/src/pages/ResponseDetails/ResponseDetails.js b/webapp/src/pages/ResponseDetails/ResponseDetails.js index abf72e4c0..8f688fb86 100644 --- a/webapp/src/pages/ResponseDetails/ResponseDetails.js +++ b/webapp/src/pages/ResponseDetails/ResponseDetails.js @@ -7,7 +7,6 @@ import AnswerValue from "../../components/answerValue"; import { eventService } from "../../services/events"; import moment from "moment"; import "./ResponseDetails.css"; -import { reviewService } from '../../services/reviews/review.service'; class ReponseDetails extends Component { constructor(props) { @@ -28,7 +27,6 @@ class ReponseDetails extends Component { applicationFormService.getForEvent(eventId), applicationFormService.getResponse(eventId), eventService.getEvent(eventId), - reviewService.getReviewAssignments(this.props.event.id) ]).then((responses) => { this.setState({ applicationForm: responses[0].formSpec, @@ -43,36 +41,8 @@ class ReponseDetails extends Component { }); } - getReviewResponses(applicationData) { - - - reviewService.getResponseReviewAdmin(applicationData.id, this.props.event.id) - .then(resp => { - if (resp.error) { - this.setState({ - error: resp.error - }); - } - if(resp.form) { - this.setState( { - reviewResponses: resp.form.review_responses, - reviewForm: resp.form.review_form, - isLoading: false, - - error: resp.error - }); - } - }); - } renderSections() { - console.log('details'); - - console.log(this.state.reviewResponses); - console.log('-------'); - console.log(this.state.applicationData); - - const applicationForm = this.state.applicationForm; const applicationData = this.state.applicationData; let html = []; @@ -226,9 +196,42 @@ class ReponseDetails extends Component { chain_number: index + 1 })); + // Trier par ordre décroissant de l'id return finalChain.sort((a, b) => a.id - b.id); }; - + + // getChainById = (data, id) => { + // const elementMap = data.reduce((map, element) => { + // map[element.id] = element; + // return map; + // }, {}); + + // function getChain(element, visited = new Set()) { + // if (!element || visited.has(element.id)) return []; + // visited.add(element.id); + + // const chain = [element]; + // if (element.parent_id !== null && elementMap[element.parent_id]) { + // chain.push(...getChain(elementMap[element.parent_id], visited)); + // } + // data.forEach((child) => { + // if (child.parent_id === element.id) { + // chain.push(...getChain(child, visited)); + // } + // }); + + // return chain; + // } + + // if (!elementMap[id]) { + // return []; + // } + + // const result = getChain(elementMap[id]); + // result.sort((a, b) => b.id - a.id); + // return result; + // }; + getSubmissionList = (applications) => { @@ -298,7 +301,7 @@ class ReponseDetails extends Component {
)} - {/*
+
} -
*/} +
{this.getSubmissionList( chain_responses.filter( diff --git a/webapp/src/pages/ResponsePage/ResponsePage.css b/webapp/src/pages/ResponsePage/ResponsePage.css index a6617a2d6..d90c20b15 100644 --- a/webapp/src/pages/ResponsePage/ResponsePage.css +++ b/webapp/src/pages/ResponsePage/ResponsePage.css @@ -405,4 +405,296 @@ 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; +} + + +/* Response Details Buttons */ +/* Style général de la lettre agrandie avec barre de défilement discrète */ +.letter { + background-color: #ffffff; + border: 1px solid #ddd; + border-radius: 8px; + max-width: 800px; /* largeur maximale agrandie */ + width: 90%; /* occupe 90% de la largeur de l'écran */ + 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; /* Limite la hauteur de la lettre pour activer le défilement */ + overflow-y: auto; /* Active le défilement vertical */ + + /* Pour Firefox */ + scrollbar-width: thin; + scrollbar-color: #aaa #f9f9f9; +} + +/* Personnalisation de la barre de défilement pour les navigateurs basés sur WebKit (Chrome, Safari) */ +.letter::-webkit-scrollbar { + width: 6px; /* largeur très discrète */ +} + +.letter::-webkit-scrollbar-track { + background: #f9f9f9; /* couleur du fond de la barre */ +} + +.letter::-webkit-scrollbar-thumb { + background-color: #aaa; /* couleur de la "poignée" */ + border-radius: 3px; /* arrondir la poignée */ +} + +/* En-tête de la lettre */ +.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; +} + +/* Corps de la lettre */ +.letter-body { + margin-top: 20px; + line-height: 1.6; +} + +.letter-body .greeting { + font-size: 18px; + margin-bottom: 15px; +} + +/* Sections d'application et de revues */ +.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; +} + +/* Style pour les conteneurs de revues */ +.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; +} + +/* Pied de la lettre */ +.letter-footer { + text-align: center; + margin-top: 30px; + border-top: 1px solid #e3e3e3; + padding-top: 15px; + font-size: 16px; + color: #2c3e50; +} + +/* Style de la zone de commentaire (désactivée ici) */ +.comment-box { + width: 100%; + height: 80px; + margin-bottom: 15px; + padding: 10px; + border: 1px solid #ccc; + border-radius: 4px; + resize: vertical; + background-color: #f2f2f2; + color: #888; +} + + +/* Style des boutons */ +/* Conteneur principal de la lettre */ +.letter { + width: 95%; /* Utilise presque toute la largeur du modal */ + max-width: 1100px; /* Empêche qu’elle devienne trop grande sur grands écrans */ + max-height: 80vh; /* Optimisation de la hauteur */ + padding: 30px; + border-radius: 10px; + background: #fff; + box-shadow: 0px 5px 15px rgba(0, 0, 0, 0.15); + overflow-y: auto; /* Active le défilement vertical si nécessaire */ + margin: auto; /* Centre la lettre horizontalement */ + scrollbar-width: thin; /* Barre de défilement discrète pour Firefox */ + scrollbar-color: #bbb transparent; /* Couleur douce */ + } + + /* Barre de défilement discrète pour Webkit (Chrome, Safari) */ + .letter::-webkit-scrollbar { + width: 6px; + } + + .letter::-webkit-scrollbar-thumb { + background: #bbb; /* Couleur douce */ + border-radius: 4px; + } + + .letter::-webkit-scrollbar-track { + background: transparent; + } + + /* En-tête de la lettre */ + .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; + } + + /* Corps de la lettre */ + .letter-body { + font-size: 1.3rem; + line-height: 1.8; + text-align: justify; + padding-right: 14px; /* Évite que le texte colle à la barre de scroll */ + } + + /* Sections de contenu */ + .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; + } + + /* Pied de la lettre */ + .letter-footer { + text-align: left; + margin-top: 20px; + font-weight: bold; + color: #333; + } + + /* Ajustement responsive */ + @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; /* Couleur de fond légère */ + 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; /* Bleu pour attirer l'attention */ + } + \ No newline at end of file diff --git a/webapp/src/pages/ResponsePage/ResponsePage.js b/webapp/src/pages/ResponsePage/ResponsePage.js index a1a7f375c..a1b86e169 100644 --- a/webapp/src/pages/ResponsePage/ResponsePage.js +++ b/webapp/src/pages/ResponsePage/ResponsePage.js @@ -11,6 +11,8 @@ 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 moment from 'moment' import { getDownloadURL } from '../../utils/files'; import TagSelectorDialog from '../../components/TagSelectorDialog'; @@ -39,6 +41,7 @@ class ResponsePage extends Component { confirmModalVisible: false, pendingOutcome: "", confirmationMessage: "", + comment: "", } }; @@ -248,6 +251,7 @@ class ResponsePage extends Component { } outcomeStatus() { const data = this.state.applicationData; + const name = data.user_title + " " + data.firstname + " " + data.lastname; if (data) { if (this.state.outcome.status && this.state.outcome.status !== "REVIEW") { const badgeClass = this.state.outcome.status === "ACCEPTED" @@ -325,7 +329,8 @@ class ResponsePage extends Component { ); } - + // console.log(this.sta); {applicationData.user_title} {applicationData.firstname} {applicationData.lastname} + return (
{buttons.map((button, index) => ( @@ -333,15 +338,111 @@ class ResponsePage extends Component { {button}
))} - -

{this.props.t(this.state.confirmationMessage)}

-
+ +
+
{this.props.t("Response Letter")}
+ +
+ +
+
+ +
+

+ {this.props.t("Dear")} { name || "User"}, +

+

+ {this.props.t( + "We are pleased to acknowledge the receipt of your application. Your submission has been carefully reviewed by our team, and we appreciate the time and effort you have invested." + )} +

+

+ {this.props.t( + "Below is a summary of the key details and evaluations of your application." + )} +

+ + {/* Application Sections */} +
+

+ {this.state.comment} +

+
+ {/* */} + + +

+ {this.props.t( + "Our team has conducted a thorough review of your application. Below, you will find our detailed feedback and recommendations." + )} +

+ + {/* Review Sections */} +
{this.renderCompleteReviews()}
+ + + {/* Application Status Section */} +
+
{this.props.t("Application Status")}
+

+ {this.props.t( + "Based on our review, your application status is as follows:" + )} +

+

{this.state.pendingOutcome}

+ {/*

+ {this.props.t( + "If any further action is required, we will notify you promptly." + )} +

*/} +
+ +

+ {this.props.t( + "Please note that our decision is based on a comprehensive analysis of your submission. We highly value your engagement and encourage you to reach out if you have any questions or require further clarification." + )} +

+
+ + {/* Letter Footer */} +
+

{this.props.t("Best regards")},

+

{this.props.t("JAISD Team")}

+
+
+
+ +
+ + +
+ + : + + +

{this.props.t(this.state.confirmationMessage)}

+
+ } + +
); } @@ -685,6 +786,21 @@ class ResponsePage extends Component { }) } + handleCommentChange = (event) => { + this.setState({ comment: event.target.value }); + }; + + renderComment = () => { + return ( + */} - - +

{this.props.t( "Our team has conducted a thorough review of your application. Below, you will find our detailed feedback and recommendations." @@ -397,7 +395,10 @@ class ResponsePage extends Component { "Based on our review, your application status is as follows:" )}

-

{this.state.pendingOutcome}

+

{this.state.pendingOutcome ==='ACCEPTED'?this.props.t("ACCEPTED"):this.state.pendingOutcome ==='REJECTED'? + this.props.t("REJECTED"):this.state.pendingOutcome ==='ACCEPT_W_REVISION'? + this.props.t("ACCEPTED WITH REVISION"):this.state.pendingOutcome ==='REJECT_W_ENCOURAGEMENT'? + this.props.t("REJECTED WITH ENCOURAGEMENT TO RESUMIT"):this.props.t("REVIEWING")}

{/*

{this.props.t( "If any further action is required, we will notify you promptly." @@ -412,11 +413,11 @@ class ResponsePage extends Component {

- {/* Letter Footer */} +

{this.props.t("Best regards")},

-

{this.props.t("JAISD Team")}

-
+ {/*

{this.props.t("JAISD Team")}

*/} + @@ -794,7 +795,7 @@ class ResponsePage extends Component { return (