From 1cc1f8c51de259b2aa84cd27cce6c39cac2a0360 Mon Sep 17 00:00:00 2001 From: Daniel Egger Date: Wed, 28 Aug 2024 22:04:37 +0200 Subject: [PATCH 1/3] Add graphql course cache code from Chrigu --- server/config/settings/base.py | 1 + server/vbv_lernwelt/course/middleware.py | 205 +++++++++++++++++++ server/vbv_lernwelt/debugtools/decorators.py | 32 +++ 3 files changed, 238 insertions(+) create mode 100644 server/vbv_lernwelt/course/middleware.py create mode 100644 server/vbv_lernwelt/debugtools/decorators.py diff --git a/server/config/settings/base.py b/server/config/settings/base.py index de7744aa..48c68bf1 100644 --- a/server/config/settings/base.py +++ b/server/config/settings/base.py @@ -201,6 +201,7 @@ MIDDLEWARE = [ "vbv_lernwelt.core.middleware.security.SecurityRequestResponseLoggingMiddleware", "wagtail.contrib.redirects.middleware.RedirectMiddleware", "vbv_lernwelt.core.middleware.auth.UserLoggedInCookieMiddleWare", + "vbv_lernwelt.course.middleware.GraphQLQueryFilterMiddleware", # "vbv_lernwelt.debugtools.middleware.QueryCountDebugMiddleware", ] diff --git a/server/vbv_lernwelt/course/middleware.py b/server/vbv_lernwelt/course/middleware.py new file mode 100644 index 00000000..d9814760 --- /dev/null +++ b/server/vbv_lernwelt/course/middleware.py @@ -0,0 +1,205 @@ +# myapp/middleware.py +import json +import re + +import structlog +from django.core.cache import cache + +logger = structlog.get_logger(__name__) + +# TODO: refactor so that the query from the client is loaded +COURSE_QUERY = """ +query courseQuery($slug: String!, $user: String) { + course(slug: $slug) { + id + title + slug + category_name + profiles + course_session_users(id: $user) { + id + __typename + chosen_profile + course_session { + id + __typename + } + } + configuration { + id + enable_circle_documents + enable_learning_mentor + enable_competence_certificates + is_uk + is_vv + __typename + } + action_competences { + competence_id + ...CoursePageFields + performance_criteria { + competence_id + learning_unit { + id + slug + evaluate_url + __typename + } + ...CoursePageFields + __typename + } + __typename + } + learning_path { + ...CoursePageFields + topics { + is_visible + ...CoursePageFields + circles { + description + goals + profiles + is_base_circle + ...CoursePageFields + learning_sequences { + icon + ...CoursePageFields + learning_units { + evaluate_url + ...CoursePageFields + performance_criteria { + ...CoursePageFields + __typename + } + learning_contents { + can_user_self_toggle_course_completion + content_url + minutes + description + ...CoursePageFields + ... on LearningContentAssignmentObjectType { + assignment_type + content_assignment { + id + assignment_type + __typename + } + competence_certificate { + ...CoursePageFields + __typename + } + __typename + } + ... on LearningContentEdoniqTestObjectType { + checkbox_text + has_extended_time_test + content_assignment { + id + assignment_type + __typename + } + competence_certificate { + ...CoursePageFields + __typename + } + __typename + } + ... on LearningContentRichTextObjectType { + text + __typename + } + __typename + } + __typename + } + __typename + } + __typename + } + __typename + } + __typename + } + __typename + } +} +fragment CoursePageFields on CoursePageInterface { + title + id + slug + content_type + frontend_url + __typename +} +""" + +COURSE_SESSION_USER_QUERY = """ +query courseQuery($slug: String!, $user: String) { + course(slug: $slug) { + course_session_users(id: $user) { + id + __typename + chosen_profile + course_session { + id + __typename + } + } + } +} +""" + + +class GraphQLQueryFilterMiddleware: + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + # Check if the request is a GraphQL request + if request.content_type == "application/json" and "/graphql" in request.path: + try: + # Parse the GraphQL query from the request body + body = json.loads(request.body) + query = body.get("query", "") + + if self.normalize_string(query) == self.normalize_string(COURSE_QUERY): + logger.debug("CourseQuery detected") + slug = body.get("variables", {}).get("slug", "") + + if slug: + key = f"course_query_{slug}" + cache_data = cache.get(key) + + if cache_data: + # Cache hit: only make course session user query and add to cache data + logger.debug("cache hit", key=key) + body["query"] = COURSE_SESSION_USER_QUERY + request._body = json.dumps(body).encode("utf-8") + response = self.get_response(request) + content = json.loads(response.content) + cache_data["data"]["course"][ + "course_session_users" + ] = content["data"]["course"]["course_session_users"] + response.content = json.dumps(cache_data) + return response + + else: + # Cache miss: make the original query and cache the result + logger.debug("cache miss", key=key) + response = self.get_response(request) + content = json.loads(response.content) + del content["data"]["course"]["course_session_users"] + cache.set(key, content, 60 * 10) + + return response + except Exception as e: + # Handle any exceptions in parsing or filtering + logger.error(f"Error in GraphQLQueryFilterMiddleware", exc_info=e) + + # Continue processing the request if not blocked + response = self.get_response(request) + return response + + def normalize_string(self, s): + # Remove all whitespace characters (space, tabs, newlines) + return re.sub(r"\s+", "", s) diff --git a/server/vbv_lernwelt/debugtools/decorators.py b/server/vbv_lernwelt/debugtools/decorators.py new file mode 100644 index 00000000..102707e5 --- /dev/null +++ b/server/vbv_lernwelt/debugtools/decorators.py @@ -0,0 +1,32 @@ +import functools +import time + +from django.db import connection, reset_queries + + +def count_queries(func): + @functools.wraps(func) + def wrapper(*args, **kwargs): + # Reset the query log + reset_queries() + + # Start the timer + start_time = time.time() + + # Execute the function + result = func(*args, **kwargs) + + # Stop the timer + end_time = time.time() + + # Count the number of queries + query_count = len(connection.queries) + + # Calculate the execution time + execution_time = end_time - start_time + + print(f"{func.__name__} executed {query_count} queries in {execution_time:.4f} seconds.") + + return result + + return wrapper From c34a42fba15df4f99d05d8b736fe5acb6b1c2ca9 Mon Sep 17 00:00:00 2001 From: Daniel Egger Date: Wed, 28 Aug 2024 22:07:55 +0200 Subject: [PATCH 2/3] Add `user__sso_id` field to CourseSessionUserAdmin --- server/vbv_lernwelt/course/admin.py | 7 +++++++ server/vbv_lernwelt/course/middleware.py | 8 ++++---- server/vbv_lernwelt/debugtools/decorators.py | 4 +++- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/server/vbv_lernwelt/course/admin.py b/server/vbv_lernwelt/course/admin.py index 8ec26bb9..1561f191 100644 --- a/server/vbv_lernwelt/course/admin.py +++ b/server/vbv_lernwelt/course/admin.py @@ -65,6 +65,7 @@ class CourseSessionUserAdmin(admin.ModelAdmin): "role", "circles", "optional_attendance", + "user_sso_id", # "created_at", # "updated_at", ] @@ -95,6 +96,12 @@ class CourseSessionUserAdmin(admin.ModelAdmin): user_last_name.short_description = "Last Name" user_last_name.admin_order_field = "user__last_name" + def user_sso_id(self, obj): + return obj.user.sso_id + + user_sso_id.short_description = "SSO ID" + user_sso_id.admin_order_field = "user__sso_id" + def circles(self, obj): return ", ".join([c.title for c in obj.expert.all()]) diff --git a/server/vbv_lernwelt/course/middleware.py b/server/vbv_lernwelt/course/middleware.py index d9814760..479e805a 100644 --- a/server/vbv_lernwelt/course/middleware.py +++ b/server/vbv_lernwelt/course/middleware.py @@ -177,9 +177,9 @@ class GraphQLQueryFilterMiddleware: request._body = json.dumps(body).encode("utf-8") response = self.get_response(request) content = json.loads(response.content) - cache_data["data"]["course"][ - "course_session_users" - ] = content["data"]["course"]["course_session_users"] + cache_data["data"]["course"]["course_session_users"] = ( + content["data"]["course"]["course_session_users"] + ) response.content = json.dumps(cache_data) return response @@ -194,7 +194,7 @@ class GraphQLQueryFilterMiddleware: return response except Exception as e: # Handle any exceptions in parsing or filtering - logger.error(f"Error in GraphQLQueryFilterMiddleware", exc_info=e) + logger.error("Error in GraphQLQueryFilterMiddleware", exc_info=e) # Continue processing the request if not blocked response = self.get_response(request) diff --git a/server/vbv_lernwelt/debugtools/decorators.py b/server/vbv_lernwelt/debugtools/decorators.py index 102707e5..19c61ace 100644 --- a/server/vbv_lernwelt/debugtools/decorators.py +++ b/server/vbv_lernwelt/debugtools/decorators.py @@ -25,7 +25,9 @@ def count_queries(func): # Calculate the execution time execution_time = end_time - start_time - print(f"{func.__name__} executed {query_count} queries in {execution_time:.4f} seconds.") + print( + f"{func.__name__} executed {query_count} queries in {execution_time:.4f} seconds." + ) return result From e470cba641f4b824540b1af7936e35c12d604ed0 Mon Sep 17 00:00:00 2001 From: Daniel Egger Date: Wed, 28 Aug 2024 22:11:32 +0200 Subject: [PATCH 3/3] Increase `unread_count` polling interval --- client/src/stores/notifications.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/stores/notifications.ts b/client/src/stores/notifications.ts index 8c0a4476..bed64f82 100644 --- a/client/src/stores/notifications.ts +++ b/client/src/stores/notifications.ts @@ -39,7 +39,7 @@ export const useNotificationsStore = defineStore("notifications", () => { updateUnreadCount(); timerHandle = setInterval( async () => await updateUnreadCount(), - 30000 + 150 * 1000 ) as unknown as number; } else if (!userStore.loggedIn) { log.debug("Notification polling stopped");