Skip to content

Commit

Permalink
Approval history UI (#746)
Browse files Browse the repository at this point in the history
* Add route for club approval history (in-progress)

* Finish UI for club approval history + testcase

* Add docs to approval history endpoint

* Fix non-admin case

* Add permissioning on route-level, change ordering convention for history

* Consolidate approval_history perms into ClubPermission
  • Loading branch information
julianweng authored Nov 13, 2024
1 parent a916b91 commit b280dc0
Show file tree
Hide file tree
Showing 6 changed files with 215 additions and 7 deletions.
8 changes: 5 additions & 3 deletions backend/clubs/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,8 @@ class ClubPermission(permissions.BasePermission):
Anyone should be able to view, if the club is approved.
Otherwise, only members or people with permission should be able to view.
Actions default to owners/officers only unless specified below.
"""

def has_object_permission(self, request, view, obj):
Expand Down Expand Up @@ -163,24 +165,24 @@ def has_object_permission(self, request, view, obj):
if membership is None:
return False
# user has to be an owner to delete a club, an officer to edit it
if view.action in ["destroy"]:
if view.action in {"destroy"}:
return membership.role <= Membership.ROLE_OWNER
else:
return membership.role <= Membership.ROLE_OFFICER

def has_permission(self, request, view):
if view.action in {
"children",
"create",
"destroy",
"history",
"parents",
"partial_update",
"update",
"upload",
"upload_file",
}:
return request.user.is_authenticated
elif view.action in {"create"}:
return request.user.is_authenticated
else:
return True

Expand Down
23 changes: 23 additions & 0 deletions backend/clubs/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2975,6 +2975,29 @@ 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):
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")
Expand Down
46 changes: 46 additions & 0 deletions backend/clubs/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@
ApplicationSubmissionCSVSerializer,
ApplicationSubmissionSerializer,
ApplicationSubmissionUserSerializer,
ApprovalHistorySerializer,
AssetSerializer,
AuthenticatedClubSerializer,
AuthenticatedMembershipSerializer,
Expand Down Expand Up @@ -1276,6 +1277,49 @@ def upload_file(self, request, *args, **kwargs):

return file_upload_endpoint_helper(request, code=club.code)

@action(detail=True, methods=["get"])
def history(self, request, *args, **kwargs):
"""
Return a simplified approval history for the club.
---
responses:
"200":
content:
application/json:
schema:
type: array
items:
type: object
properties:
approved:
type: boolean
approved_on:
type: string
format: date-time
approved_by:
type: string
description: >
The full name of the user who approved
the club.
approved_comment:
type: string
history_date:
type: string
format: date-time
description: >
The time in which the specific version
of the club was saved at.
---
"""
club = self.get_object()
return Response(
ApprovalHistorySerializer(
club.history.order_by("-history_date"),
many=True,
context={"request": request},
).data
)

@action(detail=True, methods=["get"])
def owned_badges(self, request, *args, **kwargs):
"""
Expand Down Expand Up @@ -2159,6 +2203,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"
Expand Down
13 changes: 13 additions & 0 deletions backend/tests/clubs/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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[0]["approved"])

with patch("django.conf.settings.REAPPROVAL_QUEUE_OPEN", True):
for field in {"name"}:
# edit sensitive field
Expand All @@ -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[0]["approved"])

# ensure club is marked as not approved
club.refresh_from_db()
Expand Down
124 changes: 124 additions & 0 deletions frontend/components/ClubPage/ClubApprovalDialog.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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 = {
Expand All @@ -30,9 +33,108 @@ type ConfirmParams = {
message: ReactElement | string
}

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<boolean>(false)
const [reason, setReason] = useState<string | null>(null)
const getReason = (item: HistoricItem): EmotionJSX.Element | string => {
return item.approved_comment ? (
item.approved_comment.length > 100 ? (
<span
style={{
cursor: 'pointer',
textDecoration: 'underline',
}}
onClick={() => setReason(item.approved_comment)}
>
View Reason
</span>
) : (
item.approved_comment
)
) : (
'No reason provided'
)
}
return (
<>
<div
style={{
cursor: 'pointer',
}}
className="mt-2"
onClick={() => setActive(!active)}
>
{active ? 'Hide' : 'Show'} History
<Chevron
name="chevron-down"
alt="toggle dropdown"
open={active}
color="inherit"
className="ml-1"
/>
</div>
<Modal
show={reason !== null}
closeModal={() => setReason(null)}
marginBottom={false}
width="80%"
>
<ModalContent>{reason}</ModalContent>
</Modal>
{active && (
<div style={{ maxHeight: '300px', overflowY: 'auto' }}>
{history.map((item, i) => (
<div
key={i}
className="mt-2"
style={{
fontSize: '70%',
}}
>
{item.approved === true ? (
<TextQuote className="py-0">
<b>Approved</b> by <b>{item.approved_by}</b> on{' '}
{moment(item.history_date)
.tz('America/New_York')
.format('LLL')}{' '}
- {getReason(item)}
</TextQuote>
) : item.approved === false ? (
<TextQuote className="py-0">
<b>Rejected</b> by <b>{item.approved_by}</b> on{' '}
{moment(item.history_date)
.tz('America/New_York')
.format('LLL')}{' '}
- {getReason(item)}
</TextQuote>
) : (
<TextQuote className="py-0">
<b>Submitted for re-approval</b> on{' '}
{moment(item.history_date)
.tz('America/New_York')
.format('LLL')}
</TextQuote>
)}
</div>
))}
</div>
)}
</>
)
}

const ClubApprovalDialog = ({ club }: Props): ReactElement | null => {
const router = useRouter()
const year = getCurrentSchoolYear()
const [history, setHistory] = useState<HistoricItem[]>([])
const [comment, setComment] = useState<string>(club.approved_comment || '')
const [loading, setLoading] = useState<boolean>(false)
const [confirmModal, setConfirmModal] = useState<ConfirmParams | null>(null)
Expand Down Expand Up @@ -64,6 +166,26 @@ const ClubApprovalDialog = ({ club }: Props): ReactElement | null => {
.then(setTemplates)
}

if (isOfficer || canApprove) {
doApiRequest(`/clubs/${club.code}/history/?format=json`)
.then((resp) => resp.json())
.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)
}
}
lastVersions.reverse() // Avoids O(n^2) of unshift() method
setHistory(lastVersions)
})
}

setComment(
selectedTemplates.map((template) => template.content).join('\n\n'),
)
Expand Down Expand Up @@ -130,6 +252,7 @@ const ClubApprovalDialog = ({ club }: Props): ReactElement | null => {
>
<Icon name="x" /> Revoke Approval
</button>
<ClubHistoryDropdown history={history} />
</div>
)}
{(club.active || canDeleteClub) && club.approved !== true ? (
Expand Down Expand Up @@ -366,6 +489,7 @@ const ClubApprovalDialog = ({ club }: Props): ReactElement | null => {
</button>
</>
)}
<ClubHistoryDropdown history={history} />
</div>
) : null}
{(seeFairStatus || isOfficer) && fairs.length > 0 && (
Expand Down
8 changes: 4 additions & 4 deletions frontend/components/DropdownFilter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)'}
}
`
Expand Down

0 comments on commit b280dc0

Please sign in to comment.