Skip to content
Closed
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
33 changes: 32 additions & 1 deletion backend/apps/common/extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, ...]:
Expand Down Expand Up @@ -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()}"
)
Expand All @@ -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()
Comment thread
kart-u marked this conversation as resolved.
63 changes: 61 additions & 2 deletions backend/apps/mentorship/api/internal/mutations/module.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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."
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@kart-u this check is incorrect. The basic structure is like this:

  • Admin -> can create and manage programs AND modules, add mentors to modules;
  • Mentor -> can only edit modules they are assigned to.

So admin does not have to be a mentor to update a program. But they have to be an admin of the program. Like the check you have below on L430-437.

Copy link
Copy Markdown
Contributor Author

@kart-u kart-u Jan 3, 2026

Choose a reason for hiding this comment

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

hello @kasya
In create_module it was specified that only a mentor can create modules
except Mentor.DoesNotExist as e: and and then it was checked whether the user is an admin

I took inspiration from this to decide the permissions. If the permissions should be as you specified above, should I change it accordingly?
also are not program admins a subset of mentor (as in specified program model)??

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@kart-u the permissions should be as I described above, yes. They were not set correctly initially when this project was setup and was something we missed.

As for your second point - kinda the same thing :) I'm working on updating that right now. Admins should not be a subset of mentors.

I understand this blocks you from continuing work on this task. I'll try to push updates asap.

Copy link
Copy Markdown
Contributor Author

@kart-u kart-u Jan 5, 2026

Choose a reason for hiding this comment

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

Hello @kasya
Thanks for the clarification
How about I update admin to be a subset of nest users (if you fine with it?) and then proceed with the permission structure you described above?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@kart-u thank you, but I'm already working on it 👍🏼
Need to update my pr with more changes though since it's tricky with just having them as Nest Users.

Don't worry about the deadline. I know I'm blocking you right now and this will not affect you.

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"])
Comment thread
kart-u marked this conversation as resolved.

return program
8 changes: 8 additions & 0 deletions backend/apps/mentorship/api/internal/nodes/module.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
2 changes: 1 addition & 1 deletion backend/apps/mentorship/api/internal/queries/module.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions backend/apps/mentorship/migrations/0007_module_position.py
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@kart-u as per our new contributor workflow you need to update docker file to have a custom volume name, so that this migration does not mess up our local db state.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I have added a custom db volume in commit 0882712

Original file line number Diff line number Diff line change
@@ -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"),
),
]
4 changes: 4 additions & 0 deletions backend/apps/mentorship/models/module.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ class Meta:
blank=True,
default="",
)
position = models.IntegerField(
verbose_name="Position",
default=0,
)

# FKs.
labels = models.JSONField(
Expand Down
6 changes: 4 additions & 2 deletions backend/settings/graphql.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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]
)
2 changes: 2 additions & 0 deletions backend/tests/apps/common/extensions_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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")
Expand Down
4 changes: 2 additions & 2 deletions docker-compose/local/compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ services:
networks:
- nest-network
volumes:
- db-data:/var/lib/postgresql/data
- db-data-kart-u-3016:/var/lib/postgresql/data
Comment thread
kart-u marked this conversation as resolved.

docs:
container_name: nest-docs
Expand Down Expand Up @@ -143,7 +143,7 @@ networks:
volumes:
backend-venv:
cache-data:
db-data:
db-data-kart-u-3016:
docs-venv:
frontend-next:
frontend-node-modules:
Loading