From 835355d3b386dbf1bedfe4a41e515f78117ca91f Mon Sep 17 00:00:00 2001 From: Julian Weng Date: Mon, 11 Nov 2024 02:00:25 -0500 Subject: [PATCH] Finish UI for club approval history + testcase --- backend/clubs/serializers.py | 28 +++++ backend/clubs/views.py | 11 +- backend/tests/clubs/test_views.py | 13 ++ .../ClubPage/ClubApprovalDialog.tsx | 112 +++++++++++++++++- frontend/components/DropdownFilter.tsx | 8 +- 5 files changed, 163 insertions(+), 9 deletions(-) diff --git a/backend/clubs/serializers.py b/backend/clubs/serializers.py index eb70bbb2c..0a21f11d6 100644 --- a/backend/clubs/serializers.py +++ b/backend/clubs/serializers.py @@ -2975,6 +2975,34 @@ class Meta: ) +class ApprovalHistorySerializer(serializers.ModelSerializer): + approved = serializers.BooleanField() + approved_on = serializers.DateTimeField() + approved_by = serializers.SerializerMethodField("get_approved_by") + approved_comment = serializers.CharField() + history_date = serializers.DateTimeField() + + def get_approved_by(self, obj): + user = self.context["request"].user + if not user.is_authenticated: + return None + if not user.has_perm("clubs.see_pending_clubs"): + return None + if obj.approved_by is None: + return "Unknown" + return obj.approved_by.get_full_name() + + class Meta: + model = Club + fields = ( + "approved", + "approved_on", + "approved_by", + "approved_comment", + "history_date", + ) + + class AdminNoteSerializer(ClubRouteMixin, serializers.ModelSerializer): creator = serializers.SerializerMethodField("get_creator") title = serializers.CharField(max_length=255, default="Note") diff --git a/backend/clubs/views.py b/backend/clubs/views.py index fb4c2bb10..461b36466 100644 --- a/backend/clubs/views.py +++ b/backend/clubs/views.py @@ -154,6 +154,7 @@ ApplicationSubmissionCSVSerializer, ApplicationSubmissionSerializer, ApplicationSubmissionUserSerializer, + ApprovalHistorySerializer, AssetSerializer, AuthenticatedClubSerializer, AuthenticatedMembershipSerializer, @@ -1283,9 +1284,11 @@ def history(self, request, *args, **kwargs): """ club = self.get_object() return Response( - club.history.order_by("approved_on").values( - "approved", "approved_on", "approved_by", "history_date" - ) + ApprovalHistorySerializer( + club.history.order_by("history_date"), + many=True, + context={"request": request}, + ).data ) @action(detail=True, methods=["get"]) @@ -2171,6 +2174,8 @@ def get_serializer_class(self): return ClubConstitutionSerializer if self.action == "notes_about": return NoteSerializer + if self.action == "history": + return ApprovalHistorySerializer if self.action in {"list", "fields"}: if self.request is not None and ( self.request.accepted_renderer.format == "xlsx" diff --git a/backend/tests/clubs/test_views.py b/backend/tests/clubs/test_views.py index f005efd00..646c88b4a 100644 --- a/backend/tests/clubs/test_views.py +++ b/backend/tests/clubs/test_views.py @@ -2182,6 +2182,12 @@ def test_club_sensitive_field_renew(self): club.refresh_from_db() self.assertTrue(club.approved) + # store result of approval history query + resp = self.client.get(reverse("clubs-history", args=(club.code,))) + self.assertIn(resp.status_code, [200], resp.content) + previous_history = json.loads(resp.content.decode("utf-8")) + self.assertTrue(previous_history[-1]["approved"]) + with patch("django.conf.settings.REAPPROVAL_QUEUE_OPEN", True): for field in {"name"}: # edit sensitive field @@ -2191,6 +2197,13 @@ def test_club_sensitive_field_renew(self): content_type="application/json", ) self.assertIn(resp.status_code, [200, 201], resp.content) + resp = self.client.get(reverse("clubs-history", args=(club.code,))) + # find the approval history + resp = self.client.get(reverse("clubs-history", args=(club.code,))) + self.assertIn(resp.status_code, [200], resp.content) + history = json.loads(resp.content.decode("utf-8")) + self.assertEqual(len(history), len(previous_history) + 1) + self.assertFalse(history[-1]["approved"]) # ensure club is marked as not approved club.refresh_from_db() diff --git a/frontend/components/ClubPage/ClubApprovalDialog.tsx b/frontend/components/ClubPage/ClubApprovalDialog.tsx index facc2b159..74eacd1df 100644 --- a/frontend/components/ClubPage/ClubApprovalDialog.tsx +++ b/frontend/components/ClubPage/ClubApprovalDialog.tsx @@ -1,3 +1,5 @@ +import { EmotionJSX } from '@emotion/react/types/jsx-namespace' +import moment from 'moment-timezone' import { useRouter } from 'next/router' import { ReactElement, useEffect, useState } from 'react' import Select from 'react-select' @@ -18,6 +20,7 @@ import { SITE_NAME, } from '../../utils/branding' import { Contact, Icon, Modal, Text, TextQuote } from '../common' +import { Chevron } from '../DropdownFilter' import { ModalContent } from './Actions' type Props = { @@ -34,9 +37,100 @@ type HistoricItem = { approved: boolean | null approved_on: string | null approved_by: string | null + approved_comment: string | null history_date: string } +const ClubHistoryDropdown = ({ history }: { history: HistoricItem[] }) => { + const [active, setActive] = useState(false) + const [reason, setReason] = useState(null) + const getReason = (item: HistoricItem): EmotionJSX.Element | string => { + return item.approved_comment ? ( + item.approved_comment.length > 100 ? ( + setReason(item.approved_comment)} + > + View Reason + + ) : ( + item.approved_comment + ) + ) : ( + 'No reason provided' + ) + } + return ( + <> +
setActive(!active)} + > + {active ? 'Hide' : 'Show'} History + +
+ setReason(null)} + marginBottom={false} + width="80%" + > + {reason} + + {active && ( +
+ {history.map((item, i) => ( +
+ {item.approved === true ? ( + + Approved by {item.approved_by} on{' '} + {moment(item.history_date) + .tz('America/New_York') + .format('LLL')}{' '} + - {getReason(item)} + + ) : item.approved === false ? ( + + Rejected by {item.approved_by} on{' '} + {moment(item.history_date) + .tz('America/New_York') + .format('LLL')}{' '} + - {getReason(item)} + + ) : ( + + Submitted for re-approval on{' '} + {moment(item.history_date) + .tz('America/New_York') + .format('LLL')} + + )} +
+ ))} +
+ )} + + ) +} + const ClubApprovalDialog = ({ club }: Props): ReactElement | null => { const router = useRouter() const year = getCurrentSchoolYear() @@ -73,9 +167,21 @@ const ClubApprovalDialog = ({ club }: Props): ReactElement | null => { doApiRequest(`/clubs/${club.code}/history/?format=json`) .then((resp) => resp.json()) - .then(setHistory) - } + .then((data) => { + // Get last version of club for each change in approved status + const lastVersions: HistoricItem[] = [] + for (let i = data.length - 1; i >= 0; i--) { + const item = data[i] + const lastItem = lastVersions[lastVersions.length - 1] + + if (item.approved !== lastItem?.approved || !lastItem) { + lastVersions.push(item) + } + } + setHistory(lastVersions) + }) + } setComment( selectedTemplates.map((template) => template.content).join('\n\n'), ) @@ -142,6 +248,7 @@ const ClubApprovalDialog = ({ club }: Props): ReactElement | null => { > Revoke Approval + )} {(club.active || canDeleteClub) && club.approved !== true ? ( @@ -378,6 +485,7 @@ const ClubApprovalDialog = ({ club }: Props): ReactElement | null => { )} + ) : null} {(seeFairStatus || isOfficer) && fairs.length > 0 && ( diff --git a/frontend/components/DropdownFilter.tsx b/frontend/components/DropdownFilter.tsx index 3805f3089..0ad45306b 100644 --- a/frontend/components/DropdownFilter.tsx +++ b/frontend/components/DropdownFilter.tsx @@ -73,17 +73,17 @@ const TableContainer = styled.div` } ` -const Chevron = styled(Icon)<{ open?: boolean }>` +export const Chevron = styled(Icon)<{ open?: boolean; color?: string }>` cursor: pointer; - color: ${CLUBS_GREY}; + color: ${({ color }) => color ?? CLUBS_GREY}; transform: rotate(0deg) translateY(0); transition: transform ${ANIMATION_DURATION}ms ease; - ${({ open }) => open && 'transform: rotate(180deg) translateY(-4px);'} + ${({ open }) => open && 'transform: rotate(180deg);'} ${mediaMaxWidth(MD)} { margin-top: 0.1em !important; margin-left: 0.1em !important; - color: ${LIGHT_GRAY}; + color: ${({ color }) => color ?? LIGHT_GRAY}; ${({ open }) => open && 'transform: rotate(180deg)'} } `