Skip to content

Commit

Permalink
Merge pull request #6613 from hotosm/fastapi-refactor
Browse files Browse the repository at this point in the history
Project clone and transfer project ownership refactored
  • Loading branch information
prabinoid authored Nov 5, 2024
2 parents a29799b + e23041d commit b01f1ca
Show file tree
Hide file tree
Showing 4 changed files with 174 additions and 70 deletions.
9 changes: 7 additions & 2 deletions backend/api/projects/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]},
Expand Down
18 changes: 14 additions & 4 deletions backend/api/projects/resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
167 changes: 123 additions & 44 deletions backend/models/postgis/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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"]:
Expand Down Expand Up @@ -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")
50 changes: 30 additions & 20 deletions backend/services/project_admin_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

import geojson
from databases import Database
from fastapi import BackgroundTasks
from loguru import logger

from backend.config import settings
Expand All @@ -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
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand All @@ -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
)
Expand All @@ -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(
Expand Down

0 comments on commit b01f1ca

Please sign in to comment.