Skip to content

Commit

Permalink
Merge pull request #466 from hotosm/feat-filters
Browse files Browse the repository at this point in the history
Add filters by project status to the projects list endpoint
  • Loading branch information
nrjadkry authored Feb 8, 2025
2 parents e9ab082 + f733c8b commit f1f6cdf
Show file tree
Hide file tree
Showing 7 changed files with 113 additions and 300 deletions.
2 changes: 1 addition & 1 deletion src/backend/app/__version__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "2025.1.0"
__version__ = "2025.2.0"
1 change: 1 addition & 0 deletions src/backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ async def heartbeat_plus_db(db: Annotated[Connection, Depends(get_db)]):

known_browsers = ["Mozilla", "Chrome", "Safari", "Opera", "Edge", "Firefox"]


@api.exception_handler(404)
async def custom_404_handler(request: Request, _):
"""Return Frontend HTML or throw 404 Response on 404 requests."""
Expand Down
8 changes: 8 additions & 0 deletions src/backend/app/models/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -218,3 +218,11 @@ class FlightMode(str, Enum):

waylines = "waylines"
waypoints = "waypoints"


class ProjectCompletionStatus(str, Enum):
"""Enum to describe all possible project completion status."""

NOT_STARTED = "not-started"
ON_GOING = "ongoing"
COMPLETED = "completed"
12 changes: 10 additions & 2 deletions src/backend/app/projects/project_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
from shapely.ops import unary_union
from app.projects import project_schemas, project_deps, project_logic, image_processing
from app.db import database
from app.models.enums import HTTPStatus, State
from app.models.enums import HTTPStatus, State, ProjectCompletionStatus
from app.s3 import add_file_to_bucket, s3_client
from app.config import settings
from app.users.user_deps import login_required
Expand Down Expand Up @@ -388,6 +388,9 @@ async def read_projects(
filter_by_owner: Optional[bool] = Query(
False, description="Filter projects by authenticated user (creator)"
),
status: Optional[ProjectCompletionStatus] = Query(
None, description="Filter projects by status"
),
search: Optional[str] = Query(None, description="Search projects by name"),
page: int = Query(1, ge=1, description="Page number"),
results_per_page: int = Query(
Expand All @@ -400,7 +403,12 @@ async def read_projects(
user_id = user_data.id if filter_by_owner else None
skip = (page - 1) * results_per_page
projects, total_count = await project_schemas.DbProject.all(
db, user_id=user_id, search=search, skip=skip, limit=results_per_page
db,
user_id=user_id,
search=search,
status=status,
skip=skip,
limit=results_per_page,
)
if not projects:
return {
Expand Down
142 changes: 90 additions & 52 deletions src/backend/app/projects/project_schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,13 @@
from psycopg.rows import class_row
from slugify import slugify
from app.models.enums import FinalOutput, ProjectVisibility, UserRole
from app.models.enums import IntEnum, ProjectStatus, HTTPStatus, RegulatorApprovalStatus
from app.models.enums import (
IntEnum,
ProjectStatus,
HTTPStatus,
RegulatorApprovalStatus,
ProjectCompletionStatus,
)
from app.utils import (
merge_multipolygon,
)
Expand Down Expand Up @@ -380,76 +386,108 @@ async def all(
db: Connection,
user_id: Optional[str] = None,
search: Optional[str] = None,
status: Optional[ProjectCompletionStatus] = None,
skip: int = 0,
limit: int = 100,
):
"""
Get all projects, count total tasks and task states (ongoing, completed, etc.).
Optionally filter by the project creator (user) and search by project name.
Optionally filter by the project creator (user), search by project name, and status.
"""
search_term = f"%{search}%" if search else "%"
async with db.cursor(row_factory=dict_row) as cur:
await cur.execute(
"""
SELECT
p.id, p.slug, p.name, p.description, p.per_task_instructions, p.created_at, p.author_id,
ST_AsGeoJSON(p.outline)::jsonb AS outline,
p.requires_approval_from_manager_for_locking,
-- Count total tasks for each project
COUNT(t.id) AS total_task_count,
-- Count based on the latest state of tasks
COUNT(CASE WHEN te.state IN ('LOCKED_FOR_MAPPING', 'REQUEST_FOR_MAPPING', 'IMAGE_UPLOADED', 'UNFLYABLE_TASK') THEN 1 END) AS ongoing_task_count,
status_value = status.value if status else None

-- Count based on the latest state of tasks
COUNT(CASE WHEN te.state = 'IMAGE_PROCESSING_FINISHED' THEN 1 END) AS completed_task_count
FROM projects p
LEFT JOIN tasks t ON t.project_id = p.id
LEFT JOIN (
-- Get the latest event per task
SELECT DISTINCT ON (te.task_id)
te.task_id,
te.state,
te.created_at
FROM task_events te
ORDER BY te.task_id, te.created_at DESC
) AS te ON te.task_id = t.id
WHERE (p.author_id = COALESCE(%(user_id)s, p.author_id))
AND p.name ILIKE %(search)s
-- Uncomment this if we want to restrict projects before local regulation accepts it
-- AND (
-- %(user_id)s IS NOT NULL
-- OR p.requires_approval_from_regulator = 'f'
-- OR p.regulator_approval_status = 'APPROVED'
-- )
GROUP BY p.id
ORDER BY p.created_at DESC
async with db.cursor(row_factory=dict_row) as cur:
query = """
WITH project_stats AS (
SELECT
p.id,
p.slug,
p.name,
p.description,
p.per_task_instructions,
p.created_at,
p.author_id,
ST_AsGeoJSON(p.outline)::jsonb AS outline,
p.requires_approval_from_manager_for_locking,
COUNT(t.id) AS total_task_count,
COUNT(CASE WHEN te.state IN ('LOCKED_FOR_MAPPING', 'REQUEST_FOR_MAPPING', 'IMAGE_UPLOADED', 'UNFLYABLE_TASK') THEN 1 END) AS ongoing_task_count,
COUNT(CASE WHEN te.state = 'IMAGE_PROCESSING_FINISHED' THEN 1 END) AS completed_task_count,
CASE
WHEN COUNT(CASE WHEN te.state = 'IMAGE_PROCESSING_FINISHED' THEN 1 END) = COUNT(t.id) THEN 'completed'
WHEN COUNT(CASE WHEN te.state IN ('LOCKED_FOR_MAPPING', 'REQUEST_FOR_MAPPING', 'IMAGE_UPLOADED', 'UNFLYABLE_TASK') THEN 1 END) = 0
AND COUNT(CASE WHEN te.state = 'IMAGE_PROCESSING_FINISHED' THEN 1 END) = 0 THEN 'not-started'
ELSE 'ongoing'
END AS calculated_status
FROM projects p
LEFT JOIN tasks t ON t.project_id = p.id
LEFT JOIN (
SELECT DISTINCT ON (te.task_id)
te.task_id,
te.state,
te.created_at
FROM task_events te
ORDER BY te.task_id, te.created_at DESC
) AS te ON te.task_id = t.id
WHERE (p.author_id = COALESCE(%(user_id)s, p.author_id))
AND p.name ILIKE %(search)s
GROUP BY p.id, p.slug, p.name, p.description, p.per_task_instructions, p.created_at, p.author_id, p.outline, p.requires_approval_from_manager_for_locking
)
SELECT *
FROM project_stats
WHERE CAST(%(status)s AS text) IS NULL
OR calculated_status = CAST(%(status)s AS text)
ORDER BY created_at DESC
OFFSET %(skip)s
LIMIT %(limit)s
""",
"""

await cur.execute(
query,
{
"skip": skip,
"limit": limit,
"user_id": user_id,
"search": search_term,
"status": status_value,
},
)
db_projects = await cur.fetchall()

async with db.cursor() as cur:
count_query = """
WITH project_stats AS (
SELECT
p.id,
CASE
WHEN COUNT(CASE WHEN te.state = 'IMAGE_PROCESSING_FINISHED' THEN 1 END) = COUNT(t.id) THEN 'completed'
WHEN COUNT(CASE WHEN te.state IN ('LOCKED_FOR_MAPPING', 'REQUEST_FOR_MAPPING', 'IMAGE_UPLOADED', 'UNFLYABLE_TASK') THEN 1 END) = 0
AND COUNT(CASE WHEN te.state = 'IMAGE_PROCESSING_FINISHED' THEN 1 END) = 0 THEN 'not-started'
ELSE 'ongoing'
END AS calculated_status
FROM projects p
LEFT JOIN tasks t ON t.project_id = p.id
LEFT JOIN (
SELECT DISTINCT ON (te.task_id)
te.task_id,
te.state,
te.created_at
FROM task_events te
ORDER BY te.task_id, te.created_at DESC
) AS te ON te.task_id = t.id
WHERE (p.author_id = COALESCE(%(user_id)s, p.author_id))
AND p.name ILIKE %(search)s
GROUP BY p.id
)
SELECT COUNT(*)
FROM project_stats
WHERE CAST(%(status)s AS text) IS NULL
OR calculated_status = CAST(%(status)s AS text)
"""
await cur.execute(
"""
SELECT COUNT(*) FROM projects p
WHERE (p.author_id = COALESCE(%(user_id)s, p.author_id))
AND p.name ILIKE %(search)s""",
{"user_id": user_id, "search": search_term},
count_query,
{"user_id": user_id, "search": search_term, "status": status_value},
)

total_count = await cur.fetchone()

return db_projects, total_count[0]
Expand Down Expand Up @@ -657,11 +695,11 @@ def calculate_status(cls, values):
total_task_count = values.total_task_count

if completed_task_count == 0 and ongoing_task_count == 0:
values.status = "not-started"
values.status = ProjectCompletionStatus.NOT_STARTED
elif completed_task_count == total_task_count:
values.status = "completed"
values.status = ProjectCompletionStatus.COMPLETED
else:
values.status = "ongoing"
values.status = ProjectCompletionStatus.ON_GOING

return values

Expand Down
Loading

0 comments on commit f1f6cdf

Please sign in to comment.