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 #122

Merged
merged 4 commits into from
Dec 18, 2024
Merged
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
99 changes: 91 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,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
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
6 changes: 5 additions & 1 deletion backend/services/team_service.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
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