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 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/frontend/src/components/teamsAndOrgs/members.js b/frontend/src/components/teamsAndOrgs/members.js index 8b84e783a4..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 { FormattedMessage } from 'react-intl'; +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, @@ -168,6 +173,8 @@ export function JoinRequests({ joinMethod, 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 = @@ -215,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 && (
@@ -237,14 +288,28 @@ export function JoinRequests({
{requests.map((user) => (
-
+
- + {user.username} + + {!user.joinedDate ? ( + - + ) : ( + intl.formatDate(user.joinedDate, { + year: 'numeric', + month: 'short', + day: '2-digit', + }) + )} +
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')