diff --git a/backend/api/projects/resources.py b/backend/api/projects/resources.py index 54ef51356d..6b05484e75 100644 --- a/backend/api/projects/resources.py +++ b/backend/api/projects/resources.py @@ -13,6 +13,7 @@ from backend.models.dtos.project_dto import ( DraftProjectDTO, ProjectDTO, + ProjectInfoDTO, ProjectSearchBBoxDTO, ProjectSearchDTO, ) @@ -115,7 +116,6 @@ async def get_project( request.headers.get("accept-language"), abbreviated, ) - print(project_dto) if project_dto: if as_file: project_dto = json.dumps(project_dto, default=str) @@ -301,7 +301,7 @@ async def patch( project_id: int, user: AuthUserDTO = Depends(login_required), db: Database = Depends(get_db), - project_dto: ProjectDTO = None, + project_dto: dict = None, ): """ Updates a Tasking-Manager project @@ -434,14 +434,8 @@ async def patch( }, status_code=403, ) - try: - project_dto.project_id = project_id - except Exception as e: - logger.error(f"Error validating request: {str(e)}") - return JSONResponse( - content={"Error": "Unable to update project", "SubCode": "InvalidData"}, - status_code=400, - ) + project_dto = ProjectDTO(**project_dto) + project_dto.project_id = project_id try: await ProjectAdminService.update_project(project_dto, user.id, db) diff --git a/backend/models/dtos/project_dto.py b/backend/models/dtos/project_dto.py index 8aeaab6e22..af52a255e4 100644 --- a/backend/models/dtos/project_dto.py +++ b/backend/models/dtos/project_dto.py @@ -193,6 +193,9 @@ class ProjectInfoDTO(BaseModel): default="", serialization_alias="perTaskInstructions" ) + class Config: + populate_by_name = True + class CustomEditorDTO(BaseModel): """DTO to define a custom editor""" @@ -213,7 +216,7 @@ class ProjectDTO(BaseModel): tasks: Optional[dict] = None default_locale: str = Field(alias="defaultLocale") project_info: Optional[ProjectInfoDTO] = Field(None, alias="projectInfo") - project_info_locales: Optional[List["ProjectInfoDTO"]] = Field( + project_info_locales: Optional[List[ProjectInfoDTO]] = Field( None, alias="projectInfoLocales" ) difficulty: str = Field(alias="difficulty") diff --git a/backend/models/postgis/custom_editors.py b/backend/models/postgis/custom_editors.py index 79d87b6441..4521171644 100644 --- a/backend/models/postgis/custom_editors.py +++ b/backend/models/postgis/custom_editors.py @@ -1,6 +1,8 @@ -from sqlalchemy import Column, Integer, String, ForeignKey -from backend.models.dtos.project_dto import CustomEditorDTO +from databases import Database +from sqlalchemy import Column, ForeignKey, Integer, String, delete, update + from backend.db import Base, get_session +from backend.models.dtos.project_dto import CustomEditorDTO session = get_session() @@ -29,24 +31,31 @@ def get_by_project_id(project_id: int): return session.get(CustomEditor, project_id) @classmethod - def create_from_dto(cls, project_id: int, dto: CustomEditorDTO): + async def create_from_dto(cls, project_id: int, dto: CustomEditorDTO, db: Database): """Creates a new CustomEditor from dto, used in project edit""" new_editor = cls() new_editor.project_id = project_id - new_editor.update_editor(dto) + new_editor = await new_editor.update_editor(dto, db) return new_editor - def update_editor(self, dto: CustomEditorDTO): + async def update_editor(self, dto: CustomEditorDTO, db: Database): """Upates existing CustomEditor form DTO""" self.name = dto.name self.description = dto.description self.url = dto.url - self.save() - def delete(self): + query = ( + update(CustomEditor.__table__) + .where(CustomEditor.id == self.id) + .values(name=self.name, description=self.description, url=self.url) + ) + await db.execute(query) + + async def delete(self, db: Database): """Deletes the current model from the DB""" - session.delete(self) - session.commit() + await db.execute( + delete(CustomEditor.__table__).where(CustomEditor.id == self.id) + ) def as_dto(self) -> CustomEditorDTO: """Returns the CustomEditor as a DTO""" diff --git a/backend/models/postgis/priority_area.py b/backend/models/postgis/priority_area.py index 0419ce3956..966542491e 100644 --- a/backend/models/postgis/priority_area.py +++ b/backend/models/postgis/priority_area.py @@ -1,9 +1,12 @@ -import geojson import json -from sqlalchemy import Column, Integer, ForeignKey, Table + +import geojson +from databases import Database from geoalchemy2 import Geometry -from backend.models.postgis.utils import InvalidGeoJson, ST_SetSRID, ST_GeomFromGeoJSON +from sqlalchemy import Column, ForeignKey, Integer, Table + from backend.db import Base, get_session +from backend.models.postgis.utils import InvalidGeoJson session = get_session() @@ -25,7 +28,7 @@ class PriorityArea(Base): geometry = Column(Geometry("POLYGON", srid=4326)) @classmethod - def from_dict(cls, area_poly: dict): + async def from_dict(cls, area_poly: dict, db: Database): """Create a new Priority Area from dictionary""" pa_geojson = geojson.loads(json.dumps(area_poly)) @@ -39,7 +42,15 @@ def from_dict(cls, area_poly: dict): pa = cls() valid_geojson = geojson.dumps(pa_geojson) - pa.geometry = ST_SetSRID(ST_GeomFromGeoJSON(valid_geojson), 4326) + query = """ + SELECT ST_AsText( + ST_SetSRID( + ST_GeomFromGeoJSON(:geojson), 4326 + ) + ) AS geometry_wkt; + """ + result = await db.fetch_one(query=query, values={"geojson": valid_geojson}) + pa.geometry = result["geometry_wkt"] if result else None return pa def get_as_geojson(self): diff --git a/backend/models/postgis/project.py b/backend/models/postgis/project.py index 18483aef0b..2e164042d5 100644 --- a/backend/models/postgis/project.py +++ b/backend/models/postgis/project.py @@ -7,7 +7,7 @@ import geojson from geoalchemy2 import Geometry, WKTElement from geoalchemy2.shape import to_shape -from sqlalchemy import orm, func, select, delete, update +from sqlalchemy import orm, func, select, delete, update, inspect from shapely.geometry import shape from sqlalchemy.dialects.postgresql import ARRAY import requests @@ -348,17 +348,31 @@ async def create(self, project_name: str, db: Database): ) ) - await db.execute( - Task.__table__.insert().values( - project_id=project, + for task in self.tasks: + await db.execute( + Task.__table__.insert().values( + id=task.id, + project_id=project, + x=task.x, + y=task.y, + zoom=task.zoom, + is_square=task.is_square, + task_status=TaskStatus.READY.value, + extra_properties=task.extra_properties, + geometry=task.geometry, + ) ) - ) return project - def save(self): + async def save(self, db: Database): """Save changes to db""" - session.commit() + columns = { + c.key: getattr(self, c.key) for c in inspect(self).mapper.column_attrs + } + await db.execute( + Project.__table__.update().where(Project.id == self.id).values(**columns) + ) @staticmethod async def clone(project_id: int, author_id: int, db: Database): @@ -492,7 +506,11 @@ async def update(self, project_dto: ProjectDTO, db: Database): self.osmcha_filter_id = None if project_dto.organisation: - org = Organisation.get(project_dto.organisation) + organization = await db.fetch_one( + "SELECT * FROM organisations WHERE id = :id", + values={"id": project_dto.organisation}, + ) + org = Organisation(**organization) if org is None: raise NotFound( sub_code="ORGANISATION_NOT_FOUND", @@ -535,18 +553,22 @@ async def update(self, project_dto: ProjectDTO, db: Database): role = TeamRoles[team_dto.role].value project_team = ProjectTeams(project=self, team=team, role=role) - session.add(project_team) + await project_team.create(db) # Set Project Info for all returned locales for dto in project_dto.project_info_locales: - project_info = self.project_info.filter_by(locale=dto.locale).one_or_none() + project_info = await db.fetch_one( + select(ProjectInfo).where( + ProjectInfo.project_id == self.id, ProjectInfo.locale == dto.locale + ) + ) if project_info is None: - new_info = ProjectInfo.create_from_dto( - dto + new_info = await ProjectInfo.create_from_dto( + dto, self.id, db ) # Can't find info so must be new locale self.project_info.append(new_info) else: - project_info.update_from_dto(dto) + await ProjectInfo.update_from_dto(ProjectInfo(**project_info), dto, db) self.priority_areas = [] # Always clear Priority Area prior to updating if project_dto.priority_areas: @@ -556,15 +578,17 @@ async def update(self, project_dto: ProjectDTO, db: Database): if project_dto.custom_editor: if not self.custom_editor: - new_editor = CustomEditor.create_from_dto( - self.id, project_dto.custom_editor + new_editor = await CustomEditor.create_from_dto( + self.id, project_dto.custom_editor, db ) self.custom_editor = new_editor else: - self.custom_editor.update_editor(project_dto.custom_editor) + await CustomEditor.update_editor( + self.custom_editor, project_dto.custom_editor, db + ) else: if self.custom_editor: - self.custom_editor.delete() + await CustomEditor.delete(self.custom_editor, db) # handle campaign update try: @@ -575,7 +599,9 @@ async def update(self, project_dto: ProjectDTO, db: Database): current_ids = [c.id for c in self.campaign] current_ids.sort() if new_ids != current_ids: - self.campaign = Campaign.query.filter(Campaign.id.in_(new_ids)).all() + self.campaign = await db.fetch_all( + select(Campaign).filter(Campaign.id.in_(new_ids)) + ) if project_dto.mapping_permission: self.mapping_permission = MappingPermission[ @@ -596,13 +622,25 @@ async def update(self, project_dto: ProjectDTO, db: Database): current_ids = [c.id for c in self.interests] current_ids.sort() if new_ids != current_ids: - self.interests = Interest.query.filter(Interest.id.in_(new_ids)).all() + self.interests = await db.fetch_all( + select(Interest).filter(Interest.id.in_(new_ids)) + ) # try to update country info if that information is not present if not self.country: self.set_country_info() - session.commit() + columns = { + c.key: getattr(self, c.key) for c in inspect(self).mapper.column_attrs + } + columns.pop("geometry", None) + columns.pop("centroid", None) + columns.pop("id", None) + + # Update the project in the database + await db.execute( + self.__table__.update().where(Project.id == self.id).values(**columns) + ) async def delete(self, db: Database): """Deletes the current model from the DB""" diff --git a/backend/models/postgis/project_info.py b/backend/models/postgis/project_info.py index cfad9b74c9..3479745b2f 100644 --- a/backend/models/postgis/project_info.py +++ b/backend/models/postgis/project_info.py @@ -1,7 +1,16 @@ # # from flask import current_app from sqlalchemy.dialects.postgresql import TSVECTOR from typing import List -from sqlalchemy import Column, String, Integer, ForeignKey, Index +from sqlalchemy import ( + Column, + String, + Integer, + ForeignKey, + Index, + inspect, + insert, + update, +) from backend.models.dtos.project_dto import ProjectInfoDTO from backend.db import Base, get_session @@ -41,13 +50,27 @@ def create_from_name(cls, name: str): return new_info @classmethod - def create_from_dto(cls, dto: ProjectInfoDTO): + async def create_from_dto(cls, dto: ProjectInfoDTO, project_id: int, db: Database): """Creates a new ProjectInfo class from dto, used from project edit""" - new_info = cls() - new_info.update_from_dto(dto) - return new_info + self = cls() + self.locale = dto.locale + self.name = dto.name + self.project_id = project_id + self.project_id_str = str(project_id) # Allows project_id to be searched - def update_from_dto(self, dto: ProjectInfoDTO): + # Note project info not bleached on basis that admins are trusted users and shouldn't be doing anything bad + self.short_description = dto.short_description + self.description = dto.description + self.instructions = dto.instructions + self.per_task_instructions = dto.per_task_instructions + columns = { + c.key: getattr(self, c.key) for c in inspect(self).mapper.column_attrs + } + query = insert(ProjectInfo.__table__).values(**columns) + result = await db.execute(query) + return result + + async def update_from_dto(self, dto: ProjectInfoDTO, db: Database): """Updates existing ProjectInfo from supplied DTO""" self.locale = dto.locale self.name = dto.name @@ -58,6 +81,16 @@ def update_from_dto(self, dto: ProjectInfoDTO): self.description = dto.description self.instructions = dto.instructions self.per_task_instructions = dto.per_task_instructions + columns = { + c.key: getattr(self, c.key) for c in inspect(self).mapper.column_attrs + } + query = ( + update(ProjectInfo.__table__) + .where(ProjectInfo.project_id == self.project_id) + .values(**columns) + ) + result = await db.execute(query) + return result @staticmethod async def get_dto_for_locale( diff --git a/backend/models/postgis/task.py b/backend/models/postgis/task.py index 475e1879c8..7f8150549f 100644 --- a/backend/models/postgis/task.py +++ b/backend/models/postgis/task.py @@ -10,6 +10,7 @@ from sqlalchemy.orm.exc import MultipleResultsFound from geoalchemy2 import Geometry from typing import Any, Dict, List +from shapely.geometry import shape from sqlalchemy import ( Column, @@ -763,6 +764,7 @@ def from_geojson_feature(cls, task_id, task_feature): task.y = task_feature.properties["y"] task.zoom = task_feature.properties["zoom"] task.is_square = task_feature.properties["isSquare"] + task.geometry = shape(task_feature.geometry).wkt except KeyError as e: raise InvalidData( f"PropertyNotFound: Expected property not found: {str(e)}" diff --git a/backend/services/project_admin_service.py b/backend/services/project_admin_service.py index 19dffe4619..9a6ca563a4 100644 --- a/backend/services/project_admin_service.py +++ b/backend/services/project_admin_service.py @@ -69,7 +69,9 @@ async def create_draft_project( # If we're cloning we'll copy all the project details from the clone, otherwise create brand new project if draft_project_dto.cloneFromProjectId: - draft_project = Project.clone(draft_project_dto.cloneFromProjectId, user_id) + draft_project = await Project.clone( + draft_project_dto.cloneFromProjectId, user_id, db + ) else: draft_project = Project() org = await OrganisationService.get_organisation_by_id( @@ -269,7 +271,7 @@ async def _attach_tasks_to_project( task_count = 1 for feature in tasks["features"]: try: - task = await Task.from_geojson_feature(task_count, feature, db) + task = Task.from_geojson_feature(task_count, feature) except (InvalidData, InvalidGeoJson) as e: raise e @@ -298,8 +300,7 @@ def _validate_default_locale(default_locale, project_info_locales): raise ProjectAdminServiceError( "InfoForLocaleRequired- Project Info for Default Locale not provided" ) - - for attr, value in default_info.items(): + for attr, value in default_info.dict().items(): if attr == "per_task_instructions": continue # Not mandatory field diff --git a/backend/services/recommendation_service.py b/backend/services/recommendation_service.py index ab35fb5fe7..12bec64213 100644 --- a/backend/services/recommendation_service.py +++ b/backend/services/recommendation_service.py @@ -197,7 +197,7 @@ async def get_similar_projects( similar_projects = ProjectRecommendationService.get_similar_project_ids( projects_df, target_project_df ) - user = await UserService.get_user_by_id(db, user_id) if user_id else None + user = await UserService.get_user_by_id(user_id, db) if user_id else None # Create the search query with filters applied based on user role search_query, params = await ProjectSearchService.create_search_query(db, user)