Skip to content

Commit

Permalink
recalculate function, client modal, docs improvements
Browse files Browse the repository at this point in the history
  • Loading branch information
Tschonti committed Jan 1, 2025
1 parent c668c6c commit e2fcede
Show file tree
Hide file tree
Showing 13 changed files with 195 additions and 17 deletions.
8 changes: 6 additions & 2 deletions packages/client/src/api/hooks/seasonHooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,19 +39,23 @@ export const useFetchSeasonWeights = (seasonId?: string) => {
)
}

export const useSetWeightsMutations = (seasonId: string, criterionId: number) => {
export const useSetWeightsMutation = (seasonId: string, criterionId: number) => {
return useMutation<unknown, PontozoError, CreateCriterionWeight>(
async (formdata) => await functionAxios.put(`/seasons/${seasonId}/weights/${criterionId}`, formdata),
{ onSuccess: () => queryClient.invalidateQueries(['fetchSeasonWeights', seasonId]) }
)
}

export const useCopyWeightsMutations = (destSeasonId: string) => {
export const useCopyWeightsMutation = (destSeasonId: string) => {
return useMutation<unknown, PontozoError, string>(
async (sourceSeasonId) => await functionAxios.put(`/seasons/${destSeasonId}/copyWeights/${sourceSeasonId}`)
)
}

export const useRecalculateSeasonMutation = (seasonId: string) => {
return useMutation<unknown, PontozoError>(async () => await functionAxios.post(`/seasons/${seasonId}/recalculate/`))
}

export const useCreateSeasonMutation = () => {
return useMutation<CreateResponse[], PontozoError, CreateSeason>(
async (formData) => (await functionAxios.post(`/seasons`, formData)).data
Expand Down
10 changes: 5 additions & 5 deletions packages/client/src/pages/seasons/WeightAdjustment.page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Alert, AlertDescription, AlertIcon, Button, Heading, HStack, SimpleGrid, Stack, Text, Tooltip, VStack } from '@chakra-ui/react'
import { useMemo } from 'react'
import { FaEdit, FaMedal, FaRedoAlt } from 'react-icons/fa'
import { FaEdit, FaMedal } from 'react-icons/fa'
import { useNavigate, useParams } from 'react-router-dom'
import { useAuthContext } from 'src/api/contexts/useAuthContext'
import { useFetchSeasonWeights } from 'src/api/hooks/seasonHooks'
Expand All @@ -10,6 +10,7 @@ import { NavigateWithError } from 'src/components/commons/NavigateWithError'
import { criterionWeightReducer } from 'src/util/criterionWeightHelper'
import { PATHS } from 'src/util/paths'
import { CategoryWeights } from './components/CategoryWeights'
import { RecalculateModal } from './components/RecalculateModal'
import { SourceSeasonSelectorModal } from './components/SourceSeasonSelectorModal'

export const WeightAdjustmentPage = () => {
Expand Down Expand Up @@ -86,7 +87,8 @@ export const WeightAdjustmentPage = () => {
<Text textAlign="justify">
<b>
Az oldalon egy érték átírása azonnal átírja az értéket az adatbázisban, nem kell semmilyen mentés gombra kattintani. Azonban az
értékelések eredményei nem számolódnak újra minden súlyérték változásakor, csakis ha azt manuálisan elindítod (coming soon).
értékelések eredményei nem számolódnak újra minden súlyérték változásakor, csakis ha azt manuálisan elindítod a lenti piros
gombbal.
</b>
</Text>

Expand All @@ -95,9 +97,7 @@ export const WeightAdjustmentPage = () => {
</Heading>
<HStack gap={2}>
<SourceSeasonSelectorModal currentSeasonId={seasonId ?? ''} />
<Button isDisabled colorScheme="red" leftIcon={<FaRedoAlt />}>
Pontok újraszámítása
</Button>
<RecalculateModal seasonId={seasonId ?? ''} />
</HStack>

<Heading mt={4} size="md" as="h2">
Expand Down
76 changes: 76 additions & 0 deletions packages/client/src/pages/seasons/components/RecalculateModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import {
Button,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
Text,
useDisclosure,
useToast,
} from '@chakra-ui/react'
import { FaRedoAlt } from 'react-icons/fa'
import { useRecalculateSeasonMutation } from 'src/api/hooks/seasonHooks'

export const RecalculateModal = ({ seasonId }: { seasonId: string }) => {
const { isOpen, onOpen, onClose } = useDisclosure()
const { mutate } = useRecalculateSeasonMutation(seasonId)
const toast = useToast()

const onSubmit = () => {
mutate(undefined, {
onSuccess: () => {
onClose()
toast({
title: 'Az újraszámítás megkezdődött!',
description: 'Kérlek légy türelemmel, hamarosan már a frissített eredmények fogod látni a versenyek publikus oldalán!',
status: 'success',
})
},
})
}

return (
<>
<Button onClick={onOpen} colorScheme="red" leftIcon={<FaRedoAlt />}>
Pontok újraszámítása
</Button>

<Modal size="lg" isOpen={isOpen} onClose={onClose}>
<ModalOverlay />
<ModalContent>
<ModalHeader>Pontok újraszámítása</ModalHeader>
<ModalCloseButton />
<ModalBody>
<Text mb={2} textAlign="justify">
<b>
A piros gomb megnyomásával elindítod ezen szezon lezajlott versenyeinek értékelési eredményeinek újraszámítását a jelenlegi
súlyértékekkel. Ez egy hosszabb és erőforrásigényesebb művelet is lehet{' '}
</b>
(a lezajlott versenyek számától függően), ezért lehetőleg ne használd túl gyakran. A folyamat elindítását követő pár percben
lehet, hogy lassabban fog működni az oldal. Amennyiben a teljesítmény hosszabb idő után sem javul, vagy furcsa anomáliák
történnek, kérlek jelezd a fejlesztőnek. Az admin felület Tudnivalók oldal alján láthatsz frissítéseket a folyamat
haladásáról.
<b>
{' '}
Azt is tarstd fejben, hogy az újraszámolástól a már publikált eredmények megváltozhatnak, tehát szezon közben jobb lenne ezt
elkerülni.
</b>
</Text>
</ModalBody>

<ModalFooter>
<Button mr={3} onClick={onClose}>
Mégse
</Button>
<Button onClick={onSubmit} colorScheme="red">
Újraszámítás
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,14 @@ import {
} from '@chakra-ui/react'
import { ChangeEvent, useMemo, useState } from 'react'
import { FaFileImport } from 'react-icons/fa'
import { useCopyWeightsMutations, useFetchSeasons } from 'src/api/hooks/seasonHooks'
import { useCopyWeightsMutation, useFetchSeasons } from 'src/api/hooks/seasonHooks'
import { LoadingSpinner } from 'src/components/commons/LoadingSpinner'
import { queryClient } from 'src/util/queryClient'

export const SourceSeasonSelectorModal = ({ currentSeasonId }: { currentSeasonId: string }) => {
const { isOpen, onOpen, onClose } = useDisclosure()
const { isLoading, data } = useFetchSeasons()
const { mutate } = useCopyWeightsMutations(currentSeasonId)
const { mutate } = useCopyWeightsMutation(currentSeasonId)
const [selectedSeasonId, setSelectedSeasonId] = useState<string>()
const toast = useToast()

Expand Down
4 changes: 2 additions & 2 deletions packages/client/src/pages/seasons/components/WeightInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Box, NumberDecrementStepper, NumberIncrementStepper, NumberInput, Numbe
import { Criterion, CriterionWeightKey, RatingRole } from '@pontozo/common'
import debounce from 'lodash.debounce'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useSetWeightsMutations } from 'src/api/hooks/seasonHooks'
import { useSetWeightsMutation } from 'src/api/hooks/seasonHooks'

type Props = {
roles: RatingRole[]
Expand All @@ -13,7 +13,7 @@ type Props = {
}

export const WeightInput = ({ roles, criterion, seasonId, defaultValue, weightKey }: Props) => {
const { mutate } = useSetWeightsMutations(seasonId, criterion.id)
const { mutate } = useSetWeightsMutation(seasonId, criterion.id)
const weightEditable = useMemo(() => {
const asSet = new Set(criterion.roles)
return roles.some((r) => asSet.has(r))
Expand Down
2 changes: 1 addition & 1 deletion packages/client/src/util/enumHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,5 +131,5 @@ export const eventStateColor: EventStateDict = {
[EventState.VALIDATING]: 'gray',
[EventState.ACCUMULATING]: 'gray',
[EventState.RESULTS_READY]: 'orange',
[EventState.INVALIDATED]: 'yellow',
[EventState.INVALIDATED]: 'mtfszRed',
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ import { ActivityOutput } from './closeRatingOrchestrator'

export const calculateAvgRatingActivityName = 'calculateAvgRatingActivity'

/**
* Durable Functions activity that calculates all the avarages of the ratings for a given event. It stores the result in the DB and in Redis.
* @param eventId ID of the event to calculate the results for
* @returns whether the operation succeeded and the eventId
*/
const calculateAvgRating: ActivityHandler = async (eventId: number, context: InvocationContext): Promise<ActivityOutput> => {
try {
const ads = new DataSource(DBConfig)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ import { validateRatingsActivityName } from './validateRatingsActivity'
export const orchestratorName = 'closeRatingOrchestrator'
export type ActivityOutput = { eventId: number; success: boolean }

/**
* Durable Functions orchestrator function that executes the activies that validate and accumulate the ratings of finished events.
* TODO better docs, maybe diagrams?
*/
const orchestrator: OrchestrationHandler = function* (context: OrchestrationContext) {
const events: { eventId: number; state: EventState }[] = context.df.getInput()
context.log(`Orchestrator function started, starting the validation of ratings for ${events.length} event(s).`)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { orchestratorName } from './closeRatingOrchestrator'

/**
* Called automatically every night to make events that have been rateable for more than ~8 days unrateable.
* Also deletes them from the cache. TODO
* Then it starts the closeRating orchestration that will eventually validate and accumulate the ratings.
*/
const closeRatingStarter = async (myTimer: Timer, context: InvocationContext): Promise<void> => {
try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,38 @@ import { InvocationContext } from '@azure/functions'
import * as df from 'durable-functions'
import { ActivityHandler } from 'durable-functions'
import { DataSource, In } from 'typeorm'
import { getRedisClient } from '../../../redis/redisClient'
import { DBConfig } from '../../../typeorm/configOptions'
import { RatingResult } from '../../../typeorm/entities/RatingResult'

export const deletePreviousResultsActivityName = 'deletePreviousResultsActivity'

/**
* Durable Functions activity that deletes all the previous rating results for event whose ratings have been invalidated.
* It deletes the entries by events to reduce the load on the DB.
* @param eventIds list of eventIDs, whose rating result records will be deleted
* @returns whether the operation succeeded
*/
const deletePreviousResults: ActivityHandler = async (eventIds: number[], context: InvocationContext): Promise<boolean> => {
try {
const ads = await new DataSource(DBConfig).initialize()
const redisClient = await getRedisClient(context)

const ratingResultRepo = ads.getRepository(RatingResult)
const ratingResults = await ratingResultRepo.find({ where: { eventId: In(eventIds) } })
if (ratingResults.length > 0) {
await ratingResultRepo.delete(ratingResults.map((rr) => rr.id))
const resultsGroupedByEventId = ratingResults.reduce((map, rr) => {
if (!map[rr.eventId]) {
map[rr.eventId] = []
}
map[rr.eventId].push(rr)
return map
}, {} as Record<number, RatingResult[]>)
for (const eventId of Object.keys(resultsGroupedByEventId)) {
await ratingResultRepo.delete(resultsGroupedByEventId[parseInt(eventId)].map((rr) => rr.id))
}
}
await redisClient.del(eventIds.map((eId) => `ratingResult:${eId}`))
return true
} catch (e) {
context.error(`Error in deleting previous results: ${e}`)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ import { ActivityOutput } from './closeRatingOrchestrator'

export const validateRatingsActivityName = 'validateRatingsActivity'

/**
* Durable Functions activity that validates all the ratings for a given event. For now it does nothing, just a placeholder TODO
* @param eventId ID of the event whose ratings wil be validated
* @returns whether the operation succeeded and the eventId
*/
const validateRatings: ActivityHandler = async (eventId: number, context: InvocationContext): Promise<ActivityOutput> => {
try {
const ads = await new DataSource(DBConfig).initialize()
Expand Down
8 changes: 5 additions & 3 deletions packages/functions/src/functions/results/invalidateOne.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,26 @@
import { app, HttpRequest, HttpResponseInit, InvocationContext } from '@azure/functions'
import { AlertLevel, EventState } from '@pontozo/common'
import { getRedisClient } from '../../redis/redisClient'
import { newAlertItem } from '../../service/alert.service'
import { getUserFromHeaderAndAssertAdmin } from '../../service/auth.service'
import Event from '../../typeorm/entities/Event'
import { getAppDataSource } from '../../typeorm/getConfig'
import { handleException } from '../../util/handleException'

/**
* A util function to invalidate the rating results of an event. It sets its state to 'INVALIDATED',
* so the next time the closeRating orchestrator will be run, the results will be recalculated for this event as well.
* It can't be called from the client, only manualy with an admin JWT.
*/
export const invalidateOneResult = async (req: HttpRequest, context: InvocationContext): Promise<HttpResponseInit> => {
try {
const user = await getUserFromHeaderAndAssertAdmin(req, context)
const eventId = parseInt(req.params.eventId)
const ads = await getAppDataSource(context)
const eventRepo = ads.getRepository(Event)
const redisClient = await getRedisClient(context)

const event = await eventRepo.findOne({ where: { id: eventId } })
event.state = EventState.INVALIDATED
await eventRepo.save(event)
await redisClient.del(`ratingResult:${eventId}`)
await newAlertItem({
ads,
context,
Expand Down
64 changes: 64 additions & 0 deletions packages/functions/src/functions/seasons/recalculate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { app, HttpRequest, HttpResponseInit, InvocationContext } from '@azure/functions'
import { AlertLevel, EventState, PontozoException } from '@pontozo/common'
import * as df from 'durable-functions'
import { newAlertItem } from '../../service/alert.service'
import { getUserFromHeaderAndAssertAdmin } from '../../service/auth.service'
import Event from '../../typeorm/entities/Event'
import Season from '../../typeorm/entities/Season'
import { getAppDataSource } from '../../typeorm/getConfig'
import { handleException } from '../../util/handleException'
import { validateId } from '../../util/validation'
import { orchestratorName } from '../events/closeRating/closeRatingOrchestrator'

/**
* Function to initiate the recalculation of rating scores for every event in a season.
* It is called when an admin clicks on the 'Recalculate scores' button on the season's weight adjustment page.
* The state of all the events of the season that are no longer rateable will be set to 'INVALITED', then
* the closeRating orchestration function is called, which will redo the validation and accumulation stages for these events.
*/
const recalculateRatingsInSeason = async (req: HttpRequest, context: InvocationContext): Promise<HttpResponseInit> => {
try {
const user = await getUserFromHeaderAndAssertAdmin(req, context)
const seasonId = validateId(req)
const ads = await getAppDataSource(context)

const season = await ads.getRepository(Season).findOne({ where: { id: seasonId }, relations: { events: true } })
if (!season) {
throw new PontozoException('A szezon nem talalhato!', 404)
}

await newAlertItem({
context,
desc: `User #${user.szemely_id} initiated result recalculation for Season #${seasonId}`,
level: AlertLevel.INFO,
ads,
})

const invalidatedEvents = season.events
.filter((e) => e.state !== EventState.RATEABLE)
.map((e) => ({ ...e, state: EventState.INVALIDATED }))

if (invalidatedEvents.length > 0) {
await ads.getRepository(Event).save(invalidatedEvents)
const client = df.getClient(context)
const instanceId = await client.startNew(orchestratorName, {
input: invalidatedEvents.map((e) => ({ eventId: e.id, state: e.state })),
})
context.log(`Started orchestration with ID = '${instanceId}'.`)
} else {
context.log(`No events to recalculate`)
}
return {
status: 204,
}
} catch (error) {
return handleException(req, context, error)
}
}

app.http('season-recalculate', {
route: 'seasons/{id}/recalculate',
methods: ['POST'],
handler: recalculateRatingsInSeason,
extraInputs: [df.input.durableClient()],
})

0 comments on commit e2fcede

Please sign in to comment.