Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
238 changes: 206 additions & 32 deletions backend/services/project_search_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,15 @@
ProjectSearchDTO,
ProjectSearchResultsDTO,
)
from backend.models.dtos.campaign_dto import ListCampaignDTO
from backend.models.postgis.project import Project, ProjectInfo
from backend.models.postgis.mapping_level import MappingLevel
from backend.models.postgis.statuses import (
MappingTypes,
ProjectDifficulty,
ProjectPriority,
ProjectStatus,
TaskStatus,
TeamRoles,
UserRole,
)
Expand Down Expand Up @@ -127,6 +129,26 @@
WHERE p.geometry IS NOT NULL
"""

count_base = """
SELECT COUNT(*) FROM projects p
LEFT JOIN organisations o ON o.id = p.organisation_id
LEFT JOIN users u ON u.id = p.author_id
WHERE p.geometry IS NOT NULL
"""
# Lightweight query for map results: only the columns the map needs
# (id, priority, centroid). Reduced coordinate precision (5 dp, ~1 m) keeps
# the payload small. The org/user joins mirror the main query so org
# filters resolve; the planner eliminates them when they are unused.
map_base = """
SELECT
p.id AS id,
p.priority,
ST_AsGeoJSON(p.centroid, 5) AS centroid
FROM projects p
LEFT JOIN organisations o ON o.id = p.organisation_id
LEFT JOIN users u ON u.id = p.author_id
WHERE p.geometry IS NOT NULL
"""
filters = []
params = {}
if user is None:
Expand Down Expand Up @@ -173,9 +195,12 @@
filters.append("p.private = :private")
params["private"] = False
if filters:
query += " AND (" + " AND ".join(filters) + ")"
auth_clause = " AND (" + " AND ".join(filters) + ")"

Check failure on line 198 in backend/services/project_search_service.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal " AND " 5 times.

See more on https://sonarcloud.io/project/issues?id=hotosm_tasking-manager&issues=AZ8C3_nrCF-Y5JIl-hJd&open=AZ8C3_nrCF-Y5JIl-hJd&pullRequest=7277
query += auth_clause
count_base += auth_clause
map_base += auth_clause

return query, params
return query, count_base, map_base, params

@staticmethod
async def create_result_dto(
Expand Down Expand Up @@ -220,6 +245,156 @@
list_dto.campaigns = await Project.get_project_campaigns(project.id, db)
return list_dto

@staticmethod
async def _build_list_result_dtos(

Check failure on line 249 in backend/services/project_search_service.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 26 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=hotosm_tasking-manager&issues=AZ8R0yUfQYoSBVfYg8kG&open=AZ8R0yUfQYoSBVfYg8kG&pullRequest=7277
projects, preferred_locale: str, db: Database
) -> List[ListSearchResultDTO]:
"""Build list-view DTOs for a page of projects using batched queries.

Replaces the previous per-project N+1 (localised info, active mappers,
campaigns and total contributors were each fetched one project at a
time) with a single query per concern across the whole page.
"""
if not projects:
return []

project_ids = [p.id for p in projects]

# 1) Localised name / short description. Mirrors
# ProjectInfo.get_dto_for_locale: use the requested locale when present,
# otherwise the project's default locale, with per-field fallback to the
# default locale for partial translations. Fetch the requested locale and
# every project's default locale, then resolve per project in Python.
locales = {p.default_locale for p in projects}
if preferred_locale:
locales.add(preferred_locale)
info_rows = await db.fetch_all(
"""
SELECT project_id, locale, name, short_description
FROM project_info
WHERE project_id = ANY(:ids) AND locale = ANY(:locales)
""",
{"ids": project_ids, "locales": list(locales)},
)
info_by_pid = {}
for row in info_rows:
info_by_pid.setdefault(row["project_id"], {})[row["locale"]] = row

# 2) Active mappers (locked tasks as a proxy) per project.
mapper_rows = await db.fetch_all(
"""
SELECT project_id, COUNT(DISTINCT locked_by) AS active_mappers
FROM tasks
WHERE project_id = ANY(:ids)
AND task_status IN (:locked_for_mapping, :locked_for_validation)
GROUP BY project_id
""",
{
"ids": project_ids,
"locked_for_mapping": TaskStatus.LOCKED_FOR_MAPPING.value,
"locked_for_validation": TaskStatus.LOCKED_FOR_VALIDATION.value,
},
)
mappers_by_pid = {r["project_id"]: r["active_mappers"] for r in mapper_rows}

# 3) Campaigns per project.
campaign_rows = await db.fetch_all(
"""
SELECT cp.project_id, c.id, c.name
FROM campaign_projects cp
JOIN campaigns c ON c.id = cp.campaign_id
WHERE cp.project_id = ANY(:ids)
""",
{"ids": project_ids},
)
campaigns_by_pid = {}
for row in campaign_rows:
campaigns_by_pid.setdefault(row["project_id"], []).append(
ListCampaignDTO(id=row["id"], name=row["name"])
)

# 4) Total contributors per project.
contrib_rows = await db.fetch_all(
"""
SELECT p.id AS id, COUNT(DISTINCT th.user_id) AS total
FROM projects p
LEFT JOIN task_history th
ON th.project_id = p.id AND th.action != 'COMMENT'
WHERE p.id = ANY(:ids)
GROUP BY p.id
""",
{"ids": project_ids},
)
contrib_by_pid = {r["id"]: r["total"] for r in contrib_rows}

results = []
for project in projects:
locale_map = info_by_pid.get(project.id, {})
default_locale = project.default_locale
requested_row = (
locale_map.get(preferred_locale) if preferred_locale else None
)
default_row = locale_map.get(default_locale)

if requested_row is None:
# No info for the requested locale: use the default locale's info.
resolved_locale = default_locale
name = default_row["name"] if default_row else ""
short_description = (
default_row["short_description"] if default_row else ""
)
elif preferred_locale == default_locale:
resolved_locale = requested_row["locale"]
name = requested_row["name"]
short_description = requested_row["short_description"]
else:
# Partial translation: prefer the requested locale, fall back to
# the default locale on a per-field basis when a field is empty.
resolved_locale = requested_row["locale"]
name = requested_row["name"] or (
default_row["name"] if default_row else None
)
short_description = requested_row["short_description"] or (
default_row["short_description"] if default_row else None
)

list_dto = ListSearchResultDTO()
list_dto.project_id = project.id
list_dto.locale = resolved_locale
list_dto.name = name
list_dto.short_description = short_description
list_dto.priority = ProjectPriority(project.priority).name
list_dto.difficulty = ProjectDifficulty(project.difficulty).name
list_dto.last_updated = project.last_updated
list_dto.due_date = project.due_date
list_dto.percent_mapped = Project.calculate_tasks_percent(
"mapped",
project.tasks_mapped,
project.tasks_validated,
project.total_tasks,
project.tasks_bad_imagery,
)
list_dto.percent_validated = Project.calculate_tasks_percent(
"validated",
project.tasks_mapped,
project.tasks_validated,
project.total_tasks,
project.tasks_bad_imagery,
)
list_dto.status = ProjectStatus(project.status).name
list_dto.active_mappers = mappers_by_pid.get(project.id, 0)
list_dto.total_contributors = contrib_by_pid.get(project.id, 0)
list_dto.country = project.country
list_dto.sandbox = project.sandbox
list_dto.database = project.database
list_dto.author = project.author_name or project.author_username
list_dto.organisation_name = project.organisation_name
list_dto.organisation_logo = project.organisation_logo
list_dto.campaigns = campaigns_by_pid.get(project.id, [])
results.append(list_dto)

return results

@staticmethod
async def get_total_contributions(
project_ids: List[int], db: Database
Expand Down Expand Up @@ -405,16 +580,9 @@
raise NotFound(sub_code="PROJECTS_NOT_FOUND")

dto = ProjectSearchResultsDTO()
dto.results = [
await ProjectSearchService.create_result_dto(
p,
search_dto.preferred_locale,
await Project.get_project_total_contributions(p.id, db),
db,
)
for p in paginated_results
]

dto.results = await ProjectSearchService._build_list_result_dtos(
paginated_results, search_dto.preferred_locale, db
)
dto.pagination = pagination_dto
if search_dto.omit_map_results:
return dto
Expand All @@ -438,9 +606,12 @@
async def _filter_projects(
search_dto: ProjectSearchDTO, user, db: Database, as_csv: bool = False
):
base_query, params = await ProjectSearchService.create_search_query(
db, user, as_csv
)
(
base_query,
count_base,
map_base,
params,
) = await ProjectSearchService.create_search_query(db, user, as_csv)
# Initialize filter list and parameters dictionary
filters = []
if search_dto.preferred_locale or search_dto.text_search:
Expand All @@ -460,8 +631,8 @@

subquery_filters.append(
"""
text_searchable @@ to_tsquery('english', :tsquery_search)
OR name ILIKE :text_search
(text_searchable @@ to_tsquery('english', :tsquery_search)
OR name ILIKE :text_search)
"""
)
params["tsquery_search"] = tsquery_search
Expand Down Expand Up @@ -756,30 +927,33 @@
if order_by not in ("id", "p.id"):
order_by_clause += ", p.id DESC"

if filters:
sql_query = base_query + " AND " + " AND ".join(filters)
else:
sql_query = base_query
sql_query += order_by_clause
filter_clause = " AND " + " AND ".join(filters) if filters else ""
sql_query = base_query + filter_clause + order_by_clause
count_query = count_base + filter_clause
map_query = map_base + filter_clause

page = search_dto.page
per_page = 14
offset = (page - 1) * per_page
all_results = []

if as_csv or not search_dto.omit_map_results:
all_results = await db.fetch_all(sql_query, values=params)

if as_csv:
return all_results
# CSV export needs every column for every matching row (no pagination).
if as_csv:
return await db.fetch_all(sql_query, values=params)

all_results = []
if not search_dto.omit_map_results:
# Map results: fetch only id/priority/centroid for every matching
# project via the lightweight query. Its length is the total count,
# so no separate COUNT query is needed on this path.
all_results = await db.fetch_all(map_query, values=params)
total_count = len(all_results)
paginated_results = all_results[offset : offset + per_page]
else:
count_query = f"SELECT COUNT(*) FROM ({sql_query}) as counted"
total_count = await db.fetch_val(count_query, values=params)
paginated_query = f"{sql_query} LIMIT {per_page} OFFSET {offset}"
paginated_results = await db.fetch_all(paginated_query, values=params)

# The detailed list is always paginated in the database (LIMIT/OFFSET) so
# only the current page of the heavy SELECT is materialised.
paginated_query = f"{sql_query} LIMIT {per_page} OFFSET {offset}"
paginated_results = await db.fetch_all(paginated_query, values=params)

pagination_dto = Pagination.from_total_count(page, per_page, total_count)
return all_results, paginated_results, pagination_dto
Expand Down
2 changes: 1 addition & 1 deletion backend/services/project_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -653,7 +653,7 @@ async def get_featured_projects(
"""Fetch featured projects and return results."""

# Create the search query
query, params = await ProjectSearchService.create_search_query(db)
query, _, _, params = await ProjectSearchService.create_search_query(db)

# Append filtering for featured projects
query += " AND p.featured = TRUE"
Expand Down
4 changes: 3 additions & 1 deletion backend/services/recommendation_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,9 @@ async def get_similar_projects(
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)
search_query, _, _, params = await ProjectSearchService.create_search_query(
db, user
)

# Filter out fully completed projects
search_query += """
Expand Down
7 changes: 6 additions & 1 deletion backend/services/stats_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,12 @@ async def get_popular_projects(db: Database) -> ProjectSearchResultsDTO:
return ProjectSearchResultsDTO(results=[])

# Use the existing `create_search_query` function to fetch detailed project data
project_query, query_params = await ProjectSearchService.create_search_query(db)
(
project_query,
_,
_,
query_params,
) = await ProjectSearchService.create_search_query(db)
project_query += " AND p.id = ANY(:project_ids)"
query_params["project_ids"] = project_ids

Expand Down
35 changes: 35 additions & 0 deletions migrations/versions/c3f8a1b2e9d4_.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""Add composite index on projects(status, private) and index on projects(priority)

Revision ID: c3f8a1b2e9d4
Revises: a1b2c3d4e5f6
Create Date: 2026-06-26 00:00:00.000000

"""

from alembic import op

# revision identifiers, used by Alembic.
revision = "c3f8a1b2e9d4"
down_revision = "a1b2c3d4e5f6"
branch_labels = None
depends_on = None


def upgrade():
op.create_index(
"idx_projects_status_private",
"projects",
["status", "private"],
unique=False,
)
op.create_index(
"idx_projects_priority",
"projects",
["priority"],
unique=False,
)


def downgrade():
op.drop_index("idx_projects_priority", table_name="projects")
op.drop_index("idx_projects_status_private", table_name="projects")
Loading