diff --git a/admin/src/Membership/MemberBoxQuizzes.tsx b/admin/src/Membership/MemberBoxQuizzes.tsx index 78fe6751a..4be9d404e 100644 --- a/admin/src/Membership/MemberBoxQuizzes.tsx +++ b/admin/src/Membership/MemberBoxQuizzes.tsx @@ -12,6 +12,8 @@ interface MemberQuizStatistic { quiz: Quiz; total_questions_in_quiz: number; correctly_answered_questions: number; + max_pass_rate: number; + ever_completed: boolean; } function MemberBoxQuizzes() { @@ -42,17 +44,12 @@ function MemberBoxQuizzes() { ); } - // Sort by completion ratio (descending) + // Sort by max pass rate (descending), with ever_completed items first const sortedData = [...data].sort((a, b) => { - const ratioA = - a.total_questions_in_quiz > 0 - ? a.correctly_answered_questions / a.total_questions_in_quiz - : 0; - const ratioB = - b.total_questions_in_quiz > 0 - ? b.correctly_answered_questions / b.total_questions_in_quiz - : 0; - return ratioB - ratioA; + if (a.ever_completed !== b.ever_completed) { + return a.ever_completed ? -1 : 1; + } + return b.max_pass_rate - a.max_pass_rate; }); return ( @@ -63,11 +60,12 @@ function MemberBoxQuizzes() { {t("table.quiz")} {t("table.correctly_answered")} {t("table.completion")} + {t("table.status")} {sortedData.map((stat) => { - const completionPercentage = + const currentPercentage = stat.total_questions_in_quiz > 0 ? Math.round( (stat.correctly_answered_questions / @@ -76,11 +74,6 @@ function MemberBoxQuizzes() { ) : 0; - const isComplete = - stat.total_questions_in_quiz > 0 && - stat.correctly_answered_questions >= - stat.total_questions_in_quiz; - return ( @@ -93,9 +86,17 @@ function MemberBoxQuizzes() { {stat.total_questions_in_quiz} - {isComplete ? "✅ " : ""} - {completionPercentage}% + {currentPercentage}% + {stat.max_pass_rate > + currentPercentage + 1 && ( + + {" "} + ({t("table.max_achieved")}:{" "} + {Math.round(stat.max_pass_rate)}%) + + )} + {stat.ever_completed ? `✅` : ""} ); })} diff --git a/admin/src/i18n/generated_locales/en.json b/admin/src/i18n/generated_locales/en.json index 650a9a51e..c8c41cb75 100644 --- a/admin/src/i18n/generated_locales/en.json +++ b/admin/src/i18n/generated_locales/en.json @@ -38,7 +38,9 @@ "table": { "completion": "Completion", "correctly_answered": "Correctly answered questions", - "quiz": "Quiz" + "max_achieved": "max achieved", + "quiz": "Quiz", + "status": "Status" } }, "member_tasks": { diff --git a/admin/src/i18n/generated_locales/sv.json b/admin/src/i18n/generated_locales/sv.json index c8f3400b8..fef77261b 100644 --- a/admin/src/i18n/generated_locales/sv.json +++ b/admin/src/i18n/generated_locales/sv.json @@ -38,7 +38,9 @@ "table": { "completion": "Slutförande", "correctly_answered": "Rätt besvarade frågor", - "quiz": "Quiz" + "max_achieved": "max uppnått", + "quiz": "Quiz", + "status": "Status" } }, "member_tasks": { diff --git a/admin/src/i18n/locales.ts b/admin/src/i18n/locales.ts index 21a9787a0..abf03f576 100644 --- a/admin/src/i18n/locales.ts +++ b/admin/src/i18n/locales.ts @@ -21,7 +21,7 @@ import sv from "./generated_locales/sv.json"; "common": ((key: "todo") => string) "member_quizzes": -((key: "error_loading" | "loading" | "no_quizzes" | "table.completion" | "table.correctly_answered" | "table.quiz") => string) +((key: "error_loading" | "loading" | "no_quizzes" | "table.completion" | "table.correctly_answered" | "table.max_achieved" | "table.quiz" | "table.status") => string) "member_tasks": ((key: "by_label.count" | "by_label.label" | "by_label.no_labels" | "by_label.title" | "error_loading" | "history.date" | "history.labels" | "history.no_tasks" | "history.status" | "history.task" | "history.title" | "loading" | "no_data" | "summary.not_specified" | "summary.preferred_rooms" | "summary.skill_level" | "summary.time_at_space" | "summary.title" | "summary.total_completed") => string) "task_statistics": @@ -42,7 +42,7 @@ export type LOCALE_SCHEMA_GLOBAL = ( & ((key: "box_terminator:can_be_terminated_in" | "box_terminator:expires_future" | "box_terminator:expires_past", args: { "relative_time": string | number}) => string) & ((key: "brand:billing_address" | "brand:email" | "brand:homepage_url" | "brand:makerspace_name" | "brand:organization_number") => string) & ((key: "common:todo") => string) - & ((key: "member_quizzes:error_loading" | "member_quizzes:loading" | "member_quizzes:no_quizzes" | "member_quizzes:table.completion" | "member_quizzes:table.correctly_answered" | "member_quizzes:table.quiz") => string) + & ((key: "member_quizzes:error_loading" | "member_quizzes:loading" | "member_quizzes:no_quizzes" | "member_quizzes:table.completion" | "member_quizzes:table.correctly_answered" | "member_quizzes:table.max_achieved" | "member_quizzes:table.quiz" | "member_quizzes:table.status") => string) & ((key: "member_tasks:by_label.count" | "member_tasks:by_label.label" | "member_tasks:by_label.no_labels" | "member_tasks:by_label.title" | "member_tasks:error_loading" | "member_tasks:history.date" | "member_tasks:history.labels" | "member_tasks:history.no_tasks" | "member_tasks:history.status" | "member_tasks:history.task" | "member_tasks:history.title" | "member_tasks:loading" | "member_tasks:no_data" | "member_tasks:summary.not_specified" | "member_tasks:summary.preferred_rooms" | "member_tasks:summary.skill_level" | "member_tasks:summary.time_at_space" | "member_tasks:summary.title" | "member_tasks:summary.total_completed") => string) & ((key: "task_statistics:cards.completed_count" | "task_statistics:cards.last_completed" | "task_statistics:cards.last_completer" | "task_statistics:cards.never" | "task_statistics:cards.no_cards" | "task_statistics:cards.overdue" | "task_statistics:cards.score" | "task_statistics:cards.score_for_member" | "task_statistics:cards.search_member_placeholder" | "task_statistics:cards.status" | "task_statistics:cards.task" | "task_statistics:cards.title" | "task_statistics:error_loading" | "task_statistics:loading" | "task_statistics:member_preferences.count" | "task_statistics:member_preferences.description" | "task_statistics:member_preferences.no_data" | "task_statistics:member_preferences.no_responses" | "task_statistics:member_preferences.option" | "task_statistics:member_preferences.percentage" | "task_statistics:member_preferences.title" | "task_statistics:task_log.date" | "task_statistics:task_log.member" | "task_statistics:task_log.no_logs" | "task_statistics:task_log.room" | "task_statistics:task_log.show_only_completed" | "task_statistics:task_log.status" | "task_statistics:task_log.task" | "task_statistics:task_log.title" | "task_statistics:title") => string) & ((key: "task_statistics:member_preferences.total_respondents", args: { "count": string | number}) => string) diff --git a/api/src/dispatch_emails.py b/api/src/dispatch_emails.py index 4f3885a6c..7da2d5670 100755 --- a/api/src/dispatch_emails.py +++ b/api/src/dispatch_emails.py @@ -617,12 +617,14 @@ def quiz_reminders() -> None: quiz_member = quiz_members_dict.get(member.member_id, None) if quiz_member is None: quiz_member = QuizMemberStat( - member_id=member.member_id, remaining_questions=total_quiz_questions, correctly_answered_questions=0 + member_id=member.member_id, + remaining_questions=total_quiz_questions, + correctly_answered_questions=0, + ever_completed=False, ) - # Don't bother members who have answered all questions correctly - # Or if we have added just a few questions to the quiz after they finished it. - if quiz_member.remaining_questions > 2: + # Don't bother members who have ever completed the quiz (even if new questions were added later) + if not quiz_member.ever_completed: member_data = id_to_member.get(member.member_id) assert member_data is not None member, membership = member_data diff --git a/api/src/i18n/en.py b/api/src/i18n/en.py index cb347b84e..07a9a71a0 100644 --- a/api/src/i18n/en.py +++ b/api/src/i18n/en.py @@ -28,7 +28,9 @@ "member_quizzes:no_quizzes": "No quizzes available.", "member_quizzes:table.completion": "Completion", "member_quizzes:table.correctly_answered": "Correctly answered questions", + "member_quizzes:table.max_achieved": "max achieved", "member_quizzes:table.quiz": "Quiz", + "member_quizzes:table.status": "Status", "member_tasks:by_label.count": "Count", "member_tasks:by_label.label": "Label", "member_tasks:by_label.no_labels": "No completed tasks with labels.", diff --git a/api/src/i18n/locales.py b/api/src/i18n/locales.py index dab9489f4..e39015327 100644 --- a/api/src/i18n/locales.py +++ b/api/src/i18n/locales.py @@ -32,7 +32,7 @@ class commonTranslator(Protocol): def __call__(self, key: Literal["todo","common:todo"]) -> str: ... class member_quizzesTranslator(Protocol): - def __call__(self, key: Literal["error_loading","loading","no_quizzes","table.completion","table.correctly_answered","table.quiz","member_quizzes:error_loading","member_quizzes:loading","member_quizzes:no_quizzes","member_quizzes:table.completion","member_quizzes:table.correctly_answered","member_quizzes:table.quiz"]) -> str: ... + def __call__(self, key: Literal["error_loading","loading","no_quizzes","table.completion","table.correctly_answered","table.max_achieved","table.quiz","table.status","member_quizzes:error_loading","member_quizzes:loading","member_quizzes:no_quizzes","member_quizzes:table.completion","member_quizzes:table.correctly_answered","member_quizzes:table.max_achieved","member_quizzes:table.quiz","member_quizzes:table.status"]) -> str: ... class member_tasksTranslator(Protocol): def __call__(self, key: Literal["by_label.count","by_label.label","by_label.no_labels","by_label.title","error_loading","history.date","history.labels","history.no_tasks","history.status","history.task","history.title","loading","no_data","summary.not_specified","summary.preferred_rooms","summary.skill_level","summary.time_at_space","summary.title","summary.total_completed","member_tasks:by_label.count","member_tasks:by_label.label","member_tasks:by_label.no_labels","member_tasks:by_label.title","member_tasks:error_loading","member_tasks:history.date","member_tasks:history.labels","member_tasks:history.no_tasks","member_tasks:history.status","member_tasks:history.task","member_tasks:history.title","member_tasks:loading","member_tasks:no_data","member_tasks:summary.not_specified","member_tasks:summary.preferred_rooms","member_tasks:summary.skill_level","member_tasks:summary.time_at_space","member_tasks:summary.title","member_tasks:summary.total_completed"]) -> str: ... @@ -89,7 +89,7 @@ def translate(key: Literal["todo","common:todo"]) -> str: ... @overload -def translate(key: Literal["error_loading","loading","no_quizzes","table.completion","table.correctly_answered","table.quiz","member_quizzes:error_loading","member_quizzes:loading","member_quizzes:no_quizzes","member_quizzes:table.completion","member_quizzes:table.correctly_answered","member_quizzes:table.quiz"]) -> str: ... +def translate(key: Literal["error_loading","loading","no_quizzes","table.completion","table.correctly_answered","table.max_achieved","table.quiz","table.status","member_quizzes:error_loading","member_quizzes:loading","member_quizzes:no_quizzes","member_quizzes:table.completion","member_quizzes:table.correctly_answered","member_quizzes:table.max_achieved","member_quizzes:table.quiz","member_quizzes:table.status"]) -> str: ... @overload diff --git a/api/src/i18n/sv.py b/api/src/i18n/sv.py index 42776c7d1..6b8f3d5b2 100644 --- a/api/src/i18n/sv.py +++ b/api/src/i18n/sv.py @@ -28,7 +28,9 @@ "member_quizzes:no_quizzes": "Inga quiz finns tillgängliga.", "member_quizzes:table.completion": "Slutförande", "member_quizzes:table.correctly_answered": "Rätt besvarade frågor", + "member_quizzes:table.max_achieved": "max uppnått", "member_quizzes:table.quiz": "Quiz", + "member_quizzes:table.status": "Status", "member_tasks:by_label.count": "Antal", "member_tasks:by_label.label": "Etikett", "member_tasks:by_label.no_labels": "Inga slutförda uppgifter med etiketter.", diff --git a/api/src/migrations/0045_quiz_attempts.sql b/api/src/migrations/0045_quiz_attempts.sql new file mode 100644 index 000000000..d8edd808a --- /dev/null +++ b/api/src/migrations/0045_quiz_attempts.sql @@ -0,0 +1,40 @@ +-- Migration to add quiz attempt tracking for retakes +-- Allows members to retake quizzes while preserving all historical answers + +-- Create table for quiz attempts (each time a member starts/restarts a quiz) +CREATE TABLE IF NOT EXISTS `quiz_attempts` ( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `member_id` int(10) unsigned NOT NULL, + `quiz_id` int(10) unsigned NOT NULL, + `created_at` datetime DEFAULT CURRENT_TIMESTAMP, + `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` datetime DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `member_id_key` (`member_id`), + KEY `quiz_id_key` (`quiz_id`), + CONSTRAINT `attempt_member_constraint` FOREIGN KEY (`member_id`) REFERENCES `membership_members` (`member_id`), + CONSTRAINT `attempt_quiz_constraint` FOREIGN KEY (`quiz_id`) REFERENCES `quiz_quizzes` (`id`) +); + +-- Add attempt_id column to quiz_answers table (nullable for backwards compatibility with existing answers) +ALTER TABLE `quiz_answers` ADD COLUMN `attempt_id` int(10) unsigned DEFAULT NULL; +ALTER TABLE `quiz_answers` ADD KEY `attempt_id_key` (`attempt_id`); +ALTER TABLE `quiz_answers` ADD CONSTRAINT `answer_attempt_constraint` FOREIGN KEY (`attempt_id`) REFERENCES `quiz_attempts` (`id`); + +-- Create initial attempts for all existing members who have answers +-- This groups existing answers under "attempt 1" for each member+quiz combination +INSERT INTO `quiz_attempts` (`member_id`, `quiz_id`, `created_at`) +SELECT DISTINCT qa.member_id, qq.quiz_id, MIN(qa.created_at) +FROM quiz_answers qa +JOIN quiz_questions qq ON qa.question_id = qq.id +GROUP BY qa.member_id, qq.quiz_id; + +-- Update existing answers to reference their corresponding attempt +UPDATE quiz_answers qa +JOIN quiz_questions qq ON qa.question_id = qq.id +JOIN quiz_attempts qat ON qa.member_id = qat.member_id AND qq.quiz_id = qat.quiz_id +SET qa.attempt_id = qat.id +WHERE qa.attempt_id IS NULL; + +-- Now make attempt_id NOT NULL since all existing answers have been migrated +ALTER TABLE `quiz_answers` MODIFY COLUMN `attempt_id` int(10) unsigned NOT NULL; diff --git a/api/src/quiz/entities.py b/api/src/quiz/entities.py index 5e239ddcd..0eed7e769 100644 --- a/api/src/quiz/entities.py +++ b/api/src/quiz/entities.py @@ -1,6 +1,6 @@ from service.entity import Entity, not_empty -from quiz.models import Quiz, QuizQuestion, QuizQuestionOption +from quiz.models import Quiz, QuizAttempt, QuizQuestion, QuizQuestionOption quiz_entity = Entity( Quiz, @@ -8,6 +8,12 @@ validation=dict(name=not_empty), ) +quiz_attempt_entity = Entity( + QuizAttempt, + default_sort_column=None, + validation=dict(), +) + quiz_question_entity = Entity( QuizQuestion, default_sort_column=None, diff --git a/api/src/quiz/models.py b/api/src/quiz/models.py index 35f15cd0c..dccf874a1 100644 --- a/api/src/quiz/models.py +++ b/api/src/quiz/models.py @@ -19,6 +19,24 @@ def __repr__(self): return f"Quiz(id={self.id}, name={self.name})" +class QuizAttempt(Base): + """Tracks when a member starts or restarts a quiz attempt.""" + + __tablename__ = "quiz_attempts" + + id = Column(Integer, primary_key=True, nullable=False, autoincrement=True) + member_id = Column(Integer, ForeignKey(Member.member_id), nullable=False) + quiz_id = Column(Integer, ForeignKey(Quiz.id), nullable=False) + created_at = Column(DateTime, server_default=func.now()) + updated_at = Column(DateTime, server_default=func.now()) + deleted_at = Column(DateTime) + + quiz = relationship(Quiz, backref="attempts", cascade_backrefs=False) + + def __repr__(self): + return f"QuizAttempt(id={self.id}, member_id={self.member_id}, quiz_id={self.quiz_id})" + + class QuizQuestion(Base): __tablename__ = "quiz_questions" @@ -60,6 +78,7 @@ class QuizAnswer(Base): member_id = Column(Integer, ForeignKey(Member.member_id), nullable=False) question_id = Column(Integer, ForeignKey(QuizQuestion.id), nullable=False) option_id = Column(Integer, ForeignKey(QuizQuestionOption.id), nullable=False) + attempt_id = Column(Integer, ForeignKey("quiz_attempts.id"), nullable=False) correct = Column(Boolean, nullable=False) created_at = Column(DateTime, server_default=func.now()) @@ -67,6 +86,7 @@ class QuizAnswer(Base): deleted_at = Column(DateTime) question = relationship(QuizQuestion, backref="answers", cascade_backrefs=False) + attempt = relationship(QuizAttempt, backref="answers", cascade_backrefs=False) def __repr__(self): return f"QuizAnswer(id={self.id})" diff --git a/api/src/quiz/views.py b/api/src/quiz/views.py index 84b0b6eaf..85d62fdd2 100644 --- a/api/src/quiz/views.py +++ b/api/src/quiz/views.py @@ -1,17 +1,23 @@ +import time from dataclasses import dataclass -from typing import Any, List +from datetime import timedelta +from logging import getLogger +from typing import Any, List, Optional from dataclasses_json import DataClassJsonMixin from flask import g, request from membership.models import Member +from redis_cache import redis_connection from service.api_definition import GET, MEMBER_VIEW, POST, PUBLIC, QUIZ_EDIT, USER from service.db import db_session from service.entity import OrmSingeRelation -from sqlalchemy import distinct, exists, func, text +from sqlalchemy import distinct, exists, func, select, text from quiz import service from quiz.entities import quiz_entity, quiz_question_entity, quiz_question_option_entity -from quiz.models import Quiz, QuizAnswer, QuizQuestion, QuizQuestionOption +from quiz.models import Quiz, QuizAnswer, QuizAttempt, QuizQuestion, QuizQuestionOption + +logger = getLogger("quiz") service.entity_routes( path="/quiz", @@ -58,6 +64,74 @@ ) +def _find_current_attempt(member_id: int, quiz_id: int) -> Optional[QuizAttempt]: + """Find the current (most recent) attempt for a member on a quiz.""" + return ( + db_session.query(QuizAttempt) + .filter( + QuizAttempt.member_id == member_id, + QuizAttempt.quiz_id == quiz_id, + QuizAttempt.deleted_at == None, + ) + .order_by(QuizAttempt.created_at.desc()) + .first() + ) + + +def get_or_create_current_attempt(member_id: int, quiz_id: int) -> QuizAttempt: + """Get the current (most recent) attempt for a member on a quiz, or create one if none exists.""" + attempt = _find_current_attempt(member_id, quiz_id) + + if attempt is None: + attempt = QuizAttempt(member_id=member_id, quiz_id=quiz_id) + db_session.add(attempt) + db_session.flush() + + return attempt + + +@service.route("/quiz//attempt", method=GET, permission=USER) +def get_current_attempt(quiz_id: int): + """Get the current attempt for the logged-in user on a quiz.""" + attempt = _find_current_attempt(g.user_id, quiz_id) + + if attempt is None: + return None + + # Get the timestamp of the last answer in this attempt + last_answer = ( + db_session.query(QuizAnswer) + .filter( + QuizAnswer.attempt_id == attempt.id, + QuizAnswer.deleted_at == None, + ) + .order_by(QuizAnswer.created_at.desc()) + .first() + ) + + return { + "id": attempt.id, + "quiz_id": attempt.quiz_id, + "created_at": attempt.created_at.isoformat() if attempt.created_at else None, + "last_answer_at": last_answer.created_at.isoformat() if last_answer and last_answer.created_at else None, + } + + +@service.route("/quiz//start_new_attempt", method=POST, permission=USER) +def start_new_attempt(quiz_id: int): + """Start a new quiz attempt for the logged-in user. This allows retaking the quiz.""" + # Create a new attempt + attempt = QuizAttempt(member_id=g.user_id, quiz_id=quiz_id) + db_session.add(attempt) + db_session.flush() + + return { + "id": attempt.id, + "quiz_id": attempt.quiz_id, + "created_at": attempt.created_at.isoformat() if attempt.created_at else None, + } + + @service.route("/question//answer", method=POST, permission=USER) def answer_question(question_id): data = request.json @@ -76,12 +150,24 @@ def answer_question(question_id): if option == None: return (400, f"Option id {option_id} is not an option for question id {question_id}") + # Get the quiz_id for this question and ensure we have a current attempt + question = db_session.get(QuizQuestion, question_id) + if question is None: + return (400, f"Question id {question_id} not found") + + attempt = get_or_create_current_attempt(g.user_id, int(question.quiz_id)) + db_session.add( - QuizAnswer(question_id=question_id, option_id=option_id, member_id=g.user_id, correct=option.correct) + QuizAnswer( + question_id=question_id, + option_id=option_id, + member_id=g.user_id, + correct=option.correct, + attempt_id=attempt.id, + ) ) db_session.flush() - question = db_session.get(QuizQuestion, question_id) json = quiz_question_entity.to_obj(question) json["options"] = [] options = ( @@ -98,16 +184,20 @@ def answer_question(question_id): @service.route("/quiz//next_question", method=GET, permission=USER) def next_question(quiz_id: int, include_correct=False): - # Find all questions that the user has correctly answered + # Get or create the current attempt for this user + attempt = get_or_create_current_attempt(g.user_id, quiz_id) + + # Find all questions that the user has correctly answered in the CURRENT attempt correct_questions = ( db_session.query(QuizQuestion.id) .filter(QuizQuestion.quiz_id == quiz_id) .join(QuizQuestion.answers) .filter(QuizAnswer.member_id == g.user_id) + .filter(QuizAnswer.attempt_id == attempt.id) .filter((QuizAnswer.correct) & (QuizAnswer.deleted_at == None)) ) - # Find questions which the user has not yet answered correctly + # Find questions which the user has not yet answered correctly in the current attempt q = ( db_session.query(QuizQuestion) .filter(QuizQuestion.id.notin_(correct_questions)) @@ -146,14 +236,141 @@ class MemberQuizStatistic(DataClassJsonMixin): quiz: Any total_questions_in_quiz: int correctly_answered_questions: int + # Maximum pass rate ever achieved (percentage from 0-100) + max_pass_rate: float + # Whether the member has ever fully completed the quiz + ever_completed: bool + + +def calculate_max_pass_rates_cached(member_ids: list[int], quiz_id: int) -> list[tuple[float, bool]]: + # This is enough to uniquely identify the state of the quizes at the time of the last answer + # Assuming no edits to past answers, and that no answers are deleted (never done at the moment), then the cache should stay valid. + # It's conservative, in that we check for new answers to any quiz for simplicity. + last_answer_ids = ( + db_session.execute( + select(QuizAnswer.member_id, func.max(QuizAnswer.id)) + .filter( + QuizAnswer.deleted_at == None, + ) + .group_by(QuizAnswer.member_id) + ) + .tuples() + .all() + ) + last_answer_ids_map = {member_id: last_answer_id for (member_id, last_answer_id) in last_answer_ids} + + redis_keys = [ + f"quiz:max_pass_rate:{member_id}:{quiz_id}:{last_answer_ids_map.get(member_id, None)}" + for member_id in member_ids + ] + results: list[tuple[float, bool]] = [] + cached_values = redis_connection.mget(redis_keys) + for member_id, cached, redis_key in zip(member_ids, cached_values, redis_keys): + if cached is not None: + parts = cached.decode("utf-8").split(",") + results.append((float(parts[0]), parts[1] == "1")) + continue + + (max_pass_rate, ever_completed) = calculate_max_pass_rate(member_id, quiz_id) + + redis_connection.setex(redis_key, timedelta(days=7), f"{max_pass_rate},{1 if ever_completed else 0}") + results.append((max_pass_rate, ever_completed)) + return results + + +def calculate_max_pass_rate(member_id: int, quiz_id: int) -> tuple[float, bool]: + """ + Calculate the maximum pass rate a member has ever achieved on a quiz. + + This handles the case where new questions may be added after a member has completed + the quiz. For each attempt, we calculate the pass rate based on the questions that + existed at the time of the last answer in that attempt, and return the maximum. + + Returns (max_pass_rate_percentage, ever_completed) + """ + # Get all attempts for this member on this quiz + attempts = ( + db_session.query(QuizAttempt) + .filter( + QuizAttempt.member_id == member_id, + QuizAttempt.quiz_id == quiz_id, + QuizAttempt.deleted_at == None, + ) + .order_by(QuizAttempt.created_at) + .all() + ) + + if not attempts: + return (0.0, False) + + # Get all questions for this quiz, ordered by creation time + questions = ( + db_session.query(QuizQuestion).filter(QuizQuestion.quiz_id == quiz_id).order_by(QuizQuestion.created_at).all() + ) + + if not questions: + return (0.0, False) + + max_pass_rate = 0.0 + ever_completed = False + + for attempt in attempts: + # Get all correct answers from this attempt + correct_answers = ( + db_session.query(QuizAnswer) + .join(QuizAnswer.question) + .filter( + QuizAnswer.attempt_id == attempt.id, + QuizAnswer.correct == True, + QuizAnswer.deleted_at == None, + ) + .order_by(QuizAnswer.created_at) + .all() + ) + + if not correct_answers: + continue + + # Find the timestamp of the last answer in this attempt + last_answer_time = max(a.created_at for a in correct_answers) + + # Count questions that existed at the time of the last answer + questions_at_time = [ + q + for q in questions + if q.created_at <= last_answer_time and (q.deleted_at is None or q.deleted_at > last_answer_time) + ] + if not questions_at_time: + continue + + # Count correctly answered questions in this attempt + correctly_answered_question_ids = set(a.question_id for a in correct_answers) + question_ids_at_time = set(q.id for q in questions_at_time) + + correct_count = len(correctly_answered_question_ids & question_ids_at_time) + total_count = len(question_ids_at_time) + + pass_rate = (correct_count / total_count) * 100 + + if pass_rate > max_pass_rate: + max_pass_rate = pass_rate + + if correct_count >= total_count: + ever_completed = True + + return (max_pass_rate, ever_completed) def member_quiz_statistics(member_id: int) -> list[MemberQuizStatistic]: - """Returns information about all quizzes and if the given member has completed them""" + """Returns information about all quizzes and if the given member has completed them. + Uses the maximum pass rate ever achieved, not just current state. + """ quizzes = db_session.query(Quiz).filter(Quiz.deleted_at == None).all() + + # Current count of correctly answered questions (for showing progress on current attempt) answered_questions_per_quiz_query = ( - db_session.query(QuizQuestion.quiz_id, func.count(func.distinct(QuizAnswer.option_id))) + db_session.query(QuizQuestion.quiz_id, func.count(func.distinct(QuizAnswer.question_id))) .join(QuizAnswer, QuizQuestion.id == QuizAnswer.question_id) .filter(QuizAnswer.member_id == member_id) .filter( @@ -174,16 +391,28 @@ def member_quiz_statistics(member_id: int) -> list[MemberQuizStatistic]: ).all() ) - return [ - MemberQuizStatistic( - quiz=quiz_entity.to_obj(quiz), - total_questions_in_quiz=total_questions_in_quiz[quiz.id] if quiz.id in total_questions_in_quiz else 0, - correctly_answered_questions=answered_questions_per_quiz[quiz.id] - if quiz.id in answered_questions_per_quiz - else 0, + result = [] + for quiz in quizzes: + max_pass_rate, ever_completed = calculate_max_pass_rate(member_id, quiz.id) + result.append( + MemberQuizStatistic( + quiz=quiz_entity.to_obj(quiz), + total_questions_in_quiz=total_questions_in_quiz.get(quiz.id, 0), + correctly_answered_questions=answered_questions_per_quiz.get(quiz.id, 0), + max_pass_rate=max_pass_rate, + ever_completed=ever_completed, + ) ) - for quiz in quizzes - ] + + return result + + +@dataclass(frozen=True) +class QuizMemberStat: + member_id: int + remaining_questions: int + correctly_answered_questions: int + ever_completed: bool = False @service.route("/member//statistics", method=GET, permission=MEMBER_VIEW) @@ -192,55 +421,63 @@ def member_quiz_statistics_route(member_id: int): @service.route("/unfinished/", method=GET, permission=PUBLIC) -def quiz_member_answer_stats_route(quiz_id: int): +def quiz_member_answer_stats_route(quiz_id: int) -> List[QuizMemberStat]: return quiz_member_answer_stats(quiz_id) -@dataclass(frozen=True) -class QuizMemberStat: - member_id: int - remaining_questions: int - correctly_answered_questions: int - - def quiz_member_answer_stats(quiz_id: int) -> List[QuizMemberStat]: - """Returns all members which haven't completed the quiz""" - - # Calculates how many questions each member has answered correctly - # Includes an entry for all members, even if it is zero - correctly_answered_questions = ( - db_session.query(Member.member_id, func.count(distinct(QuizAnswer.option_id)).label("count")) - .join(QuizAnswer, Member.member_id == QuizAnswer.member_id, isouter=True) - .join(QuizAnswer.question, isouter=True) - .filter(QuizQuestion.quiz_id == quiz_id) - .filter( - (QuizAnswer.id == None) - | ((QuizAnswer.correct) & (QuizAnswer.deleted_at == None) & (QuizQuestion.deleted_at == None)) - ) - .group_by(Member.member_id) - .subquery() - ) + """Returns all members and their quiz completion status. + Uses maximum pass rate calculation to determine if they've ever completed the quiz, + even if new questions were added afterward. + """ question_count = ( db_session.query(QuizQuestion) .filter((QuizQuestion.quiz_id == quiz_id) & (QuizQuestion.deleted_at == None)) .count() ) - members = ( - db_session.query(Member.member_id, correctly_answered_questions.c.count) - .join(correctly_answered_questions, (correctly_answered_questions.c.member_id == Member.member_id)) - .filter(Member.deleted_at == None) + # Get all members who have any answers for this quiz + members_with_answers = list( + db_session.execute( + select(Member.member_id) + .join(QuizAnswer, Member.member_id == QuizAnswer.member_id, isouter=True) + .join(QuizAnswer.question, isouter=True) + .filter(Member.deleted_at == None) + .distinct() + ) + .scalars() + .all() ) - return [ - QuizMemberStat( - member_id=member[0], - remaining_questions=question_count - member[1], - correctly_answered_questions=member[1], + result = [] + pass_rates = calculate_max_pass_rates_cached(members_with_answers, quiz_id) + for member_id, (max_pass_rate, ever_completed) in zip(members_with_answers, pass_rates): + # Count currently correctly answered questions across all attempts (unique questions) + correctly_answered = ( + db_session.query(func.count(distinct(QuizAnswer.question_id))) + .join(QuizAnswer.question) + .filter( + QuizQuestion.quiz_id == quiz_id, + QuizAnswer.member_id == member_id, + QuizAnswer.correct == True, + QuizAnswer.deleted_at == None, + QuizQuestion.deleted_at == None, + ) + .scalar() + or 0 ) - for member in members.all() - ] + + result.append( + QuizMemberStat( + member_id=member_id, + remaining_questions=max(0, question_count - correctly_answered), + correctly_answered_questions=correctly_answered, + ever_completed=ever_completed, + ) + ) + + return result @service.route("/quiz//statistics", method=GET, permission=PUBLIC) diff --git a/api/src/tasks/delegate.py b/api/src/tasks/delegate.py index ab9a4ff0c..cc7c28b08 100644 --- a/api/src/tasks/delegate.py +++ b/api/src/tasks/delegate.py @@ -482,7 +482,7 @@ def from_member(member_id: int, now: datetime) -> "MemberTaskInfo": quizzes = member_quiz_statistics(member_id) completed_courses = set() for quiz in quizzes: - if quiz.correctly_answered_questions > math.ceil(quiz.total_questions_in_quiz * 0.9): + if quiz.ever_completed: completed_courses.add(quiz.quiz["id"]) return MemberTaskInfo( diff --git a/config/locales/en/admin/member_quizzes.json5 b/config/locales/en/admin/member_quizzes.json5 index 6a2b59741..c542e400b 100644 --- a/config/locales/en/admin/member_quizzes.json5 +++ b/config/locales/en/admin/member_quizzes.json5 @@ -6,5 +6,7 @@ quiz: "Quiz", correctly_answered: "Correctly answered questions", completion: "Completion", + status: "Status", + max_achieved: "max achieved", }, } diff --git a/config/locales/sv/admin/member_quizzes.json5 b/config/locales/sv/admin/member_quizzes.json5 index db87c1ede..2a20c65e9 100644 --- a/config/locales/sv/admin/member_quizzes.json5 +++ b/config/locales/sv/admin/member_quizzes.json5 @@ -6,5 +6,7 @@ quiz: "Quiz", correctly_answered: "Rätt besvarade frågor", completion: "Slutförande", + status: "Status", + max_achieved: "max uppnått", }, } diff --git a/public/scss/style.scss b/public/scss/style.scss index 751cfcdee..327d972e2 100644 --- a/public/scss/style.scss +++ b/public/scss/style.scss @@ -1222,6 +1222,7 @@ $palette-red: $palette4; color: white; justify-content: center; text-align: center; + align-items: center; h2 { font-family: "Bitter"; @@ -1336,9 +1337,9 @@ $palette-red: $palette4; font-style: italic; } - .uk-button-primary { + .uk-button { margin-top: 20px; - background-color: $palette2; + box-shadow: 0px 0px 3px 0px rgba(0, 0, 0, 0.2); transition: 200ms box-shadow; @@ -1347,6 +1348,10 @@ $palette-red: $palette4; } } + .uk-button-primary { + background-color: $palette2; + } + @media screen and (max-width: 900px) { margin: 0px; border-radius: 0px; diff --git a/public/ts/quiz.tsx b/public/ts/quiz.tsx index 31abcfdfa..2a69258e0 100644 --- a/public/ts/quiz.tsx +++ b/public/ts/quiz.tsx @@ -31,6 +31,13 @@ export interface Quiz { deleted_at: string | null; } +interface Attempt { + id: number; + quiz_id: number; + created_at: string | null; + last_answer_at: string | null; +} + interface State { question: Question | null; state: "intro" | "started" | "done"; @@ -41,6 +48,7 @@ interface State { selected: number; }; quiz: Quiz | null; + currentAttempt: Attempt | null; } interface QuizManagerProps { @@ -59,6 +67,7 @@ class QuizManager extends Component { loginState: "pending", state: "intro", quiz: null, + currentAttempt: null, }; } @@ -83,6 +92,8 @@ class QuizManager extends Component { null, ); this.setState({ loginState: "logged in" }); + // Load current attempt after confirming login + await this.loadCurrentAttempt(); } catch (error: any) { if (error.status == "unauthorized") { this.setState({ loginState: "logged out" }); @@ -90,6 +101,18 @@ class QuizManager extends Component { } } + async loadCurrentAttempt() { + try { + const attempt: ServerResponse = await common.ajax( + "GET", + `${window.apiBasePath}/quiz/quiz/${this.props.quiz_id}/attempt`, + ); + this.setState({ currentAttempt: attempt.data }); + } catch (error) { + // No attempt yet, that's fine + } + } + async start() { this.setState({ state: "started" }); try { @@ -111,6 +134,53 @@ class QuizManager extends Component { } } + async startNewAttempt() { + try { + // Create a new attempt + const attempt: ServerResponse = await common.ajax( + "POST", + `${window.apiBasePath}/quiz/quiz/${this.props.quiz_id}/start_new_attempt`, + ); + this.setState({ currentAttempt: attempt.data, state: "intro" }); + // Then start the quiz + await this.start(); + } catch (data: any) { + if (data.status == "unauthorized") { + window.location.href = url("/member"); + } + } + } + + isAttemptStale(): boolean { + const attempt = this.state.currentAttempt; + if (!attempt || !attempt.last_answer_at) { + return false; + } + const lastAnswerDate = new Date(attempt.last_answer_at); + const sixtyDaysAgo = new Date(); + sixtyDaysAgo.setDate(sixtyDaysAgo.getDate() - 60); + return lastAnswerDate < sixtyDaysAgo; + } + + getStartButtonText(): string { + const attempt = this.state.currentAttempt; + if (!attempt) { + return "Start quiz"; + } + if (this.isAttemptStale()) { + return "Start new quiz"; + } + return "Continue quiz"; + } + + async handleStartClick() { + if (this.isAttemptStale()) { + await this.startNewAttempt(); + } else { + await this.start(); + } + } + render() { if (this.state.quiz == null) { return ( @@ -140,13 +210,22 @@ class QuizManager extends Component { you good luck with all your exciting projects at Stockholm Makerspace!

-
+ {/* Vi hoppas att det var lärorikt och önskar dig lycka till med alla spännande projekt på Stockholm Makerspace */}
@@ -208,9 +287,9 @@ class QuizManager extends Component {

Alright, are you ready to get started?

this.start()} + onClick={() => this.handleStartClick()} > - Start! + {this.getStartButtonText()} )}