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 */}