backend/models/postgis/project.py (985 lines of code) (raw):

import json import re from typing import Optional from cachetools import TTLCache, cached import geojson import datetime from flask import current_app from geoalchemy2 import Geometry from geoalchemy2.shape import to_shape from sqlalchemy.sql.expression import cast, or_ from sqlalchemy import text, desc, func, Time, orm, literal from shapely.geometry import shape from sqlalchemy.dialects.postgresql import ARRAY import requests from backend import db from backend.models.dtos.campaign_dto import CampaignDTO from backend.models.dtos.project_dto import ( ProjectDTO, DraftProjectDTO, ProjectSummary, PMDashboardDTO, ProjectStatsDTO, ProjectUserStatsDTO, ProjectSearchDTO, ProjectTeamDTO, ProjectInfoDTO, ) from backend.models.dtos.interests_dto import InterestDTO from backend.models.dtos.tags_dto import TagsDTO from backend.models.postgis.organisation import Organisation from backend.models.postgis.custom_editors import CustomEditor from backend.models.postgis.priority_area import PriorityArea, project_priority_areas from backend.models.postgis.project_info import ProjectInfo from backend.models.postgis.project_chat import ProjectChat from backend.models.postgis.statuses import ( ProjectStatus, ProjectPriority, MappingLevel, TaskStatus, MappingTypes, TaskCreationMode, Editors, TeamRoles, MappingPermission, ValidationPermission, ) from backend.models.postgis.task import Task, TaskHistory from backend.models.postgis.team import Team from backend.models.postgis.user import User from backend.models.postgis.campaign import Campaign, campaign_projects from backend.models.postgis.utils import ( ST_SetSRID, ST_GeomFromGeoJSON, timestamp, ST_Centroid, NotFound, ) from backend.services.grid.grid_service import GridService from backend.models.postgis.interests import Interest, project_interests # Secondary table defining many-to-many join for projects that were favorited by users. project_favorites = db.Table( "project_favorites", db.metadata, db.Column("project_id", db.Integer, db.ForeignKey("projects.id")), db.Column("user_id", db.BigInteger, db.ForeignKey("users.id")), ) # Secondary table defining many-to-many join for private projects that only defined users can map on project_allowed_users = db.Table( "project_allowed_users", db.metadata, db.Column("project_id", db.Integer, db.ForeignKey("projects.id")), db.Column("user_id", db.BigInteger, db.ForeignKey("users.id")), ) class ProjectTeams(db.Model): __tablename__ = "project_teams" team_id = db.Column(db.Integer, db.ForeignKey("teams.id"), primary_key=True) project_id = db.Column(db.Integer, db.ForeignKey("projects.id"), primary_key=True) role = db.Column(db.Integer, nullable=False) project = db.relationship( "Project", backref=db.backref("teams", cascade="all, delete-orphan") ) team = db.relationship( Team, backref=db.backref("projects", 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() # cache mapper counts for 30 seconds active_mappers_cache = TTLCache(maxsize=1024, ttl=30) class Project(db.Model): """ Describes a HOT Mapping Project """ __tablename__ = "projects" # Columns id = db.Column(db.Integer, primary_key=True) status = db.Column(db.Integer, default=ProjectStatus.DRAFT.value, nullable=False) created = db.Column(db.DateTime, default=timestamp, nullable=False) priority = db.Column(db.Integer, default=ProjectPriority.MEDIUM.value) default_locale = db.Column( db.String(10), default="en" ) # The locale that is returned if requested locale not available author_id = db.Column( db.BigInteger, db.ForeignKey("users.id", name="fk_users"), nullable=False ) mapper_level = db.Column( db.Integer, default=2, nullable=False, index=True ) # Mapper level project is suitable for mapping_permission = db.Column(db.Integer, default=MappingPermission.ANY.value) validation_permission = db.Column( db.Integer, default=ValidationPermission.ANY.value ) # Means only users with validator role can validate enforce_random_task_selection = db.Column( db.Boolean, default=False ) # Force users to edit at random to avoid mapping "easy" tasks private = db.Column(db.Boolean, default=False) # Only allowed users can validate featured = db.Column( db.Boolean, default=False ) # Only PMs can set a project as featured changeset_comment = db.Column(db.String) osmcha_filter_id = db.Column( db.String ) # Optional custom filter id for filtering on OSMCha due_date = db.Column(db.DateTime) imagery = db.Column(db.String) josm_preset = db.Column(db.String) id_presets = db.Column(ARRAY(db.String)) rapid_power_user = db.Column(db.Boolean, default=False) last_updated = db.Column(db.DateTime, default=timestamp) license_id = db.Column(db.Integer, db.ForeignKey("licenses.id", name="fk_licenses")) geometry = db.Column(Geometry("MULTIPOLYGON", srid=4326), nullable=False) centroid = db.Column(Geometry("POINT", srid=4326), nullable=False) country = db.Column(ARRAY(db.String), default=[]) task_creation_mode = db.Column( db.Integer, default=TaskCreationMode.GRID.value, nullable=False ) organisation_id = db.Column( db.Integer, db.ForeignKey("organisations.id", name="fk_organisations"), index=True, ) # Tags mapping_types = db.Column(ARRAY(db.Integer), index=True) # Editors mapping_editors = db.Column( ARRAY(db.Integer), default=[ Editors.ID.value, Editors.JOSM.value, Editors.CUSTOM.value, Editors.RAPID.value, ], index=True, nullable=False, ) validation_editors = db.Column( ARRAY(db.Integer), default=[ Editors.ID.value, Editors.JOSM.value, Editors.CUSTOM.value, Editors.RAPID.value, ], index=True, nullable=False, ) # Stats total_tasks = db.Column(db.Integer, nullable=False) tasks_mapped = db.Column(db.Integer, default=0, nullable=False) tasks_validated = db.Column(db.Integer, default=0, nullable=False) tasks_bad_imagery = db.Column(db.Integer, default=0, nullable=False) # Mapped Objects tasks = db.relationship( Task, backref="projects", cascade="all, delete, delete-orphan", lazy="dynamic" ) project_info = db.relationship(ProjectInfo, lazy="dynamic", cascade="all") project_chat = db.relationship(ProjectChat, lazy="dynamic", cascade="all") author = db.relationship(User) allowed_users = db.relationship(User, secondary=project_allowed_users) priority_areas = db.relationship( PriorityArea, secondary=project_priority_areas, cascade="all, delete-orphan", single_parent=True, ) custom_editor = db.relationship(CustomEditor, uselist=False) favorited = db.relationship(User, secondary=project_favorites, backref="favorites") organisation = db.relationship(Organisation, backref="projects") campaign = db.relationship( Campaign, secondary=campaign_projects, backref="projects" ) interests = db.relationship( Interest, secondary=project_interests, backref="projects" ) def create_draft_project(self, draft_project_dto: DraftProjectDTO): """ Creates a draft project :param draft_project_dto: DTO containing draft project details :param aoi: Area of Interest for the project (eg boundary of project) """ self.project_info.append( ProjectInfo.create_from_name(draft_project_dto.project_name) ) self.organisation = draft_project_dto.organisation self.status = ProjectStatus.DRAFT.value self.author_id = draft_project_dto.user_id self.last_updated = timestamp() def set_project_aoi(self, draft_project_dto: DraftProjectDTO): """ Sets the AOI for the supplied project """ aoi_geojson = geojson.loads(json.dumps(draft_project_dto.area_of_interest)) aoi_geometry = GridService.merge_to_multi_polygon(aoi_geojson, dissolve=True) valid_geojson = geojson.dumps(aoi_geometry) self.geometry = ST_SetSRID(ST_GeomFromGeoJSON(valid_geojson), 4326) self.centroid = ST_Centroid(self.geometry) def set_default_changeset_comment(self): """ Sets the default changeset comment""" default_comment = current_app.config["DEFAULT_CHANGESET_COMMENT"] self.changeset_comment = ( f"{default_comment}-{self.id} {self.changeset_comment}" if self.changeset_comment is not None else f"{default_comment}-{self.id}" ) self.save() def set_country_info(self): """ Sets the default country based on centroid""" centroid = to_shape(self.centroid) lat, lng = (centroid.y, centroid.x) url = "{0}/reverse?format=jsonv2&lat={1}&lon={2}&accept-language=en".format( current_app.config["OSM_NOMINATIM_SERVER_URL"], lat, lng ) try: country_info = requests.get(url).json() # returns a dict if country_info["address"].get("country") is not None: self.country = [country_info["address"]["country"]] except (KeyError, AttributeError, requests.exceptions.ConnectionError): pass self.save() 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() @staticmethod def clone(project_id: int, author_id: int): """ Clone project """ orig = Project.query.get(project_id) if orig is None: raise NotFound() # Transform into dictionary. orig_metadata = orig.__dict__.copy() # 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 orig_metadata.update( { "total_tasks": 0, "tasks_mapped": 0, "tasks_validated": 0, "tasks_bad_imagery": 0, "last_updated": timestamp(), "created": timestamp(), "author_id": author_id, "status": ProjectStatus.DRAFT.value, } ) new_proj = Project(**orig_metadata) db.session.add(new_proj) 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)} ) proj_info.append(ProjectInfo(**info_data)) new_proj.project_info = proj_info # Replace changeset comment. default_comment = current_app.config["DEFAULT_CHANGESET_COMMENT"] 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) new_proj.custom_editor = orig.custom_editor return new_proj @staticmethod def get(project_id: int): """ Gets specified project :param project_id: project ID in scope :return: Project if found otherwise None """ return Project.query.options( orm.noload("tasks"), orm.noload("messages"), orm.noload("project_chat") ).get(project_id) def update(self, project_dto: ProjectDTO): """ Updates project from DTO """ self.status = ProjectStatus[project_dto.project_status].value self.priority = ProjectPriority[project_dto.project_priority].value if self.default_locale != project_dto.default_locale: new_locale_dto = ProjectInfoDTO() new_locale_dto.locale = project_dto.default_locale project_dto.project_info_locales.append(new_locale_dto) self.default_locale = project_dto.default_locale self.enforce_random_task_selection = project_dto.enforce_random_task_selection self.private = project_dto.private self.mapper_level = MappingLevel[project_dto.mapper_level.upper()].value self.changeset_comment = project_dto.changeset_comment self.due_date = project_dto.due_date self.imagery = project_dto.imagery self.josm_preset = project_dto.josm_preset self.id_presets = project_dto.id_presets self.rapid_power_user = project_dto.rapid_power_user self.last_updated = timestamp() self.license_id = project_dto.license_id if project_dto.osmcha_filter_id: # Support simple extraction of OSMCha filter id from OSMCha URL match = re.search(r"aoi=([\w-]+)", project_dto.osmcha_filter_id) self.osmcha_filter_id = ( match.group(1) if match else project_dto.osmcha_filter_id ) else: self.osmcha_filter_id = None if project_dto.organisation: org = Organisation.get(project_dto.organisation) if org is None: raise NotFound("Organisation does not exist") self.organisation = org # Cast MappingType strings to int array type_array = [] for mapping_type in project_dto.mapping_types: type_array.append(MappingTypes[mapping_type].value) self.mapping_types = type_array # Cast Editor strings to int array mapping_editors_array = [] for mapping_editor in project_dto.mapping_editors: mapping_editors_array.append(Editors[mapping_editor].value) self.mapping_editors = mapping_editors_array validation_editors_array = [] for validation_editor in project_dto.validation_editors: validation_editors_array.append(Editors[validation_editor].value) self.validation_editors = validation_editors_array self.country = project_dto.country_tag # Add list of allowed users, meaning the project can only be mapped by users in this list if hasattr(project_dto, "allowed_users"): self.allowed_users = [] # Clear existing relationships then re-insert for user in project_dto.allowed_users: self.allowed_users.append(user) # Update teams and projects relationship. self.teams = [] if hasattr(project_dto, "project_teams") and project_dto.project_teams: for team_dto in project_dto.project_teams: team = Team.get(team_dto.team_id) if team is None: raise NotFound("Team not found") role = TeamRoles[team_dto.role].value ProjectTeams(project=self, team=team, role=role) # 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() if project_info is None: new_info = ProjectInfo.create_from_dto( dto ) # Can't find info so must be new locale self.project_info.append(new_info) else: project_info.update_from_dto(dto) self.priority_areas = [] # Always clear Priority Area prior to updating if project_dto.priority_areas: for priority_area in project_dto.priority_areas: pa = PriorityArea.from_dict(priority_area) self.priority_areas.append(pa) if project_dto.custom_editor: if not self.custom_editor: new_editor = CustomEditor.create_from_dto( self.id, project_dto.custom_editor ) self.custom_editor = new_editor else: self.custom_editor.update_editor(project_dto.custom_editor) else: if self.custom_editor: self.custom_editor.delete() # handle campaign update try: new_ids = [c.id for c in project_dto.campaigns] new_ids.sort() except TypeError: new_ids = [] 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() if project_dto.mapping_permission: self.mapping_permission = MappingPermission[ project_dto.mapping_permission.upper() ].value if project_dto.validation_permission: self.validation_permission = ValidationPermission[ project_dto.validation_permission.upper() ].value # handle interests update try: new_ids = [c.id for c in project_dto.interests] new_ids.sort() except TypeError: new_ids = [] 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() # try to update country info if that information is not present if not self.country: self.set_country_info() db.session.commit() def delete(self): """ Deletes the current model from the DB """ db.session.delete(self) db.session.commit() @staticmethod def exists(project_id): query = Project.query.filter(Project.id == project_id).exists() return db.session.query(literal(True)).filter(query).scalar() def is_favorited(self, user_id: int) -> bool: user = User.query.get(user_id) if user not in self.favorited: return False return True def favorite(self, user_id: int): user = User.query.get(user_id) self.favorited.append(user) db.session.commit() def unfavorite(self, user_id: int): user = User.query.get(user_id) if user not in self.favorited: raise ValueError("Project not been favorited by user") self.favorited.remove(user) db.session.commit() def set_as_featured(self): if self.featured is True: raise ValueError("Project is already featured") self.featured = True db.session.commit() def unset_as_featured(self): if self.featured is False: raise ValueError("Project is not featured") self.featured = False db.session.commit() def can_be_deleted(self) -> bool: """ Projects can be deleted if they have no mapped work """ task_count = self.tasks.filter( Task.task_status != TaskStatus.READY.value ).count() if task_count == 0: return True else: return False @staticmethod def get_projects_for_admin( admin_id: int, preferred_locale: str, search_dto: ProjectSearchDTO ) -> PMDashboardDTO: """ Get projects for admin """ query = Project.query.filter(Project.author_id == admin_id) # Do Filtering Here if search_dto.order_by: if search_dto.order_by_type == "DESC": query = query.order_by(desc(search_dto.order_by)) else: query = query.order_by(search_dto.order_by) admins_projects = query.all() if admins_projects is None: raise NotFound("No projects found for admin") admin_projects_dto = PMDashboardDTO() for project in admins_projects: pm_project = project.get_project_summary(preferred_locale) project_status = ProjectStatus(project.status) if project_status == ProjectStatus.DRAFT: admin_projects_dto.draft_projects.append(pm_project) elif project_status == ProjectStatus.PUBLISHED: admin_projects_dto.active_projects.append(pm_project) elif project_status == ProjectStatus.ARCHIVED: admin_projects_dto.archived_projects.append(pm_project) else: current_app.logger.error(f"Unexpected state project {project.id}") return admin_projects_dto def get_project_user_stats(self, user_id: int) -> ProjectUserStatsDTO: """Compute project specific stats for a given user""" stats_dto = ProjectUserStatsDTO() stats_dto.time_spent_mapping = 0 stats_dto.time_spent_validating = 0 stats_dto.total_time_spent = 0 total_mapping_time = ( db.session.query( func.sum( cast(func.to_timestamp(TaskHistory.action_text, "HH24:MI:SS"), Time) ) ) .filter( or_( TaskHistory.action == "LOCKED_FOR_MAPPING", TaskHistory.action == "AUTO_UNLOCKED_FOR_MAPPING", ) ) .filter(TaskHistory.user_id == user_id) .filter(TaskHistory.project_id == self.id) ) for time in total_mapping_time: total_mapping_time = time[0] if total_mapping_time: stats_dto.time_spent_mapping = total_mapping_time.total_seconds() stats_dto.total_time_spent += stats_dto.time_spent_mapping query = ( TaskHistory.query.with_entities( func.date_trunc("minute", TaskHistory.action_date).label("trn"), func.max(TaskHistory.action_text).label("tm"), ) .filter(TaskHistory.user_id == user_id) .filter(TaskHistory.project_id == self.id) .filter(TaskHistory.action == "LOCKED_FOR_VALIDATION") .group_by("trn") .subquery() ) total_validation_time = db.session.query( func.sum(cast(func.to_timestamp(query.c.tm, "HH24:MI:SS"), Time)) ).all() for time in total_validation_time: total_validation_time = time[0] if total_validation_time: stats_dto.time_spent_validating = total_validation_time.total_seconds() stats_dto.total_time_spent += stats_dto.time_spent_validating return stats_dto def get_project_stats(self) -> ProjectStatsDTO: """ Create Project Stats model for postgis project object""" project_stats = ProjectStatsDTO() project_stats.project_id = self.id project_area_sql = "select ST_Area(geometry, true)/1000000 as area from public.projects where id = :id" project_area_result = db.engine.execute(text(project_area_sql), id=self.id) project_stats.area = project_area_result.fetchone()["area"] project_stats.total_mappers = ( db.session.query(User).filter(User.projects_mapped.any(self.id)).count() ) project_stats.total_tasks = self.total_tasks project_stats.total_comments = ( db.session.query(ProjectChat) .filter(ProjectChat.project_id == self.id) .count() ) project_stats.percent_mapped = Project.calculate_tasks_percent( "mapped", self.total_tasks, self.tasks_mapped, self.tasks_validated, self.tasks_bad_imagery, ) project_stats.percent_validated = Project.calculate_tasks_percent( "validated", self.total_tasks, self.tasks_mapped, self.tasks_validated, self.tasks_bad_imagery, ) project_stats.percent_bad_imagery = Project.calculate_tasks_percent( "bad_imagery", self.total_tasks, self.tasks_mapped, self.tasks_validated, self.tasks_bad_imagery, ) centroid_geojson = db.session.scalar(self.centroid.ST_AsGeoJSON()) project_stats.aoi_centroid = geojson.loads(centroid_geojson) project_stats.total_time_spent = 0 project_stats.total_mapping_time = 0 project_stats.total_validation_time = 0 project_stats.average_mapping_time = 0 project_stats.average_validation_time = 0 total_mapping_time, total_mapping_tasks = ( db.session.query( func.sum( cast(func.to_timestamp(TaskHistory.action_text, "HH24:MI:SS"), Time) ), func.count(TaskHistory.action), ) .filter( or_( TaskHistory.action == "LOCKED_FOR_MAPPING", TaskHistory.action == "AUTO_UNLOCKED_FOR_MAPPING", ) ) .filter(TaskHistory.project_id == self.id) .one() ) if total_mapping_tasks > 0: total_mapping_time = total_mapping_time.total_seconds() project_stats.total_mapping_time = total_mapping_time project_stats.average_mapping_time = ( total_mapping_time / total_mapping_tasks ) project_stats.total_time_spent += total_mapping_time total_validation_time, total_validation_tasks = ( db.session.query( func.sum( cast(func.to_timestamp(TaskHistory.action_text, "HH24:MI:SS"), Time) ), func.count(TaskHistory.action), ) .filter( or_( TaskHistory.action == "LOCKED_FOR_VALIDATION", TaskHistory.action == "AUTO_UNLOCKED_FOR_VALIDATION", ) ) .filter(TaskHistory.project_id == self.id) .one() ) if total_validation_tasks > 0: total_validation_time = total_validation_time.total_seconds() project_stats.total_validation_time = total_validation_time project_stats.average_validation_time = ( total_validation_time / total_validation_tasks ) project_stats.total_time_spent += total_validation_time actions = [] if project_stats.average_mapping_time <= 0: actions.append(TaskStatus.LOCKED_FOR_MAPPING.name) if project_stats.average_validation_time <= 0: actions.append(TaskStatus.LOCKED_FOR_VALIDATION.name) zoom_levels = [] # Check that averages are non-zero. if len(actions) != 0: zoom_levels = ( Task.query.with_entities(Task.zoom.distinct()) .filter(Task.project_id == self.id) .all() ) zoom_levels = [z[0] for z in zoom_levels] # Validate project has arbitrary tasks. is_square = True if None in zoom_levels: is_square = False sq = ( TaskHistory.query.with_entities( Task.zoom, TaskHistory.action, ( cast(func.to_timestamp(TaskHistory.action_text, "HH24:MI:SS"), Time) ).label("ts"), ) .filter(Task.is_square == is_square) .filter(TaskHistory.project_id == Task.project_id) .filter(TaskHistory.task_id == Task.id) .filter(TaskHistory.action.in_(actions)) ) if is_square is True: sq = sq.filter(Task.zoom.in_(zoom_levels)) sq = sq.subquery() nz = ( db.session.query(sq.c.zoom, sq.c.action, sq.c.ts) .filter(sq.c.ts > datetime.time(0)) .limit(10000) .subquery() ) if project_stats.average_mapping_time <= 0: mapped_avg = ( db.session.query(nz.c.zoom, (func.avg(nz.c.ts)).label("avg")) .filter(nz.c.action == TaskStatus.LOCKED_FOR_MAPPING.name) .group_by(nz.c.zoom) .all() ) mapping_time = sum([t.avg.total_seconds() for t in mapped_avg]) / len( mapped_avg ) project_stats.average_mapping_time = mapping_time if project_stats.average_validation_time <= 0: val_avg = ( db.session.query(nz.c.zoom, (func.avg(nz.c.ts)).label("avg")) .filter(nz.c.action == TaskStatus.LOCKED_FOR_VALIDATION.name) .group_by(nz.c.zoom) .all() ) validation_time = sum([t.avg.total_seconds() for t in val_avg]) / len( val_avg ) project_stats.average_validation_time = validation_time time_to_finish_mapping = ( self.total_tasks - (self.tasks_mapped + self.tasks_bad_imagery + self.tasks_validated) ) * project_stats.average_mapping_time project_stats.time_to_finish_mapping = time_to_finish_mapping project_stats.time_to_finish_validating = ( self.total_tasks - (self.tasks_validated + self.tasks_bad_imagery) ) * project_stats.average_validation_time return project_stats def get_project_summary(self, preferred_locale) -> ProjectSummary: """ Create Project Summary model for postgis project object""" summary = ProjectSummary() summary.project_id = self.id priority = self.priority if priority == 0: summary.priority = "URGENT" elif priority == 1: summary.priority = "HIGH" elif priority == 2: summary.priority = "MEDIUM" else: summary.priority = "LOW" summary.author = User.get_by_id(self.author_id).username summary.default_locale = self.default_locale summary.country_tag = self.country summary.changeset_comment = self.changeset_comment summary.due_date = self.due_date summary.created = self.created summary.last_updated = self.last_updated summary.osmcha_filter_id = self.osmcha_filter_id summary.mapper_level = MappingLevel(self.mapper_level).name summary.mapping_permission = MappingPermission(self.mapping_permission).name summary.validation_permission = ValidationPermission( self.validation_permission ).name summary.random_task_selection_enforced = self.enforce_random_task_selection summary.private = self.private summary.license_id = self.license_id summary.status = ProjectStatus(self.status).name summary.id_presets = self.id_presets summary.rapid_power_user = self.rapid_power_user summary.imagery = self.imagery if self.organisation_id: summary.organisation = self.organisation_id summary.organisation_name = self.organisation.name summary.organisation_slug = self.organisation.slug summary.organisation_logo = self.organisation.logo if self.campaign: summary.campaigns = [i.as_dto() for i in self.campaign] # Cast MappingType values to related string array mapping_types_array = [] if self.mapping_types: for mapping_type in self.mapping_types: mapping_types_array.append(MappingTypes(mapping_type).name) summary.mapping_types = mapping_types_array if self.mapping_editors: mapping_editors = [] for mapping_editor in self.mapping_editors: mapping_editors.append(Editors(mapping_editor).name) summary.mapping_editors = mapping_editors if self.validation_editors: validation_editors = [] for validation_editor in self.validation_editors: validation_editors.append(Editors(validation_editor).name) summary.validation_editors = validation_editors if self.custom_editor: summary.custom_editor = self.custom_editor.as_dto() # If project is private, fetch list of allowed users if self.private: allowed_users = [] for user in self.allowed_users: allowed_users.append(user.username) summary.allowed_users = allowed_users centroid_geojson = db.session.scalar(self.centroid.ST_AsGeoJSON()) summary.aoi_centroid = geojson.loads(centroid_geojson) summary.percent_mapped = Project.calculate_tasks_percent( "mapped", self.total_tasks, self.tasks_mapped, self.tasks_validated, self.tasks_bad_imagery, ) summary.percent_validated = Project.calculate_tasks_percent( "validated", self.total_tasks, self.tasks_mapped, self.tasks_validated, self.tasks_bad_imagery, ) summary.percent_bad_imagery = Project.calculate_tasks_percent( "bad_imagery", self.total_tasks, self.tasks_mapped, self.tasks_validated, self.tasks_bad_imagery, ) summary.project_teams = [ ProjectTeamDTO( dict( team_id=t.team.id, team_name=t.team.name, role=TeamRoles(t.role).name, ) ) for t in self.teams ] project_info = ProjectInfo.get_dto_for_locale( self.id, preferred_locale, self.default_locale ) summary.project_info = project_info return summary def get_project_title(self, preferred_locale): project_info = ProjectInfo.get_dto_for_locale( self.id, preferred_locale, self.default_locale ) return project_info.name @staticmethod def get_project_total_contributions(project_id: int) -> int: project_contributors_count = ( TaskHistory.query.with_entities(TaskHistory.user_id) .filter( TaskHistory.project_id == project_id, TaskHistory.action != "COMMENT" ) .distinct(TaskHistory.user_id) .count() ) return project_contributors_count def get_aoi_geometry_as_geojson(self): """ Helper which returns the AOI geometry as a geojson object """ aoi_geojson = db.engine.execute(self.geometry.ST_AsGeoJSON()).scalar() return geojson.loads(aoi_geojson) def get_project_teams(self): """ Helper to return teams with members so we can handle permissions """ project_teams = [] for t in self.teams: project_teams.append( { "name": t.team.name, "role": t.role, "members": [m.member.username for m in t.team.members], } ) return project_teams @staticmethod @cached(active_mappers_cache) def get_active_mappers(project_id) -> int: """ Get count of Locked tasks as a proxy for users who are currently active on the project """ return ( Task.query.filter( Task.task_status.in_( ( TaskStatus.LOCKED_FOR_MAPPING.value, TaskStatus.LOCKED_FOR_VALIDATION.value, ) ) ) .filter(Task.project_id == project_id) .distinct(Task.locked_by) .count() ) def _get_project_and_base_dto(self): """ Populates a project DTO with properties common to all roles """ base_dto = ProjectDTO() base_dto.project_id = self.id base_dto.project_status = ProjectStatus(self.status).name base_dto.default_locale = self.default_locale base_dto.project_priority = ProjectPriority(self.priority).name base_dto.area_of_interest = self.get_aoi_geometry_as_geojson() base_dto.aoi_bbox = shape(base_dto.area_of_interest).bounds base_dto.mapping_permission = MappingPermission(self.mapping_permission).name base_dto.validation_permission = ValidationPermission( self.validation_permission ).name base_dto.enforce_random_task_selection = self.enforce_random_task_selection base_dto.private = self.private base_dto.mapper_level = MappingLevel(self.mapper_level).name base_dto.changeset_comment = self.changeset_comment base_dto.osmcha_filter_id = self.osmcha_filter_id base_dto.due_date = self.due_date base_dto.imagery = self.imagery base_dto.josm_preset = self.josm_preset base_dto.id_presets = self.id_presets base_dto.rapid_power_user = self.rapid_power_user base_dto.country_tag = self.country base_dto.organisation_id = self.organisation_id base_dto.license_id = self.license_id base_dto.created = self.created base_dto.last_updated = self.last_updated base_dto.author = User.get_by_id(self.author_id).username base_dto.active_mappers = Project.get_active_mappers(self.id) base_dto.task_creation_mode = TaskCreationMode(self.task_creation_mode).name base_dto.percent_mapped = Project.calculate_tasks_percent( "mapped", self.total_tasks, self.tasks_mapped, self.tasks_validated, self.tasks_bad_imagery, ) base_dto.percent_validated = Project.calculate_tasks_percent( "validated", self.total_tasks, self.tasks_mapped, self.tasks_validated, self.tasks_bad_imagery, ) base_dto.percent_bad_imagery = Project.calculate_tasks_percent( "bad_imagery", self.total_tasks, self.tasks_mapped, self.tasks_validated, self.tasks_bad_imagery, ) base_dto.project_teams = [ ProjectTeamDTO( dict( team_id=t.team.id, team_name=t.team.name, role=TeamRoles(t.role).name, ) ) for t in self.teams ] if self.custom_editor: base_dto.custom_editor = self.custom_editor.as_dto() if self.private: # If project is private it should have a list of allowed users allowed_usernames = [] for user in self.allowed_users: allowed_usernames.append(user.username) base_dto.allowed_usernames = allowed_usernames if self.mapping_types: mapping_types = [] for mapping_type in self.mapping_types: mapping_types.append(MappingTypes(mapping_type).name) base_dto.mapping_types = mapping_types if self.campaign: base_dto.campaigns = [i.as_dto() for i in self.campaign] if self.mapping_editors: mapping_editors = [] for mapping_editor in self.mapping_editors: mapping_editors.append(Editors(mapping_editor).name) base_dto.mapping_editors = mapping_editors if self.validation_editors: validation_editors = [] for validation_editor in self.validation_editors: validation_editors.append(Editors(validation_editor).name) base_dto.validation_editors = validation_editors if self.priority_areas: geojson_areas = [] for priority_area in self.priority_areas: geojson_areas.append(priority_area.get_as_geojson()) base_dto.priority_areas = geojson_areas base_dto.interests = [ InterestDTO(dict(id=i.id, name=i.name)) for i in self.interests ] return self, base_dto def as_dto_for_mapping( self, authenticated_user_id: int = None, locale: str = "en", abbrev: bool = True ) -> Optional[ProjectDTO]: """ Creates a Project DTO suitable for transmitting to mapper users """ project, project_dto = self._get_project_and_base_dto() if abbrev is False: project_dto.tasks = Task.get_tasks_as_geojson_feature_collection( self.id, None ) else: project_dto.tasks = Task.get_tasks_as_geojson_feature_collection_no_geom( self.id ) project_dto.project_info = ProjectInfo.get_dto_for_locale( self.id, locale, project.default_locale ) if project.organisation_id: project_dto.organisation = project.organisation.id project_dto.organisation_name = project.organisation.name project_dto.organisation_logo = project.organisation.logo project_dto.organisation_slug = project.organisation.slug project_dto.project_info_locales = ProjectInfo.get_dto_for_all_locales(self.id) return project_dto def tasks_as_geojson( self, task_ids_str: str, order_by=None, order_by_type="ASC", status=None ): """ Creates a geojson of all areas """ project_tasks = Task.get_tasks_as_geojson_feature_collection( self.id, task_ids_str, order_by, order_by_type, status ) return project_tasks @staticmethod def get_all_countries(): query = ( db.session.query(func.unnest(Project.country).label("country")) .distinct() .order_by("country") .all() ) tags_dto = TagsDTO() tags_dto.tags = [r[0] for r in query] return tags_dto @staticmethod def calculate_tasks_percent( target, total_tasks, tasks_mapped, tasks_validated, tasks_bad_imagery ): """ Calculates percentages of contributions """ try: if target == "mapped": return int( (tasks_mapped + tasks_validated) / (total_tasks - tasks_bad_imagery) * 100 ) elif target == "validated": return int(tasks_validated / (total_tasks - tasks_bad_imagery) * 100) elif target == "bad_imagery": return int((tasks_bad_imagery / total_tasks) * 100) except ZeroDivisionError: return 0 def as_dto_for_admin(self, project_id): """ Creates a Project DTO suitable for transmitting to project admins """ project, project_dto = self._get_project_and_base_dto() if project is None: return None project_dto.project_info_locales = ProjectInfo.get_dto_for_all_locales( project_id ) return project_dto def create_or_update_interests(self, interests_ids): self.interests = [] objs = [Interest.get_by_id(i) for i in interests_ids] self.interests.extend(objs) db.session.commit() @staticmethod def get_project_campaigns(project_id: int): query = ( Campaign.query.join(campaign_projects) .filter(campaign_projects.c.project_id == project_id) .all() ) campaign_list = [] for campaign in query: campaign_dto = CampaignDTO() campaign_dto.id = campaign.id campaign_dto.name = campaign.name campaign_list.append(campaign_dto) return campaign_list # Add index on project geometry db.Index("idx_geometry", Project.geometry, postgresql_using="gist")