Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
67 changes: 52 additions & 15 deletions backend/apps/common/extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,24 +32,23 @@ def get_protected_fields(schema: Schema) -> tuple[str, ...]:
)


class CacheExtension(SchemaExtension):
"""CacheExtension class."""
def generate_key(field_name: str, field_args: dict) -> str:
"""Generate a unique cache key for a query.

def generate_key(self, field_name: str, field_args: dict) -> str:
"""Generate a unique cache key for a query.
Args:
field_name (str): The GraphQL field name.
field_args (dict): The field's arguments.

Args:
field_name (str): The GraphQL field name.
field_args (dict): The field's arguments.
Returns:
str: The unique cache key.

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

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

class CacheExtension(SchemaExtension):
"""CacheExtension class."""

def resolve(self, _next, root, info, *args, **kwargs):
"""Wrap the resolver to provide caching."""
Expand All @@ -61,7 +60,45 @@ def resolve(self, _next, root, info, *args, **kwargs):
return _next(root, info, *args, **kwargs)

return cache.get_or_set(
self.generate_key(info.field_name, kwargs),
generate_key(info.field_name, kwargs),
lambda: _next(root, info, *args, **kwargs),
settings.GRAPHQL_RESOLVER_CACHE_TIME_SECONDS,
)


def invalidate_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.

"""
cache_key = generate_key(field_name, field_args)
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_cache("getProgram", {"programKey": program_key})
invalidate_cache("getProgramModules", {"programKey": program_key})


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

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

"""
invalidate_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.extensions 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)

Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
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(old_module_key, module.program.key)
if module.key != old_module_key:
invalidate_module_cache(module.key, module.program.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.extensions 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
31 changes: 13 additions & 18 deletions backend/tests/apps/common/extensions_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,44 +5,39 @@
import pytest
from strawberry.permission import PermissionExtension

from apps.common.extensions import CacheExtension, get_protected_fields
from apps.common.extensions import CacheExtension, generate_key, get_protected_fields


class TestGenerateKey:
"""Test cases for the generate_key method."""

@pytest.fixture
def extension(self):
"""Return a CacheExtension instance."""
return CacheExtension()

def test_creates_deterministic_hash(self, extension):
def test_creates_deterministic_hash(self):
"""Test that generate_key creates a deterministic hash key."""
key1 = extension.generate_key("chapter", {"key": "germany"})
key2 = extension.generate_key("chapter", {"key": "germany"})
key1 = generate_key("chapter", {"key": "germany"})
key2 = generate_key("chapter", {"key": "germany"})

assert key1 == key2
assert key1.startswith("graphql-")
assert len(key1.split("-")[-1]) == 64 # SHA256 hex digest length

def test_differs_for_different_field_names(self, extension):
def test_differs_for_different_field_names(self):
"""Test that different field names produce different keys."""
key1 = extension.generate_key("chapter", {"key": "germany"})
key2 = extension.generate_key("project", {"key": "germany"})
key1 = generate_key("chapter", {"key": "germany"})
key2 = generate_key("project", {"key": "germany"})

assert key1 != key2

def test_differs_for_different_args(self, extension):
def test_differs_for_different_args(self):
"""Test that different arguments produce different keys."""
key1 = extension.generate_key("chapter", {"key": "germany"})
key2 = extension.generate_key("chapter", {"key": "canada"})
key1 = generate_key("chapter", {"key": "germany"})
key2 = generate_key("chapter", {"key": "canada"})

assert key1 != key2

def test_sorts_args_for_consistency(self, extension):
def test_sorts_args_for_consistency(self):
"""Test that argument order doesn't affect the key."""
key1 = extension.generate_key("chapter", {"a": "1", "b": "2"})
key2 = extension.generate_key("chapter", {"b": "2", "a": "1"})
key1 = generate_key("chapter", {"a": "1", "b": "2"})
key2 = generate_key("chapter", {"b": "2", "a": "1"})

assert key1 == key2

Expand Down
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),
})
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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',
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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
Loading