diff --git a/backend/api/projects/actions.py b/backend/api/projects/actions.py index 818bf381bd..31309a4c03 100644 --- a/backend/api/projects/actions.py +++ b/backend/api/projects/actions.py @@ -84,8 +84,13 @@ async def post( status_code=400, ) try: - await ProjectAdminService.transfer_project_to(project_id, user.id, username, db) - return JSONResponse(content={"Success": "Project Transferred"}, status_code=200) + async with db.transaction(): + await ProjectAdminService.transfer_project_to( + project_id, user.id, username, db + ) + return JSONResponse( + content={"Success": "Project Transferred"}, status_code=200 + ) except (ValueError, ProjectAdminServiceError) as e: return JSONResponse( content={"Error": str(e).split("-")[1], "SubCode": str(e).split("-")[0]}, diff --git a/backend/api/projects/resources.py b/backend/api/projects/resources.py index e16f14a48a..212ddf8b56 100644 --- a/backend/api/projects/resources.py +++ b/backend/api/projects/resources.py @@ -230,21 +230,31 @@ async def post( ) try: - draft_project_id = await ProjectAdminService.create_draft_project( - draft_project_dto, db - ) - return JSONResponse(content={"projectId": draft_project_id}, status_code=201) + async with db.transaction(): + draft_project_id = await ProjectAdminService.create_draft_project( + draft_project_dto, db + ) + return JSONResponse( + content={"projectId": draft_project_id}, status_code=201 + ) except ProjectAdminServiceError as e: return JSONResponse( content={"Error": str(e).split("-")[1], "SubCode": str(e).split("-")[0]}, status_code=403, ) + except (InvalidGeoJson, InvalidData) as e: return JSONResponse( content={"Error": str(e).split("-")[1], "SubCode": str(e).split("-")[0]}, status_code=400, ) + except Exception as e: + return JSONResponse( + content={"Error": str(e).split("-")[1], "SubCode": str(e).split("-")[0]}, + status_code=400, + ) + # @router.head("/{project_id}", response_model=ProjectDTO) # @requires('authenticated') diff --git a/backend/models/postgis/project.py b/backend/models/postgis/project.py index 66470502ef..f4f4f8ad03 100644 --- a/backend/models/postgis/project.py +++ b/backend/models/postgis/project.py @@ -376,20 +376,20 @@ async def save(self, db: Database): @staticmethod async def clone(project_id: int, author_id: int, db: Database): - """Clone project""" + """Clone a project using encode databases and raw SQL.""" + # Fetch the original project data + orig_query = "SELECT * FROM projects WHERE id = :project_id" + orig = await db.fetch_one(orig_query, {"project_id": project_id}) - orig = await db.fetch_one(Project, id=project_id) - if orig is None: + if not orig: raise NotFound(sub_code="PROJECT_NOT_FOUND", project_id=project_id) - # Transform into dictionary. - orig_metadata = orig.__dict__.copy() + orig_metadata = dict(orig) + items_to_remove = ["id", "allowed_users"] + for item in items_to_remove: + orig_metadata.pop(item, None) - # Remove unneeded data. - items_to_remove = ["_sa_instance_state", "id", "allowed_users"] - [orig_metadata.pop(i, None) for i in items_to_remove] - - # Remove clone from session so we can reinsert it as a new object + # Update metadata for the new project orig_metadata.update( { "total_tasks": 0, @@ -403,45 +403,113 @@ async def clone(project_id: int, author_id: int, db: Database): } ) - new_proj = Project(**orig_metadata) - session.add(new_proj) + # Construct the INSERT query for the new project + columns = ", ".join(orig_metadata.keys()) + values = ", ".join([f":{key}" for key in orig_metadata.keys()]) + insert_project_query = ( + f"INSERT INTO projects ({columns}) VALUES ({values}) RETURNING id" + ) + new_project_id = await db.execute(insert_project_query, orig_metadata) - proj_info = [] - for info in orig.project_info.all(): - info_data = info.__dict__.copy() - info_data.pop("_sa_instance_state") - info_data.update( - {"project_id": new_proj.id, "project_id_str": str(new_proj.id)} + # Clone project_info data + project_info_query = "SELECT * FROM project_info WHERE project_id = :project_id" + project_info_records = await db.fetch_all( + project_info_query, {"project_id": project_id} + ) + + for info in project_info_records: + info_data = dict(info) + info_data.pop("id", None) + info_data.update({"project_id": new_project_id}) + columns_info = ", ".join(info_data.keys()) + values_info = ", ".join([f":{key}" for key in info_data.keys()]) + insert_info_query = ( + f"INSERT INTO project_info ({columns_info}) VALUES ({values_info})" + ) + await db.execute(insert_info_query, info_data) + + # Clone teams data + teams_query = "SELECT * FROM project_teams WHERE project_id = :project_id" + team_records = await db.fetch_all(teams_query, {"project_id": project_id}) + + for team in team_records: + team_data = dict(team) + team_data.pop("id", None) + team_data.update({"project_id": new_project_id}) + columns_team = ", ".join(team_data.keys()) + values_team = ", ".join([f":{key}" for key in team_data.keys()]) + insert_team_query = ( + f"INSERT INTO project_teams ({columns_team}) VALUES ({values_team})" ) - proj_info.append(ProjectInfo(**info_data)) + await db.execute(insert_team_query, team_data) - new_proj.project_info = proj_info + # Clone campaigns associated with the original project + campaign_query = ( + "SELECT campaign_id FROM campaign_projects WHERE project_id = :project_id" + ) + campaign_ids = await db.fetch_all(campaign_query, {"project_id": project_id}) - # Replace changeset comment. - default_comment = settings.DEFAULT_CHANGESET_COMMENT + for campaign in campaign_ids: + clone_campaign_query = """ + INSERT INTO campaign_projects (campaign_id, project_id) + VALUES (:campaign_id, :new_project_id) + """ + await db.execute( + clone_campaign_query, + { + "campaign_id": campaign["campaign_id"], + "new_project_id": new_project_id, + }, + ) - if default_comment is not None: - orig_changeset = f"{default_comment}-{orig.id}" # Preserve space - new_proj.changeset_comment = orig.changeset_comment.replace( - orig_changeset, "" - ).strip() - - # Populate teams, interests and campaigns - teams = [] - for team in orig.teams: - team_data = team.__dict__.copy() - team_data.pop("_sa_instance_state") - team_data.update({"project_id": new_proj.id}) - teams.append(ProjectTeams(**team_data)) - new_proj.teams = teams - - for field in ["interests", "campaign"]: - value = getattr(orig, field) - setattr(new_proj, field, value) - if orig.custom_editor: - new_proj.custom_editor = orig.custom_editor.clone_to_project(new_proj.id) - - return new_proj + # Clone interests associated with the original project + interest_query = ( + "SELECT interest_id FROM project_interests WHERE project_id = :project_id" + ) + interest_ids = await db.fetch_all(interest_query, {"project_id": project_id}) + + for interest in interest_ids: + clone_interest_query = """ + INSERT INTO project_interests (interest_id, project_id) + VALUES (:interest_id, :new_project_id) + """ + await db.execute( + clone_interest_query, + { + "interest_id": interest["interest_id"], + "new_project_id": new_project_id, + }, + ) + + # Clone CustomEditor associated with the original project + custom_editor_query = """ + SELECT name, description, url FROM project_custom_editors WHERE project_id = :project_id + """ + custom_editor = await db.fetch_one( + custom_editor_query, {"project_id": project_id} + ) + + if custom_editor: + clone_custom_editor_query = """ + INSERT INTO project_custom_editors (project_id, name, description, url) + VALUES (:new_project_id, :name, :description, :url) + """ + await db.execute( + clone_custom_editor_query, + { + "new_project_id": new_project_id, + "name": custom_editor["name"], + "description": custom_editor["description"], + "url": custom_editor["url"], + }, + ) + + # Return the new project data + new_project_query = "SELECT * FROM projects WHERE id = :new_project_id" + new_project = await db.fetch_one( + new_project_query, {"new_project_id": new_project_id} + ) + return Project(**new_project) @staticmethod async def get(project_id: int, db: Database) -> Optional["Project"]: @@ -1874,6 +1942,17 @@ async def clear_existing_priority_areas(db: Database, project_id: int): query=delete_priority_areas_query, values={"ids": existing_ids} ) + async def update_project_author(project_id: int, new_author_id: int, db: Database): + query = """ + UPDATE projects + SET author_id = :new_author_id + WHERE id = :project_id + """ + values = {"new_author_id": new_author_id, "project_id": project_id} + + # Execute the query + await db.execute(query=query, values=values) + # Add index on project geometry Index("idx_geometry", Project.geometry, postgresql_using="gist") diff --git a/backend/services/project_admin_service.py b/backend/services/project_admin_service.py index 9a6ca563a4..a956bce3f9 100644 --- a/backend/services/project_admin_service.py +++ b/backend/services/project_admin_service.py @@ -2,7 +2,6 @@ import geojson from databases import Database -from fastapi import BackgroundTasks from loguru import logger from backend.config import settings @@ -20,7 +19,6 @@ from backend.models.postgis.utils import InvalidData, InvalidGeoJson from backend.services.grid.grid_service import GridService from backend.services.license_service import LicenseService -from backend.services.messaging.message_service import MessageService from backend.services.organisation_service import OrganisationService from backend.services.team_service import TeamService from backend.services.users.user_service import UserService @@ -91,18 +89,20 @@ async def create_draft_project( draft_project.task_creation_mode = TaskCreationMode.ARBITRARY.value else: tasks = draft_project_dto.tasks - await ProjectAdminService._attach_tasks_to_project(draft_project, tasks, db) + await ProjectAdminService._attach_tasks_to_project(draft_project, tasks, db) draft_project.set_default_changeset_comment() draft_project.set_country_info() if draft_project_dto.cloneFromProjectId: - draft_project.save() # Update the clone + draft_project.save(db) # Update the clone + return draft_project.id + else: project_id = await Project.create( draft_project, draft_project_dto.project_name, db ) # Create the new project - return project_id + return project_id @staticmethod def _set_default_changeset_comment(draft_project: Project): @@ -328,19 +328,24 @@ async def transfer_project_to( transfering_user_id: int, username: str, db: Database, - background_tasks: BackgroundTasks, + # background_tasks: BackgroundTasks, ): """Transfers project from old owner (transfering_user_id) to new owner (username)""" - project = await ProjectAdminService._get_project_by_id(project_id, db) + project = await Project.get(project_id, db) new_owner = await UserService.get_user_by_username(username, db) - # No operation is required if the new owner is same as old owner - if username == project.author.username: + author_id = project.author_id + if not author_id: + raise ProjectAdminServiceError( + "TransferPermissionError- User does not have permissions to transfer project" + ) + author = await User.get_by_id(author_id, db) + if username == author.username: return - # Check permissions for the user (transferring_user_id) who initiatied the action is_admin = await UserService.is_user_an_admin(transfering_user_id, db) + is_author = UserService.is_user_the_project_author( - transfering_user_id, project.author_id, db + transfering_user_id, project.author_id ) is_org_manager = await OrganisationService.is_user_an_org_manager( project.organisation_id, transfering_user_id, db @@ -350,7 +355,6 @@ async def transfer_project_to( "TransferPermissionError- User does not have permissions to transfer project" ) - # Check permissions for the new owner - must be project's org manager is_new_owner_org_manager = await OrganisationService.is_user_an_org_manager( project.organisation_id, new_owner.id, db ) @@ -362,17 +366,23 @@ async def transfer_project_to( logger.debug(error_message) raise ValueError(error_message) else: - transferred_by = User.get_by_id(transfering_user_id, db) + transferred_by = await User.get_by_id(transfering_user_id, db) transferred_by = transferred_by.username project.author_id = new_owner.id - Project.save(project, db) + await Project.update_project_author(project_id, new_owner.id, db) + # TODO # Adding the background task - background_tasks.add_task( - MessageService.send_project_transfer_message, - project_id, - username, - transferred_by, - ) + # background_tasks.add_task( + # await MessageService.send_project_transfer_message, + # project_id, + # username, + # transferred_by, + # db + # ) + # threading.Thread( + # target=MessageService.send_project_transfer_message, + # args=(project_id, username, transferred_by, db), + # ).start() @staticmethod async def is_user_action_permitted_on_project(