Skip to content
Open
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
53 changes: 53 additions & 0 deletions backend/apps/common/graphql_cache.py
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you move the invalidation logic to extensions.py instead and reuse generate_key method to generate a cache key?

Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
"""GraphQL cache utilities for invalidating cached queries."""

import hashlib
import json
import logging

from django.conf import settings
from django.core.cache import cache

logger = logging.getLogger(__name__)


def invalidate_graphql_cache(field_name: str, field_args: dict) -> bool:
"""Invalidate a specific GraphQL query from the resolver cache.

Args:
field_name: The GraphQL field name (e.g., 'getProgram').
field_args: The field's arguments as a dict (e.g., {'programKey': 'my-program'}).

Returns:
True if cache was invalidated, False if key didn't exist.

"""
key = f"{field_name}:{json.dumps(field_args, sort_keys=True)}"
cache_key = (
f"{settings.GRAPHQL_RESOLVER_CACHE_PREFIX}-{hashlib.sha256(key.encode()).hexdigest()}"
)
return cache.delete(cache_key)


def invalidate_program_cache(program_key: str) -> None:
"""Invalidate all GraphQL caches related to a program.

Args:
program_key: The program's key identifier.

"""
invalidate_graphql_cache("getProgram", {"programKey": program_key})

invalidate_graphql_cache("getProgramModules", {"programKey": program_key})


def invalidate_module_cache(program_key: str, module_key: str) -> None:
"""Invalidate all GraphQL caches related to a module.

Args:
program_key: The program's key identifier.
module_key: The module's key identifier.

"""
invalidate_graphql_cache("getModule", {"moduleKey": module_key, "programKey": program_key})

invalidate_program_cache(program_key)
10 changes: 9 additions & 1 deletion backend/apps/mentorship/api/internal/mutations/module.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from django.db import transaction
from django.utils import timezone

from apps.common.graphql_cache import invalidate_module_cache, invalidate_program_cache
from apps.github.models import User as GithubUser
from apps.mentorship.api.internal.nodes.module import (
CreateModuleInput,
Expand Down Expand Up @@ -119,6 +120,8 @@ def create_module(self, info: strawberry.Info, input_data: CreateModuleInput) ->
mentors_to_set.add(creator_as_mentor)
module.mentors.set(list(mentors_to_set))

invalidate_program_cache(program.key)

return module

@strawberry.mutation(permission_classes=[IsAuthenticated])
Expand Down Expand Up @@ -330,8 +333,9 @@ def update_module(self, info: strawberry.Info, input_data: UpdateModuleInput) ->
module = Module.objects.select_related("program").get(
key=input_data.key, program__key=input_data.program_key
)
old_module_key = module.key
except Module.DoesNotExist as e:
raise ObjectDoesNotExist(msg=MODULE_NOT_FOUND_MSG) from e
raise ObjectDoesNotExist(MODULE_NOT_FOUND_MSG) from e

try:
creator_as_mentor = Mentor.objects.get(nest_user=user)
Expand Down Expand Up @@ -400,4 +404,8 @@ def update_module(self, info: strawberry.Info, input_data: UpdateModuleInput) ->

module.program.save(update_fields=["experience_levels"])

invalidate_module_cache(module.program.key, old_module_key)
if module.key != old_module_key:
invalidate_module_cache(module.program.key, module.key)

return module
8 changes: 8 additions & 0 deletions backend/apps/mentorship/api/internal/mutations/program.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied, ValidationError
from django.db import transaction

from apps.common.graphql_cache import invalidate_program_cache
from apps.mentorship.api.internal.mutations.module import resolve_mentors_from_logins
from apps.mentorship.api.internal.nodes.enum import ProgramStatusEnum
from apps.mentorship.api.internal.nodes.program import (
Expand Down Expand Up @@ -76,6 +77,7 @@ def update_program(self, info: strawberry.Info, input_data: UpdateProgramInput)

try:
program = Program.objects.get(key=input_data.key)
old_key = program.key
except Program.DoesNotExist as err:
msg = f"Program with key '{input_data.key}' not found."
logger.warning(msg, exc_info=True)
Expand Down Expand Up @@ -133,6 +135,10 @@ def update_program(self, info: strawberry.Info, input_data: UpdateProgramInput)
admins_to_set = resolve_mentors_from_logins(input_data.admin_logins)
program.admins.set(admins_to_set)

invalidate_program_cache(old_key)
if program.key != old_key:
invalidate_program_cache(program.key)

return program

@strawberry.mutation(permission_classes=[IsAuthenticated])
Expand Down Expand Up @@ -161,6 +167,8 @@ def update_program_status(
program.status = input_data.status.value
program.save()

invalidate_program_cache(program.key)

logger.info("Updated status of program '%s' to '%s'", program.key, program.status)

return program
28 changes: 16 additions & 12 deletions frontend/__tests__/unit/pages/CreateProgram.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -160,19 +160,23 @@ describe('CreateProgramPage (comprehensive tests)', () => {
fireEvent.submit(screen.getByText('Save').closest('form'))

await waitFor(() => {
expect(mockCreateProgram).toHaveBeenCalledWith({
variables: {
input: {
name: 'Test Program',
description: 'A description',
menteesLimit: 0,
startedAt: '2025-01-01',
endedAt: '2025-12-31',
tags: ['tag1', 'tag2'],
domains: ['domain1', 'domain2'],
expect(mockCreateProgram).toHaveBeenCalledWith(
expect.objectContaining({
variables: {
input: {
name: 'Test Program',
description: 'A description',
menteesLimit: 0,
startedAt: '2025-01-01',
endedAt: '2025-12-31',
tags: ['tag1', 'tag2'],
domains: ['domain1', 'domain2'],
},
},
},
})
awaitRefetchQueries: true,
refetchQueries: expect.any(Array),
})
)

expect(mockRouterPush).toHaveBeenCalledWith('/my/mentorship')
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const ModuleDetailsPage = () => {
programKey,
moduleKey,
},
fetchPolicy: 'cache-and-network',
})

const programModule = data?.getModule
Expand All @@ -34,7 +35,7 @@ const ModuleDetailsPage = () => {
}
}, [error])

if (isLoading) return <LoadingSpinner />
if (isLoading && !data) return <LoadingSpinner />

if (error) {
return (
Expand Down
5 changes: 3 additions & 2 deletions frontend/src/app/mentorship/programs/[programKey]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const ProgramDetailsPage = () => {
} = useQuery(GetProgramAndModulesDocument, {
variables: { programKey },
skip: !programKey,
fetchPolicy: 'cache-and-network',
})

const program = data?.getProgram
Expand All @@ -29,9 +30,9 @@ const ProgramDetailsPage = () => {
if (graphQLRequestError) {
handleAppError(graphQLRequestError)
}
}, [graphQLRequestError, programKey])
}, [graphQLRequestError])

if (isLoading) return <LoadingSpinner />
if (isLoading && !data) return <LoadingSpinner />

if (graphQLRequestError) {
return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ import { useState, useEffect } from 'react'
import { ErrorDisplay, handleAppError } from 'app/global-error'
import { ProgramStatusEnum } from 'types/__generated__/graphql'
import { UpdateProgramDocument } from 'types/__generated__/programsMutations.generated'
import { GetProgramDetailsDocument } from 'types/__generated__/programsQueries.generated'
import {
GetMyProgramsDocument,
GetProgramDetailsDocument,
} from 'types/__generated__/programsQueries.generated'
import type { ExtendedSession } from 'types/auth'
import { formatDateForInput } from 'utils/dateFormatter'
import { parseCommaSeparated } from 'utils/parser'
Expand Down Expand Up @@ -104,7 +107,11 @@ const EditProgramPage = () => {
status: formData.status,
}

const result = await updateProgram({ variables: { input } })
const result = await updateProgram({
variables: { input },
refetchQueries: [{ query: GetMyProgramsDocument }],
awaitRefetchQueries: true,
})
const updatedProgramKey = result.data?.updateProgram?.key || programKey

addToast({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { ErrorDisplay, handleAppError } from 'app/global-error'
import { ExperienceLevelEnum } from 'types/__generated__/graphql'
import { UpdateModuleDocument } from 'types/__generated__/moduleMutations.generated'
import { GetProgramAdminsAndModulesDocument } from 'types/__generated__/moduleQueries.generated'
import { GetProgramAndModulesDocument } from 'types/__generated__/programsQueries.generated'
import type { ExtendedSession } from 'types/auth'
import type { ModuleFormData } from 'types/mentorship'
import { formatDateForInput } from 'utils/dateFormatter'
Expand Down Expand Up @@ -111,7 +112,11 @@ const EditModulePage = () => {
tags: parseCommaSeparated(formData.tags),
}

const result = await updateModule({ variables: { input } })
const result = await updateModule({
variables: { input },
refetchQueries: [{ query: GetProgramAndModulesDocument, variables: { programKey } }],
awaitRefetchQueries: true,
})
const updatedModuleKey = result.data?.updateModule?.key || moduleKey

addToast({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const ModuleDetailsPage = () => {
programKey,
moduleKey,
},
fetchPolicy: 'cache-and-network',
})

useEffect(() => {
Expand All @@ -36,7 +37,7 @@ const ModuleDetailsPage = () => {
}
}, [data, error])

if (isLoading) return <LoadingSpinner />
if (isLoading && !data) return <LoadingSpinner />

if (!module) {
return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { addToast } from '@heroui/toast'
import { useRouter, useParams } from 'next/navigation'
import { useSession } from 'next-auth/react'
import React, { useEffect, useState } from 'react'
import { ErrorDisplay, handleAppError } from 'app/global-error'
import { ErrorDisplay } from 'app/global-error'
import { ExperienceLevelEnum } from 'types/__generated__/graphql'
import { CreateModuleDocument } from 'types/__generated__/moduleMutations.generated'
import {
Expand Down Expand Up @@ -100,29 +100,8 @@ const CreateModulePage = () => {

await createModule({
variables: { input },
update: (cache, { data: mutationData }) => {
const created = mutationData?.createModule
if (!created) return
try {
const existing = cache.readQuery({
query: GetProgramAndModulesDocument,
variables: { programKey },
})
if (existing?.getProgram && existing?.getProgramModules) {
cache.writeQuery({
query: GetProgramAndModulesDocument,
variables: { programKey },
data: {
getProgram: existing.getProgram,
getProgramModules: [created, ...existing.getProgramModules],
},
})
}
} catch (_err) {
handleAppError(_err)
return
}
},
refetchQueries: [{ query: GetProgramAndModulesDocument, variables: { programKey } }],
awaitRefetchQueries: true,
})

addToast({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const ProgramDetailsPage = () => {
const { data, loading: isQueryLoading } = useQuery(GetProgramAndModulesDocument, {
variables: { programKey },
skip: !programKey,
fetchPolicy: 'cache-and-network',
notifyOnNetworkStatusChange: true,
})

Expand Down Expand Up @@ -89,7 +90,7 @@ const ProgramDetailsPage = () => {
}
}, [data])

if (isLoading) return <LoadingSpinner />
if (isLoading && !data) return <LoadingSpinner />

if (!program && !isLoading) {
return (
Expand Down
7 changes: 6 additions & 1 deletion frontend/src/app/my/mentorship/programs/create/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { useSession } from 'next-auth/react'
import React, { useEffect, useState } from 'react'

import { CreateProgramDocument } from 'types/__generated__/programsMutations.generated'
import { GetMyProgramsDocument } from 'types/__generated__/programsQueries.generated'
import { ExtendedSession } from 'types/auth'
import { parseCommaSeparated } from 'utils/parser'
import LoadingSpinner from 'components/LoadingSpinner'
Expand Down Expand Up @@ -61,7 +62,11 @@ const CreateProgramPage = () => {
domains: parseCommaSeparated(formData.domains),
}

await createProgram({ variables: { input } })
await createProgram({
variables: { input },
refetchQueries: [{ query: GetMyProgramsDocument }],
awaitRefetchQueries: true,
})

addToast({
description: 'Program created successfully!',
Expand Down