Skip to content

Commit

Permalink
Merge pull request #122 from naxa-developers/feat/team-member-join-date
Browse files Browse the repository at this point in the history
Feat/team member join date
  • Loading branch information
prabinoid authored Dec 18, 2024
2 parents 11f0d03 + b7de175 commit 19b7559
Show file tree
Hide file tree
Showing 7 changed files with 207 additions and 16 deletions.
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')

0 comments on commit 19b7559

Please sign in to comment.