From 443bf60dbe6e41a5584192cbf0a62aeb9d947407 Mon Sep 17 00:00:00 2001 From: bshankar Date: Tue, 25 Jun 2024 15:47:16 +0530 Subject: [PATCH 01/55] Docker: Dev setup: Use yarn instead of npm in tm-frontend --- scripts/docker/Dockerfile.frontend_development | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/docker/Dockerfile.frontend_development b/scripts/docker/Dockerfile.frontend_development index 1508a0f9cf..a0f6d624a3 100644 --- a/scripts/docker/Dockerfile.frontend_development +++ b/scripts/docker/Dockerfile.frontend_development @@ -4,7 +4,7 @@ WORKDIR /usr/src/app COPY ./frontend . ## SETUP -RUN npm install +RUN yarn install # SERVE -CMD ["npm", "start"] +CMD ["yarn", "start"] From cdaad2f91ce9826efdcebc2818ab925b8e381ead Mon Sep 17 00:00:00 2001 From: bshankar Date: Wed, 26 Jun 2024 09:19:27 +0530 Subject: [PATCH 02/55] Docs: Fix Oauth 2 redirect URL --- docs/developers/development-setup.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developers/development-setup.md b/docs/developers/development-setup.md index 064a7597c6..2e942013b5 100644 --- a/docs/developers/development-setup.md +++ b/docs/developers/development-setup.md @@ -25,7 +25,7 @@ In order to use the frontend, you may need to create keys for OSM: 2. Register your Tasking Manager instance to OAuth 2 applications. - Put your login redirect url as `http://127.0.0.1:3000/authorized/` + Put your login redirect url as `http://127.0.0.1:3000/authorized` > Note: `127.0.0.1` is required for debugging instead of `localhost` > due to OSM restrictions. From a59836bcc7ba9c016fb8f219eca2e668a60161c3 Mon Sep 17 00:00:00 2001 From: bshankar Date: Wed, 26 Jun 2024 09:22:24 +0530 Subject: [PATCH 03/55] Docs: Give tasking-manager.env as default ENV for compose --- docs/developers/development-setup.md | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/docs/developers/development-setup.md b/docs/developers/development-setup.md index 2e942013b5..d9b30785ac 100644 --- a/docs/developers/development-setup.md +++ b/docs/developers/development-setup.md @@ -86,9 +86,7 @@ cp example.env tasking-manager.env Now you can proceed with starting the services. ```bash -docker compose pull -docker compose build -docker compose up --detach +docker compose --env-file tasking-manager.env up -d ``` Tasking Manager should be available from: @@ -104,11 +102,10 @@ in addition to any variables including a port, e.g. TM_APP_BASE_URL. The default dotenv file can also be changed. ```bash -TM_DEV_PORT=9000 ENV_FILE=.env docker compose up --detach +TM_DEV_PORT=9000 docker compose --env-file tasking-manager.env up -d ``` ```bash -docker compose build -docker compose up --detach +docker compose --env-file tasking-manager.env up -d ``` #### (Optional) Overriding `docker-compose.yml` If you want to add custom configuration for the docker services. You can make a copy of `docker-compose.override.sample.yml` which you can edit as per your need. @@ -133,7 +130,7 @@ POSTGRES_PORT=5432 Once Updated, recreate containers with ``` -docker compose up -d +docker compose --env-file tasking-manager.env up -d ``` ### Frontend Only Deployment @@ -148,7 +145,7 @@ TM_APP_API_URL=https://tasking-manager-staging-api.hotosm.org ``` Then proceed with starting only frontend service with docker. ``` -docker compose up -d tm-frontend +docker compose --env-file tasking-manager.env up -d tm-frontend ``` Check server logs with @@ -494,7 +491,7 @@ It is possible to install and run the Tasking Manager using [Docker](https://docker.com) and [Docker Compose](https://docs.docker.com/compose/). -Clone the Tasking Manager repository and use `docker-compose up` to +Clone the Tasking Manager repository and use `docker compose --env-file tasking-manager.env up` to get a working version of the API running. ## Sysadmins guide From ece5c339382a041eeccf94b4f37615fe50a849b9 Mon Sep 17 00:00:00 2001 From: bshankar Date: Thu, 27 Jun 2024 18:53:17 +0530 Subject: [PATCH 04/55] Add migrations for linking partners with projects --- migrations/versions/749a9ae35ce5_.py | 74 ++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 migrations/versions/749a9ae35ce5_.py diff --git a/migrations/versions/749a9ae35ce5_.py b/migrations/versions/749a9ae35ce5_.py new file mode 100644 index 0000000000..cedaa46ae0 --- /dev/null +++ b/migrations/versions/749a9ae35ce5_.py @@ -0,0 +1,74 @@ +""" + +Create a table for linking partners and projects. +Create a table to maintain a log for all changes to these links. + +Revision ID: 749a9ae35ce5 +Revises: e8ffa33a9c18 +Create Date: 2024-06-27 09:36:30.577884 + +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = "749a9ae35ce5" +down_revision = "e8ffa33a9c18" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "project_partners", + sa.Column("project_id", sa.INTEGER(), autoincrement=True, nullable=False), + sa.Column("partner_id", sa.INTEGER(), autoincrement=True, nullable=False), + sa.Column( + "started_on", postgresql.TIMESTAMP(), autoincrement=False, nullable=False + ), + sa.Column( + "ended_on", postgresql.TIMESTAMP(), autoincrement=False, nullable=True + ), + sa.ForeignKeyConstraint( + ["partner_id"], ["partners.id"], name="project_partners_partners_fk" + ), + sa.ForeignKeyConstraint( + ["project_id"], ["projects.id"], name="project_partners_projects_fk" + ), + sa.PrimaryKeyConstraint("project_id", "partner_id", name="project_partners_pk"), + ) + + op.create_table( + "project_partners_history", + sa.Column("id", sa.BIGINT(), autoincrement=True, nullable=False), + sa.Column("project_id", sa.INTEGER(), autoincrement=True, nullable=False), + sa.Column("partner_id", sa.INTEGER(), autoincrement=True, nullable=False), + sa.Column( + "action_date", postgresql.TIMESTAMP(), autoincrement=False, nullable=False + ), + sa.Column("action", sa.VARCHAR(length=50), autoincrement=False, nullable=False), + sa.Column( + "started_on_old", postgresql.TIMESTAMP(), autoincrement=False, nullable=True + ), + sa.Column( + "ended_on_old", postgresql.TIMESTAMP(), autoincrement=False, nullable=True + ), + sa.Column( + "started_on_new", postgresql.TIMESTAMP(), autoincrement=False, nullable=True + ), + sa.Column( + "ended_on_new", postgresql.TIMESTAMP(), autoincrement=False, nullable=True + ), + sa.PrimaryKeyConstraint("id", name="project_partners_history_pk"), + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("project_partners") + op.drop_table("project_partners_history") + # ### end Alembic commands ### From de6bfcd9d23b8be16183d4c516fb50a419db8366 Mon Sep 17 00:00:00 2001 From: bshankar Date: Fri, 28 Jun 2024 08:52:01 +0530 Subject: [PATCH 05/55] Fix: project_partners table doesn't need ended_on column --- migrations/versions/749a9ae35ce5_.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/migrations/versions/749a9ae35ce5_.py b/migrations/versions/749a9ae35ce5_.py index cedaa46ae0..a41877d2c4 100644 --- a/migrations/versions/749a9ae35ce5_.py +++ b/migrations/versions/749a9ae35ce5_.py @@ -29,9 +29,6 @@ def upgrade(): sa.Column( "started_on", postgresql.TIMESTAMP(), autoincrement=False, nullable=False ), - sa.Column( - "ended_on", postgresql.TIMESTAMP(), autoincrement=False, nullable=True - ), sa.ForeignKeyConstraint( ["partner_id"], ["partners.id"], name="project_partners_partners_fk" ), From e5e0f14e29029e6e55980335cb55d1d8b449844b Mon Sep 17 00:00:00 2001 From: bshankar Date: Fri, 28 Jun 2024 09:35:34 +0530 Subject: [PATCH 06/55] Add DTOs for project_partners --- backend/models/dtos/project_partner_dto.py | 59 ++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 backend/models/dtos/project_partner_dto.py diff --git a/backend/models/dtos/project_partner_dto.py b/backend/models/dtos/project_partner_dto.py new file mode 100644 index 0000000000..d040f6d137 --- /dev/null +++ b/backend/models/dtos/project_partner_dto.py @@ -0,0 +1,59 @@ +from schematics import Model +from schematics.types import ( + LongType, + UTCDateTimeType, + StringType, + Enum, + ValidationError, +) + + +def is_known_action(value): + """Validates that the action performed on a Project-Partner link is known""" + valid_values = f"{ProjectPartnerAction.START.name}, {ProjectPartnerAction.END.name}, {ProjectPartnerAction.UPDATE.name}" + + try: + action = ProjectPartnerAction[value.upper()] + except KeyError: + raise ValidationError( + f'"{action}" is an unknown Project-Partner link action. Valid action names are {valid_values}' + ) + + +class ProjectPartnerDTO(Model): + """DTO for the link between a Partner and a Project""" + + project_id = LongType(required=True, serialized_name="projectId") + partner_id = LongType(required=True, serialized_name="partnerId") + started_on = UTCDateTimeType(required=True, serialized_name="startedOn") + + +class ProjectPartnerHistoryDTO(Model): + """DTO for Logs of changes to all Project-Partner links""" + + id = LongType(required=True) + project_id = LongType(required=True, serialized_name="projectId") + partner_id = LongType(required=True, serialized_name="partnerId") + started_on_old = UTCDateTimeType( + serialized_name="startedOnOld", serialize_when_none=False + ) + ended_on_old = UTCDateTimeType( + serialized_name="endedOnOld", serialize_when_none=False + ) + started_on_new = UTCDateTimeType( + serialized_name="startedOnNew", serialize_when_none=False + ) + ended_on_new = UTCDateTimeType( + serialized_name="endedOnNew", serialize_when_none=False + ) + + action = StringType(validators=[is_known_action]) + actionDate = UTCDateTimeType(serialized_name="actionDate") + + +class ProjectPartnerAction(Enum): + """Enum describing available actions related to updating Project-Partner links""" + + START = 0 + END = 1 + UPDATE = 2 # Updates the time range of when partner was linked with a project. From 3ecd0c7b894dd0df65f016abcf5da56b7b1a305a Mon Sep 17 00:00:00 2001 From: bshankar Date: Fri, 28 Jun 2024 11:49:38 +0530 Subject: [PATCH 07/55] Add models for project_partners --- backend/models/postgis/project_partner.py | 139 ++++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 backend/models/postgis/project_partner.py diff --git a/backend/models/postgis/project_partner.py b/backend/models/postgis/project_partner.py new file mode 100644 index 0000000000..5a1fd57468 --- /dev/null +++ b/backend/models/postgis/project_partner.py @@ -0,0 +1,139 @@ +from backend import db +from backend.models.postgis.utils import timestamp +from backend.models.postgis.project import Project +from backend.models.postgis.partner import Partner +from typing import List + + +class ProjectPartnerHistory(db.Model): + __tablename__ = "project_partners_history" + + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + project_id = db.Column( + db.Integer, db.ForeignKey("project.id"), nullable=False, index=True + ) + partner_id = db.Column( + db.Integer, db.ForeignKey("partner.id"), nullable=False, index=True + ) + + started_on_old = db.Column(db.DateTime, default=timestamp) + ended_on_old = db.Column(db.DateTime, default=timestamp) + started_on_new = db.Column(db.DateTime, default=timestamp) + ended_on_new = db.Column(db.DateTime, default=timestamp) + + project = db.relationship( + "Project", backref=db.backref("projects", cascade="all, delete-orphan") + ) + partner = db.relationship( + "Partner", backref=db.backref("partners", cascade="all, delete-orphan") + ) + + def create(self): + """Creates and saves the current model to the DB""" + db.session.add(self) + db.session.commit() + + def save(self): + """Save changes to db""" + db.session.commit() + + def delete(self): + """Deletes the current model from the DB""" + db.session.delete(self) + db.session.commit() + + +class ProjectPartner(db.Model): + __tablename__ = "project_partners" + + project_id = db.Column( + db.Integer, db.ForeignKey("project.id"), primary_key=True, index=True + ) + partner_id = db.Column( + db.Integer, db.ForeignKey("partner.id"), primary_key=True, index=True + ) + started_on = db.Column(db.DateTime, default=timestamp, nullable=False) + + project = db.relationship( + "Project", backref=db.backref("projects", cascade="all, delete-orphan") + ) + partner = db.relationship( + "Partner", backref=db.backref("partners", cascade="all, delete-orphan") + ) + + def create(self): + """Creates and saves the current model to the DB""" + db.session.add(self) + db.session.commit() + + def save(self): + """Save changes to db""" + db.session.commit() + + def delete(self): + """Deletes the current model from the DB""" + db.session.delete(self) + db.session.commit() + + @staticmethod + def get_partners(project_id: int) -> List[Project]: + """Get the partners associated with a project""" + return ProjectPartner.query.filter_by(project_id=project_id).all() + + @staticmethod + def get_projects(partner_id: int) -> List[Partner]: + """Get the projects associated with a partner""" + return ProjectPartner.query.filter_by(partner_id=partner_id).all() + + @staticmethod + def start_partner(partner_id: int, project_id: int) -> bool: + """Begin a partnership for a project""" + row = ProjectPartner.query.filter_by( + project_id=project_id, partner_id=partner_id + ).one_or_none() + + if row is not None: + return False + + started_on = timestamp() + partnership_new = { + "project_id": project_id, + "partner_id": partner_id, + "started_on": started_on, + } + + ProjectPartner.insert.values(partnership_new) + ProjectPartnerHistory.insert.values( + { + "project_id": project_id, + "partner_id": partner_id, + "action": "START", + "action_date": started_on, + "started_on_new": started_on, + } + ) + return True + + @staticmethod + def end_partner(partner_id: int, project_id: int) -> bool: + """End a project partnership""" + row = ProjectPartner.query.filter_by( + project_id=project_id, partner_id=partner_id + ).one_or_none() + + if row is None: + return False + + row.delete() + ended_on = timestamp() + + ProjectPartnerHistory.insert.values( + { + "project_id": project_id, + "partner_id": partner_id, + "action": "END", + "action_date": ended_on, + "ended_on_new": ended_on, + } + ) + return True From 06f552878827713bec4d51dae791be7b63042156 Mon Sep 17 00:00:00 2001 From: bshankar Date: Mon, 1 Jul 2024 13:19:05 +0530 Subject: [PATCH 08/55] Remove update time range action (for now) --- backend/models/dtos/project_partner_dto.py | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/models/dtos/project_partner_dto.py b/backend/models/dtos/project_partner_dto.py index d040f6d137..7f808080d5 100644 --- a/backend/models/dtos/project_partner_dto.py +++ b/backend/models/dtos/project_partner_dto.py @@ -56,4 +56,3 @@ class ProjectPartnerAction(Enum): START = 0 END = 1 - UPDATE = 2 # Updates the time range of when partner was linked with a project. From 914f8ab5635ff490d6cfb987d6d2cb3074a2af97 Mon Sep 17 00:00:00 2001 From: bshankar Date: Wed, 3 Jul 2024 07:45:02 +0530 Subject: [PATCH 09/55] Update migrations for new project-partner spec --- migrations/versions/749a9ae35ce5_.py | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/migrations/versions/749a9ae35ce5_.py b/migrations/versions/749a9ae35ce5_.py index a41877d2c4..1ecb197869 100644 --- a/migrations/versions/749a9ae35ce5_.py +++ b/migrations/versions/749a9ae35ce5_.py @@ -23,24 +23,26 @@ def upgrade(): # ### commands auto generated by Alembic - please adjust! ### op.create_table( - "project_partners", + "project_partnerships", + sa.Column("id", sa.BIGINT(), autoincrement=True, primary_key=True), sa.Column("project_id", sa.INTEGER(), autoincrement=True, nullable=False), sa.Column("partner_id", sa.INTEGER(), autoincrement=True, nullable=False), sa.Column( "started_on", postgresql.TIMESTAMP(), autoincrement=False, nullable=False ), + sa.ForeignKeyConstraint( ["partner_id"], ["partners.id"], name="project_partners_partners_fk" ), sa.ForeignKeyConstraint( ["project_id"], ["projects.id"], name="project_partners_projects_fk" - ), - sa.PrimaryKeyConstraint("project_id", "partner_id", name="project_partners_pk"), + ) ) op.create_table( - "project_partners_history", - sa.Column("id", sa.BIGINT(), autoincrement=True, nullable=False), + "project_partnerships_history", + sa.Column("id", sa.BIGINT(), autoincrement=True, primary_key=True), + sa.Column("partnership_id", sa.BIGINT(), autoincrement=True, nullable=False), sa.Column("project_id", sa.INTEGER(), autoincrement=True, nullable=False), sa.Column("partner_id", sa.INTEGER(), autoincrement=True, nullable=False), sa.Column( @@ -59,7 +61,16 @@ def upgrade(): sa.Column( "ended_on_new", postgresql.TIMESTAMP(), autoincrement=False, nullable=True ), - sa.PrimaryKeyConstraint("id", name="project_partners_history_pk"), + + sa.ForeignKeyConstraint( + ["partnership_id"], ["project_partnerships.id"], name="project_partnerships_fk" + ), + sa.ForeignKeyConstraint( + ["partner_id"], ["partners.id"], name="project_partners_partners_fk" + ), + sa.ForeignKeyConstraint( + ["project_id"], ["projects.id"], name="project_partners_projects_fk" + ) ) # ### end Alembic commands ### From 8144a3829cbb859e7c7f20ac6ac0f95ca472d6c8 Mon Sep 17 00:00:00 2001 From: bshankar Date: Wed, 3 Jul 2024 07:56:13 +0530 Subject: [PATCH 10/55] Update DTOs for new project-partner spec --- backend/models/dtos/project_partner_dto.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/backend/models/dtos/project_partner_dto.py b/backend/models/dtos/project_partner_dto.py index 7f808080d5..acbf873380 100644 --- a/backend/models/dtos/project_partner_dto.py +++ b/backend/models/dtos/project_partner_dto.py @@ -23,6 +23,7 @@ def is_known_action(value): class ProjectPartnerDTO(Model): """DTO for the link between a Partner and a Project""" + id = LongType(required=True) project_id = LongType(required=True, serialized_name="projectId") partner_id = LongType(required=True, serialized_name="partnerId") started_on = UTCDateTimeType(required=True, serialized_name="startedOn") @@ -32,6 +33,7 @@ class ProjectPartnerHistoryDTO(Model): """DTO for Logs of changes to all Project-Partner links""" id = LongType(required=True) + partnership_id = LongType(required=True, serialized_name="partnershipId") project_id = LongType(required=True, serialized_name="projectId") partner_id = LongType(required=True, serialized_name="partnerId") started_on_old = UTCDateTimeType( @@ -56,3 +58,4 @@ class ProjectPartnerAction(Enum): START = 0 END = 1 + UPDATE = 2 From 30fbb758c5594c8c79b8270769102d07cc6180b3 Mon Sep 17 00:00:00 2001 From: bshankar Date: Wed, 3 Jul 2024 08:12:53 +0530 Subject: [PATCH 11/55] Update postgis models for new project-partner spec --- backend/models/postgis/project_partner.py | 85 +++-------------------- 1 file changed, 11 insertions(+), 74 deletions(-) diff --git a/backend/models/postgis/project_partner.py b/backend/models/postgis/project_partner.py index 5a1fd57468..4d790c86fb 100644 --- a/backend/models/postgis/project_partner.py +++ b/backend/models/postgis/project_partner.py @@ -1,14 +1,14 @@ from backend import db from backend.models.postgis.utils import timestamp -from backend.models.postgis.project import Project -from backend.models.postgis.partner import Partner -from typing import List class ProjectPartnerHistory(db.Model): - __tablename__ = "project_partners_history" + __tablename__ = "project_partnerships_history" id = db.Column(db.Integer, primary_key=True, autoincrement=True) + partnership_id = db.Column( + db.Integer, db.ForeignKey("project_partnerships.id"), nullable=False, index=True + ) project_id = db.Column( db.Integer, db.ForeignKey("project.id"), nullable=False, index=True ) @@ -21,6 +21,9 @@ class ProjectPartnerHistory(db.Model): started_on_new = db.Column(db.DateTime, default=timestamp) ended_on_new = db.Column(db.DateTime, default=timestamp) + partnership = db.relationship( + "ProjectPartner", backref=db.backref("project_partnerships", cascade="all, delete-orphan") + ) project = db.relationship( "Project", backref=db.backref("projects", cascade="all, delete-orphan") ) @@ -44,14 +47,11 @@ def delete(self): class ProjectPartner(db.Model): - __tablename__ = "project_partners" + __tablename__ = "project_partnerships" - project_id = db.Column( - db.Integer, db.ForeignKey("project.id"), primary_key=True, index=True - ) - partner_id = db.Column( - db.Integer, db.ForeignKey("partner.id"), primary_key=True, index=True - ) + id = db.Column(db.Integer, primary_key=True) + project_id = db.Column(db.Integer, db.ForeignKey("project.id")) + partner_id = db.Column(db.Integer, db.ForeignKey("partner.id")) started_on = db.Column(db.DateTime, default=timestamp, nullable=False) project = db.relationship( @@ -74,66 +74,3 @@ def delete(self): """Deletes the current model from the DB""" db.session.delete(self) db.session.commit() - - @staticmethod - def get_partners(project_id: int) -> List[Project]: - """Get the partners associated with a project""" - return ProjectPartner.query.filter_by(project_id=project_id).all() - - @staticmethod - def get_projects(partner_id: int) -> List[Partner]: - """Get the projects associated with a partner""" - return ProjectPartner.query.filter_by(partner_id=partner_id).all() - - @staticmethod - def start_partner(partner_id: int, project_id: int) -> bool: - """Begin a partnership for a project""" - row = ProjectPartner.query.filter_by( - project_id=project_id, partner_id=partner_id - ).one_or_none() - - if row is not None: - return False - - started_on = timestamp() - partnership_new = { - "project_id": project_id, - "partner_id": partner_id, - "started_on": started_on, - } - - ProjectPartner.insert.values(partnership_new) - ProjectPartnerHistory.insert.values( - { - "project_id": project_id, - "partner_id": partner_id, - "action": "START", - "action_date": started_on, - "started_on_new": started_on, - } - ) - return True - - @staticmethod - def end_partner(partner_id: int, project_id: int) -> bool: - """End a project partnership""" - row = ProjectPartner.query.filter_by( - project_id=project_id, partner_id=partner_id - ).one_or_none() - - if row is None: - return False - - row.delete() - ended_on = timestamp() - - ProjectPartnerHistory.insert.values( - { - "project_id": project_id, - "partner_id": partner_id, - "action": "END", - "action_date": ended_on, - "ended_on_new": ended_on, - } - ) - return True From bf40de4043514606ecc62d414bdc22bb166079fc Mon Sep 17 00:00:00 2001 From: bshankar Date: Wed, 3 Jul 2024 08:15:12 +0530 Subject: [PATCH 12/55] Fix: Rename project-partners table -> project-partnerships --- migrations/versions/749a9ae35ce5_.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/migrations/versions/749a9ae35ce5_.py b/migrations/versions/749a9ae35ce5_.py index 1ecb197869..184716dc5b 100644 --- a/migrations/versions/749a9ae35ce5_.py +++ b/migrations/versions/749a9ae35ce5_.py @@ -77,6 +77,6 @@ def upgrade(): def downgrade(): # ### commands auto generated by Alembic - please adjust! ### - op.drop_table("project_partners") - op.drop_table("project_partners_history") + op.drop_table("project_partnerships") + op.drop_table("project_partnerships_history") # ### end Alembic commands ### From 759d96e59a044f459735839cc736fe7fa6a2cbad Mon Sep 17 00:00:00 2001 From: bshankar Date: Wed, 3 Jul 2024 15:33:22 +0530 Subject: [PATCH 13/55] Fix: Add ended_on column for partnerships --- backend/models/dtos/project_partner_dto.py | 1 + migrations/versions/749a9ae35ce5_.py | 15 +++++++-------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/backend/models/dtos/project_partner_dto.py b/backend/models/dtos/project_partner_dto.py index acbf873380..31dc58172b 100644 --- a/backend/models/dtos/project_partner_dto.py +++ b/backend/models/dtos/project_partner_dto.py @@ -27,6 +27,7 @@ class ProjectPartnerDTO(Model): project_id = LongType(required=True, serialized_name="projectId") partner_id = LongType(required=True, serialized_name="partnerId") started_on = UTCDateTimeType(required=True, serialized_name="startedOn") + ended_on = UTCDateTimeType(serialized_name="endedOn") class ProjectPartnerHistoryDTO(Model): diff --git a/migrations/versions/749a9ae35ce5_.py b/migrations/versions/749a9ae35ce5_.py index 184716dc5b..0074f4810b 100644 --- a/migrations/versions/749a9ae35ce5_.py +++ b/migrations/versions/749a9ae35ce5_.py @@ -27,16 +27,14 @@ def upgrade(): sa.Column("id", sa.BIGINT(), autoincrement=True, primary_key=True), sa.Column("project_id", sa.INTEGER(), autoincrement=True, nullable=False), sa.Column("partner_id", sa.INTEGER(), autoincrement=True, nullable=False), - sa.Column( - "started_on", postgresql.TIMESTAMP(), autoincrement=False, nullable=False - ), - + sa.Column("started_on", postgresql.TIMESTAMP(), nullable=False), + sa.Column("ended_on", postgresql.TIMESTAMP(), nullable=True), sa.ForeignKeyConstraint( ["partner_id"], ["partners.id"], name="project_partners_partners_fk" ), sa.ForeignKeyConstraint( ["project_id"], ["projects.id"], name="project_partners_projects_fk" - ) + ), ) op.create_table( @@ -61,16 +59,17 @@ def upgrade(): sa.Column( "ended_on_new", postgresql.TIMESTAMP(), autoincrement=False, nullable=True ), - sa.ForeignKeyConstraint( - ["partnership_id"], ["project_partnerships.id"], name="project_partnerships_fk" + ["partnership_id"], + ["project_partnerships.id"], + name="project_partnerships_fk", ), sa.ForeignKeyConstraint( ["partner_id"], ["partners.id"], name="project_partners_partners_fk" ), sa.ForeignKeyConstraint( ["project_id"], ["projects.id"], name="project_partners_projects_fk" - ) + ), ) # ### end Alembic commands ### From 6f8bbe18a438cdd95832d5f67df7c8adf03f3aa7 Mon Sep 17 00:00:00 2001 From: bshankar Date: Wed, 3 Jul 2024 17:19:37 +0530 Subject: [PATCH 14/55] Fix: Remove all relationships --- backend/models/postgis/project_partner.py | 21 ++------------------- 1 file changed, 2 insertions(+), 19 deletions(-) diff --git a/backend/models/postgis/project_partner.py b/backend/models/postgis/project_partner.py index 4d790c86fb..f4561fb7c3 100644 --- a/backend/models/postgis/project_partner.py +++ b/backend/models/postgis/project_partner.py @@ -2,7 +2,7 @@ from backend.models.postgis.utils import timestamp -class ProjectPartnerHistory(db.Model): +class ProjectPartnershipHistory(db.Model): __tablename__ = "project_partnerships_history" id = db.Column(db.Integer, primary_key=True, autoincrement=True) @@ -21,16 +21,6 @@ class ProjectPartnerHistory(db.Model): started_on_new = db.Column(db.DateTime, default=timestamp) ended_on_new = db.Column(db.DateTime, default=timestamp) - partnership = db.relationship( - "ProjectPartner", backref=db.backref("project_partnerships", cascade="all, delete-orphan") - ) - project = db.relationship( - "Project", backref=db.backref("projects", cascade="all, delete-orphan") - ) - partner = db.relationship( - "Partner", backref=db.backref("partners", cascade="all, delete-orphan") - ) - def create(self): """Creates and saves the current model to the DB""" db.session.add(self) @@ -46,7 +36,7 @@ def delete(self): db.session.commit() -class ProjectPartner(db.Model): +class ProjectPartnership(db.Model): __tablename__ = "project_partnerships" id = db.Column(db.Integer, primary_key=True) @@ -54,13 +44,6 @@ class ProjectPartner(db.Model): partner_id = db.Column(db.Integer, db.ForeignKey("partner.id")) started_on = db.Column(db.DateTime, default=timestamp, nullable=False) - project = db.relationship( - "Project", backref=db.backref("projects", cascade="all, delete-orphan") - ) - partner = db.relationship( - "Partner", backref=db.backref("partners", cascade="all, delete-orphan") - ) - def create(self): """Creates and saves the current model to the DB""" db.session.add(self) From 617dc9723378b050aad8cd707a29a9bfd341b3af Mon Sep 17 00:00:00 2001 From: bshankar Date: Wed, 3 Jul 2024 17:39:36 +0530 Subject: [PATCH 15/55] Fix: Add missing column ended_on for project partnerships --- backend/models/postgis/project_partner.py | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/models/postgis/project_partner.py b/backend/models/postgis/project_partner.py index f4561fb7c3..8e0c655d7e 100644 --- a/backend/models/postgis/project_partner.py +++ b/backend/models/postgis/project_partner.py @@ -43,6 +43,7 @@ class ProjectPartnership(db.Model): project_id = db.Column(db.Integer, db.ForeignKey("project.id")) partner_id = db.Column(db.Integer, db.ForeignKey("partner.id")) started_on = db.Column(db.DateTime, default=timestamp, nullable=False) + ended_on = db.Column(db.DateTime, default=timestamp, nullable=True) def create(self): """Creates and saves the current model to the DB""" From c319edd711236149d4f9a78c437b5cf4ce895597 Mon Sep 17 00:00:00 2001 From: bshankar Date: Thu, 4 Jul 2024 10:10:48 +0530 Subject: [PATCH 16/55] Implement API to retrieve partnership by ID --- backend/__init__.py | 7 +++++ backend/api/projects/partnerships.py | 31 +++++++++++++++++++ backend/models/dtos/project_partner_dto.py | 14 +++------ backend/models/postgis/project_partner.py | 5 +++ .../services/project_partnership_service.py | 29 +++++++++++++++++ 5 files changed, 77 insertions(+), 9 deletions(-) create mode 100644 backend/api/projects/partnerships.py create mode 100644 backend/services/project_partnership_service.py diff --git a/backend/__init__.py b/backend/__init__.py index 2e38f5076f..789790e9c7 100644 --- a/backend/__init__.py +++ b/backend/__init__.py @@ -243,6 +243,7 @@ def add_api_endpoints(app): ) from backend.api.projects.favorites import ProjectsFavoritesAPI + from backend.api.projects.partnerships import ProjectPartnershipsRestApi # Tasks API import from backend.api.tasks.resources import ( @@ -470,6 +471,12 @@ def add_api_endpoints(app): ProjectsStatisticsQueriesPopularAPI, format_url("projects/queries/popular/") ) + api.add_resource( + ProjectPartnershipsRestApi, + format_url("projects/partnerships//"), + methods=["GET"], + ) + api.add_resource( ProjectsTeamsAPI, format_url("projects//teams/"), diff --git a/backend/api/projects/partnerships.py b/backend/api/projects/partnerships.py new file mode 100644 index 0000000000..3b5dee988d --- /dev/null +++ b/backend/api/projects/partnerships.py @@ -0,0 +1,31 @@ +from flask_restful import Resource +from backend.services.project_partnership_service import ProjectPartnershipService + +class ProjectPartnershipsRestApi(Resource): + def get(self, partnership_id): + """ + Retrieves a Partnership by id + --- + tags: + - partnership + - partners + produces: + - application/json + parameters: + - name: partnership_id + in: path + description: Unique partnership ID + required: true + type: integer + default: 1 + responses: + 200: + description: Team found + 404: + description: Partnership not found + 500: + description: Internal Server Error + """ + + partnership_dto = ProjectPartnershipService.get_partnership_as_dto(partnership_id) + return partnership_dto.to_primitive(), 200 diff --git a/backend/models/dtos/project_partner_dto.py b/backend/models/dtos/project_partner_dto.py index 31dc58172b..4f6feadb48 100644 --- a/backend/models/dtos/project_partner_dto.py +++ b/backend/models/dtos/project_partner_dto.py @@ -1,11 +1,7 @@ from schematics import Model -from schematics.types import ( - LongType, - UTCDateTimeType, - StringType, - Enum, - ValidationError, -) +from schematics.types import LongType, UTCDateTimeType, StringType +from schematics.exceptions import ValidationError +from enum import Enum def is_known_action(value): @@ -20,7 +16,7 @@ def is_known_action(value): ) -class ProjectPartnerDTO(Model): +class ProjectPartnershipDTO(Model): """DTO for the link between a Partner and a Project""" id = LongType(required=True) @@ -30,7 +26,7 @@ class ProjectPartnerDTO(Model): ended_on = UTCDateTimeType(serialized_name="endedOn") -class ProjectPartnerHistoryDTO(Model): +class ProjectPartnershipHistoryDTO(Model): """DTO for Logs of changes to all Project-Partner links""" id = LongType(required=True) diff --git a/backend/models/postgis/project_partner.py b/backend/models/postgis/project_partner.py index 8e0c655d7e..962d6cdeac 100644 --- a/backend/models/postgis/project_partner.py +++ b/backend/models/postgis/project_partner.py @@ -45,6 +45,11 @@ class ProjectPartnership(db.Model): started_on = db.Column(db.DateTime, default=timestamp, nullable=False) ended_on = db.Column(db.DateTime, default=timestamp, nullable=True) + @staticmethod + def get_by_id(partnership_id: int): + """Return the user for the specified id, or None if not found""" + return db.session.get(ProjectPartnership, partnership_id) + def create(self): """Creates and saves the current model to the DB""" db.session.add(self) diff --git a/backend/services/project_partnership_service.py b/backend/services/project_partnership_service.py new file mode 100644 index 0000000000..1c5d7700ad --- /dev/null +++ b/backend/services/project_partnership_service.py @@ -0,0 +1,29 @@ +from flask import current_app +from backend.exceptions import NotFound +from backend.models.postgis.project_partner import ProjectPartnership +from backend.models.dtos.project_partner_dto import ProjectPartnershipDTO + +class ProjectPartnershipServiceError(Exception): + """Custom Exception to notify callers an error occurred when handling project partnerships""" + + def __init__(self, message): + if current_app: + current_app.logger.debug(message) + + +class ProjectPartnershipService: + @staticmethod + def get_partnership_as_dto(partnership_id: int) -> ProjectPartnershipDTO: + partnership = ProjectPartnership.get_by_id(partnership_id) + if partnership is None: + raise NotFound( + sub_code="PARTNERSHIP_NOT_FOUND", partnership_id=partnership_id + ) + + partnership_dto = ProjectPartnershipDTO() + partnership_dto.id = partnership.id + partnership_dto.project_id = partnership.project_id + partnership_dto.partner_id = partnership.partner_id + partnership_dto.started_on = partnership.started_on + partnership_dto.ended_on = partnership.ended_on + return partnership_dto From f9fb193eb67acbd16453ba800b625aee094e081c Mon Sep 17 00:00:00 2001 From: bshankar Date: Thu, 4 Jul 2024 18:58:55 +0530 Subject: [PATCH 17/55] Implement API to add partners to projects --- backend/__init__.py | 7 ++ backend/api/projects/partnerships.py | 102 +++++++++++++++++- backend/models/postgis/project_partner.py | 11 +- .../services/project_partnership_service.py | 27 +++++ 4 files changed, 138 insertions(+), 9 deletions(-) diff --git a/backend/__init__.py b/backend/__init__.py index 789790e9c7..41036084b0 100644 --- a/backend/__init__.py +++ b/backend/__init__.py @@ -477,6 +477,13 @@ def add_api_endpoints(app): methods=["GET"], ) + api.add_resource( + ProjectPartnershipsRestApi, + format_url("projects/partnerships/"), + endpoint="create_partnership", + methods=["POST"], + ) + api.add_resource( ProjectsTeamsAPI, format_url("projects//teams/"), diff --git a/backend/api/projects/partnerships.py b/backend/api/projects/partnerships.py index 3b5dee988d..4112ccda0e 100644 --- a/backend/api/projects/partnerships.py +++ b/backend/api/projects/partnerships.py @@ -1,8 +1,13 @@ -from flask_restful import Resource +from flask_restful import Resource, request from backend.services.project_partnership_service import ProjectPartnershipService +from backend.services.users.authentication_service import token_auth +from backend.services.project_admin_service import ProjectAdminService +from backend.models.dtos.project_partner_dto import ProjectPartnershipDTO +from backend.models.postgis.utils import timestamp + class ProjectPartnershipsRestApi(Resource): - def get(self, partnership_id): + def get(self, partnership_id: int): """ Retrieves a Partnership by id --- @@ -20,12 +25,101 @@ def get(self, partnership_id): default: 1 responses: 200: - description: Team found + description: Partnership found 404: description: Partnership not found 500: description: Internal Server Error """ - partnership_dto = ProjectPartnershipService.get_partnership_as_dto(partnership_id) + partnership_dto = ProjectPartnershipService.get_partnership_as_dto( + partnership_id + ) return partnership_dto.to_primitive(), 200 + + @token_auth.login_required + def post(self): + """Assign a partner to a project + --- + tags: + - projects + - partners + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - in: body + name: body + required: true + description: JSON object for creating a partnership + schema: + properties: + projectId: + required: true + type: int + description: Unique project ID + default: 1 + partnerId: + required: true + type: int + description: Unique partner ID + default: 1 + startedOn: + type: date + description: The timestamp when the partner is added to a project. Defaults to current time. + default: "2017-04-11T12:38:49" + endedOn: + type: date + description: The timestamp when the partner ended their work on a project. + default: "2018-04-11T12:38:49" + responses: + 201: + description: Partner project association created + 400: + description: Ivalid dates or started_on was after ended_on + 401: + description: Forbidden, if user is not a manager of the project + 403: + description: Forbidden, if user is not authenticated + 404: + description: Not found + 500: + description: Internal Server Error + """ + try: + partnership_dto = ProjectPartnershipDTO(request.get_json()) + + # if not ProjectAdminService.is_user_action_permitted_on_project( + # token_auth.current_user, partnership_dto.project_id + # ): + # raise ValueError() + + if partnership_dto.started_on is None: + partnership_dto.started_on = timestamp() + + partnership_dto = ProjectPartnershipDTO(request.get_json()) + partnership_id = ProjectPartnershipService.create_partnership( + partnership_dto.project_id, + partnership_dto.partner_id, + partnership_dto.started_on, + partnership_dto.ended_on, + ) + return ( + { + "Success": "Partner {} assigned to project {}".format( + partnership_dto.partner_id, partnership_dto.project_id + ), + "partnershipId": partnership_id, + }, + 201, + ) + except ValueError: + return { + "Error": "User is not a manager of the project", + "SubCode": "UserPermissionError", + }, 401 diff --git a/backend/models/postgis/project_partner.py b/backend/models/postgis/project_partner.py index 962d6cdeac..d56025c489 100644 --- a/backend/models/postgis/project_partner.py +++ b/backend/models/postgis/project_partner.py @@ -10,10 +10,10 @@ class ProjectPartnershipHistory(db.Model): db.Integer, db.ForeignKey("project_partnerships.id"), nullable=False, index=True ) project_id = db.Column( - db.Integer, db.ForeignKey("project.id"), nullable=False, index=True + db.Integer, db.ForeignKey("projects.id"), nullable=False, index=True ) partner_id = db.Column( - db.Integer, db.ForeignKey("partner.id"), nullable=False, index=True + db.Integer, db.ForeignKey("partners.id"), nullable=False, index=True ) started_on_old = db.Column(db.DateTime, default=timestamp) @@ -39,9 +39,9 @@ def delete(self): class ProjectPartnership(db.Model): __tablename__ = "project_partnerships" - id = db.Column(db.Integer, primary_key=True) - project_id = db.Column(db.Integer, db.ForeignKey("project.id")) - partner_id = db.Column(db.Integer, db.ForeignKey("partner.id")) + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + project_id = db.Column(db.Integer, db.ForeignKey("projects.id")) + partner_id = db.Column(db.Integer, db.ForeignKey("partners.id")) started_on = db.Column(db.DateTime, default=timestamp, nullable=False) ended_on = db.Column(db.DateTime, default=timestamp, nullable=True) @@ -54,6 +54,7 @@ def create(self): """Creates and saves the current model to the DB""" db.session.add(self) db.session.commit() + return self.id def save(self): """Save changes to db""" diff --git a/backend/services/project_partnership_service.py b/backend/services/project_partnership_service.py index 1c5d7700ad..8a5cc71a65 100644 --- a/backend/services/project_partnership_service.py +++ b/backend/services/project_partnership_service.py @@ -2,6 +2,11 @@ from backend.exceptions import NotFound from backend.models.postgis.project_partner import ProjectPartnership from backend.models.dtos.project_partner_dto import ProjectPartnershipDTO +from backend.models.dtos.project_dto import ProjectDTO + +from typing import List, Optional +import datetime + class ProjectPartnershipServiceError(Exception): """Custom Exception to notify callers an error occurred when handling project partnerships""" @@ -27,3 +32,25 @@ def get_partnership_as_dto(partnership_id: int) -> ProjectPartnershipDTO: partnership_dto.started_on = partnership.started_on partnership_dto.ended_on = partnership.ended_on return partnership_dto + + @staticmethod + def get_partnerships_by_project(project_id: int) -> List[ProjectDTO]: + projects = ProjectPartnership.query.filter( + ProjectPartnership.project_id == project_id + ).all() + return list(map(lambda project: project.as_dto_for_admin(), projects)) + + @staticmethod + def create_partnership( + project_id: int, + partner_id: int, + started_on: Optional[datetime.datetime], + ended_on: Optional[datetime.datetime], + ) -> int: + partnership = ProjectPartnership() + partnership.project_id = project_id + partnership.partner_id = partner_id + partnership.started_on = started_on + partnership.ended_on = ended_on + + return partnership.create() From f292f6aa9784377b1667c2c2ceb414e63fb08e18 Mon Sep 17 00:00:00 2001 From: bshankar Date: Thu, 4 Jul 2024 20:01:45 +0530 Subject: [PATCH 18/55] Fix: Only admins can link projects with partners --- backend/api/projects/partnerships.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/backend/api/projects/partnerships.py b/backend/api/projects/partnerships.py index 4112ccda0e..040e5bb8b3 100644 --- a/backend/api/projects/partnerships.py +++ b/backend/api/projects/partnerships.py @@ -1,7 +1,7 @@ from flask_restful import Resource, request from backend.services.project_partnership_service import ProjectPartnershipService from backend.services.users.authentication_service import token_auth -from backend.services.project_admin_service import ProjectAdminService +from backend.services.users.user_service import UserService from backend.models.dtos.project_partner_dto import ProjectPartnershipDTO from backend.models.postgis.utils import timestamp @@ -93,11 +93,10 @@ def post(self): """ try: partnership_dto = ProjectPartnershipDTO(request.get_json()) + is_admin = UserService.is_user_an_admin(token_auth.current_user()) - # if not ProjectAdminService.is_user_action_permitted_on_project( - # token_auth.current_user, partnership_dto.project_id - # ): - # raise ValueError() + if not is_admin: + raise ValueError() if partnership_dto.started_on is None: partnership_dto.started_on = timestamp() @@ -120,6 +119,6 @@ def post(self): ) except ValueError: return { - "Error": "User is not a manager of the project", + "Error": "User is not an admin", "SubCode": "UserPermissionError", }, 401 From fcb3ec018dd9fc7f67b8b5892a0ade07d7b7857d Mon Sep 17 00:00:00 2001 From: bshankar Date: Thu, 4 Jul 2024 20:52:59 +0530 Subject: [PATCH 19/55] Implement patch and delete of project partner links --- backend/__init__.py | 2 +- backend/api/projects/partnerships.py | 147 +++++++++++++++++- backend/models/dtos/project_partner_dto.py | 7 + .../services/project_partnership_service.py | 30 ++++ 4 files changed, 182 insertions(+), 4 deletions(-) diff --git a/backend/__init__.py b/backend/__init__.py index 41036084b0..958a345ae6 100644 --- a/backend/__init__.py +++ b/backend/__init__.py @@ -474,7 +474,7 @@ def add_api_endpoints(app): api.add_resource( ProjectPartnershipsRestApi, format_url("projects/partnerships//"), - methods=["GET"], + methods=["GET", "PATCH", "DELETE"], ) api.add_resource( diff --git a/backend/api/projects/partnerships.py b/backend/api/projects/partnerships.py index 040e5bb8b3..2bc9f95a52 100644 --- a/backend/api/projects/partnerships.py +++ b/backend/api/projects/partnerships.py @@ -2,7 +2,10 @@ from backend.services.project_partnership_service import ProjectPartnershipService from backend.services.users.authentication_service import token_auth from backend.services.users.user_service import UserService -from backend.models.dtos.project_partner_dto import ProjectPartnershipDTO +from backend.models.dtos.project_partner_dto import ( + ProjectPartnershipDTO, + ProjectPartnershipUpdateDTO, +) from backend.models.postgis.utils import timestamp @@ -12,8 +15,9 @@ def get(self, partnership_id: int): Retrieves a Partnership by id --- tags: - - partnership + - projects - partners + - partnerships produces: - application/json parameters: @@ -44,6 +48,7 @@ def post(self): tags: - projects - partners + - partnerships produces: - application/json parameters: @@ -83,7 +88,7 @@ def post(self): 400: description: Ivalid dates or started_on was after ended_on 401: - description: Forbidden, if user is not a manager of the project + description: Forbidden, if user is not an admin 403: description: Forbidden, if user is not authenticated 404: @@ -122,3 +127,139 @@ def post(self): "Error": "User is not an admin", "SubCode": "UserPermissionError", }, 401 + + @staticmethod + @token_auth.login_required + def patch(partnership_id: int): + """Update the time range for a partner project link + --- + tags: + - projects + - partners + - partnerships + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - name: partnership_id + in: path + description: Unique partnership ID + required: true + type: integer + default: 1 + - in: body + name: body + required: true + description: JSON object for creating a partnership + schema: + properties: + startedOn: + type: date + description: The timestamp when the partner is added to a project. Defaults to current time. + default: "2017-04-11T12:38:49" + endedOn: + type: date + description: The timestamp when the partner ended their work on a project. + default: "2018-04-11T12:38:49" + responses: + 201: + description: Partner project association created + 400: + description: Ivalid dates or started_on was after ended_on + 401: + description: Forbidden, if user is not an admin + 403: + description: Forbidden, if user is not authenticated + 404: + description: Not found + 500: + description: Internal Server Error + """ + try: + partnership_updates = ProjectPartnershipUpdateDTO(request.get_json()) + is_admin = UserService.is_user_an_admin(token_auth.current_user()) + + if not is_admin: + raise ValueError() + + partnership = ProjectPartnershipService.update_partnership_time_range( + partnership_id, + partnership_updates.started_on, + partnership_updates.ended_on, + ) + + return ( + { + "Success": "Updated time range. startedOn: {}, endedOn: {}".format( + partnership.started_on, partnership.ended_on + ), + "startedOn": f"{partnership.started_on}", + "endedOn": f"{partnership.ended_on}", + }, + 200, + ) + except ValueError: + return { + "Error": "User is not an admin", + "SubCode": "UserPermissionError", + }, 401 + + @staticmethod + @token_auth.login_required + def delete(partnership_id: int): + """Deletes a link between a project and a partner + --- + tags: + - projects + - partners + - partnerships + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - name: partnership_id + in: path + description: Unique partnership ID + required: true + type: integer + default: 1 + responses: + 201: + description: Partner project association created + 401: + description: Forbidden, if user is not an admin + 403: + description: Forbidden, if user is not authenticated + 404: + description: Not found + 500: + description: Internal Server Error + """ + try: + is_admin = UserService.is_user_an_admin(token_auth.current_user()) + + if not is_admin: + raise ValueError() + + ProjectPartnershipService.delete_partnership(partnership_id) + return ( + { + "Success": "Partnership ID {} deleted".format(partnership_id), + }, + 200, + ) + except ValueError: + return { + "Error": "User is not an admin", + "SubCode": "UserPermissionError", + }, 401 diff --git a/backend/models/dtos/project_partner_dto.py b/backend/models/dtos/project_partner_dto.py index 4f6feadb48..2aa48befe7 100644 --- a/backend/models/dtos/project_partner_dto.py +++ b/backend/models/dtos/project_partner_dto.py @@ -26,6 +26,13 @@ class ProjectPartnershipDTO(Model): ended_on = UTCDateTimeType(serialized_name="endedOn") +class ProjectPartnershipUpdateDTO(Model): + """DTO for updating the time range of the link between a Partner and a Project""" + + started_on = UTCDateTimeType(serialized_name="startedOn") + ended_on = UTCDateTimeType(serialized_name="endedOn") + + class ProjectPartnershipHistoryDTO(Model): """DTO for Logs of changes to all Project-Partner links""" diff --git a/backend/services/project_partnership_service.py b/backend/services/project_partnership_service.py index 8a5cc71a65..0c2ebfbc59 100644 --- a/backend/services/project_partnership_service.py +++ b/backend/services/project_partnership_service.py @@ -54,3 +54,33 @@ def create_partnership( partnership.ended_on = ended_on return partnership.create() + + @staticmethod + def update_partnership_time_range( + partnership_id: int, + started_on: Optional[datetime.datetime], + ended_on: Optional[datetime.datetime], + ) -> ProjectPartnership: + partnership = ProjectPartnership.get_by_id(partnership_id) + if partnership is None: + raise NotFound( + sub_code="PARTNERSHIP_NOT_FOUND", partnership_id=partnership_id + ) + + if started_on is not None: + partnership.started_on = started_on + + if ended_on is not None: + partnership.ended_on = ended_on + + partnership.save() + return partnership + + @staticmethod + def delete_partnership(partnership_id: int): + partnership = ProjectPartnership.get_by_id(partnership_id) + if partnership is None: + raise NotFound( + sub_code="PARTNERSHIP_NOT_FOUND", partnership_id=partnership_id + ) + partnership.delete() From 3c878367da64d443e78d4c26668b52fd3b754fec Mon Sep 17 00:00:00 2001 From: bshankar Date: Fri, 5 Jul 2024 11:09:08 +0530 Subject: [PATCH 20/55] Implement API to get partners associated with a project --- backend/__init__.py | 11 ++++++- backend/api/projects/partnerships.py | 29 +++++++++++++++++++ backend/models/postgis/project_partner.py | 12 ++++++++ .../services/project_partnership_service.py | 13 +++++++-- 4 files changed, 61 insertions(+), 4 deletions(-) diff --git a/backend/__init__.py b/backend/__init__.py index 958a345ae6..c72913f422 100644 --- a/backend/__init__.py +++ b/backend/__init__.py @@ -243,7 +243,10 @@ def add_api_endpoints(app): ) from backend.api.projects.favorites import ProjectsFavoritesAPI - from backend.api.projects.partnerships import ProjectPartnershipsRestApi + from backend.api.projects.partnerships import ( + ProjectPartnershipsRestApi, + PartnersByProjectAPI, + ) # Tasks API import from backend.api.tasks.resources import ( @@ -484,6 +487,12 @@ def add_api_endpoints(app): methods=["POST"], ) + api.add_resource( + PartnersByProjectAPI, + format_url("/projects//partners"), + methods=["GET"], + ) + api.add_resource( ProjectsTeamsAPI, format_url("projects//teams/"), diff --git a/backend/api/projects/partnerships.py b/backend/api/projects/partnerships.py index 2bc9f95a52..d746217c19 100644 --- a/backend/api/projects/partnerships.py +++ b/backend/api/projects/partnerships.py @@ -263,3 +263,32 @@ def delete(partnership_id: int): "Error": "User is not an admin", "SubCode": "UserPermissionError", }, 401 + + +class PartnersByProjectAPI(Resource): + @staticmethod + def get(project_id: int): + """ + Retrieves the list of partners associated with a project + --- + tags: + - projects + - partners + - partnerships + produces: + - application/json + parameters: + - name: project_id + in: path + description: Unique project ID + required: true + type: integer + default: 1 + responses: + 200: + description: List (possibly empty) of partners associated with this project_id + 500: + description: Internal Server Error + """ + partnerships = ProjectPartnershipService.get_partnerships_by_project(project_id) + return { "partnerships": partnerships }, 200 diff --git a/backend/models/postgis/project_partner.py b/backend/models/postgis/project_partner.py index d56025c489..62d9960323 100644 --- a/backend/models/postgis/project_partner.py +++ b/backend/models/postgis/project_partner.py @@ -1,6 +1,8 @@ from backend import db from backend.models.postgis.utils import timestamp +from backend.models.dtos.project_partner_dto import ProjectPartnershipDTO + class ProjectPartnershipHistory(db.Model): __tablename__ = "project_partnerships_history" @@ -64,3 +66,13 @@ def delete(self): """Deletes the current model from the DB""" db.session.delete(self) db.session.commit() + + def as_dto(self) -> ProjectPartnershipDTO: + """Creates a Partnership DTO""" + partnership_dto = ProjectPartnershipDTO() + partnership_dto.id = self.id + partnership_dto.project_id = self.project_id + partnership_dto.partner_id = self.partner_id + partnership_dto.started_on = self.started_on + partnership_dto.ended_on = self.ended_on + return partnership_dto diff --git a/backend/services/project_partnership_service.py b/backend/services/project_partnership_service.py index 0c2ebfbc59..bf6170a8af 100644 --- a/backend/services/project_partnership_service.py +++ b/backend/services/project_partnership_service.py @@ -4,6 +4,8 @@ from backend.models.dtos.project_partner_dto import ProjectPartnershipDTO from backend.models.dtos.project_dto import ProjectDTO +from backend.models.postgis.partner import Partner + from typing import List, Optional import datetime @@ -34,11 +36,12 @@ def get_partnership_as_dto(partnership_id: int) -> ProjectPartnershipDTO: return partnership_dto @staticmethod - def get_partnerships_by_project(project_id: int) -> List[ProjectDTO]: - projects = ProjectPartnership.query.filter( + def get_partnerships_by_project(project_id: int) -> List[ProjectPartnershipDTO]: + partnerships = ProjectPartnership.query.filter( ProjectPartnership.project_id == project_id ).all() - return list(map(lambda project: project.as_dto_for_admin(), projects)) + + return list(map(lambda partnership: partnership.as_dto().to_primitive(), partnerships)) @staticmethod def create_partnership( @@ -84,3 +87,7 @@ def delete_partnership(partnership_id: int): sub_code="PARTNERSHIP_NOT_FOUND", partnership_id=partnership_id ) partnership.delete() + + @staticmethod + def get_partners_by_project(project_id: int) -> List[Partner]: + return ProjectPartnership.query.filter(project_id=project_id).all() From 50ab96403a40bbf36f8b260cb3eaa3b896ca50fd Mon Sep 17 00:00:00 2001 From: bshankar Date: Mon, 8 Jul 2024 11:21:42 +0530 Subject: [PATCH 21/55] ProjectPartnerActions rename: START -> CREATE, END -> DELETE --- backend/models/dtos/project_partner_dto.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/models/dtos/project_partner_dto.py b/backend/models/dtos/project_partner_dto.py index 2aa48befe7..aaa2dcc2fa 100644 --- a/backend/models/dtos/project_partner_dto.py +++ b/backend/models/dtos/project_partner_dto.py @@ -6,7 +6,7 @@ def is_known_action(value): """Validates that the action performed on a Project-Partner link is known""" - valid_values = f"{ProjectPartnerAction.START.name}, {ProjectPartnerAction.END.name}, {ProjectPartnerAction.UPDATE.name}" + valid_values = f"{ProjectPartnerAction.CREATE.name}, {ProjectPartnerAction.DELETE.name}, {ProjectPartnerAction.UPDATE.name}" try: action = ProjectPartnerAction[value.upper()] @@ -60,6 +60,6 @@ class ProjectPartnershipHistoryDTO(Model): class ProjectPartnerAction(Enum): """Enum describing available actions related to updating Project-Partner links""" - START = 0 - END = 1 + CREATE = 0 + DELETE = 1 UPDATE = 2 From 779b0227e7159d203b4ef35146be64fd93c2c998 Mon Sep 17 00:00:00 2001 From: bshankar Date: Mon, 8 Jul 2024 11:26:40 +0530 Subject: [PATCH 22/55] Fix: action column in project_partners_history is integer --- migrations/versions/749a9ae35ce5_.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/migrations/versions/749a9ae35ce5_.py b/migrations/versions/749a9ae35ce5_.py index 0074f4810b..4d4c7fde03 100644 --- a/migrations/versions/749a9ae35ce5_.py +++ b/migrations/versions/749a9ae35ce5_.py @@ -46,7 +46,7 @@ def upgrade(): sa.Column( "action_date", postgresql.TIMESTAMP(), autoincrement=False, nullable=False ), - sa.Column("action", sa.VARCHAR(length=50), autoincrement=False, nullable=False), + sa.Column("action", sa.INTEGER(), autoincrement=False, nullable=False), sa.Column( "started_on_old", postgresql.TIMESTAMP(), autoincrement=False, nullable=True ), From 95e92a35c3240de9eff8723f03c76bc25686b616 Mon Sep 17 00:00:00 2001 From: bshankar Date: Mon, 8 Jul 2024 13:49:41 +0530 Subject: [PATCH 23/55] Log changes to project partner associations in DB --- backend/api/projects/partnerships.py | 2 +- backend/models/postgis/project_partner.py | 37 ++++++++---- .../services/project_partnership_service.py | 57 ++++++++++++++++--- migrations/versions/749a9ae35ce5_.py | 7 ++- 4 files changed, 79 insertions(+), 24 deletions(-) diff --git a/backend/api/projects/partnerships.py b/backend/api/projects/partnerships.py index d746217c19..f6d4508489 100644 --- a/backend/api/projects/partnerships.py +++ b/backend/api/projects/partnerships.py @@ -291,4 +291,4 @@ def get(project_id: int): description: Internal Server Error """ partnerships = ProjectPartnershipService.get_partnerships_by_project(project_id) - return { "partnerships": partnerships }, 200 + return {"partnerships": partnerships}, 200 diff --git a/backend/models/postgis/project_partner.py b/backend/models/postgis/project_partner.py index 62d9960323..b8f8726179 100644 --- a/backend/models/postgis/project_partner.py +++ b/backend/models/postgis/project_partner.py @@ -1,7 +1,10 @@ from backend import db from backend.models.postgis.utils import timestamp -from backend.models.dtos.project_partner_dto import ProjectPartnershipDTO +from backend.models.dtos.project_partner_dto import ( + ProjectPartnershipDTO, + ProjectPartnerAction, +) class ProjectPartnershipHistory(db.Model): @@ -9,19 +12,31 @@ class ProjectPartnershipHistory(db.Model): id = db.Column(db.Integer, primary_key=True, autoincrement=True) partnership_id = db.Column( - db.Integer, db.ForeignKey("project_partnerships.id"), nullable=False, index=True + db.Integer, + db.ForeignKey("project_partnerships.id", ondelete="SET NULL"), + nullable=True, + index=True, ) project_id = db.Column( - db.Integer, db.ForeignKey("projects.id"), nullable=False, index=True + db.Integer, + db.ForeignKey("projects.id", ondelete="CASCADE"), + nullable=False, + index=True, ) partner_id = db.Column( - db.Integer, db.ForeignKey("partners.id"), nullable=False, index=True + db.Integer, + db.ForeignKey("partners.id", ondelete="CASCADE"), + nullable=False, + index=True, ) - started_on_old = db.Column(db.DateTime, default=timestamp) - ended_on_old = db.Column(db.DateTime, default=timestamp) - started_on_new = db.Column(db.DateTime, default=timestamp) - ended_on_new = db.Column(db.DateTime, default=timestamp) + action = db.Column(db.Integer, default=ProjectPartnerAction.CREATE.value) + action_date = db.Column(db.DateTime, nullable=False, default=timestamp) + + started_on_old = db.Column(db.DateTime) + ended_on_old = db.Column(db.DateTime) + started_on_new = db.Column(db.DateTime) + ended_on_new = db.Column(db.DateTime) def create(self): """Creates and saves the current model to the DB""" @@ -42,10 +57,10 @@ class ProjectPartnership(db.Model): __tablename__ = "project_partnerships" id = db.Column(db.Integer, primary_key=True, autoincrement=True) - project_id = db.Column(db.Integer, db.ForeignKey("projects.id")) - partner_id = db.Column(db.Integer, db.ForeignKey("partners.id")) + project_id = db.Column(db.Integer, db.ForeignKey("projects.id", ondelete="CASCADE")) + partner_id = db.Column(db.Integer, db.ForeignKey("partners.id", ondelete="CASCADE")) started_on = db.Column(db.DateTime, default=timestamp, nullable=False) - ended_on = db.Column(db.DateTime, default=timestamp, nullable=True) + ended_on = db.Column(db.DateTime, nullable=True) @staticmethod def get_by_id(partnership_id: int): diff --git a/backend/services/project_partnership_service.py b/backend/services/project_partnership_service.py index bf6170a8af..05e047d0af 100644 --- a/backend/services/project_partnership_service.py +++ b/backend/services/project_partnership_service.py @@ -1,8 +1,11 @@ from flask import current_app from backend.exceptions import NotFound -from backend.models.postgis.project_partner import ProjectPartnership +from backend.models.postgis.project_partner import ( + ProjectPartnership, + ProjectPartnershipHistory, + ProjectPartnerAction, +) from backend.models.dtos.project_partner_dto import ProjectPartnershipDTO -from backend.models.dtos.project_dto import ProjectDTO from backend.models.postgis.partner import Partner @@ -41,7 +44,9 @@ def get_partnerships_by_project(project_id: int) -> List[ProjectPartnershipDTO]: ProjectPartnership.project_id == project_id ).all() - return list(map(lambda partnership: partnership.as_dto().to_primitive(), partnerships)) + return list( + map(lambda partnership: partnership.as_dto().to_primitive(), partnerships) + ) @staticmethod def create_partnership( @@ -55,8 +60,17 @@ def create_partnership( partnership.partner_id = partner_id partnership.started_on = started_on partnership.ended_on = ended_on + partnership_id = partnership.create() - return partnership.create() + partnership_history = ProjectPartnershipHistory() + partnership_history.partnership_id = partnership_id + partnership_history.project_id = project_id + partnership_history.partner_id = partner_id + partnership_history.started_on_new = partnership.started_on + partnership_history.ended_on_new = partnership.ended_on + partnership_history.create() + + return partnership_id @staticmethod def update_partnership_time_range( @@ -70,13 +84,28 @@ def update_partnership_time_range( sub_code="PARTNERSHIP_NOT_FOUND", partnership_id=partnership_id ) - if started_on is not None: - partnership.started_on = started_on + if (started_on is not None or ended_on is not None) and ( + started_on != partnership.started_on or ended_on != partnership.ended_on + ): + partnership_history = ProjectPartnershipHistory() + partnership_history.partnership_id = partnership.id + partnership_history.project_id = partnership.project_id + partnership_history.partner_id = partnership.partner_id + partnership_history.action = ProjectPartnerAction.UPDATE.value + + if started_on is not None and started_on != partnership.started_on: + partnership_history.started_on_old = partnership.started_on + partnership_history.started_on_new = started_on + partnership.started_on = started_on + + if ended_on is not None and ended_on != partnership.ended_on: + partnership_history.ended_on_old = partnership.ended_on + partnership_history.ended_on_new = ended_on + partnership.ended_on = ended_on - if ended_on is not None: - partnership.ended_on = ended_on + partnership.save() + partnership_history.create() - partnership.save() return partnership @staticmethod @@ -86,6 +115,16 @@ def delete_partnership(partnership_id: int): raise NotFound( sub_code="PARTNERSHIP_NOT_FOUND", partnership_id=partnership_id ) + + partnership_history = ProjectPartnershipHistory() + partnership_history.partnership_id = partnership_id + partnership_history.project_id = partnership.project_id + partnership_history.partner_id = partnership.partner_id + partnership_history.started_on_old = partnership.started_on + partnership_history.ended_on_old = partnership.ended_on + partnership_history.action = ProjectPartnerAction.DELETE.value + partnership_history.create() + partnership.delete() @staticmethod diff --git a/migrations/versions/749a9ae35ce5_.py b/migrations/versions/749a9ae35ce5_.py index 4d4c7fde03..b7b665c012 100644 --- a/migrations/versions/749a9ae35ce5_.py +++ b/migrations/versions/749a9ae35ce5_.py @@ -40,7 +40,7 @@ def upgrade(): op.create_table( "project_partnerships_history", sa.Column("id", sa.BIGINT(), autoincrement=True, primary_key=True), - sa.Column("partnership_id", sa.BIGINT(), autoincrement=True, nullable=False), + sa.Column("partnership_id", sa.BIGINT(), autoincrement=True, nullable=True), sa.Column("project_id", sa.INTEGER(), autoincrement=True, nullable=False), sa.Column("partner_id", sa.INTEGER(), autoincrement=True, nullable=False), sa.Column( @@ -63,12 +63,13 @@ def upgrade(): ["partnership_id"], ["project_partnerships.id"], name="project_partnerships_fk", + ondelete='SET NULL' ), sa.ForeignKeyConstraint( - ["partner_id"], ["partners.id"], name="project_partners_partners_fk" + ["partner_id"], ["partners.id"], name="project_partners_partners_fk", ondelete='CASCADE' ), sa.ForeignKeyConstraint( - ["project_id"], ["projects.id"], name="project_partners_projects_fk" + ["project_id"], ["projects.id"], name="project_partners_projects_fk", ondelete='CASCADE' ), ) # ### end Alembic commands ### From 09390eeaffc975892dcd1af2ea59ef9bd4e90968 Mon Sep 17 00:00:00 2001 From: bshankar Date: Mon, 8 Jul 2024 14:20:11 +0530 Subject: [PATCH 24/55] Validate the time range of a partnership: start <= end date --- .../services/project_partnership_service.py | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/backend/services/project_partnership_service.py b/backend/services/project_partnership_service.py index 05e047d0af..30c7b6dbda 100644 --- a/backend/services/project_partnership_service.py +++ b/backend/services/project_partnership_service.py @@ -1,5 +1,5 @@ from flask import current_app -from backend.exceptions import NotFound +from backend.exceptions import NotFound, BadRequest from backend.models.postgis.project_partner import ( ProjectPartnership, ProjectPartnershipHistory, @@ -60,6 +60,18 @@ def create_partnership( partnership.partner_id = partner_id partnership.started_on = started_on partnership.ended_on = ended_on + + if ( + partnership.ended_on is not None + and partnership.started_on > partnership.ended_on + ): + raise BadRequest( + sub_code="INVALID_TIME_RANGE", + message=f"Partnership started on {partnership.started_on} but ended at a previous time: {partnership.ended_on}", + started_on=partnership.started_on, + ended_on=partnership.ended_on, + ) + partnership_id = partnership.create() partnership_history = ProjectPartnershipHistory() @@ -103,6 +115,17 @@ def update_partnership_time_range( partnership_history.ended_on_new = ended_on partnership.ended_on = ended_on + if ( + partnership.ended_on is not None + and partnership.started_on > partnership.ended_on + ): + raise BadRequest( + sub_code="INVALID_TIME_RANGE", + message=f"Partnership started on {partnership.started_on} but ended at a previous time: {partnership.ended_on}", + started_on=partnership.started_on, + ended_on=partnership.ended_on, + ) + partnership.save() partnership_history.create() From 5f38e874047ae605c81159bf7357d2e99340ec27 Mon Sep 17 00:00:00 2001 From: bshankar Date: Wed, 10 Jul 2024 08:52:59 +0530 Subject: [PATCH 25/55] Fix: Docker dev setup: Frontend hot reloading --- docker-compose.yml | 5 +++++ scripts/docker/Dockerfile.frontend_development | 5 ++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index d33b1902c1..a1ae005198 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -112,6 +112,11 @@ services: build: context: . dockerfile: "./scripts/docker/Dockerfile.frontend_development" + volumes: + - ./frontend/:/usr/src/app + - /usr/src/app/node_modules + environment: + - CHOKIDAR_USEPOLLING=true env_file: - tasking-manager.env labels: diff --git a/scripts/docker/Dockerfile.frontend_development b/scripts/docker/Dockerfile.frontend_development index a0f6d624a3..1c75079d16 100644 --- a/scripts/docker/Dockerfile.frontend_development +++ b/scripts/docker/Dockerfile.frontend_development @@ -2,8 +2,11 @@ FROM node:18.19.1 WORKDIR /usr/src/app -COPY ./frontend . ## SETUP + +COPY ./frontend/package.json ./ +COPY ./frontend/yarn.lock ./ + RUN yarn install # SERVE From 14fbc0fcbeeab8370729268abc21c24598763b8f Mon Sep 17 00:00:00 2001 From: A Vinayak Rugvedi Date: Fri, 12 Jul 2024 15:30:23 +0530 Subject: [PATCH 26/55] chore: introduce partners section to link partners to projects --- frontend/src/views/projectEdit.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/frontend/src/views/projectEdit.js b/frontend/src/views/projectEdit.js index e34dff1f0a..fb03b749ed 100644 --- a/frontend/src/views/projectEdit.js +++ b/frontend/src/views/projectEdit.js @@ -15,6 +15,8 @@ import { PermissionsForm } from '../components/projectEdit/permissionsForm'; import { SettingsForm } from '../components/projectEdit/settingsForm'; import { ActionsForm } from '../components/projectEdit/actionsForm'; import { CustomEditorForm } from '../components/projectEdit/customEditorForm'; +import { PartnersForm } from '../components/projectEdit/partnersForm'; + import { Button } from '../components/button'; import { Dropdown } from '../components/dropdown'; import { Alert } from '../components/alert'; @@ -211,6 +213,7 @@ export function ProjectEdit() { { value: 'description', required: true }, { value: 'instructions', required: true }, { value: 'metadata', required: true }, + { value: 'partners' }, { value: 'priority_areas' }, { value: 'imagery' }, { value: 'permissions' }, @@ -241,6 +244,8 @@ export function ProjectEdit() { return ; case 'metadata': return ; + case 'partners': + return ; case 'imagery': return ; case 'permissions': From 140fb5246f7dbf08a90da76c13c89c8fc919b7aa Mon Sep 17 00:00:00 2001 From: A Vinayak Rugvedi Date: Fri, 12 Jul 2024 15:31:17 +0530 Subject: [PATCH 27/55] chore: add circle exclamation and circle minus icons --- .../components/svgIcons/circleExclamation.js | 21 +++++++++++++++++++ .../src/components/svgIcons/circleMinus.js | 21 +++++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 frontend/src/components/svgIcons/circleExclamation.js create mode 100644 frontend/src/components/svgIcons/circleMinus.js diff --git a/frontend/src/components/svgIcons/circleExclamation.js b/frontend/src/components/svgIcons/circleExclamation.js new file mode 100644 index 0000000000..4163d4fa03 --- /dev/null +++ b/frontend/src/components/svgIcons/circleExclamation.js @@ -0,0 +1,21 @@ +import { PureComponent } from 'react'; + +export class CircleExclamationIcon extends PureComponent { + render() { + return ( + + + + ); + } +} diff --git a/frontend/src/components/svgIcons/circleMinus.js b/frontend/src/components/svgIcons/circleMinus.js new file mode 100644 index 0000000000..51c67bb61a --- /dev/null +++ b/frontend/src/components/svgIcons/circleMinus.js @@ -0,0 +1,21 @@ +import { PureComponent } from 'react'; + +export class CircleMinusIcon extends PureComponent { + render() { + return ( + + + + ); + } +} From 4331212ee527f895672d175dd323b2c536e2291f Mon Sep 17 00:00:00 2001 From: A Vinayak Rugvedi Date: Fri, 12 Jul 2024 15:33:02 +0530 Subject: [PATCH 28/55] chore: update messages for partners linking form, listing, remove, and update --- .../src/components/projectEdit/messages.js | 78 ++++++++++++++++++- frontend/src/views/messages.js | 4 + 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/projectEdit/messages.js b/frontend/src/components/projectEdit/messages.js index a086a6c048..ab293f6a61 100644 --- a/frontend/src/components/projectEdit/messages.js +++ b/frontend/src/components/projectEdit/messages.js @@ -444,7 +444,7 @@ export default defineMessages({ projectNameValidationError: { id: 'management.projects.edit.errors.project_name_validation_error', defaultMessage: 'Project name should start with an alphabet.', - }, + }, dueDate: { id: 'projects.formInputs.dueDate', defaultMessage: 'Due date', @@ -675,4 +675,80 @@ export default defineMessages({ id: 'projects.formInputs.extraIdParams.iDAPIDocs', defaultMessage: 'iD editor documentation', }, + partner: { + id: 'projects.formInputs.partner.title', + defaultMessage: 'Partner', + }, + partnerDescription: { + id: 'projects.formInputs.partner.description', + defaultMessage: 'Partner that is supporting this project, if there is any.', + }, + selectPartner: { + id: 'projects.formInputs.partner.select', + defaultMessage: 'Select partner', + }, + savePartner: { + id: 'projects.formInputs.partner.save.button', + defaultMessage: 'Save', + }, + partnerStartDate: { + id: 'projects.partner.start.date', + defaultMessage: 'Start Date', + }, + partnerEndDate: { + id: 'projects.partner.end.date', + defaultMessage: 'End Date', + }, + partnerDateFormat: { + id: 'projects.partner.date.format', + defaultMessage: 'dd/mm/yyyy', + }, + partnerNotSelectedError: { + id: 'projects.partner.input.not_selected', + defaultMessage: 'Please select a partner.', + }, + partnerEndDateError: { + id: 'projects.partner.end_date.earlier_than.start_date', + defaultMessage: 'End date cannot be earlier than the start date.', + }, + partnerActionsApiError: { + id: 'projects.partner.actions.api.error', + defaultMessage: 'Something went wrong! Try again later.', + }, + partnerRemoveModalTitle: { + id: 'projects.partner.actions.remove.modal.title', + defaultMessage: 'Confirm Removal', + }, + partnerRemoveModalText: { + id: 'projects.partner.actions.remove.modal.text', + defaultMessage: 'Are you sure you want to remove the following partner from the project?', + }, + partnerRemove: { + id: 'projects.partner.actions.remove.button', + defaultMessage: 'Remove', + }, + partnerRemoveActionSuccessToast: { + id: 'projects.partner.actions.remove.success.toast', + defaultMessage: 'Successfully removed the partner.', + }, + partnerUpdateModalTitle: { + id: 'projects.partner.actions.update.modal.title', + defaultMessage: 'Edit Partner Assignment', + }, + partnerUpdateActionSuccessToast: { + id: 'projects.partner.actions.update.success.toast', + defaultMessage: "Successfully updated the partner's assignment date range.", + }, + partnerLinkActionSuccessToast: { + id: 'projects.partner.actions.link.success.toast', + defaultMessage: 'Successfully linked the partner.', + }, + partnerListingEmpty: { + id: 'projects.partner.listing.empty', + defaultMessage: 'No partners are associated with this project yet.', + }, + partnerListingError: { + id: 'projects.partner.listing.error', + defaultMessage: 'Something went wrong!', + }, }); diff --git a/frontend/src/views/messages.js b/frontend/src/views/messages.js index 4fa50c13bd..782dc5ab87 100644 --- a/frontend/src/views/messages.js +++ b/frontend/src/views/messages.js @@ -778,6 +778,10 @@ export default defineMessages({ id: 'pages.edit_project.sections.metadata', defaultMessage: 'Metadata', }, + projectEditSection_partners: { + id: 'pages.edit_project.sections.partners', + defaultMessage: 'Partners', + }, projectEditSection_priority_areas: { id: 'pages.edit_project.sections.priority_areas', defaultMessage: 'Priority areas', From e535dbf2f407b198187958b9b4d6cd34212e351c Mon Sep 17 00:00:00 2001 From: A Vinayak Rugvedi Date: Fri, 12 Jul 2024 15:34:10 +0530 Subject: [PATCH 29/55] feat: create partners input form to link a partner to the project --- .../components/projectEdit/partnersForm.js | 257 ++++++++++++++++++ 1 file changed, 257 insertions(+) create mode 100644 frontend/src/components/projectEdit/partnersForm.js diff --git a/frontend/src/components/projectEdit/partnersForm.js b/frontend/src/components/projectEdit/partnersForm.js new file mode 100644 index 0000000000..7870da0d9e --- /dev/null +++ b/frontend/src/components/projectEdit/partnersForm.js @@ -0,0 +1,257 @@ +import { useEffect, useState, forwardRef, useMemo } from 'react'; +import { useParams } from 'react-router-dom'; + +import { useSelector } from 'react-redux'; +import Select from 'react-select'; +import ReactDatePicker from 'react-datepicker'; +import { FormattedMessage } from 'react-intl'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { format } from 'date-fns'; +import toast from 'react-hot-toast'; + +import messages from './messages'; +import { Alert } from '../alert'; +import { ChevronDownIcon } from '../svgIcons/chevron-down'; +import { CloseIcon } from '../svgIcons/close'; +import { Button } from '../button'; +import { styleClasses } from '../../views/projectEdit'; +import { fetchLocalJSONAPI, pushToLocalJSONAPI } from '../../network/genericJSONRequest'; + +const DateCustomInput = forwardRef( + ({ value, onClick, date, handleClear, isStartDate = true, hideCloseIcon = false }, ref) => { + return ( +
+ + {(message) => { + return ( + + ); + }} + + + {(date && hideCloseIcon) || !date ? ( +
+ +
+ ) : null} + + {date && !hideCloseIcon ? ( +
+ +
+ ) : null} +
+ ); + }, +); + +export const PartnersForm = () => { + const [selectedPartner, setSelectedPartner] = useState({}); + const [dateRange, setDateRange] = useState({ + startDate: new Date(), + endDate: null, + }); + const [errorMessage, setErrorMessage] = useState({}); + const userDetails = useSelector((state) => state.auth.userDetails); + const token = useSelector((state) => state.auth.token); + const queryClient = useQueryClient(); + const { id } = useParams(); + + useEffect(() => { + if ( + selectedPartner && + errorMessage.id && + errorMessage.id === messages.partnerNotSelectedError.id + ) { + setErrorMessage({}); + } + }, [selectedPartner]); + + useEffect(() => { + if (!dateRange.endDate && errorMessage.id === messages.partnerEndDateError.id) { + setErrorMessage({}); + return; + } + + if ( + dateRange.endDate && + dateRange.startDate < dateRange.endDate && + errorMessage.id === messages.partnerEndDateError.id + ) { + setErrorMessage({}); + return; + } + }, [dateRange]); + + const { + isPending, + isError, + data: partners, + } = useQuery({ + queryKey: ['all-partners', userDetails.id], + queryFn: () => fetchLocalJSONAPI('partners/', token), + }); + + const savePartnerMutation = useMutation({ + mutationFn: () => { + const startDate = `${format(dateRange.startDate, 'yyyy-MM-dd')}T00:00:00.000Z`; + const endDate = dateRange.endDate + ? `${format(dateRange.endDate, 'yyyy-MM-dd')}T00:00:00.000Z` + : null; + + return pushToLocalJSONAPI( + `projects/partnerships/`, + JSON.stringify({ + endedOn: endDate, + partnerId: selectedPartner.id, + projectId: id, + startedOn: startDate, + }), + token, + 'POST', + ); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['linked-partners', id] }); + setDateRange({ + startDate: new Date(), + endDate: null, + }); + toast.success(); + }, + onError: () => { + toast.error(); + }, + }); + + const partnerIdToDetailsMapping = useMemo(() => { + const mapping = {}; + for (let i = 0; i < partners?.length; i++) { + mapping[partners[i].id] = partners[i]; + } + return mapping; + }, [partners]); + + const handleSave = () => { + if (!selectedPartner || !selectedPartner.id) { + setErrorMessage(messages.partnerNotSelectedError); + return; + } + + if (dateRange.endDate && dateRange.startDate > dateRange.endDate) { + setErrorMessage(messages.partnerEndDateError); + return; + } + + savePartnerMutation.mutate(); + }; + + return ( +
+
+ +

+ +

+