Skip to content

Commit c2b5b40

Browse files
authored
Merge pull request #7073 from hotosm/develop
v5.3.1 to staging
2 parents 1639ec5 + 61cb20c commit c2b5b40

File tree

44 files changed

+1408
-320
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

44 files changed

+1408
-320
lines changed

.github/workflows/issue_label.yml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# We add a label `repo:repo-name` to each new issue,
2+
# for easier tracking in external systems
3+
4+
name: 🏷️ Issue Label
5+
6+
on:
7+
issues:
8+
types:
9+
- opened
10+
11+
jobs:
12+
issue-label:
13+
runs-on: ubuntu-latest
14+
permissions:
15+
issues: write
16+
17+
steps:
18+
- run: gh issue edit "$NUMBER" --add-label "$LABELS"
19+
env:
20+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
21+
GH_REPO: ${{ github.repository }}
22+
NUMBER: ${{ github.event.issue.number }}
23+
LABELS: repo:tm

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ Status | Feature | Release
4040
✅ | MapSwipe Stats Integration: Display MapSwipe statistics on Partner Pages.|[v4.8.2](https://github.com/hotosm/tasking-manager/releases/tag/v4.8.2)
4141
✅ | iD Editor Latest Features: Integrate the newest features of the iD editor.|[v5.0.5](https://github.com/hotosm/tasking-manager/releases/tag/v5.0.5)
4242
✅ | FastAPI Migration: Improve performance and scalability of Tasking Manager to handle large scale validation and mapping efforts.| [v5 launch 🎉](https://github.com/hotosm/tasking-manager/releases/tag/v5.0.0)
43-
🔄 | Super Mapper: Redefine Mapper Level Milestones
43+
| Super Mapper: Redefine Mapper Level Milestones | [v5.2.0](https://github.com/hotosm/tasking-manager/releases/tag/v5.2.0)
4444
🔄 | OSM Practice Projects: Enable users to engage in OSM practice projects within Tasking Manager workflow.
4545
📅 | Expanding Project Types beyond basemap features
4646
📅 | AI Integration: task assignment, difficulty estimation, and validation

backend/api/partners/statistics.py

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -109,11 +109,15 @@ async def get_filtered_statistics(
109109
sub_code=MAPSWIPE_GROUP_EMPTY_SUBCODE,
110110
message=MAPSWIPE_GROUP_EMPTY_MESSAGE,
111111
)
112-
113112
mapswipe = MapswipeService()
114-
return mapswipe.fetch_filtered_partner_stats(
115-
partner.id, partner.mapswipe_group_id, from_date, to_date
116-
)
113+
try:
114+
result = await mapswipe.fetch_filtered_partner_stats(
115+
partner.id, partner.mapswipe_group_id, from_date, to_date
116+
)
117+
finally:
118+
await mapswipe.aclose()
119+
120+
return result
117121

118122

119123
@router.get("/{permalink:str}/general-statistics/")
@@ -176,13 +180,16 @@ async def get_general_statistics(
176180
)
177181

178182
mapswipe = MapswipeService()
179-
group_dto = mapswipe.fetch_grouped_partner_stats(
180-
partner.id,
181-
partner.mapswipe_group_id,
182-
limit,
183-
offset,
184-
download_as_csv,
185-
)
183+
try:
184+
group_dto = await mapswipe.fetch_grouped_partner_stats(
185+
partner.id,
186+
partner.mapswipe_group_id,
187+
limit,
188+
offset,
189+
download_as_csv,
190+
)
191+
finally:
192+
await mapswipe.aclose()
186193

187194
if download_as_csv:
188195
csv_content = group_dto.to_csv()

backend/api/teams/actions.py

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from typing import List
2+
from backend.models.dtos.team_dto import ProjectTeamPairDTOList
13
from databases import Database
24
from fastapi import APIRouter, BackgroundTasks, Body, Depends, Request
35
from fastapi.responses import JSONResponse
@@ -407,3 +409,213 @@ async def message_team(
407409
)
408410
except ValueError as e:
409411
return JSONResponse(content={"Error": str(e)}, status_code=400)
412+
413+
414+
@router.delete("/projects/{project_id}/teams/{team_id}/")
415+
async def remove_team_from_project(
416+
project_id: int,
417+
team_id: int,
418+
request: Request,
419+
user: AuthUserDTO = Depends(login_required),
420+
db: Database = Depends(get_db),
421+
):
422+
"""
423+
Unlink a Team from a Project.
424+
"""
425+
permitted = await TeamService.is_user_team_manager(team_id, user.id, db)
426+
if not permitted:
427+
return JSONResponse(
428+
{
429+
"Error": "User is not a manager of the team",
430+
"SubCode": "UserPermissionError",
431+
},
432+
status_code=403,
433+
)
434+
435+
deny_resp = await TeamService.ensure_unlink_allowed(project_id, team_id, db)
436+
if deny_resp:
437+
return deny_resp
438+
439+
try:
440+
deleted = await TeamService.unlink_team(project_id, team_id, db)
441+
if not deleted:
442+
return JSONResponse(
443+
{"Error": "No such team linked to project", "SubCode": "NotFoundError"},
444+
status_code=404,
445+
)
446+
return JSONResponse({"Success": True}, status_code=200)
447+
except Exception as e:
448+
return JSONResponse(
449+
{"Error": "Internal server error", "Details": str(e)},
450+
status_code=500,
451+
)
452+
453+
454+
@router.delete("/projects/teams/{team_id}/unlink")
455+
async def remove_team_from_all_projects(
456+
team_id: int,
457+
request: Request,
458+
user: AuthUserDTO = Depends(login_required),
459+
db: Database = Depends(get_db),
460+
):
461+
"""
462+
Unlink the given team from all projects it is assigned to.
463+
464+
Steps:
465+
- ensure caller is a manager of the team
466+
- fetch all project_ids for the team from project_teams
467+
- run ensure_unlink_allowed(project_id, team_id, db) for every project
468+
- if all checks pass, unlink each (inside one DB transaction)
469+
"""
470+
permitted = await TeamService.is_user_team_manager(team_id, user.id, db)
471+
if not permitted:
472+
return JSONResponse(
473+
{
474+
"Error": (
475+
f"Cannot unlink team with team id-{team_id}: "
476+
f"user {user.id} is not a manager of the team"
477+
),
478+
"SubCode": "UserPermissionError",
479+
},
480+
status_code=403,
481+
)
482+
483+
rows = await db.fetch_all(
484+
"SELECT project_id FROM project_teams WHERE team_id = :tid",
485+
{"tid": team_id},
486+
)
487+
project_ids: List[int] = [r["project_id"] for r in rows] if rows else []
488+
489+
if not project_ids:
490+
return JSONResponse(
491+
{
492+
"Error": (
493+
f"Cannot unlink team with team id-{team_id}: "
494+
"team is not linked to any projects"
495+
),
496+
"SubCode": "NotFoundError",
497+
},
498+
status_code=404,
499+
)
500+
501+
for pid in project_ids:
502+
deny_resp = await TeamService.ensure_unlink_allowed(pid, team_id, db)
503+
if deny_resp:
504+
return deny_resp
505+
506+
try:
507+
async with db.transaction():
508+
for pid in project_ids:
509+
deleted = await TeamService.unlink_team(pid, team_id, db)
510+
if not deleted:
511+
raise RuntimeError(f"NOT_FOUND:{pid}:{team_id}")
512+
513+
projects_str = ", ".join(str(p) for p in project_ids)
514+
return JSONResponse(
515+
{
516+
"Success": True,
517+
"Message": (
518+
f"Team id-{team_id} unlinked from projects: {projects_str}"
519+
),
520+
},
521+
status_code=200,
522+
)
523+
524+
except Exception as e:
525+
return JSONResponse(
526+
{
527+
"Error": (
528+
f"Cannot unlink team with team id-{team_id}: internal server error - {str(e)}"
529+
),
530+
"SubCode": "InternalServerError",
531+
},
532+
status_code=500,
533+
)
534+
535+
536+
@router.delete("/projects/unlink")
537+
async def remove_teams_from_projects(
538+
payload: ProjectTeamPairDTOList,
539+
request: Request,
540+
user: AuthUserDTO = Depends(login_required),
541+
db: Database = Depends(get_db),
542+
):
543+
"""
544+
Bulk unlink teams from projects.
545+
546+
Body:
547+
{
548+
"items": [
549+
{"project_id": 1442, "team_id": 43},
550+
{"project_id": 2000, "team_id": 55}
551+
]
552+
}
553+
554+
First: run all checks for all items (manager check + ensure_unlink_allowed).
555+
If any check fails, return error immediately and do NOT modify DB.
556+
If all checks pass, perform unlink operations inside a single DB transaction.
557+
"""
558+
items = payload.items or []
559+
if not items:
560+
return JSONResponse(
561+
{"Error": "No project/team pairs provided", "SubCode": "InvalidRequest"},
562+
status_code=400,
563+
)
564+
565+
seen = set()
566+
pairs = []
567+
for it in items:
568+
key = (it.project_id, it.team_id)
569+
if key in seen:
570+
continue
571+
seen.add(key)
572+
pairs.append(it)
573+
574+
for it in pairs:
575+
pid = it.project_id
576+
tid = it.team_id
577+
578+
permitted = await TeamService.is_user_team_manager(tid, user.id, db)
579+
if not permitted:
580+
return JSONResponse(
581+
{
582+
"Error": (
583+
f"Cannot unlink team with team id-{tid}: user {user.id} is not a manager of the team"
584+
),
585+
"SubCode": "UserPermissionError",
586+
},
587+
status_code=403,
588+
)
589+
590+
deny_resp = await TeamService.ensure_unlink_allowed(pid, tid, db)
591+
if deny_resp:
592+
return deny_resp
593+
594+
try:
595+
async with db.transaction():
596+
for it in pairs:
597+
pid = it.project_id
598+
tid = it.team_id
599+
600+
deleted = await TeamService.unlink_team(pid, tid, db)
601+
if not deleted:
602+
raise RuntimeError(f"NOT_FOUND:{pid}:{tid}")
603+
604+
pairs_str = ", ".join(
605+
[f"(project {p.project_id}, team {p.team_id})" for p in pairs]
606+
)
607+
return JSONResponse(
608+
{
609+
"Success": True,
610+
"Message": f"Unlinked teams: {pairs_str}",
611+
},
612+
status_code=200,
613+
)
614+
except Exception as e:
615+
return JSONResponse(
616+
{
617+
"Error": f"Cannot unlink teams: internal server error - {str(e)}",
618+
"SubCode": "InternalServerError",
619+
},
620+
status_code=500,
621+
)

backend/config.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ class Config:
5757

5858
# The address to use as the receiver in contact form.
5959
EMAIL_CONTACT_ADDRESS: str = os.getenv(
60-
"TM_EMAIL_CONTACT_ADDRESS", "sysadmin@hotosm.org"
60+
"TM_EMAIL_CONTACT_ADDRESS", "admin@yourorganisation.com"
6161
)
6262

6363
# A freely definable secret key for connecting the front end with the back end
@@ -269,6 +269,11 @@ def assemble_db_connection(
269269
# Sentry backend DSN
270270
SENTRY_BACKEND_DSN: Optional[str] = os.getenv("TM_SENTRY_BACKEND_DSN", None)
271271

272+
# Mapswipe backend url
273+
MAPSWIPE_API_URL: str = os.getenv(
274+
"MAPSWIPE_API_URL", "https://backend.mapswipe.org/graphql/"
275+
)
276+
272277
# Ohsome Stats Token
273278
OHSOME_STATS_TOKEN: str = os.getenv("OHSOME_STATS_TOKEN", None)
274279
OHSOME_STATS_API_URL: str = os.getenv(

backend/models/dtos/team_dto.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,3 +247,12 @@ class TeamSearchDTO(BaseModel):
247247

248248
class Config:
249249
populate_by_name = True
250+
251+
252+
class ProjectTeamPairDTO(BaseModel):
253+
project_id: int
254+
team_id: int
255+
256+
257+
class ProjectTeamPairDTOList(BaseModel):
258+
items: List[ProjectTeamPairDTO]

backend/models/dtos/user_dto.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ class UserDTO(BaseModel):
3030
username: Optional[str] = None
3131
role: Optional[str] = None
3232
mapping_level: Optional[str] = Field(None, alias="mappingLevel")
33+
level_ordering: Optional[int] = None
3334
projects_mapped: Optional[int] = Field(None, alias="projectsMapped")
3435
email_address: Optional[str] = Field(None, alias="emailAddress")
3536
is_email_verified: Optional[bool] = Field(

backend/models/postgis/project.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -286,7 +286,7 @@ async def set_project_aoi(self, draft_project_dto: DraftProjectDTO, db: Database
286286
valid_geojson = geojson.dumps(aoi_geometry)
287287

288288
query = """
289-
SELECT ST_AsText(
289+
SELECT ST_AsEWKT(
290290
ST_SetSRID(
291291
ST_GeomFromGeoJSON(:geojson), 4326
292292
)
@@ -295,9 +295,8 @@ async def set_project_aoi(self, draft_project_dto: DraftProjectDTO, db: Database
295295
# Execute the query with the GeoJSON value passed in as a parameter
296296
result = await db.fetch_one(query=query, values={"geojson": valid_geojson})
297297
self.geometry = result["geometry_wkt"] if result else None
298-
299298
query = """
300-
SELECT ST_AsText(ST_Centroid(ST_SetSRID(ST_GeomFromGeoJSON(:geometry), 4326))) AS centroid
299+
SELECT ST_AsEWKT(ST_Centroid(ST_SetSRID(ST_GeomFromGeoJSON(:geometry), 4326))) AS centroid
301300
"""
302301

303302
# Execute the query and pass the GeoJSON as a parameter

backend/models/postgis/task.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -749,7 +749,9 @@ def from_geojson_feature(cls, task_id, task_feature):
749749
task.y = task_feature.properties["y"]
750750
task.zoom = task_feature.properties["zoom"]
751751
task.is_square = task_feature.properties["isSquare"]
752-
task.geometry = shape(task_feature.geometry).wkt
752+
wkt = shape(task_feature.geometry).wkt
753+
ewkt = f"SRID=4326;{wkt}"
754+
task.geometry = ewkt
753755
except KeyError as e:
754756
raise InvalidData(
755757
f"PropertyNotFound: Expected property not found: {str(e)}"

0 commit comments

Comments
 (0)