Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat/team member join date #6663

Open
wants to merge 6 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion backend/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -832,6 +836,9 @@ def add_api_endpoints(app):
format_url("teams/<int:team_id>/"),
methods=["GET", "DELETE", "PATCH"],
)
api.add_resource(
TeamsJoinRequestAPI, format_url("teams/join_requests/"), methods=["GET"]
)

# Teams actions endpoints
api.add_resource(
Expand Down
101 changes: 93 additions & 8 deletions backend/api/teams/resources.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down Expand Up @@ -368,3 +370,86 @@ 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", "Date Joined (UTC)", "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
2 changes: 2 additions & 0 deletions backend/models/dtos/team_dto.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
LongType,
ListType,
ModelType,
UTCDateTimeType,
)

from backend.models.dtos.stats_dto import Pagination
Expand Down Expand Up @@ -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):
Expand Down
5 changes: 5 additions & 0 deletions backend/models/postgis/team.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
TeamRoles,
)
from backend.models.postgis.user import User
from backend.models.postgis.utils import timestamp


class TeamMembers(db.Model):
Expand All @@ -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"""
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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:
Expand All @@ -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,
}
)

Expand Down
15 changes: 14 additions & 1 deletion backend/services/project_search_service.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import pandas as pd
from backend.models.postgis.user import User
from flask import current_app
import math
import geojson
Expand Down Expand Up @@ -92,14 +93,23 @@ 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)
).label("total_area"),
)
.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 = (
Expand Down Expand Up @@ -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 = (
Expand All @@ -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 = {
Expand Down
77 changes: 71 additions & 6 deletions frontend/src/components/teamsAndOrgs/members.js
Original file line number Diff line number Diff line change
@@ -1,17 +1,22 @@
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';
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,
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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(<FormattedMessage {...projectsMessages.downloadAsCSVError} />);
} finally {
setIsCSVDownloading(false);
}
};

return (
<div className="bg-white b--grey-light pa4 ba blue-dark">
<div className="cf db">
<h3 className="f3 blue-dark mt0 fw6 fl">
<div className="db flex justify-between items-start">
<h3 className="f3 blue-dark mv0 fw6 fl">
<FormattedMessage {...messages.joinRequests} />
</h3>
{!!requests.length && (
<button
className={`ml3 lh-title f6 ${
isCSVDownloading ? 'gray' : 'blue-dark'
} inline-flex items-baseline b--none bg-white underline pointer`}
onClick={handleTeamRequestsDownload}
disabled={isCSVDownloading}
>
{isCSVDownloading ? (
<LoadingIcon
className="mr2 self-center h1 w1 gray"
style={{ animation: 'spin 1s linear infinite' }}
/>
) : (
<DownloadIcon className="mr2 self-center" />
)}
<FormattedMessage {...projectsMessages.downloadAsCSV} />
</button>
)}
</div>
{showJoinRequestSwitch && (
<div className="flex justify-between blue-grey">
Expand All @@ -237,14 +288,28 @@ export function JoinRequests({
<div className="cf db mt3">
{requests.map((user) => (
<div className="cf db pt2" key={user.username}>
<div className="fl pt1">
<div className="fl pt1 flex">
<UserAvatar
username={user.username}
picture={user.pictureUrl}
colorClasses="white bg-blue-grey"
/>
<Link to={`/users/${user.username}`} className="v-mid link blue-dark">
<Link
to={`/users/${user.username}`}
className="v-mid link blue-dark flex flex-column ml1"
>
<span>{user.username}</span>
<span>
{!user.joinedDate ? (
<span className="ml2">-</span>
) : (
intl.formatDate(user.joinedDate, {
year: 'numeric',
month: 'short',
day: '2-digit',
})
)}
</span>
</Link>
</div>
<div className="fr">
Expand Down
25 changes: 25 additions & 0 deletions migrations/versions/8e5144b55919_.py
Original file line number Diff line number Diff line change
@@ -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')
Loading