diff --git a/backend/apps/common/extensions.py b/backend/apps/common/extensions.py index 21e9373950..9554f7902d 100644 --- a/backend/apps/common/extensions.py +++ b/backend/apps/common/extensions.py @@ -11,6 +11,25 @@ from strawberry.schema import Schema from strawberry.utils.str_converters import to_camel_case +CACHE_VERSION_KEY = "graphql:cache_version" + + +def get_cache_version() -> int: + """Get the current cache version.""" + version = cache.get(CACHE_VERSION_KEY) + if version is None: + cache.set(CACHE_VERSION_KEY, 1, timeout=None) + return 1 + return version + + +def bump_cache_version() -> None: + """Bump the cache version.""" + try: + cache.incr(CACHE_VERSION_KEY) + except ValueError: + cache.set(CACHE_VERSION_KEY, 2) + @lru_cache(maxsize=1) def get_protected_fields(schema: Schema) -> tuple[str, ...]: @@ -46,7 +65,10 @@ def generate_key(self, field_name: str, field_args: dict) -> str: str: The unique cache key. """ - key = f"{field_name}:{json.dumps(field_args, sort_keys=True)}" + version = get_cache_version() + key = ( + f"{field_name}:{json.dumps({'args': field_args, 'version': version}, sort_keys=True)}" + ) return ( f"{settings.GRAPHQL_RESOLVER_CACHE_PREFIX}-{hashlib.sha256(key.encode()).hexdigest()}" ) @@ -65,3 +87,12 @@ def resolve(self, _next, root, info, *args, **kwargs): lambda: _next(root, info, *args, **kwargs), settings.GRAPHQL_RESOLVER_CACHE_TIME_SECONDS, ) + + +class CacheInvalidationExtension(SchemaExtension): + """CacheInvalidationExtension class.""" + + def on_execute(self): + """Invalidate cache on mutation.""" + if str(self.execution_context.operation_type) == "OperationType.MUTATION": + bump_cache_version() diff --git a/backend/apps/mentorship/api/internal/mutations/module.py b/backend/apps/mentorship/api/internal/mutations/module.py index f83ab80668..e172635737 100644 --- a/backend/apps/mentorship/api/internal/mutations/module.py +++ b/backend/apps/mentorship/api/internal/mutations/module.py @@ -12,8 +12,10 @@ from apps.mentorship.api.internal.nodes.module import ( CreateModuleInput, ModuleNode, + SetModuleOrderInput, UpdateModuleInput, ) +from apps.mentorship.api.internal.nodes.program import ProgramNode from apps.mentorship.models import Mentor, Module, Program from apps.mentorship.models.issue_user_interest import IssueUserInterest from apps.mentorship.models.task import Task @@ -75,9 +77,10 @@ def create_module(self, info: strawberry.Info, input_data: CreateModuleInput) -> user = info.context.request.user try: - program = Program.objects.get(key=input_data.program_key) + program = Program.objects.select_for_update().get(key=input_data.program_key) project = Project.objects.get(id=input_data.project_id) creator_as_mentor = Mentor.objects.get(nest_user=user) + new_position = program.modules.count() except (Program.DoesNotExist, Project.DoesNotExist) as e: msg = f"{e.__class__.__name__} matching query does not exist." raise ObjectDoesNotExist(msg) from e @@ -106,6 +109,7 @@ def create_module(self, info: strawberry.Info, input_data: CreateModuleInput) -> tags=input_data.tags, program=program, project=project, + position=new_position, ) if module.experience_level not in program.experience_levels: @@ -397,5 +401,60 @@ def update_module(self, info: strawberry.Info, input_data: UpdateModuleInput) -> module.program.experience_levels.remove(old_experience_level) module.program.save(update_fields=["experience_levels"]) - return module + + @strawberry.mutation(permission_classes=[IsAuthenticated]) + @transaction.atomic + def set_module_order( + self, info: strawberry.Info, input_data: SetModuleOrderInput + ) -> ProgramNode: + """Set the order of modules within a program. User must be an admin of the program.""" + user = info.context.request.user + try: + program = Program.objects.select_for_update().get(key=input_data.program_key) + except Program.DoesNotExist as e: + msg = f"Program with key '{input_data.program_key}' not found." + raise ObjectDoesNotExist(msg) from e + + try: + admin = Mentor.objects.get(nest_user=user) + except Mentor.DoesNotExist as err: + msg = "You must be a mentor to update a program." + logger.warning( + "User '%s' is not a mentor and cannot update programs.", + user.username, + exc_info=True, + ) + raise PermissionDenied(msg) from err + + if not program.admins.filter(id=admin.id).exists(): + msg = "You must be an admin of this program to update it." + logger.warning( + "Permission denied for user '%s' to update program '%s'.", + user.username, + program.key, + ) + raise PermissionDenied(msg) + + existing_modules = Module.objects.filter(program=program) + existing_module_keys = {m.key for m in existing_modules} + + if len(input_data.module_keys) != len(set(input_data.module_keys)): + raise ValidationError(message="Duplicate module keys are not allowed in the ordering.") + + if set(input_data.module_keys) != existing_module_keys: + raise ValidationError( + message="All modules in the program must be included in the ordering." + ) + + modules_by_key = {m.key: m for m in existing_modules} + modules_to_update = [] + + for index, module_key in enumerate(input_data.module_keys): + module = modules_by_key[module_key] + module.position = index + modules_to_update.append(module) + + Module.objects.bulk_update(modules_to_update, ["position"]) + + return program diff --git a/backend/apps/mentorship/api/internal/nodes/module.py b/backend/apps/mentorship/api/internal/nodes/module.py index 8ccbdf46f1..caf58f2b6d 100644 --- a/backend/apps/mentorship/api/internal/nodes/module.py +++ b/backend/apps/mentorship/api/internal/nodes/module.py @@ -194,3 +194,11 @@ class UpdateModuleInput: project_name: str started_at: datetime tags: list[str] = strawberry.field(default_factory=list) + + +@strawberry.input +class SetModuleOrderInput: + """Input for setting the order of modules within a program.""" + + program_key: str + module_keys: list[str] diff --git a/backend/apps/mentorship/api/internal/queries/module.py b/backend/apps/mentorship/api/internal/queries/module.py index 8fec753752..fcf0508e71 100644 --- a/backend/apps/mentorship/api/internal/queries/module.py +++ b/backend/apps/mentorship/api/internal/queries/module.py @@ -22,7 +22,7 @@ def get_program_modules(self, program_key: str) -> list[ModuleNode]: Module.objects.filter(program__key=program_key) .select_related("program", "project") .prefetch_related("mentors__github_user") - .order_by("started_at") + .order_by("position", "started_at") ) @strawberry.field diff --git a/backend/apps/mentorship/migrations/0007_module_position.py b/backend/apps/mentorship/migrations/0007_module_position.py new file mode 100644 index 0000000000..53fd5c2bf9 --- /dev/null +++ b/backend/apps/mentorship/migrations/0007_module_position.py @@ -0,0 +1,17 @@ +# Generated by Django 6.0 on 2025-12-29 08:41 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("mentorship", "0006_alter_menteemodule_ended_at"), + ] + + operations = [ + migrations.AddField( + model_name="module", + name="position", + field=models.IntegerField(default=0, verbose_name="Position"), + ), + ] diff --git a/backend/apps/mentorship/models/module.py b/backend/apps/mentorship/models/module.py index 27fae9a8d6..c89754de4a 100644 --- a/backend/apps/mentorship/models/module.py +++ b/backend/apps/mentorship/models/module.py @@ -45,6 +45,10 @@ class Meta: blank=True, default="", ) + position = models.IntegerField( + verbose_name="Position", + default=0, + ) # FKs. labels = models.JSONField( diff --git a/backend/settings/graphql.py b/backend/settings/graphql.py index edb830c1ff..74bab2d202 100644 --- a/backend/settings/graphql.py +++ b/backend/settings/graphql.py @@ -4,7 +4,7 @@ from apps.api.internal.mutations import ApiMutations from apps.api.internal.queries import ApiKeyQueries -from apps.common.extensions import CacheExtension +from apps.common.extensions import CacheExtension, CacheInvalidationExtension from apps.github.api.internal.queries import GithubQuery from apps.mentorship.api.internal.mutations import ( ModuleMutation, @@ -41,4 +41,6 @@ class Query( """Schema queries.""" -schema = strawberry.Schema(mutation=Mutation, query=Query, extensions=[CacheExtension]) +schema = strawberry.Schema( + mutation=Mutation, query=Query, extensions=[CacheInvalidationExtension, CacheExtension] +) diff --git a/backend/tests/apps/common/extensions_test.py b/backend/tests/apps/common/extensions_test.py index 3b7bb1c982..8420f2e0ee 100644 --- a/backend/tests/apps/common/extensions_test.py +++ b/backend/tests/apps/common/extensions_test.py @@ -143,6 +143,7 @@ def test_skips_protected_fields(self, extension, mock_info, mock_next): @patch("apps.common.extensions.cache") def test_returns_cached_result_on_hit(self, mock_cache, extension, mock_info, mock_next): """Test that cached result is returned on cache hit.""" + mock_cache.get.return_value = 1 cached_result = {"name": "Cached OWASP"} mock_cache.get_or_set.return_value = cached_result @@ -155,6 +156,7 @@ def test_returns_cached_result_on_hit(self, mock_cache, extension, mock_info, mo @patch("apps.common.extensions.cache") def test_caches_result_on_miss(self, mock_cache, extension, mock_info, mock_next): """Test that result is cached on cache miss.""" + mock_cache.get.return_value = 1 mock_cache.get_or_set.side_effect = lambda _key, default, _timeout: default() extension.resolve(mock_next, None, mock_info, key="germany") diff --git a/docker-compose/local/compose.yaml b/docker-compose/local/compose.yaml index 5ed83ff803..93f4ffd7df 100644 --- a/docker-compose/local/compose.yaml +++ b/docker-compose/local/compose.yaml @@ -66,7 +66,7 @@ services: networks: - nest-network volumes: - - db-data:/var/lib/postgresql/data + - db-data-kart-u-3016:/var/lib/postgresql/data docs: container_name: nest-docs @@ -143,7 +143,7 @@ networks: volumes: backend-venv: cache-data: - db-data: + db-data-kart-u-3016: docs-venv: frontend-next: frontend-node-modules: diff --git a/frontend/__tests__/unit/components/ModuleCard.test.tsx b/frontend/__tests__/unit/components/ModuleCard.test.tsx new file mode 100644 index 0000000000..344cc7ae25 --- /dev/null +++ b/frontend/__tests__/unit/components/ModuleCard.test.tsx @@ -0,0 +1,207 @@ +import { render, screen, fireEvent } from '@testing-library/react' +import React from 'react' +import { ExperienceLevelEnum } from 'types/__generated__/graphql' +import { Module } from 'types/mentorship' +import ModuleCard, { getSimpleDuration } from 'components/ModuleCard' + +// Mock necessary modules +jest.mock('next/navigation', () => ({ + usePathname: () => '/mentorship/123', +})) +jest.mock('components/SingleModuleCard', () => ({ + __esModule: true, + default: ({ module }: { module: Module }) => ( +