diff --git a/backend/api/issues/resources.py b/backend/api/issues/resources.py index dc2d148f67..9a8b43c63f 100644 --- a/backend/api/issues/resources.py +++ b/backend/api/issues/resources.py @@ -1,11 +1,13 @@ from databases import Database -from fastapi import APIRouter, Depends, Request, Body +from fastapi import APIRouter, Body, Depends, Request from fastapi.responses import JSONResponse from loguru import logger +from backend.db import get_db from backend.models.dtos.mapping_issues_dto import MappingIssueCategoryDTO +from backend.models.dtos.user_dto import AuthUserDTO from backend.services.mapping_issues_service import MappingIssueCategoryService -from backend.db import get_db +from backend.services.users.authentication_service import login_required router = APIRouter( prefix="/tasks", diff --git a/backend/api/organisations/resources.py b/backend/api/organisations/resources.py index 8748e52cc8..7ca800708c 100644 --- a/backend/api/organisations/resources.py +++ b/backend/api/organisations/resources.py @@ -1,5 +1,5 @@ from databases import Database -from fastapi import APIRouter, Depends, Query, Request, Path +from fastapi import APIRouter, Depends, Query, Request from fastapi.responses import JSONResponse, Response from loguru import logger @@ -28,52 +28,94 @@ ) -async def get_organisation_by_identifier( - identifier: str = Path(..., description="Either organisation ID or slug"), +@router.get("/{organisation_id:int}/", response_model=OrganisationDTO) +async def retrieve_organisation( + request: Request, + organisation_id: int, db: Database = Depends(get_db), omit_managers: bool = Query( False, alias="omitManagerList", description="Omit organization managers list from the response.", ), - request: Request = None, -) -> OrganisationDTO: - authenticated_user_id = request.user.display_name if request.user else None - user_id = authenticated_user_id or 0 - - try: - organisation_id = int(identifier) - organisation_dto = await OrganisationService.get_organisation_by_id_as_dto( - organisation_id, user_id, omit_managers, db - ) - except ValueError: - organisation_dto = await OrganisationService.get_organisation_by_slug_as_dto( - identifier, user_id, omit_managers, db - ) - - if not organisation_dto: - return JSONResponse( - content={"Error": "Organisation not found."}, status_code=404 - ) - +): + """ + Retrieves an organisation + --- + tags: + - organisations + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + type: string + default: Token sessionTokenHere== + - name: organisation_id + in: path + description: The unique organisation ID + required: true + type: integer + default: 1 + - in: query + name: omitManagerList + type: boolean + description: Set it to true if you don't want the managers list on the response. + default: False + responses: + 200: + description: Organisation found + 401: + description: Unauthorized - Invalid credentials + 404: + description: Organisation not found + 500: + description: Internal Server Error + """ + authenticated_user_id = ( + request.user.display_name + if request.user and request.user.display_name + else None + ) + if authenticated_user_id is None: + user_id = 0 + else: + user_id = authenticated_user_id + # Validate abbreviated. + organisation_dto = await OrganisationService.get_organisation_by_id_as_dto( + organisation_id, user_id, omit_managers, db + ) return organisation_dto -@router.get("/{identifier}/", response_model=OrganisationDTO) -async def retrieve_organisation( - organisation: OrganisationDTO = Depends(get_organisation_by_identifier), +@router.get("/{slug:str}/", response_model=OrganisationDTO) +async def retrieve_organisation_by_slug( + request: Request, + slug: str, + db: Database = Depends(get_db), + omit_managers: bool = Query( + True, + alias="omitManagerList", + description="Omit organization managers list from the response.", + ), ): """ - Retrieve an organisation by either ID or slug. + Retrieves an organisation --- tags: - organisations produces: - application/json parameters: - - in: path - name: identifier - description: The organisation ID or slug + - in: header + name: Authorization + description: Base64 encoded session token + type: string + default: Token sessionTokenHere== + - name: slug + in: path + description: The unique organisation slug required: true type: string default: hot @@ -90,7 +132,19 @@ async def retrieve_organisation( 500: description: Internal Server Error """ - return organisation + authenticated_user_id = ( + request.user.display_name + if request.user and request.user.display_name + else None + ) + if authenticated_user_id is None: + user_id = 0 + else: + user_id = authenticated_user_id + organisation_dto = await OrganisationService.get_organisation_by_slug_as_dto( + slug, user_id, omit_managers, db + ) + return organisation_dto @router.post("/") @@ -447,7 +501,12 @@ async def list_organisation( 500: description: Internal Server Error """ - authenticated_user_id = request.user.display_name if request.user else None + authenticated_user_id = ( + request.user.display_name + if request.user and request.user.display_name + else None + ) + if manager_user_id is not None and not authenticated_user_id: return Response( content={ diff --git a/backend/api/partners/resources.py b/backend/api/partners/resources.py index bd3b4ff247..1a0382cd82 100644 --- a/backend/api/partners/resources.py +++ b/backend/api/partners/resources.py @@ -1,400 +1,476 @@ -from flask_restful import Resource, request +# from flask_restful import Resource, request +import json +from databases import Database +from fastapi import APIRouter, Depends, Request +from fastapi.responses import JSONResponse -from backend.services.partner_service import PartnerService, PartnerServiceError -from backend.services.users.authentication_service import token_auth +from backend.db import get_db +from backend.models.dtos.partner_dto import PartnerDTO +from backend.models.dtos.user_dto import AuthUserDTO from backend.models.postgis.user import User +from backend.services.partner_service import PartnerService, PartnerServiceError +from backend.services.users.authentication_service import login_required + +router = APIRouter( + prefix="/partners", + tags=["partners"], + dependencies=[Depends(get_db)], + responses={404: {"description": "Not found"}}, +) -class PartnerRestAPI(Resource): - @token_auth.login_required - def get(self, partner_id): - """ - Get partner by id - --- - tags: - - partners - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - name: partner_id - in: path - description: The id of the partner - required: true - type: integer - default: 1 - responses: - 200: - description: Partner found - 401: - description: Unauthorized - Invalid credentials - 404: - description: Partner not found - 500: - description: Internal Server Error - """ +@router.get("/{partner_id:int}/") +async def retrieve_partner( + request: Request, + partner_id: int, + db: Database = Depends(get_db), + user: AuthUserDTO = Depends(login_required), +): + """ + Get partner by id + --- + tags: + - partners + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - name: partner_id + in: path + description: The id of the partner + required: true + type: integer + default: 1 + responses: + 200: + description: Partner found + 401: + description: Unauthorized - Invalid credentials + 404: + description: Partner not found + 500: + description: Internal Server Error + """ - request_user = User.get_by_id(token_auth.current_user()) - if request_user.role != 1: - return { + request_user = await User.get_by_id(user.id, db) + if request_user.role != 1: + return JSONResponse( + content={ "Error": "Only admin users can manage partners.", "SubCode": "OnlyAdminAccess", - }, 403 + }, + status_code=403, + ) - partner = PartnerService.get_partner_by_id(partner_id) - if partner: - partner_dict = partner.as_dto().to_primitive() - website_links = partner_dict.pop("website_links", []) - for i, link in enumerate(website_links, start=1): - partner_dict[f"name_{i}"] = link["name"] - partner_dict[f"url_{i}"] = link["url"] - return partner_dict, 200 - else: - return {"message": "Partner not found"}, 404 + partner = await PartnerService.get_partner_by_id(partner_id, db) + if partner: + partner_dto = PartnerDTO.from_record(partner) + partner_dict = partner_dto.dict() + website_links = partner_dict.pop("website_links", []) + for i, link in enumerate(website_links, start=1): + partner_dict[f"name_{i}"] = link.get("name") + partner_dict[f"url_{i}"] = link.get("url") + + return partner_dict + else: + return JSONResponse(content={"message": "Partner not found"}, status_code=404) - @token_auth.login_required - def delete(self, partner_id): - """ - Deletes an existing partner - --- - tags: - - partners - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - type: string - required: true - default: Token sessionTokenHere== - - in: header - name: Accept-Language - description: Language partner is requesting - type: string - required: true - default: en - - name: partner_id - in: path - description: Partner ID - required: true - type: integer - default: 1 - responses: - 200: - description: Partner deleted successfully - 401: - description: Unauthorized - Invalid credentials - 403: - description: Forbidden - 404: - description: Partner not found - 500: - description: Internal Server Error - """ - request_user = User.get_by_id(token_auth.current_user()) - if request_user.role != 1: - return { + +@router.delete("/{partner_id}/") +async def delete_partner( + request: Request, + partner_id: int, + db: Database = Depends(get_db), + user: AuthUserDTO = Depends(login_required), +): + """ + Deletes an existing partner + --- + tags: + - partners + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + type: string + required: true + default: Token sessionTokenHere== + - in: header + name: Accept-Language + description: Language partner is requesting + type: string + required: true + default: en + - name: partner_id + in: path + description: Partner ID + required: true + type: integer + default: 1 + responses: + 200: + description: Partner deleted successfully + 401: + description: Unauthorized - Invalid credentials + 403: + description: Forbidden + 404: + description: Partner not found + 500: + description: Internal Server Error + """ + request_user = await User.get_by_id(user.id, db) + if request_user.role != 1: + return JSONResponse( + content={ "Error": "Only admin users can manage partners.", "SubCode": "OnlyAdminAccess", - }, 403 + }, + status_code=403, + ) - try: - PartnerService.delete_partner(partner_id) - return {"Success": "Partner deleted"}, 200 - except PartnerServiceError as e: - return {"message": str(e)}, 404 + try: + async with db.transaction(): + await PartnerService.delete_partner(partner_id, db) + return JSONResponse(content={"Success": "Partner deleted"}, status_code=200) + except PartnerServiceError as e: + return JSONResponse(content={"message": str(e)}, status_code=404) - @token_auth.login_required - def put(self, partner_id): - """ - Updates an existing partner - --- - tags: - - partners - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - type: string - required: true - default: Token sessionTokenHere== - - in: header - name: Accept-Language - description: Language partner is requesting - type: string - required: true - default: en - - name: partner_id - in: path - description: Partner ID - required: true - type: integer - - in: body - name: body - required: true - description: JSON object for updating a Partner - schema: - properties: - name: - type: string - example: Cool Partner Inc. - primary_hashtag: - type: string - example: CoolPartner - secondary_hashtag: - type: string - example: CoolPartner,coolProject-* - link_x: - type: string - example: https://x.com/CoolPartner - link_meta: - type: string - example: https://facebook.com/CoolPartner - link_instagram: - type: string - example: https://instagram.com/CoolPartner - current_projects: - type: string - example: 3425,2134,2643 - permalink: - type: string - example: cool-partner - logo_url: - type: string - example: https://tasks.hotosm.org/assets/img/hot-tm-logo.svg - website_links: - type: array - items: - type: string - mapswipe_group_id: + +@router.put("/{partner_id}/") +async def update_partner( + request: Request, + partner_id: int, + db: Database = Depends(get_db), + user: AuthUserDTO = Depends(login_required), +): + """ + Updates an existing partner + --- + tags: + - partners + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + type: string + required: true + default: Token sessionTokenHere== + - in: header + name: Accept-Language + description: Language partner is requesting + type: string + required: true + default: en + - name: partner_id + in: path + description: Partner ID + required: true + type: integer + - in: body + name: body + required: true + description: JSON object for updating a Partner + schema: + properties: + name: + type: string + example: Cool Partner Inc. + primary_hashtag: + type: string + example: CoolPartner + secondary_hashtag: + type: string + example: CoolPartner,coolProject-* + link_x: + type: string + example: https://x.com/CoolPartner + link_meta: + type: string + example: https://facebook.com/CoolPartner + link_instagram: + type: string + example: https://instagram.com/CoolPartner + current_projects: + type: string + example: 3425,2134,2643 + permalink: + type: string + example: cool-partner + logo_url: + type: string + example: https://tasks.hotosm.org/assets/img/hot-tm-logo.svg + website_links: + type: array + items: type: string - example: -NL6WXPOdFyWACqwNU2O - responses: - 200: - description: Partner updated successfully - 401: - description: Unauthorized - Invalid credentials - 403: - description: Forbidden - 404: - description: Partner not found - 409: - description: Resource duplication - 500: - description: Internal Server Error - """ + mapswipe_group_id: + type: string + example: -NL6WXPOdFyWACqwNU2O + responses: + 200: + description: Partner updated successfully + 401: + description: Unauthorized - Invalid credentials + 403: + description: Forbidden + 404: + description: Partner not found + 409: + description: Resource duplication + 500: + description: Internal Server Error + """ - request_user = User.get_by_id(token_auth.current_user()) - if request_user.role != 1: - return { + request_user = await User.get_by_id(user.id, db) + if request_user.role != 1: + return JSONResponse( + content={ "Error": "Only admin users can manage partners.", "SubCode": "OnlyAdminAccess", - }, 403 + }, + status_code=403, + ) - try: - data = request.json - updated_partner = PartnerService.update_partner(partner_id, data) - updated_partner_dict = updated_partner.as_dto().to_primitive() - return updated_partner_dict, 200 - except PartnerServiceError as e: - return {"message": str(e)}, 404 + try: + data = await request.json() + async with db.transaction(): + updated_partner = await PartnerService.update_partner(partner_id, data, db) + return updated_partner + except PartnerServiceError as e: + return JSONResponse(content={"message": str(e)}, status_code=404) -class PartnersAllRestAPI(Resource): - @token_auth.login_required - def get(self): - """ - Get all active partners - --- - tags: - - partners - produces: - - application/json - responses: - 200: - description: All Partners returned successfully - 500: - description: Internal Server Error - """ +@router.get("/") +async def list_partners( + request: Request, + db: Database = Depends(get_db), + user: AuthUserDTO = Depends(login_required), +): + """ + Get all active partners + --- + tags: + - partners + produces: + - application/json + responses: + 200: + description: All Partners returned successfully + 500: + description: Internal Server Error + """ - request_user = User.get_by_id(token_auth.current_user()) - if request_user.role != 1: - return { + request_user = await User.get_by_id(user.id, db) + if request_user.role != 1: + return JSONResponse( + content={ "Error": "Only admin users can manage partners.", "SubCode": "OnlyAdminAccess", - }, 403 + }, + status_code=403, + ) - partner_ids = PartnerService.get_all_partners() - partners = [] - for partner_id in partner_ids: - partner = PartnerService.get_partner_by_id(partner_id) - partner_dict = partner.as_dto().to_primitive() - website_links = partner_dict.pop("website_links", []) - for i, link in enumerate(website_links, start=1): - partner_dict[f"name_{i}"] = link["name"] - partner_dict[f"url_{i}"] = link["url"] - partners.append(partner_dict) - return partners, 200 + partner_ids = await PartnerService.get_all_partners(db) + partners = [] + for partner_id in partner_ids: + partner = await PartnerService.get_partner_by_id(partner_id, db) + partner_dict = PartnerDTO.from_record(partner).dict() + website_links = partner_dict.pop("website_links", []) + for i, link in enumerate(website_links, start=1): + partner_dict[f"name_{i}"] = link.get("name") + partner_dict[f"url_{i}"] = link.get("url") + partners.append(partner_dict) - @token_auth.login_required - def post(self): - """ - Creates a new partner - --- - tags: - - partners - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - type: string - required: true - default: Token sessionTokenHere== - - in: header - name: Accept-Language - description: Language partner is requesting - type: string - required: true - default: en - - in: body - name: body - required: true - description: JSON object for creating a new Partner - schema: - properties: - name: - type: string - required: true - example: "American red cross" - primary_hashtag: - type: string - required: true - example: "#americanredcross" - logo_url: - type: string - example: https://tasks.hotosm.org/assets/img/hot-tm-logo.svg - name: - type: string - example: Cool Partner Inc. - primary_hashtag: - type: string - example: CoolPartner - secondary_hashtag: - type: string - example: CoolPartner,coolProject-* - link_x: - type: string - example: https://x.com/CoolPartner - link_meta: - type: string - example: https://facebook.com/CoolPartner - link_instagram: - type: string - example: https://instagram.com/CoolPartner - current_projects: - type: string - example: 3425,2134,2643 - permalink: - type: string - example: cool-partner - logo_url: - type: string - example: https://tasks.hotosm.org/assets/img/hot-tm-logo.svg - website_links: - type: array - items: - type: string - default: [ - ] - mapswipe_group_id: + return partners + + +@router.post("/") +async def create_partner( + request: Request, + db: Database = Depends(get_db), + user: AuthUserDTO = Depends(login_required), +): + """ + Creates a new partner + --- + tags: + - partners + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + type: string + required: true + default: Token sessionTokenHere== + - in: header + name: Accept-Language + description: Language partner is requesting + type: string + required: true + default: en + - in: body + name: body + required: true + description: JSON object for creating a new Partner + schema: + properties: + name: + type: string + required: true + example: "American red cross" + primary_hashtag: + type: string + required: true + example: "#americanredcross" + logo_url: + type: string + example: https://tasks.hotosm.org/assets/img/hot-tm-logo.svg + name: + type: string + example: Cool Partner Inc. + primary_hashtag: + type: string + example: CoolPartner + secondary_hashtag: + type: string + example: CoolPartner,coolProject-* + link_x: + type: string + example: https://x.com/CoolPartner + link_meta: + type: string + example: https://facebook.com/CoolPartner + link_instagram: + type: string + example: https://instagram.com/CoolPartner + current_projects: + type: string + example: 3425,2134,2643 + permalink: + type: string + example: cool-partner + logo_url: + type: string + example: https://tasks.hotosm.org/assets/img/hot-tm-logo.svg + website_links: + type: array + items: type: string - example: -NL6WXPOdFyWACqwNU2O - responses: - 201: - description: New partner created successfully - 401: - description: Unauthorized - Invalid credentials - 403: - description: Forbidden - 409: - description: Resource duplication - 500: - description: Internal Server Error - """ + default: [ + ] + mapswipe_group_id: + type: string + example: -NL6WXPOdFyWACqwNU2O + responses: + 201: + description: New partner created successfully + 401: + description: Unauthorized - Invalid credentials + 403: + description: Forbidden + 409: + description: Resource duplication + 500: + description: Internal Server Error + """ - request_user = User.get_by_id(token_auth.current_user()) - if request_user.role != 1: - return { + request_user = await User.get_by_id(user.id, db) + if request_user.role != 1: + return JSONResponse( + content={ "Error": "Only admin users can manage partners.", "SubCode": "OnlyAdminAccess", - }, 403 + }, + status_code=403, + ) - try: - data = request.json - if data: - if data.get("name") is None: - return {"message": "Partner name is not provided"}, 400 + try: + data = await request.json() + if data: + if data.get("name") is None: + return JSONResponse( + content={"message": "Partner name is not provided"}, status_code=400 + ) - if data.get("primary_hashtag") is None: - return {"message": "Partner primary_hashtag is not provided"}, 400 + if data.get("primary_hashtag") is None: + return JSONResponse( + content={"message": "Partner primary_hashtag is not provided"}, + status_code=400, + ) + async with db.transaction(): + new_partner_id = await PartnerService.create_partner(data, db) + partner_data = await PartnerService.get_partner_by_id(new_partner_id, db) + return partner_data - new_partner = PartnerService.create_partner(data) - partner_dict = new_partner.as_dto().to_primitive() - return partner_dict, 201 - else: - return {"message": "Data not provided"}, 400 - except PartnerServiceError as e: - return {"message": str(e)}, 500 + else: + return JSONResponse( + content={"message": "Data not provided"}, status_code=400 + ) + except PartnerServiceError as e: + return JSONResponse(content={"message": str(e)}, status_code=500) -class PartnerPermalinkRestAPI(Resource): - def get(self, permalink): - """ - Get partner by permalink - --- - tags: - - partners - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - name: permalink - in: path - description: The permalink of the partner - required: true - type: string - responses: - 200: - description: Partner found - 401: - description: Unauthorized - Invalid credentials - 404: - description: Partner not found - 500: - description: Internal Server Error - """ - partner = PartnerService.get_partner_by_permalink(permalink) - if partner: - partner_dict = partner.as_dto().to_primitive() - website_links = partner_dict.pop("website_links", []) - for i, link in enumerate(website_links, start=1): - partner_dict[f"name_{i}"] = link["name"] - partner_dict[f"url_{i}"] = link["url"] - return partner_dict, 200 - else: - return {"message": "Partner not found"}, 404 +@router.get("/{permalink:str}/") +async def get_partner( + request: Request, + permalink: str, + db: Database = Depends(get_db), +): + """ + Get partner by permalink + --- + tags: + - partners + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - name: permalink + in: path + description: The permalink of the partner + required: true + type: string + responses: + 200: + description: Partner found + 401: + description: Unauthorized - Invalid credentials + 404: + description: Partner not found + 500: + description: Internal Server Error + """ + try: + partner_record = await PartnerService.get_partner_by_permalink(permalink, db) + if not partner_record: + return JSONResponse( + content={"message": "Partner not found"}, status_code=404 + ) + + partner = dict(partner_record) + website_links = json.loads(partner.get("website_links", "[]")) + for i, link in enumerate(website_links, start=1): + partner[f"name_{i}"] = link["name"] + partner[f"url_{i}"] = link["url"] + + partner.pop("website_links", None) + return partner + except Exception as e: + return JSONResponse(content={"message": str(e)}, status_code=500) diff --git a/backend/api/partners/statistics.py b/backend/api/partners/statistics.py index 6d661fec99..d5be58d008 100644 --- a/backend/api/partners/statistics.py +++ b/backend/api/partners/statistics.py @@ -1,15 +1,14 @@ import io -from flask import send_file -from flask_restful import Resource, request from typing import Optional +from databases import Database +from fastapi import APIRouter, Depends, Request +from fastapi.responses import StreamingResponse -from backend.services.partner_service import PartnerService +from backend.db import get_db from backend.exceptions import BadRequest - -# Replaceable by another service which implements the method: -# fetch_partner_stats(id_inside_service, from_date, to_date) -> PartnerStatsDTO from backend.services.mapswipe_service import MapswipeService +from backend.services.partner_service import PartnerService MAPSWIPE_GROUP_EMPTY_SUBCODE = "EMPTY_MAPSWIPE_GROUP" MAPSWIPE_GROUP_EMPTY_MESSAGE = "Mapswipe group is not set for this partner." @@ -19,156 +18,170 @@ def is_valid_group_id(group_id: Optional[str]) -> bool: return group_id is not None and len(group_id) > 0 -class FilteredPartnerStatisticsAPI(Resource): - def get(self, permalink: str): - """ - Get partner statistics by id and time range - --- - tags: - - partners - produces: - - application/json - parameters: - - in: query - name: fromDate - type: string - description: Fetch partner statistics from date as yyyy-mm-dd - example: "2024-01-01" - - in: query - name: toDate - type: string - example: "2024-09-01" - description: Fetch partner statistics to date as yyyy-mm-dd - - name: partner_id - in: path - - name: permalink - in: path - description: The permalink of the partner - required: true - type: string - responses: - 200: - description: Partner found - 401: - description: Unauthorized - Invalid credentials - 404: - description: Partner not found - 500: - description: Internal Server Error - """ - mapswipe = MapswipeService() - from_date = request.args.get("fromDate") - to_date = request.args.get("toDate") - - if from_date is None: - raise BadRequest( - sub_code="INVALID_TIME_RANGE", - message="fromDate is missing", - from_date=from_date, - to_date=to_date, - ) - - if to_date is None: - raise BadRequest( - sub_code="INVALID_TIME_RANGE", - message="toDate is missing", - from_date=from_date, - to_date=to_date, - ) - - if from_date > to_date: - raise BadRequest( - sub_code="INVALID_TIME_RANGE", - message="fromDate should be less than toDate", - from_date=from_date, - to_date=to_date, - ) - - partner = PartnerService.get_partner_by_permalink(permalink) - - if not is_valid_group_id(partner.mapswipe_group_id): - raise BadRequest( - sub_code=MAPSWIPE_GROUP_EMPTY_SUBCODE, - message=MAPSWIPE_GROUP_EMPTY_MESSAGE, - ) - - return ( - mapswipe.fetch_filtered_partner_stats( - partner.id, partner.mapswipe_group_id, from_date, to_date - ).to_primitive(), - 200, +router = APIRouter( + prefix="/partners", + tags=["partners"], + dependencies=[Depends(get_db)], + responses={404: {"description": "Not found"}}, +) + + +@router.get("/{permalink:str}/filtered-statistics/") +async def get_statistics( + request: Request, + permalink: str, + db: Database = Depends(get_db), +): + """ + Get partner statistics by id and time range + --- + tags: + - partners + produces: + - application/json + parameters: + - in: query + name: fromDate + type: string + description: Fetch partner statistics from date as yyyy-mm-dd + example: "2024-01-01" + - in: query + name: toDate + type: string + example: "2024-09-01" + description: Fetch partner statistics to date as yyyy-mm-dd + - name: partner_id + in: path + - name: permalink + in: path + description: The permalink of the partner + required: true + type: string + responses: + 200: + description: Partner found + 401: + description: Unauthorized - Invalid credentials + 404: + description: Partner not found + 500: + description: Internal Server Error + """ + mapswipe = MapswipeService() + from_date = request.query_params.get("fromDate") + to_date = request.query_params.get("toDate") + + if from_date is None: + raise BadRequest( + sub_code="INVALID_TIME_RANGE", + message="fromDate is missing", + from_date=from_date, + to_date=to_date, + ) + + if to_date is None: + raise BadRequest( + sub_code="INVALID_TIME_RANGE", + message="toDate is missing", + from_date=from_date, + to_date=to_date, + ) + + if from_date > to_date: + raise BadRequest( + sub_code="INVALID_TIME_RANGE", + message="fromDate should be less than toDate", + from_date=from_date, + to_date=to_date, ) + partner = await PartnerService.get_partner_by_permalink(permalink, db) -class GroupPartnerStatisticsAPI(Resource): - def get(self, permalink: str): - """ - Get partner statistics by id and broken down by each contributor. - This API is paginated with limit and offset query parameters. - --- - tags: - - partners - produces: - - application/json - parameters: - - in: query - name: limit - description: The number of partner members to fetch - type: integer - example: 10 - - in: query - name: offset - description: The starting index from which to fetch partner members - type: integer - example: 0 - - in: query - name: downloadAsCSV - description: Download users in this group as CSV - type: boolean - example: false - - name: permalink - in: path - description: The permalink of the partner - required: true - type: string - responses: - 200: - description: Partner found - 401: - description: Unauthorized - Invalid credentials - 404: - description: Partner not found - 500: - description: Internal Server Error - """ - - mapswipe = MapswipeService() - partner = PartnerService.get_partner_by_permalink(permalink) - - if not is_valid_group_id(partner.mapswipe_group_id): - raise BadRequest( - sub_code=MAPSWIPE_GROUP_EMPTY_SUBCODE, - message=MAPSWIPE_GROUP_EMPTY_MESSAGE, - ) - - limit = int(request.args.get("limit", 10)) - offset = int(request.args.get("offset", 0)) - download_as_csv = bool(request.args.get("downloadAsCSV", "false") == "true") - - group_dto = mapswipe.fetch_grouped_partner_stats( - partner.id, - partner.mapswipe_group_id, - limit, - offset, - download_as_csv, + if not is_valid_group_id(partner.mapswipe_group_id): + raise BadRequest( + sub_code=MAPSWIPE_GROUP_EMPTY_SUBCODE, + message=MAPSWIPE_GROUP_EMPTY_MESSAGE, ) - if download_as_csv: - return send_file( - io.BytesIO(group_dto.to_csv().encode()), - mimetype="text/csv", - as_attachment=True, - download_name="partner_members.csv", - ) + return mapswipe.fetch_filtered_partner_stats( + partner.id, partner.mapswipe_group_id, from_date, to_date + ) + + +@router.get("/{permalink:str}/general-statistics/") +async def get_statistics( + request: Request, + permalink: str, + db: Database = Depends(get_db), +): + """ + Get partner statistics by id and broken down by each contributor. + This API is paginated with limit and offset query parameters. + --- + tags: + - partners + produces: + - application/json + parameters: + - in: query + name: limit + description: The number of partner members to fetch + type: integer + example: 10 + - in: query + name: offset + description: The starting index from which to fetch partner members + type: integer + example: 0 + - in: query + name: downloadAsCSV + description: Download users in this group as CSV + type: boolean + example: false + - name: permalink + in: path + description: The permalink of the partner + required: true + type: string + responses: + 200: + description: Partner found + 401: + description: Unauthorized - Invalid credentials + 404: + description: Partner not found + 500: + description: Internal Server Error + """ + + mapswipe = MapswipeService() + partner = await PartnerService.get_partner_by_permalink(permalink, db) + + if not is_valid_group_id(partner.mapswipe_group_id): + raise BadRequest( + sub_code=MAPSWIPE_GROUP_EMPTY_SUBCODE, + message=MAPSWIPE_GROUP_EMPTY_MESSAGE, + ) + + limit = int(request.query_params.get("limit", 10)) + offset = int(request.query_params.get("offset", 0)) + download_as_csv = bool(request.query_params.get("downloadAsCSV", "false") == "true") + + group_dto = mapswipe.fetch_grouped_partner_stats( + partner.id, + partner.mapswipe_group_id, + limit, + offset, + download_as_csv, + ) + + if download_as_csv: + csv_content = group_dto.to_csv() + csv_buffer = io.StringIO(csv_content) + return StreamingResponse( + content=csv_buffer, + media_type="text/csv", + headers={"Content-Disposition": "attachment; filename=partner_members.csv"}, + ) - return group_dto.to_primitive(), 200 + return group_dto diff --git a/backend/api/projects/favorites.py b/backend/api/projects/favorites.py index 45ee25de9b..617fdc8a3c 100644 --- a/backend/api/projects/favorites.py +++ b/backend/api/projects/favorites.py @@ -51,7 +51,12 @@ async def get( 500: description: Internal Server Error """ - user_id = request.user.display_name if request.user else None + user_id = ( + request.user.display_name + if request.user and request.user.display_name + else None + ) + favorited = await ProjectService.is_favorited(project_id, user_id, db) if favorited is True: return JSONResponse(content={"favorited": True}, status_code=200) diff --git a/backend/api/projects/partnerships.py b/backend/api/projects/partnerships.py index 6a5152996d..48dc21c9c2 100644 --- a/backend/api/projects/partnerships.py +++ b/backend/api/projects/partnerships.py @@ -1,293 +1,343 @@ -from flask_restful import Resource, request -from backend.services.project_partnership_service import ProjectPartnershipService -from backend.services.users.authentication_service import token_auth -from backend.services.project_admin_service import ProjectAdminService +from databases import Database +from fastapi import APIRouter, Depends, Request +from fastapi.responses import JSONResponse + +from backend.db import get_db from backend.models.dtos.project_partner_dto import ( ProjectPartnershipDTO, ProjectPartnershipUpdateDTO, ) +from backend.models.dtos.user_dto import AuthUserDTO from backend.models.postgis.utils import timestamp +from backend.services.project_admin_service import ProjectAdminService +from backend.services.project_partnership_service import ProjectPartnershipService +from backend.services.users.authentication_service import login_required + +router = APIRouter( + prefix="/projects", + tags=["projects"], + dependencies=[Depends(get_db)], + responses={404: {"description": "Not found"}}, +) -def check_if_manager(partnership_dto: ProjectPartnershipDTO): - if not ProjectAdminService.is_user_action_permitted_on_project( - token_auth.current_user(), partnership_dto.project_id +@staticmethod +async def check_if_manager( + partnership_dto: ProjectPartnershipDTO, user_id: int, db: Database +): + if not await ProjectAdminService.is_user_action_permitted_on_project( + user_id, partnership_dto.project_id, db ): - return { - "Error": "User is not a manager of the project", - "SubCode": "UserPermissionError", - }, 401 + return JSONResponse( + content={ + "Error": "User is not a manager of the project", + "SubCode": "UserPermissionError", + }, + status_code=401, + ) -class ProjectPartnershipsRestApi(Resource): - @staticmethod - def get(partnership_id: int): - """ - Retrieves a Partnership by id - --- - tags: - - projects - - partners - - partnerships - produces: - - application/json - parameters: - - name: partnership_id - in: path - description: Unique partnership ID - required: true - type: integer - default: 1 - responses: - 200: - description: Partnership found - 404: - description: Partnership not found - 500: - description: Internal Server Error - """ +@router.get("/partnerships/{partnership_id}/") +async def retrieve_partnership( + request: Request, + partnership_id: int, + db: Database = Depends(get_db), +): + """ + Retrieves a Partnership by id + --- + tags: + - projects + - partners + - partnerships + produces: + - application/json + parameters: + - name: partnership_id + in: path + description: Unique partnership ID + required: true + type: integer + default: 1 + responses: + 200: + description: Partnership found + 404: + description: Partnership not found + 500: + description: Internal Server Error + """ - partnership_dto = ProjectPartnershipService.get_partnership_as_dto( - partnership_id - ) - return partnership_dto.to_primitive(), 200 + partnership_dto = await ProjectPartnershipService.get_partnership_as_dto( + partnership_id, db + ) + return partnership_dto - @token_auth.login_required - def post(self): - """Assign a partner to a project - --- - tags: - - projects - - partners - - partnerships - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - in: body - name: body - required: true - description: JSON object for creating a partnership - schema: - properties: - projectId: - required: true - type: int - description: Unique project ID - default: 1 - partnerId: - required: true - type: int - description: Unique partner ID - default: 1 - startedOn: - type: date - description: The timestamp when the partner is added to a project. Defaults to current time. - default: "2017-04-11T12:38:49" - endedOn: - type: date - description: The timestamp when the partner ended their work on a project. - default: "2018-04-11T12:38:49" - responses: - 201: - description: Partner project association created - 400: - description: Ivalid dates or started_on was after ended_on - 401: - description: Forbidden, if user is not a manager of this project - 403: - description: Forbidden, if user is not authenticated - 404: - description: Not found - 500: - description: Internal Server Error - """ - partnership_dto = ProjectPartnershipDTO(request.get_json()) - is_not_manager_error = check_if_manager(partnership_dto) - if is_not_manager_error is not None: - return is_not_manager_error +@router.post("/partnerships/") +async def create_partnership( + request: Request, + db: Database = Depends(get_db), + user: AuthUserDTO = Depends(login_required), +): + """Assign a partner to a project + --- + tags: + - projects + - partners + - partnerships + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - in: body + name: body + required: true + description: JSON object for creating a partnership + schema: + properties: + projectId: + required: true + type: int + description: Unique project ID + default: 1 + partnerId: + required: true + type: int + description: Unique partner ID + default: 1 + startedOn: + type: date + description: The timestamp when the partner is added to a project. Defaults to current time. + default: "2017-04-11T12:38:49" + endedOn: + type: date + description: The timestamp when the partner ended their work on a project. + default: "2018-04-11T12:38:49" + responses: + 201: + description: Partner project association created + 400: + description: Ivalid dates or started_on was after ended_on + 401: + description: Forbidden, if user is not a manager of this project + 403: + description: Forbidden, if user is not authenticated + 404: + description: Not found + 500: + description: Internal Server Error + """ + request_data = await request.json() - if partnership_dto.started_on is None: - partnership_dto.started_on = timestamp() + partnership_dto = ProjectPartnershipDTO(**request_data) + is_not_manager_error = await check_if_manager(partnership_dto, user.id, db) + if is_not_manager_error is not None: + return is_not_manager_error - partnership_dto = ProjectPartnershipDTO(request.get_json()) - partnership_id = ProjectPartnershipService.create_partnership( + if partnership_dto.started_on is None: + partnership_dto.started_on = timestamp() + + async with db.transaction(): + partnership_id = await ProjectPartnershipService.create_partnership( + db, partnership_dto.project_id, partnership_dto.partner_id, partnership_dto.started_on, partnership_dto.ended_on, ) - return ( - { + return ( + JSONResponse( + content={ "Success": "Partner {} assigned to project {}".format( partnership_dto.partner_id, partnership_dto.project_id ), "partnershipId": partnership_id, }, - 201, - ) + status_code=201, + ), + ) - @staticmethod - @token_auth.login_required - def patch(partnership_id: int): - """Update the time range for a partner project link - --- - tags: - - projects - - partners - - partnerships - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - name: partnership_id - in: path - description: Unique partnership ID - required: true - type: integer - default: 1 - - in: body - name: body - required: true - description: JSON object for creating a partnership - schema: - properties: - startedOn: - type: date - description: The timestamp when the partner is added to a project. Defaults to current time. - default: "2017-04-11T12:38:49" - endedOn: - type: date - description: The timestamp when the partner ended their work on a project. - default: "2018-04-11T12:38:49" - responses: - 201: - description: Partner project association created - 400: - description: Ivalid dates or started_on was after ended_on - 401: - description: Forbidden, if user is not a manager of this project - 403: - description: Forbidden, if user is not authenticated - 404: - description: Not found - 500: - description: Internal Server Error - """ - partnership_updates = ProjectPartnershipUpdateDTO(request.get_json()) - partnership_dto = ProjectPartnershipService.get_partnership_as_dto( - partnership_id - ) - is_not_manager_error = check_if_manager(partnership_dto) - if is_not_manager_error is not None: - return is_not_manager_error +@router.patch("/partnerships/{partnership_id}/") +async def patch_partnership( + request: Request, + partnership_id: int, + db: Database = Depends(get_db), + user: AuthUserDTO = Depends(login_required), +): + """Update the time range for a partner project link + --- + tags: + - projects + - partners + - partnerships + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - name: partnership_id + in: path + description: Unique partnership ID + required: true + type: integer + default: 1 + - in: body + name: body + required: true + description: JSON object for creating a partnership + schema: + properties: + startedOn: + type: date + description: The timestamp when the partner is added to a project. Defaults to current time. + default: "2017-04-11T12:38:49" + endedOn: + type: date + description: The timestamp when the partner ended their work on a project. + default: "2018-04-11T12:38:49" + responses: + 201: + description: Partner project association created + 400: + description: Ivalid dates or started_on was after ended_on + 401: + description: Forbidden, if user is not a manager of this project + 403: + description: Forbidden, if user is not authenticated + 404: + description: Not found + 500: + description: Internal Server Error + """ + request_data = await request.json() + partnership_updates = ProjectPartnershipUpdateDTO(**request_data) + partnership_dto = await ProjectPartnershipService.get_partnership_as_dto( + partnership_id, db + ) - partnership = ProjectPartnershipService.update_partnership_time_range( + is_not_manager_error = await check_if_manager(partnership_dto, user.id, db) + if is_not_manager_error is not None: + return is_not_manager_error + + async with db.transaction(): + partnership = await ProjectPartnershipService.update_partnership_time_range( + db, partnership_id, partnership_updates.started_on, partnership_updates.ended_on, ) - - return ( - { + return ( + JSONResponse( + content={ "Success": "Updated time range. startedOn: {}, endedOn: {}".format( partnership.started_on, partnership.ended_on ), "startedOn": f"{partnership.started_on}", "endedOn": f"{partnership.ended_on}", }, - 200, - ) - - @staticmethod - @token_auth.login_required - def delete(partnership_id: int): - """Deletes a link between a project and a partner - --- - tags: - - projects - - partners - - partnerships - produces: - - application/json - parameters: - - in: header - name: Authorization - description: Base64 encoded session token - required: true - type: string - default: Token sessionTokenHere== - - name: partnership_id - in: path - description: Unique partnership ID - required: true - type: integer - default: 1 - responses: - 201: - description: Partner project association created - 401: - description: Forbidden, if user is not a manager of this project - 403: - description: Forbidden, if user is not authenticated - 404: - description: Not found - 500: - description: Internal Server Error - """ - partnership_dto = ProjectPartnershipService.get_partnership_as_dto( - partnership_id - ) + status_code=200, + ), + ) - is_not_manager_error = check_if_manager(partnership_dto) - if is_not_manager_error is not None: - return is_not_manager_error - ProjectPartnershipService.delete_partnership(partnership_id) - return ( - { +@router.delete("/partnerships/{partnership_id}/") +async def delete_partnership( + request: Request, + partnership_id: int, + db: Database = Depends(get_db), + user: AuthUserDTO = Depends(login_required), +): + """Deletes a link between a project and a partner + --- + tags: + - projects + - partners + - partnerships + produces: + - application/json + parameters: + - in: header + name: Authorization + description: Base64 encoded session token + required: true + type: string + default: Token sessionTokenHere== + - name: partnership_id + in: path + description: Unique partnership ID + required: true + type: integer + default: 1 + responses: + 201: + description: Partner project association created + 401: + description: Forbidden, if user is not a manager of this project + 403: + description: Forbidden, if user is not authenticated + 404: + description: Not found + 500: + description: Internal Server Error + """ + partnership_dto = await ProjectPartnershipService.get_partnership_as_dto( + partnership_id, db + ) + is_not_manager_error = await check_if_manager(partnership_dto, user.id, db) + if is_not_manager_error is not None: + return is_not_manager_error + async with db.transaction(): + await ProjectPartnershipService.delete_partnership(partnership_id, db) + return ( + JSONResponse( + content={ "Success": "Partnership ID {} deleted".format(partnership_id), }, - 200, - ) + status_code=200, + ), + ) -class PartnersByProjectAPI(Resource): - @staticmethod - def get(project_id: int): - """ - Retrieves the list of partners associated with a project - --- - tags: - - projects - - partners - - partnerships - produces: - - application/json - parameters: - - name: project_id - in: path - description: Unique project ID - required: true - type: integer - default: 1 - responses: - 200: - description: List (possibly empty) of partners associated with this project_id - 500: - description: Internal Server Error - """ - partnerships = ProjectPartnershipService.get_partnerships_by_project(project_id) - return {"partnerships": partnerships}, 200 +@router.get("/{project_id}/partners/") +async def get_partners( + request: Request, + project_id: int, + db: Database = Depends(get_db), +): + """ + Retrieves the list of partners associated with a project + --- + tags: + - projects + - partners + - partnerships + produces: + - application/json + parameters: + - name: project_id + in: path + description: Unique project ID + required: true + type: integer + default: 1 + responses: + 200: + description: List (possibly empty) of partners associated with this project_id + 500: + description: Internal Server Error + """ + partnerships = await ProjectPartnershipService.get_partnerships_by_project( + project_id, db + ) + return {"partnerships": partnerships} diff --git a/backend/api/projects/resources.py b/backend/api/projects/resources.py index f51d407f8e..0949795320 100644 --- a/backend/api/projects/resources.py +++ b/backend/api/projects/resources.py @@ -549,7 +549,12 @@ def setup_search_dto(request) -> ProjectSearchDTO: # See https://github.com/hotosm/tasking-manager/pull/922 for more info try: - authenticated_user_id = request.user.display_name if request.user else None + authenticated_user_id = ( + request.user.display_name + if request.user and request.user.display_name + else None + ) + if request.query_params.get("createdByMe") == "true": search_dto.created_by = authenticated_user_id @@ -798,7 +803,11 @@ async def get( 500: description: Internal Server Error """ - authenticated_user_id = request.user.display_name if request.user else None + authenticated_user_id = ( + request.user.display_name + if request.user and request.user.display_name + else None + ) orgs_dto = await OrganisationService.get_organisations_managed_by_user_as_dto( authenticated_user_id, db ) @@ -889,7 +898,11 @@ async def get( 500: description: Internal Server Error """ - authenticated_user_id = request.user.display_name if request.user else None + authenticated_user_id = ( + request.user.display_name + if request.user and request.user.display_name + else None + ) orgs_dto = await OrganisationService.get_organisations_managed_by_user_as_dto( authenticated_user_id, db ) @@ -1255,7 +1268,11 @@ async def get(request: Request, project_id: int, db: Database = Depends(get_db)) 500: description: Internal Server Error """ - authenticated_user_id = request.user.display_name if request.user else None + authenticated_user_id = ( + request.user.display_name + if request.user and request.user.display_name + else None + ) limit = int(request.query_params.get("limit", 4)) preferred_locale = request.headers.get("accept-language", "en") projects_dto = await ProjectRecommendationService.get_similar_projects( diff --git a/backend/models/dtos/partner_dto.py b/backend/models/dtos/partner_dto.py index bf9b2087df..b296b38690 100644 --- a/backend/models/dtos/partner_dto.py +++ b/backend/models/dtos/partner_dto.py @@ -1,19 +1,30 @@ -from schematics import Model -from schematics.types import StringType, ListType, LongType +# from schematics import Model +# from schematics.types import StringType, ListType, LongType +import json +from typing import Dict, List, Optional +from pydantic import BaseModel, HttpUrl -class PartnerDTO(Model): + +class PartnerDTO(BaseModel): """DTO for Partner""" - id = LongType() - name = StringType(serialized_name="name") - primary_hashtag = StringType(serialized_name="primary_hashtag") - secondary_hashtag = StringType(serialized_name="secondary_hashtag") - link_x = StringType(serialized_name="link_x") - link_meta = StringType(serialized_name="link_meta") - link_instagram = StringType(serialized_name="link_instagram") - logo_url = StringType(serialized_name="logo_url") - current_projects = StringType(serialized_name="current_projects") - permalink = StringType(serialized_name="permalink") - website_links = ListType(StringType, serialized_name="website_links") - mapswipe_group_id = StringType() + id: Optional[int] = None + name: str + primary_hashtag: str + secondary_hashtag: Optional[str] = None + link_x: Optional[str] = None + link_meta: Optional[str] = None + link_instagram: Optional[str] = None + logo_url: Optional[HttpUrl] = None # Ensures it's a valid URL + current_projects: Optional[str] = None + permalink: Optional[str] = None + website_links: Optional[List[Dict]] = None + mapswipe_group_id: Optional[str] = None + + @classmethod + def from_record(cls, record): + record_dict = dict(record) + if record_dict.get("website_links"): + record_dict["website_links"] = json.loads(record_dict["website_links"]) + return cls(**record_dict) diff --git a/backend/models/dtos/partner_stats_dto.py b/backend/models/dtos/partner_stats_dto.py index 59b75430c2..20bba2179e 100644 --- a/backend/models/dtos/partner_stats_dto.py +++ b/backend/models/dtos/partner_stats_dto.py @@ -1,98 +1,122 @@ +from typing import List, Optional + import pandas as pd -from schematics import Model -from schematics.types import ( - StringType, - LongType, - IntType, - ListType, - ModelType, - FloatType, - BooleanType, -) +from pydantic import BaseModel, Field + + +class UserGroupMemberDTO(BaseModel): + """Describes a JSON model for a user group member.""" + + id: Optional[str] = None + user_id: Optional[str] = Field(None, alias="userId") + username: Optional[str] = None + is_active: Optional[bool] = Field(None, alias="isActive") + total_mapping_projects: Optional[int] = Field(None, alias="totalMappingProjects") + total_contribution_time: Optional[int] = Field(None, alias="totalcontributionTime") + total_contributions: Optional[int] = Field(None, alias="totalcontributions") + + class Config: + populate_by_name = True + + +class OrganizationContributionsDTO(BaseModel): + """Describes a JSON model for organization contributions.""" + + organization_name: Optional[str] = Field(None, alias="organizationName") + total_contributions: Optional[int] = Field(None, alias="totalcontributions") + class Config: + populate_by_name = True -class UserGroupMemberDTO(Model): - id = StringType() - user_id = StringType(serialized_name="userId") - username = StringType() - is_active = BooleanType(serialized_name="isActive") - total_mapping_projects = IntType(serialized_name="totalMappingProjects") - total_contribution_time = IntType(serialized_name="totalcontributionTime") - total_contributions = IntType(serialized_name="totalcontributions") +class UserContributionsDTO(BaseModel): + """Describes a JSON model for user contributions.""" -class OrganizationContributionsDTO(Model): - organization_name = StringType(serialized_name="organizationName") - total_contributions = IntType(serialized_name="totalcontributions") + total_mapping_projects: Optional[int] = Field(None, alias="totalMappingProjects") + total_contribution_time: Optional[int] = Field(None, alias="totalcontributionTime") + total_contributions: Optional[int] = Field(None, alias="totalcontributions") + username: Optional[str] = None + user_id: Optional[str] = Field(None, alias="userId") + class Config: + populate_by_name = True -class UserContributionsDTO(Model): - total_mapping_projects = IntType(serialized_name="totalMappingProjects") - total_contribution_time = IntType(serialized_name="totalcontributionTime") - total_contributions = IntType(serialized_name="totalcontributions") - username = StringType() - user_id = StringType(serialized_name="userId") +class GeojsonDTO(BaseModel): + type: Optional[str] = None + coordinates: Optional[List[float]] = None -class GeojsonDTO(Model): - type = StringType() - coordinates = ListType(FloatType) +class GeoContributionsDTO(BaseModel): + geojson: Optional[GeojsonDTO] = None + total_contributions: Optional[int] = Field(None, alias="totalcontributions") -class GeoContributionsDTO(Model): - geojson = ModelType(GeojsonDTO) - total_contributions = IntType(serialized_name="totalcontributions") + class Config: + populate_by_name = True -class ContributionsByDateDTO(Model): - task_date = StringType(serialized_name="taskDate") - total_contributions = IntType(serialized_name="totalcontributions") +class ContributionsByDateDTO(BaseModel): + task_date: str = Field(None, alias="taskDate") + total_contributions: int = Field(None, alias="totalcontributions") -class ContributionTimeByDateDTO(Model): - date = StringType(serialized_name="date") - total_contribution_time = IntType(serialized_name="totalcontributionTime") +class ContributionTimeByDateDTO(BaseModel): + date: str = Field(None, alias="date") + total_contribution_time: int = Field(None, alias="totalcontributionTime") + class Config: + populate_by_name = True -class ContributionsByProjectTypeDTO(Model): - project_type = StringType(serialized_name="projectType") - project_type_display = StringType(serialized_name="projectTypeDisplay") - total_contributions = IntType(serialized_name="totalcontributions") +class ContributionsByProjectTypeDTO(BaseModel): + project_type: str = Field(None, alias="projectType") + project_type_display: str = Field(None, alias="projectTypeDisplay") + total_contributions: int = Field(None, alias="totalcontributions") -class AreaSwipedByProjectTypeDTO(Model): - total_area = FloatType(serialized_name="totalArea") - project_type = StringType(serialized_name="projectType") - project_type_display = StringType(serialized_name="projectTypeDisplay") + class Config: + populate_by_name = True -class GroupedPartnerStatsDTO(Model): +class AreaSwipedByProjectTypeDTO(BaseModel): + total_area: Optional[float] = Field(None, alias="totalArea") + project_type: str = Field(None, alias="projectType") + project_type_display: str = Field(None, alias="projectTypeDisplay") + + class Config: + populate_by_name = True + + +class GroupedPartnerStatsDTO(BaseModel): """General statistics of a partner and its members.""" - id = LongType() - provider = StringType() - id_inside_provider = StringType(serialized_name="idInsideProvider") - name_inside_provider = StringType(serialized_name="nameInsideProvider") - description_inside_provider = StringType( - serialized_name="descriptionInsideProvider" + id: Optional[int] = None + provider: str + id_inside_provider: Optional[str] = Field(None, alias="idInsideProvider") + name_inside_provider: Optional[str] = Field(None, alias="nameInsideProvider") + description_inside_provider: Optional[str] = Field( + None, alias="descriptionInsideProvider" ) - members_count = IntType(serialized_name="membersCount") - members = ListType(ModelType(UserGroupMemberDTO)) + members_count: Optional[int] = Field(None, alias="membersCount") + members: List[UserGroupMemberDTO] = None # General stats of partner - total_contributors = IntType(serialized_name="totalContributors") - total_contributions = IntType(serialized_name="totalcontributions") - total_contribution_time = IntType(serialized_name="totalcontributionTime") + total_contributors: Optional[int] = Field(None, alias="totalContributors") + total_contributions: Optional[int] = Field(None, alias="totalcontributions") + total_contribution_time: Optional[int] = Field(None, alias="totalcontributionTime") # Recent contributions during the last 1 month - total_recent_contributors = IntType(serialized_name="totalRecentContributors") - total_recent_contributions = IntType(serialized_name="totalRecentcontributions") - total_recent_contribution_time = IntType( - serialized_name="totalRecentcontributionTime" + total_recent_contributors: Optional[int] = Field( + None, alias="totalRecentContributors" + ) + total_recent_contributions: Optional[int] = Field( + None, alias="totalRecentcontributions" + ) + total_recent_contribution_time: Optional[int] = Field( + None, alias="totalRecentcontributionTime" ) def to_csv(self): - df = pd.json_normalize(self.to_primitive()["members"]) + df = pd.json_normalize(self.dict(by_alias=True)["members"]) df.drop( columns=["id"], @@ -109,37 +133,40 @@ def to_csv(self): return df.to_csv(index=False) + class Config: + populate_by_name = True + -class FilteredPartnerStatsDTO(Model): +class FilteredPartnerStatsDTO(BaseModel): """Statistics of a partner contributions filtered by time range.""" - id = LongType() - provider = StringType() - id_inside_provider = StringType(serialized_name="idInsideProvider") + id: Optional[int] = None + provider: str + id_inside_provider: Optional[str] = Field(None, alias="idInsideProvider") - from_date = StringType(serialized_name="fromDate") - to_date = StringType(serialized_name="toDate") - contributions_by_user = ListType( - ModelType(UserContributionsDTO), serialized_name="contributionsByUser" + from_date: Optional[str] = Field(None, alias="fromDate") + to_date: Optional[str] = Field(None, alias="toDate") + contributions_by_user: List[UserContributionsDTO] = Field( + [], alias="contributionsByUser" ) - contributions_by_geo = ListType( - ModelType(GeoContributionsDTO), serialized_name="contributionsByGeo" + contributions_by_geo: List[GeoContributionsDTO] = Field( + [], alias="contributionsByGeo" ) - area_swiped_by_project_type = ListType( - ModelType(AreaSwipedByProjectTypeDTO), serialized_name="areaSwipedByProjectType" + area_swiped_by_project_type: List[AreaSwipedByProjectTypeDTO] = Field( + [], alias="areaSwipedByProjectType" ) - - contributions_by_project_type = ListType( - ModelType(ContributionsByProjectTypeDTO), - serialized_name="contributionsByProjectType", + contributions_by_project_type: List[ContributionsByProjectTypeDTO] = Field( + [], alias="contributionsByProjectType" ) - contributions_by_date = ListType( - ModelType(ContributionsByDateDTO), serialized_name="contributionsByDate" + contributions_by_date: List[ContributionsByDateDTO] = Field( + [], alias="contributionsByDate" ) - contributions_by_organization_name = ListType( - ModelType(OrganizationContributionsDTO), - serialized_name="contributionsByorganizationName", + contributions_by_organization_name: List[OrganizationContributionsDTO] = Field( + [], alias="contributionsByorganizationName" ) - contribution_time_by_date = ListType( - ModelType(ContributionTimeByDateDTO), serialized_name="contributionTimeByDate" + contribution_time_by_date: List[ContributionTimeByDateDTO] = Field( + [], alias="contributionTimeByDate" ) + + class Config: + populate_by_name = True diff --git a/backend/models/dtos/project_partner_dto.py b/backend/models/dtos/project_partner_dto.py index 3b068be389..8b1d202d56 100644 --- a/backend/models/dtos/project_partner_dto.py +++ b/backend/models/dtos/project_partner_dto.py @@ -1,6 +1,6 @@ -from schematics import Model -from schematics.types import LongType, UTCDateTimeType, StringType -from schematics.exceptions import ValidationError +from pydantic import BaseModel, Field +from datetime import datetime +from typing import Optional from enum import Enum @@ -20,45 +20,86 @@ def is_known_action(value): ) -class ProjectPartnershipDTO(Model): +# class ProjectPartnershipDTO(Model): +# """DTO for the link between a Partner and a Project""" + +# id = LongType(required=True) +# project_id = LongType(required=True, serialized_name="projectId") +# partner_id = LongType(required=True, serialized_name="partnerId") +# started_on = UTCDateTimeType(required=True, serialized_name="startedOn") +# ended_on = UTCDateTimeType(serialized_name="endedOn") + + +class ProjectPartnershipDTO(BaseModel): """DTO for the link between a Partner and a Project""" - id = LongType(required=True) - project_id = LongType(required=True, serialized_name="projectId") - partner_id = LongType(required=True, serialized_name="partnerId") - started_on = UTCDateTimeType(required=True, serialized_name="startedOn") - ended_on = UTCDateTimeType(serialized_name="endedOn") + id: Optional[int] = None + project_id: int = Field(..., alias="projectId") + partner_id: int = Field(..., alias="partnerId") + started_on: datetime = Field(..., alias="startedOn") + ended_on: Optional[datetime] = Field(None, alias="endedOn") + class Config: + populate_by_name = True + json_encoders = {datetime: lambda v: v.isoformat() + "Z" if v else None} -class ProjectPartnershipUpdateDTO(Model): - """DTO for updating the time range of the link between a Partner and a Project""" - started_on = UTCDateTimeType(serialized_name="startedOn") - ended_on = UTCDateTimeType(serialized_name="endedOn") +# class ProjectPartnershipUpdateDTO(Model): +# """DTO for updating the time range of the link between a Partner and a Project""" +# started_on = UTCDateTimeType(serialized_name="startedOn") +# ended_on = UTCDateTimeType(serialized_name="endedOn") -class ProjectPartnershipHistoryDTO(Model): - """DTO for Logs of changes to all Project-Partner links""" - id = LongType(required=True) - partnership_id = LongType(required=True, serialized_name="partnershipId") - project_id = LongType(required=True, serialized_name="projectId") - partner_id = LongType(required=True, serialized_name="partnerId") - started_on_old = UTCDateTimeType( - serialized_name="startedOnOld", serialize_when_none=False - ) - ended_on_old = UTCDateTimeType( - serialized_name="endedOnOld", serialize_when_none=False - ) - started_on_new = UTCDateTimeType( - serialized_name="startedOnNew", serialize_when_none=False - ) - ended_on_new = UTCDateTimeType( - serialized_name="endedOnNew", serialize_when_none=False - ) +class ProjectPartnershipUpdateDTO(BaseModel): + """DTO for updating the time range of the link between a Partner and a Project""" + + started_on: Optional[datetime] = Field(None, alias="startedOn") + ended_on: Optional[datetime] = Field(None, alias="endedOn") + + class Config: + populate_by_name = True + + +# class ProjectPartnershipHistoryDTO(Model): +# """DTO for Logs of changes to all Project-Partner links""" + +# id = LongType(required=True) +# partnership_id = LongType(required=True, serialized_name="partnershipId") +# project_id = LongType(required=True, serialized_name="projectId") +# partner_id = LongType(required=True, serialized_name="partnerId") +# started_on_old = UTCDateTimeType( +# serialized_name="startedOnOld", serialize_when_none=False +# ) +# ended_on_old = UTCDateTimeType( +# serialized_name="endedOnOld", serialize_when_none=False +# ) +# started_on_new = UTCDateTimeType( +# serialized_name="startedOnNew", serialize_when_none=False +# ) +# ended_on_new = UTCDateTimeType( +# serialized_name="endedOnNew", serialize_when_none=False +# ) + + +# action = StringType(validators=[is_known_action]) +# actionDate = UTCDateTimeType(serialized_name="actionDate") +class ProjectPartnershipHistoryDTO(BaseModel): + """DTO for Logs of changes to all Project-Partner links""" - action = StringType(validators=[is_known_action]) - actionDate = UTCDateTimeType(serialized_name="actionDate") + id: int + partnership_id: int = Field(..., alias="partnershipId") + project_id: int = Field(..., alias="projectId") + partner_id: int = Field(..., alias="partnerId") + started_on_old: Optional[datetime] = Field(None, alias="startedOnOld") + ended_on_old: Optional[datetime] = Field(None, alias="endedOnOld") + started_on_new: Optional[datetime] = Field(None, alias="startedOnNew") + ended_on_new: Optional[datetime] = Field(None, alias="endedOnNew") + action: str + action_date: Optional[datetime] = Field(None, alias="actionDate") + + class Config: + populate_by_name = True class ProjectPartnerAction(Enum): diff --git a/backend/models/postgis/partner.py b/backend/models/postgis/partner.py index 8b595e0e36..35d7162390 100644 --- a/backend/models/postgis/partner.py +++ b/backend/models/postgis/partner.py @@ -1,27 +1,32 @@ import json +from databases import Database +from sqlalchemy import Column, Integer, String + from backend import db +from backend.db import Base from backend.exceptions import NotFound from backend.models.dtos.partner_dto import PartnerDTO +from typing import Optional -class Partner(db.Model): - """Model for Partners""" +class Partner(Base): + """Describes a Partner""" __tablename__ = "partners" - id = db.Column(db.Integer, primary_key=True, autoincrement=True) - name = db.Column(db.String(150), nullable=False, unique=True) - primary_hashtag = db.Column(db.String(200), nullable=False) - secondary_hashtag = db.Column(db.String(200)) - logo_url = db.Column(db.String(500)) - link_meta = db.Column(db.String(300)) - link_x = db.Column(db.String(300)) - link_instagram = db.Column(db.String(300)) - current_projects = db.Column(db.String) - permalink = db.Column(db.String(500), unique=True) - website_links = db.Column(db.String) - mapswipe_group_id = db.Column(db.String, nullable=True) + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(150), nullable=False, unique=True) + primary_hashtag = Column(String(200), nullable=False) + secondary_hashtag = Column(String(200), nullable=True) + logo_url = Column(String(500), nullable=True) + link_meta = Column(String(300), nullable=True) + link_x = Column(String(300), nullable=True) # Formerly link_twitter + link_instagram = Column(String(300), nullable=True) + current_projects = Column(String, nullable=True) + permalink = Column(String(500), unique=True, nullable=True) + website_links = Column(String, nullable=True) + mapswipe_group_id = Column(String, nullable=True) def create(self): """Creates and saves the current model to the DB""" @@ -38,22 +43,30 @@ def delete(self): db.session.commit() @staticmethod - def get_all_partners(): - """Get all partners in DB""" - return db.session.query(Partner.id).all() + async def get_all_partners(db: Database): + """ + Retrieve all partner IDs + """ + query = "SELECT id FROM partners" + results = await db.fetch_all(query) + return [row["id"] for row in results] @staticmethod - def get_by_permalink(permalink: str): - """Get partner by permalink""" - return Partner.query.filter_by(permalink=permalink).one_or_none() + async def get_by_permalink(permalink: str, db: Database) -> Optional[PartnerDTO]: + """Get partner by permalink using raw SQL.""" + query = "SELECT * FROM partners WHERE permalink = :permalink" + result = await db.fetch_one(query, values={"permalink": permalink}) + if result is None: + raise NotFound(sub_code="PARTNER_NOT_FOUND", permalink=permalink) + return result @staticmethod - def get_by_id(partner_id: int): - """Get partner by id""" - partner = db.session.get(Partner, partner_id) - if partner is None: + async def get_by_id(partner_id: int, db: Database) -> PartnerDTO: + query = "SELECT * FROM partners WHERE id = :partner_id" + result = await db.fetch_one(query, values={"partner_id": partner_id}) + if result is None: raise NotFound(sub_code="PARTNER_NOT_FOUND", partner_id=partner_id) - return partner + return result def as_dto(self) -> PartnerDTO: """Creates partner from DTO""" diff --git a/backend/models/postgis/project.py b/backend/models/postgis/project.py index 79822ca8ab..d935aa6324 100644 --- a/backend/models/postgis/project.py +++ b/backend/models/postgis/project.py @@ -15,7 +15,6 @@ from sqlalchemy.dialects.postgresql import ARRAY from sqlalchemy.ext.hybrid import hybrid_property -import requests from sqlalchemy import ( BigInteger, @@ -34,7 +33,6 @@ select, update, ) -from sqlalchemy.dialects.postgresql import ARRAY from sqlalchemy.orm import backref, relationship from backend.config import settings @@ -269,7 +267,7 @@ def percent_validated(self): interests = orm.relationship( Interest, secondary=project_interests, backref="projects" ) - partnerships = db.relationship("ProjectPartnership", backref="project") + partnerships = orm.relationship("ProjectPartnership", backref="project") def create_draft_project(self, draft_project_dto: DraftProjectDTO): """ diff --git a/backend/models/postgis/project_partner.py b/backend/models/postgis/project_partner.py index b8f8726179..a77ecfa70c 100644 --- a/backend/models/postgis/project_partner.py +++ b/backend/models/postgis/project_partner.py @@ -1,47 +1,109 @@ -from backend import db -from backend.models.postgis.utils import timestamp +from datetime import datetime, timezone +from databases import Database +from sqlalchemy import Column, DateTime, ForeignKey, Integer + +from backend import db +from backend.db import Base from backend.models.dtos.project_partner_dto import ( - ProjectPartnershipDTO, ProjectPartnerAction, + ProjectPartnershipDTO, ) +from backend.models.postgis.utils import timestamp -class ProjectPartnershipHistory(db.Model): +class ProjectPartnershipHistory(Base): + """Logs changes to the Project-Partnership links""" + __tablename__ = "project_partnerships_history" - id = db.Column(db.Integer, primary_key=True, autoincrement=True) - partnership_id = db.Column( - db.Integer, - db.ForeignKey("project_partnerships.id", ondelete="SET NULL"), + id = Column(Integer, primary_key=True, autoincrement=True) + partnership_id = Column( + Integer, + ForeignKey("project_partnerships.id", ondelete="SET NULL"), nullable=True, index=True, ) - project_id = db.Column( - db.Integer, - db.ForeignKey("projects.id", ondelete="CASCADE"), + project_id = Column( + Integer, + ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True, ) - partner_id = db.Column( - db.Integer, - db.ForeignKey("partners.id", ondelete="CASCADE"), + partner_id = Column( + Integer, + ForeignKey("partners.id", ondelete="CASCADE"), nullable=False, index=True, ) - action = db.Column(db.Integer, default=ProjectPartnerAction.CREATE.value) - action_date = db.Column(db.DateTime, nullable=False, default=timestamp) - - started_on_old = db.Column(db.DateTime) - ended_on_old = db.Column(db.DateTime) - started_on_new = db.Column(db.DateTime) - ended_on_new = db.Column(db.DateTime) - - def create(self): - """Creates and saves the current model to the DB""" - db.session.add(self) - db.session.commit() + action = Column(Integer, nullable=False, default=ProjectPartnerAction.CREATE.value) + action_date = Column(DateTime, nullable=False, default=timestamp) + + started_on_old = Column(DateTime, nullable=True) + ended_on_old = Column(DateTime, nullable=True) + started_on_new = Column(DateTime, nullable=True) + ended_on_new = Column(DateTime, nullable=True) + + def convert_to_utc_naive(self, dt: datetime) -> datetime: + """Converts a timezone-aware datetime to a UTC timezone-naive datetime.""" + if dt.tzinfo is not None: + # return dt.astimezone(datetime.timezone.utc).replace(tzinfo=None) + return dt.astimezone(timezone.utc).replace(tzinfo=None) + return dt + + async def create(self, db: Database) -> int: + """ + Inserts the current object as a record in the database and returns its ID. + """ + + if self.started_on_old: + self.started_on_old = self.convert_to_utc_naive(self.started_on_old) + if self.ended_on_old: + self.ended_on_old = self.convert_to_utc_naive(self.ended_on_old) + if self.started_on_new: + self.started_on_new = self.convert_to_utc_naive(self.started_on_new) + if self.ended_on_new: + self.ended_on_new = self.convert_to_utc_naive(self.ended_on_new) + + query = """ + INSERT INTO project_partnerships_history ( + partnership_id, + project_id, + partner_id, + action, + action_date, + started_on_old, + ended_on_old, + started_on_new, + ended_on_new + ) + VALUES ( + :partnership_id, + :project_id, + :partner_id, + :action, + :action_date, + :started_on_old, + :ended_on_old, + :started_on_new, + :ended_on_new + ) + RETURNING id + """ + values = { + "partnership_id": self.partnership_id, + "project_id": self.project_id, + "partner_id": self.partner_id, + "action": self.action if self.action else ProjectPartnerAction.CREATE.value, + "action_date": timestamp(), + "started_on_old": self.started_on_old if self.started_on_old else None, + "ended_on_old": self.ended_on_old if self.ended_on_old else None, + "started_on_new": self.started_on_new if self.started_on_new else None, + "ended_on_new": self.ended_on_new if self.ended_on_new else None, + } + result = await db.fetch_one(query, values=values) + return result["id"] def save(self): """Save changes to db""" @@ -53,34 +115,98 @@ def delete(self): db.session.commit() -class ProjectPartnership(db.Model): +class ProjectPartnership(Base): + """Describes the relationship between a Project and a Partner""" + __tablename__ = "project_partnerships" - id = db.Column(db.Integer, primary_key=True, autoincrement=True) - project_id = db.Column(db.Integer, db.ForeignKey("projects.id", ondelete="CASCADE")) - partner_id = db.Column(db.Integer, db.ForeignKey("partners.id", ondelete="CASCADE")) - started_on = db.Column(db.DateTime, default=timestamp, nullable=False) - ended_on = db.Column(db.DateTime, nullable=True) + id = Column(Integer, primary_key=True, autoincrement=True) + project_id = Column( + Integer, ForeignKey("projects.id", ondelete="CASCADE"), nullable=False + ) + partner_id = Column( + Integer, ForeignKey("partners.id", ondelete="CASCADE"), nullable=False + ) + started_on = Column(DateTime, nullable=False, default=timestamp) + ended_on = Column(DateTime, nullable=True) + + def convert_to_utc_naive(self, dt: datetime) -> datetime: + """Converts a timezone-aware datetime to a UTC timezone-naive datetime.""" + if dt.tzinfo is not None: + return dt.astimezone(timezone.utc).replace(tzinfo=None) + return dt @staticmethod - def get_by_id(partnership_id: int): + async def get_by_id(partnership_id: int, db: Database): """Return the user for the specified id, or None if not found""" - return db.session.get(ProjectPartnership, partnership_id) - - def create(self): - """Creates and saves the current model to the DB""" - db.session.add(self) - db.session.commit() - return self.id - - 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() + query = """ + SELECT * + FROM project_partnerships + WHERE id = :partnership_id + """ + result = await db.fetch_one(query, values={"partnership_id": partnership_id}) + return result if result else None + + async def create(self, db: Database) -> int: + """ + Inserts the current object as a record in the database and returns its ID. + """ + + self.started_on = self.convert_to_utc_naive(self.started_on) + self.ended_on = ( + self.convert_to_utc_naive(self.ended_on) if self.ended_on else None + ) + + query = """ + INSERT INTO project_partnerships (project_id, partner_id, started_on, ended_on) + VALUES (:project_id, :partner_id, :started_on, :ended_on) + RETURNING id + """ + values = { + "project_id": self.project_id, + "partner_id": self.partner_id, + "started_on": self.started_on, + "ended_on": self.ended_on if self.ended_on else None, + } + result = await db.fetch_one(query, values=values) + return result["id"] + + async def save(self, db: Database) -> None: + """ + Updates the current object in the database. + """ + self.started_on = self.convert_to_utc_naive(self.started_on) + self.ended_on = ( + self.convert_to_utc_naive(self.ended_on) if self.ended_on else None + ) + + query = """ + UPDATE project_partnerships + SET + project_id = :project_id, + partner_id = :partner_id, + started_on = :started_on, + ended_on = :ended_on + WHERE id = :id + """ + values = { + "id": self.id, + "project_id": self.project_id, + "partner_id": self.partner_id, + "started_on": self.started_on, + "ended_on": self.ended_on if self.ended_on else None, + } + await db.execute(query, values=values) + + async def delete(self, db: Database) -> None: + """ + Deletes the current object from the database. + """ + query = """ + DELETE FROM project_partnerships + WHERE id = :id + """ + await db.execute(query, values={"id": self.id}) def as_dto(self) -> ProjectPartnershipDTO: """Creates a Partnership DTO""" diff --git a/backend/routes.py b/backend/routes.py index 7fd97cd3ff..c8a5fc3bca 100644 --- a/backend/routes.py +++ b/backend/routes.py @@ -8,6 +8,7 @@ campaigns as project_campaigns, actions as project_actions, favorites as project_favorites, + partnerships as project_partnerships, ) from backend.api.comments import resources as comment_resources @@ -48,11 +49,17 @@ authentication as system_authentication, image_upload as system_image_upload, ) + from backend.api.notifications import ( resources as notification_resources, actions as notification_actions, ) +from backend.api.partners import ( + resources as partners_resources, + statistics as partners_statistics, +) + v2 = APIRouter(prefix="/api/v2") @@ -65,6 +72,7 @@ def add_api_end_points(api): v2.include_router(project_campaigns.router) v2.include_router(project_actions.router) v2.include_router(project_favorites.router) + v2.include_router(project_partnerships.router) # Comments REST endpoint v2.include_router(comment_resources.router) @@ -120,5 +128,7 @@ def add_api_end_points(api): # Issues REST endpoint v2.include_router(issue_resources.router) + v2.include_router(partners_resources.router) + v2.include_router(partners_statistics.router) api.include_router(v2) diff --git a/backend/services/mapswipe_service.py b/backend/services/mapswipe_service.py index b41552c3df..12249f2438 100644 --- a/backend/services/mapswipe_service.py +++ b/backend/services/mapswipe_service.py @@ -1,20 +1,22 @@ import json + +import requests +from cachetools import TTLCache, cached + from backend.exceptions import Conflict from backend.models.dtos.partner_stats_dto import ( - GroupedPartnerStatsDTO, - FilteredPartnerStatsDTO, - UserGroupMemberDTO, - UserContributionsDTO, - GeojsonDTO, - GeoContributionsDTO, AreaSwipedByProjectTypeDTO, ContributionsByDateDTO, - ContributionTimeByDateDTO, ContributionsByProjectTypeDTO, + ContributionTimeByDateDTO, + FilteredPartnerStatsDTO, + GeoContributionsDTO, + GeojsonDTO, + GroupedPartnerStatsDTO, OrganizationContributionsDTO, + UserContributionsDTO, + UserGroupMemberDTO, ) -from cachetools import TTLCache, cached -import requests grouped_partner_stats_cache = TTLCache(maxsize=128, ttl=60 * 60 * 24) filtered_partner_stats_cache = TTLCache(maxsize=128, ttl=60 * 60 * 24) @@ -138,9 +140,9 @@ def setup_group_dto( self, partner_id: str, group_id: str, resp_body: str ) -> GroupedPartnerStatsDTO: group_stats = json.loads(resp_body)["data"] - group_dto = GroupedPartnerStatsDTO() + print(group_stats) + group_dto = GroupedPartnerStatsDTO(provider="mapswipe") group_dto.id = partner_id - group_dto.provider = "mapswipe" group_dto.id_inside_provider = group_id if group_stats["userGroup"] is None: @@ -154,6 +156,7 @@ def setup_group_dto( group_dto.members_count = group_stats["userGroup"]["userMemberships"]["count"] group_dto.members = [] + print(group_stats["userGroup"]["userMemberships"]["items"]) for user_resp in group_stats["userGroup"]["userMemberships"]["items"]: user = UserGroupMemberDTO() user.id = user_resp["id"] @@ -194,9 +197,8 @@ def setup_filtered_dto( to_date: str, resp_body: str, ): - filtered_stats_dto = FilteredPartnerStatsDTO() + filtered_stats_dto = FilteredPartnerStatsDTO(provider="mapswipe") filtered_stats_dto.id = partner_id - filtered_stats_dto.provider = "mapswipe" filtered_stats_dto.id_inside_provider = group_id filtered_stats_dto.from_date = from_date filtered_stats_dto.to_date = to_date diff --git a/backend/services/organisation_service.py b/backend/services/organisation_service.py index d646c8b0b7..2270afc328 100644 --- a/backend/services/organisation_service.py +++ b/backend/services/organisation_service.py @@ -188,7 +188,6 @@ async def get_organisation_dto(org, user_id: int, abbreviated: bool, db): raise NotFound(sub_code="ORGANISATION_NOT_FOUND") organisation_dto = Organisation.as_dto(org, abbreviated) - if user_id != 0: organisation_dto.is_manager = ( await OrganisationService.can_user_manage_organisation( diff --git a/backend/services/partner_service.py b/backend/services/partner_service.py index cde96e6383..e1435bcd57 100644 --- a/backend/services/partner_service.py +++ b/backend/services/partner_service.py @@ -1,5 +1,10 @@ -from flask import current_app +# from flask import current_app import json + +from databases import Database +from fastapi.responses import JSONResponse +from loguru import logger + from backend.models.dtos.partner_dto import PartnerDTO from backend.models.postgis.partner import Partner @@ -8,22 +13,21 @@ class PartnerServiceError(Exception): """Custom Exception to notify callers an error occurred when handling partners""" def __init__(self, message): - if current_app: - current_app.logger.debug(message) + logger.debug(message) class PartnerService: @staticmethod - def get_partner_by_id(partner_id: int) -> Partner: - return Partner.get_by_id(partner_id) + async def get_partner_by_id(partner_id: int, db: Database): + return await Partner.get_by_id(partner_id, db) @staticmethod - def get_partner_by_permalink(permalink: str) -> Partner: - return Partner.get_by_permalink(permalink) + async def get_partner_by_permalink(permalink: str, db: Database) -> Partner: + return await Partner.get_by_permalink(permalink, db) @staticmethod - def create_partner(data): - """Create a new partner in database""" + async def create_partner(data, db: Database) -> int: + """Create a new partner in the database""" website_links = [] for i in range(1, 6): name_key = f"name_{i}" @@ -32,34 +36,54 @@ def create_partner(data): url = data.get(url_key) if name and url: website_links.append({"name": name, "url": url}) - new_partner = Partner( - name=data.get("name"), - primary_hashtag=data.get("primary_hashtag"), - secondary_hashtag=data.get("secondary_hashtag"), - logo_url=data.get("logo_url"), - link_meta=data.get("link_meta"), - link_x=data.get("link_x"), - link_instagram=data.get("link_instagram"), - current_projects=data.get("current_projects"), - permalink=data.get("permalink"), - website_links=json.dumps(website_links), - mapswipe_group_id=data.get("mapswipe_group_id"), - ) - new_partner.create() - return new_partner + + query = """ + INSERT INTO partners ( + name, primary_hashtag, secondary_hashtag, logo_url, link_meta, + link_x, link_instagram, current_projects, permalink, + website_links, mapswipe_group_id + ) VALUES ( + :name, :primary_hashtag, :secondary_hashtag, :logo_url, :link_meta, + :link_x, :link_instagram, :current_projects, :permalink, + :website_links, :mapswipe_group_id + ) RETURNING id + """ + + values = { + "name": data.get("name"), + "primary_hashtag": data.get("primary_hashtag"), + "secondary_hashtag": data.get("secondary_hashtag"), + "logo_url": data.get("logo_url"), + "link_meta": data.get("link_meta"), + "link_x": data.get("link_x"), + "link_instagram": data.get("link_instagram"), + "current_projects": data.get("current_projects"), + "permalink": data.get("permalink"), + "website_links": json.dumps(website_links), + "mapswipe_group_id": data.get("mapswipe_group_id"), + } + + new_partner_id = await db.execute(query, values) + return new_partner_id @staticmethod - def delete_partner(partner_id: int): - partner = Partner.get_by_id(partner_id) + async def delete_partner(partner_id: int, db: Database): + partner = await Partner.get_by_id(partner_id, db) if partner: - partner.delete() - return {"Success": "Team deleted"}, 200 + delete_partner_query = """ + DELETE FROM partners WHERE id = :partner_id + """ + await db.execute(delete_partner_query, {"partner_id": partner_id}) + return JSONResponse(content={"Success": "Team deleted"}, status_code=200) else: - return {"Error": "Partner cannot be deleted"}, 400 + return JSONResponse( + content={"Error": "Partner cannot be deleted"}, status_code=400 + ) @staticmethod - def update_partner(partner_id: int, data: dict) -> Partner: - partner = Partner.get_by_id(partner_id) + async def update_partner(partner_id: int, data: dict, db: Database) -> dict: + partner = await Partner.get_by_id(partner_id, db) + # Handle dynamic website links from name_* and url_* website_links = [] for key, value in data.items(): if key.startswith("name_"): @@ -67,12 +91,36 @@ def update_partner(partner_id: int, data: dict) -> Partner: url_key = f"url_{index}" if url_key in data and value.strip(): website_links.append({"name": value, "url": data[url_key]}) + + set_clauses = [] + params = {"partner_id": partner_id} + for key, value in data.items(): - if hasattr(partner, key): - setattr(partner, key, value) - partner.website_links = json.dumps(website_links) - partner.save() - return partner + # Exclude name_* and url_* fields from direct update + if key.startswith("name_") or key.startswith("url_"): + continue + set_clauses.append(f"{key} = :{key}") + params[key] = value + + if website_links: + set_clauses.append("website_links = :website_links") + params["website_links"] = json.dumps(website_links) + + set_clause = ", ".join(set_clauses) + query = f""" + UPDATE partners + SET {set_clause} + WHERE id = :partner_id + RETURNING * + """ + + updated_partner = await db.fetch_one(query, params) + if not updated_partner: + raise PartnerServiceError(f"Failed to update Partner with ID {partner_id}.") + partner_dict = dict(updated_partner) + if "website_links" in partner_dict and partner_dict["website_links"]: + partner_dict["website_links"] = json.loads(partner_dict["website_links"]) + return partner_dict @staticmethod def get_partner_dto_by_id(partner: int, request_partner: int) -> PartnerDTO: @@ -83,6 +131,6 @@ def get_partner_dto_by_id(partner: int, request_partner: int) -> PartnerDTO: return partner.as_dto() @staticmethod - def get_all_partners(): + async def get_all_partners(db: Database): """Get all partners""" - return Partner.get_all_partners() + return await Partner.get_all_partners(db) diff --git a/backend/services/project_partnership_service.py b/backend/services/project_partnership_service.py index 1cc8ce5a94..28509a98a6 100644 --- a/backend/services/project_partnership_service.py +++ b/backend/services/project_partnership_service.py @@ -1,55 +1,80 @@ -from flask import current_app -from backend.exceptions import NotFound, BadRequest +# from flask import current_app +import datetime +from typing import List, Optional + +from databases import Database +from loguru import logger + +from backend.exceptions import BadRequest, NotFound +from backend.models.dtos.project_partner_dto import ProjectPartnershipDTO +from backend.models.postgis.partner import Partner from backend.models.postgis.project_partner import ( + ProjectPartnerAction, ProjectPartnership, ProjectPartnershipHistory, - ProjectPartnerAction, ) -from backend.models.dtos.project_partner_dto import ProjectPartnershipDTO - -from backend.models.postgis.partner import Partner - -from typing import List, Optional -import datetime class ProjectPartnershipServiceError(Exception): """Custom Exception to notify callers an error occurred when handling project partnerships""" def __init__(self, message): - if current_app: - current_app.logger.debug(message) + logger.debug(message) class ProjectPartnershipService: @staticmethod - def get_partnership_as_dto(partnership_id: int) -> ProjectPartnershipDTO: - partnership = ProjectPartnership.get_by_id(partnership_id) + async def get_partnership_as_dto( + partnership_id: int, db: Database + ) -> ProjectPartnershipDTO: + partnership = await ProjectPartnership.get_by_id(partnership_id, db) if partnership is None: raise NotFound( sub_code="PARTNERSHIP_NOT_FOUND", partnership_id=partnership_id ) - partnership_dto = ProjectPartnershipDTO() - partnership_dto.id = partnership.id - partnership_dto.project_id = partnership.project_id - partnership_dto.partner_id = partnership.partner_id - partnership_dto.started_on = partnership.started_on - partnership_dto.ended_on = partnership.ended_on + partnership_dto = ProjectPartnershipDTO( + id=partnership.id, + project_id=partnership.project_id, + partner_id=partnership.partner_id, + started_on=partnership.started_on, + ended_on=partnership.ended_on, + ) + # partnership_dto.id = partnership.id + # partnership_dto.project_id = partnership.project_id + # partnership_dto.partner_id = partnership.partner_id + # partnership_dto.started_on = partnership.started_on + # partnership_dto.ended_on = partnership.ended_on return partnership_dto - @staticmethod - def get_partnerships_by_project(project_id: int) -> List[ProjectPartnershipDTO]: - partnerships = ProjectPartnership.query.filter( - ProjectPartnership.project_id == project_id - ).all() + # @staticmethod + # def get_partnerships_by_project(project_id: int) -> List[ProjectPartnershipDTO]: + # partnerships = ProjectPartnership.query.filter( + # ProjectPartnership.project_id == project_id + # ).all() - return list( - map(lambda partnership: partnership.as_dto().to_primitive(), partnerships) - ) + # return list( + # map(lambda partnership: partnership.as_dto().to_primitive(), partnerships) + # ) @staticmethod - def create_partnership( + async def get_partnerships_by_project( + project_id: int, db: Database + ) -> List[ProjectPartnershipDTO]: + """ + Retrieves all partnerships for a specific project ID. + """ + query = """ + SELECT id, project_id, partner_id, started_on, ended_on + FROM project_partnerships + WHERE project_id = :project_id + """ + rows = await db.fetch_all(query, values={"project_id": project_id}) + return [ProjectPartnershipDTO(**row) for row in rows] + + @staticmethod + async def create_partnership( + db: Database, project_id: int, partner_id: int, started_on: Optional[datetime.datetime], @@ -72,7 +97,7 @@ def create_partnership( ended_on=partnership.ended_on, ) - partnership_id = partnership.create() + partnership_id = await partnership.create(db) partnership_history = ProjectPartnershipHistory() partnership_history.partnership_id = partnership_id @@ -80,17 +105,19 @@ def create_partnership( partnership_history.partner_id = partner_id partnership_history.started_on_new = partnership.started_on partnership_history.ended_on_new = partnership.ended_on - partnership_history.create() + await partnership_history.create(db) return partnership_id @staticmethod - def update_partnership_time_range( + async def update_partnership_time_range( + db: Database, partnership_id: int, started_on: Optional[datetime.datetime], ended_on: Optional[datetime.datetime], ) -> ProjectPartnership: - partnership = ProjectPartnership.get_by_id(partnership_id) + partnership_record = await ProjectPartnership.get_by_id(partnership_id, db) + partnership = ProjectPartnership(**partnership_record) if partnership is None: raise NotFound( sub_code="PARTNERSHIP_NOT_FOUND", partnership_id=partnership_id @@ -126,14 +153,15 @@ def update_partnership_time_range( ended_on=partnership.ended_on, ) - partnership.save() - partnership_history.create() + await partnership.save(db) + await partnership_history.create(db) return partnership @staticmethod - def delete_partnership(partnership_id: int): - partnership = ProjectPartnership.get_by_id(partnership_id) + async def delete_partnership(partnership_id: int, db: Database): + partnership_record = await ProjectPartnership.get_by_id(partnership_id, db) + partnership = ProjectPartnership(**partnership_record) if partnership is None: raise NotFound( sub_code="PARTNERSHIP_NOT_FOUND", partnership_id=partnership_id @@ -146,9 +174,8 @@ def delete_partnership(partnership_id: int): partnership_history.started_on_old = partnership.started_on partnership_history.ended_on_old = partnership.ended_on partnership_history.action = ProjectPartnerAction.DELETE.value - partnership_history.create() - - partnership.delete() + await partnership_history.create(db) + await partnership.delete(db) @staticmethod def get_partners_by_project(project_id: int) -> List[Partner]: diff --git a/backend/services/project_search_service.py b/backend/services/project_search_service.py index 68095bfc11..27425c8024 100644 --- a/backend/services/project_search_service.py +++ b/backend/services/project_search_service.py @@ -3,6 +3,7 @@ from typing import List import geojson +import pandas as pd from cachetools import TTLCache, cached from databases import Database from fastapi import HTTPException @@ -20,8 +21,9 @@ ProjectSearchDTO, ProjectSearchResultsDTO, ) -from backend.models.postgis.project import Project, ProjectInfo, ProjectTeams from backend.models.postgis.partner import Partner +from backend.models.postgis.project import Project, ProjectInfo +from backend.models.postgis.project_partner import ProjectPartnership from backend.models.postgis.statuses import ( MappingLevel, MappingPermission, @@ -33,17 +35,6 @@ UserRole, ValidationPermission, ) -from backend.models.postgis.project_partner import ProjectPartnership -from backend.models.postgis.campaign import Campaign -from backend.models.postgis.organisation import Organisation -from backend.models.postgis.task import TaskHistory -from backend.models.postgis.utils import ( - ST_Intersects, - ST_MakeEnvelope, - ST_Transform, - ST_Area, -) -from backend.models.postgis.interests import project_interests from backend.services.users.user_service import UserService session = get_session() diff --git a/frontend/src/components/userDetail/elementsMapped.js b/frontend/src/components/userDetail/elementsMapped.js index c52baa1555..6dc4b3bc31 100644 --- a/frontend/src/components/userDetail/elementsMapped.js +++ b/frontend/src/components/userDetail/elementsMapped.js @@ -1,5 +1,5 @@ import humanizeDuration from 'humanize-duration'; -import { useIntl, FormattedMessage } from 'react-intl'; +import { FormattedMessage } from 'react-intl'; import messages from './messages'; import { @@ -113,7 +113,6 @@ export const shortEnglishHumanizer = humanizeDuration.humanizer({ }); export const ElementsMapped = ({ userStats, osmStats }) => { - const intl = useIntl(); const duration = shortEnglishHumanizer(userStats.timeSpentMapping * 1000, { round: true, delimiter: ' ', @@ -124,8 +123,6 @@ export const ElementsMapped = ({ userStats, osmStats }) => { const iconClass = 'h-50 w-50'; const iconStyle = { height: '45px' }; - const { data: osmStatsMetadata } = useOsmStatsMetadataQuery(); - return (