From 4a11ef7acc8a9bc1fb2486f4b287fa8155c6b762 Mon Sep 17 00:00:00 2001 From: prabinoid <38830224+prabinoid@users.noreply.github.com> Date: Mon, 25 Nov 2024 11:04:50 +0545 Subject: [PATCH 1/6] Added date joined in team members table and its related functions --- backend/models/dtos/team_dto.py | 2 ++ backend/models/postgis/team.py | 5 +++++ backend/services/team_service.py | 6 +++++- migrations/versions/8e5144b55919_.py | 25 +++++++++++++++++++++++++ 4 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 migrations/versions/8e5144b55919_.py diff --git a/backend/models/dtos/team_dto.py b/backend/models/dtos/team_dto.py index 58f2ee692b..2c1347d2b4 100644 --- a/backend/models/dtos/team_dto.py +++ b/backend/models/dtos/team_dto.py @@ -7,6 +7,7 @@ LongType, ListType, ModelType, + UTCDateTimeType, ) from backend.models.dtos.stats_dto import Pagination @@ -64,6 +65,7 @@ class TeamMembersDTO(Model): default=False, serialized_name="joinRequestNotifications" ) picture_url = StringType(serialized_name="pictureUrl") + joined_date = UTCDateTimeType(serialized_name="joinedDate") class TeamProjectDTO(Model): diff --git a/backend/models/postgis/team.py b/backend/models/postgis/team.py index ca9ac2a8f9..c6f5c17d8f 100644 --- a/backend/models/postgis/team.py +++ b/backend/models/postgis/team.py @@ -15,6 +15,7 @@ TeamRoles, ) from backend.models.postgis.user import User +from backend.models.postgis.utils import timestamp class TeamMembers(db.Model): @@ -36,6 +37,7 @@ class TeamMembers(db.Model): team = db.relationship( "Team", backref=db.backref("members", cascade="all, delete-orphan") ) + joined_date = db.Column(db.DateTime, default=timestamp) def create(self): """Creates and saves the current model to the DB""" @@ -105,6 +107,7 @@ def create_from_dto(cls, new_team_dto: NewTeamDTO): new_member.user_id = new_team_dto.creator new_member.function = TeamMemberFunctions.MANAGER.value new_member.active = True + new_member.joined_date = timestamp() new_team.members.append(new_member) @@ -222,6 +225,7 @@ def as_dto_team_member(self, member) -> TeamMembersDTO: member_dto.picture_url = user.picture_url member_dto.active = member.active member_dto.join_request_notifications = member.join_request_notifications + member_dto.joined_date = member.joined_date return member_dto def as_dto_team_project(self, project) -> TeamProjectDTO: @@ -242,6 +246,7 @@ def _get_team_members(self): "pictureUrl": mem.member.picture_url, "function": TeamMemberFunctions(mem.function).name, "active": mem.active, + "joinedDate": mem.joined_date, } ) diff --git a/backend/services/team_service.py b/backend/services/team_service.py index 6eeaf7b5b3..b197ff35be 100644 --- a/backend/services/team_service.py +++ b/backend/services/team_service.py @@ -1,3 +1,4 @@ +from backend.models.postgis.utils import timestamp from flask import current_app from sqlalchemy import and_, or_ from markdown import markdown @@ -121,12 +122,15 @@ def add_user_to_team( ) @staticmethod - def add_team_member(team_id, user_id, function, active=False): + def add_team_member( + team_id, user_id, function, active=False, joined_date=timestamp() + ): team_member = TeamMembers() team_member.team_id = team_id team_member.user_id = user_id team_member.function = function team_member.active = active + team_member.joined_date = joined_date team_member.create() @staticmethod diff --git a/migrations/versions/8e5144b55919_.py b/migrations/versions/8e5144b55919_.py new file mode 100644 index 0000000000..30fb926ffc --- /dev/null +++ b/migrations/versions/8e5144b55919_.py @@ -0,0 +1,25 @@ +"""Add date joined in teams table +Revision ID: 8e5144b55919 +Revises: ecb6985693c0_ +Create Date: 2024-11-22 10:25:38.551015 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '8e5144b55919' +down_revision = 'ecb6985693c0_' +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column( + 'team_members', + sa.Column('joined_date', sa.DateTime(), nullable=True) + ) + +def downgrade(): + op.drop_column('team_members', 'joined_date') From 5e7b06a11227aeabf52504758d51da433f028e02 Mon Sep 17 00:00:00 2001 From: royallsilwallz Date: Mon, 25 Nov 2024 17:34:02 +0545 Subject: [PATCH 2/6] Add `joinedDate` to join requests in `Manage Team` page --- .../src/components/teamsAndOrgs/members.js | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/teamsAndOrgs/members.js b/frontend/src/components/teamsAndOrgs/members.js index 8b84e783a4..ec71a11462 100644 --- a/frontend/src/components/teamsAndOrgs/members.js +++ b/frontend/src/components/teamsAndOrgs/members.js @@ -1,7 +1,7 @@ import { useState, useEffect, useCallback, useRef } from 'react'; import { Link } from 'react-router-dom'; import { useSelector } from 'react-redux'; -import { FormattedMessage } from 'react-intl'; +import { useIntl, FormattedMessage } from 'react-intl'; import AsyncSelect from 'react-select/async'; import messages from './messages'; @@ -168,6 +168,7 @@ export function JoinRequests({ joinMethod, members, }: Object) { + const intl = useIntl(); const token = useSelector((state) => state.auth.token); const { username: loggedInUsername } = useSelector((state) => state.auth.userDetails); const showJoinRequestSwitch = @@ -237,14 +238,28 @@ export function JoinRequests({
{requests.map((user) => (
-
+
- + {user.username} + + {!user.joinedDate ? ( + - + ) : ( + intl.formatDate(user.joinedDate, { + year: 'numeric', + month: 'short', + day: '2-digit', + }) + )} +
From b2ec4b2cdbacb974deab4420cb6b1533efd1668c Mon Sep 17 00:00:00 2001 From: prabinoid <38830224+prabinoid@users.noreply.github.com> Date: Tue, 26 Nov 2024 12:20:15 +0545 Subject: [PATCH 3/6] Export join requests to csv --- backend/__init__.py | 9 +++- backend/api/teams/resources.py | 99 +++++++++++++++++++++++++++++++--- 2 files changed, 99 insertions(+), 9 deletions(-) diff --git a/backend/__init__.py b/backend/__init__.py index 4e032534ae..9953912aae 100644 --- a/backend/__init__.py +++ b/backend/__init__.py @@ -328,7 +328,11 @@ def add_api_endpoints(app): from backend.api.countries.resources import CountriesRestAPI # Teams API endpoint - from backend.api.teams.resources import TeamsRestAPI, TeamsAllAPI + from backend.api.teams.resources import ( + TeamsRestAPI, + TeamsAllAPI, + TeamsJoinRequestAPI, + ) from backend.api.teams.actions import ( TeamsActionsJoinAPI, TeamsActionsAddAPI, @@ -832,6 +836,9 @@ def add_api_endpoints(app): format_url("teams//"), methods=["GET", "DELETE", "PATCH"], ) + api.add_resource( + TeamsJoinRequestAPI, format_url("teams/join_requests/"), methods=["GET"] + ) # Teams actions endpoints api.add_resource( diff --git a/backend/api/teams/resources.py b/backend/api/teams/resources.py index 06df030d27..d1cb663f37 100644 --- a/backend/api/teams/resources.py +++ b/backend/api/teams/resources.py @@ -1,16 +1,18 @@ -from flask_restful import Resource, request, current_app +import csv +import io +from distutils.util import strtobool +from datetime import datetime +from flask_restful import Resource, current_app, request +from flask import Response from schematics.exceptions import DataError -from backend.models.dtos.team_dto import ( - NewTeamDTO, - UpdateTeamDTO, - TeamSearchDTO, -) +from backend.models.dtos.team_dto import NewTeamDTO, TeamSearchDTO, UpdateTeamDTO +from backend.models.postgis.team import Team, TeamMembers +from backend.models.postgis.user import User +from backend.services.organisation_service import OrganisationService from backend.services.team_service import TeamService, TeamServiceError from backend.services.users.authentication_service import token_auth -from backend.services.organisation_service import OrganisationService from backend.services.users.user_service import UserService -from distutils.util import strtobool class TeamsRestAPI(Resource): @@ -368,3 +370,84 @@ def post(self): return {"Error": error_msg, "SubCode": "CreateTeamNotPermitted"}, 403 except TeamServiceError as e: return str(e), 400 + + +class TeamsJoinRequestAPI(Resource): + # @tm.pm_only() + @token_auth.login_required + def get(self): + """ + Downloads join requests for a specific team as a CSV. + --- + tags: + - teams + produces: + - text/csv + parameters: + - in: query + name: team_id + description: ID of the team to filter by + required: true + type: integer + default: null + responses: + 200: + description: CSV file with inactive team members + 400: + description: Missing or invalid parameters + 401: + description: Unauthorized access + 500: + description: Internal server error + """ + # Parse the team_id from query parameters + team_id = request.args.get("team_id", type=int) + if not team_id: + return {"message": "team_id is required"}, 400 + + # Query the database + try: + team_members = ( + TeamMembers.query.join(User, TeamMembers.user_id == User.id) + .join(Team, TeamMembers.team_id == Team.id) + .filter(TeamMembers.team_id == team_id, TeamMembers.active == False) + .with_entities( + User.username.label("username"), + TeamMembers.joined_date.label("joined_date"), + Team.name.label("team_name"), + ) + .all() + ) + + if not team_members: + return { + "message": "No inactive members found for the specified team" + }, 404 + + # Generate CSV in memory + csv_output = io.StringIO() + writer = csv.writer(csv_output) + writer.writerow(["Username", "Joined Date", "Team Name"]) # CSV header + + for member in team_members: + writer.writerow( + [ + member.username, + member.joined_date.strftime("%Y-%m-%d %H:%M:%S") + if member.joined_date + else "N/A", + member.team_name, + ] + ) + + # Prepare response + csv_output.seek(0) + return Response( + csv_output.getvalue(), + mimetype="text/csv", + headers={ + "Content-Disposition": f"attachment; filename=join_requests_{team_id}_{datetime.now().strftime('%Y%m%d')}.csv" + }, + ) + except Exception as e: + return {"message": f"Error occurred: {str(e)}"}, 500 From b7de17565dfb9228d03a462f1218236995464811 Mon Sep 17 00:00:00 2001 From: royallsilwallz Date: Fri, 29 Nov 2024 13:11:44 +0545 Subject: [PATCH 4/6] Add `Download CSV` feature to `Join requests` in Teams page --- .../src/components/teamsAndOrgs/members.js | 56 ++++++++++++++++++- 1 file changed, 53 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/teamsAndOrgs/members.js b/frontend/src/components/teamsAndOrgs/members.js index ec71a11462..34ff3a6893 100644 --- a/frontend/src/components/teamsAndOrgs/members.js +++ b/frontend/src/components/teamsAndOrgs/members.js @@ -1,10 +1,13 @@ import { useState, useEffect, useCallback, useRef } from 'react'; -import { Link } from 'react-router-dom'; +import { Link, useParams } from 'react-router-dom'; +import axios from 'axios'; import { useSelector } from 'react-redux'; import { useIntl, FormattedMessage } from 'react-intl'; import AsyncSelect from 'react-select/async'; +import toast from 'react-hot-toast'; import messages from './messages'; +import projectsMessages from '../projects/messages'; import { UserAvatar } from '../user/avatar'; import { EditModeControl } from './editMode'; import { Button } from '../button'; @@ -12,6 +15,8 @@ import { SwitchToggle } from '../formInputs'; import { fetchLocalJSONAPI, pushToLocalJSONAPI } from '../../network/genericJSONRequest'; import { Alert } from '../alert'; import { useOnClickOutside } from '../../hooks/UseOnClickOutside'; +import { API_URL } from '../../config'; +import { DownloadIcon, LoadingIcon } from '../svgIcons'; export function Members({ addMembers, @@ -169,6 +174,7 @@ export function JoinRequests({ members, }: Object) { const intl = useIntl(); + const { id } = useParams(); const token = useSelector((state) => state.auth.token); const { username: loggedInUsername } = useSelector((state) => state.auth.userDetails); const showJoinRequestSwitch = @@ -216,12 +222,56 @@ export function JoinRequests({ }); }; + const [isCSVDownloading, setIsCSVDownloading] = useState(false); + + const handleTeamRequestsDownload = async () => { + setIsCSVDownloading(true); + try { + const url = `${API_URL}teams/join_requests/?team_id=${id}`; + const response = await axios.get(url, { + headers: { Authorization: `Token ${token}` }, + responseType: 'blob', + }); + const href = URL.createObjectURL(response.data); + const link = document.createElement('a'); + link.href = href; + link.setAttribute('download', 'join_requests.csv'); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(url); + } catch (error) { + toast.error(); + } finally { + setIsCSVDownloading(false); + } + }; + return (
-
-

+
+

+ {!!requests.length && ( + + )}
{showJoinRequestSwitch && (
From cc16b0a1525d2657a426850efda84a73e3c9cee7 Mon Sep 17 00:00:00 2001 From: prabinoid <38830224+prabinoid@users.noreply.github.com> Date: Thu, 19 Dec 2024 11:42:34 +0545 Subject: [PATCH 5/6] Team member join date set by default --- backend/api/teams/resources.py | 4 +++- backend/services/team_service.py | 6 +----- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/backend/api/teams/resources.py b/backend/api/teams/resources.py index d1cb663f37..870ca88677 100644 --- a/backend/api/teams/resources.py +++ b/backend/api/teams/resources.py @@ -427,7 +427,9 @@ def get(self): # Generate CSV in memory csv_output = io.StringIO() writer = csv.writer(csv_output) - writer.writerow(["Username", "Joined Date", "Team Name"]) # CSV header + writer.writerow( + ["Username", "Date Joined (UTC)", "Team Name"] + ) # CSV header for member in team_members: writer.writerow( diff --git a/backend/services/team_service.py b/backend/services/team_service.py index b197ff35be..6eeaf7b5b3 100644 --- a/backend/services/team_service.py +++ b/backend/services/team_service.py @@ -1,4 +1,3 @@ -from backend.models.postgis.utils import timestamp from flask import current_app from sqlalchemy import and_, or_ from markdown import markdown @@ -122,15 +121,12 @@ def add_user_to_team( ) @staticmethod - def add_team_member( - team_id, user_id, function, active=False, joined_date=timestamp() - ): + def add_team_member(team_id, user_id, function, active=False): team_member = TeamMembers() team_member.team_id = team_id team_member.user_id = user_id team_member.function = function team_member.active = active - team_member.joined_date = joined_date team_member.create() @staticmethod From 7a7871b3eb1d6819b755026d664de54b36247392 Mon Sep 17 00:00:00 2001 From: prabinoid <38830224+prabinoid@users.noreply.github.com> Date: Thu, 19 Dec 2024 12:40:40 +0545 Subject: [PATCH 6/6] Author name in project csv export --- backend/services/project_search_service.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/backend/services/project_search_service.py b/backend/services/project_search_service.py index bdddc463c9..bdb7633dbc 100644 --- a/backend/services/project_search_service.py +++ b/backend/services/project_search_service.py @@ -1,4 +1,5 @@ import pandas as pd +from backend.models.postgis.user import User from flask import current_app import math import geojson @@ -92,6 +93,8 @@ def create_search_query(user=None, as_csv: bool = False): Project.country, Organisation.name.label("organisation_name"), Organisation.logo.label("organisation_logo"), + User.name.label("author_name"), + User.username.label("author_username"), Project.created.label("creation_date"), func.coalesce( func.sum(func.ST_Area(Project.geometry, True) / 1000000) @@ -99,7 +102,14 @@ def create_search_query(user=None, as_csv: bool = False): ) .filter(Project.geometry is not None) .outerjoin(Organisation, Organisation.id == Project.organisation_id) - .group_by(Organisation.id, Project.id, ProjectInfo.name) + .outerjoin(User, User.id == Project.author_id) + .group_by( + Organisation.id, + Project.id, + ProjectInfo.name, + User.username, + User.name, + ) ) else: query = ( @@ -246,6 +256,7 @@ def search_projects_as_csv(search_dto: ProjectSearchDTO, user) -> str: row["total_contributors"] = Project.get_project_total_contributions( row["id"] ) + row["author"] = row["author_name"] or row["author_username"] if is_user_admin: partners_names = ( @@ -269,6 +280,8 @@ def search_projects_as_csv(search_dto: ProjectSearchDTO, user) -> str: "tasks_validated", "total_tasks", "centroid", + "author_name", + "author_username", ] colummns_to_rename = {