Skip to content
Merged
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
37 changes: 19 additions & 18 deletions admin/src/Membership/MemberBoxQuizzes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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 (
Expand All @@ -63,11 +60,12 @@ function MemberBoxQuizzes() {
<th>{t("table.quiz")}</th>
<th>{t("table.correctly_answered")}</th>
<th>{t("table.completion")}</th>
<th>{t("table.status")}</th>
</tr>
</thead>
<tbody>
{sortedData.map((stat) => {
const completionPercentage =
const currentPercentage =
stat.total_questions_in_quiz > 0
? Math.round(
(stat.correctly_answered_questions /
Expand All @@ -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 (
<tr key={stat.quiz.id}>
<td>
Expand All @@ -93,9 +86,17 @@ function MemberBoxQuizzes() {
{stat.total_questions_in_quiz}
</td>
<td>
{isComplete ? "✅ " : ""}
{completionPercentage}%
{currentPercentage}%
{stat.max_pass_rate >
currentPercentage + 1 && (
<span className="uk-text-muted">
{" "}
({t("table.max_achieved")}:{" "}
{Math.round(stat.max_pass_rate)}%)
</span>
)}
</td>
<td>{stat.ever_completed ? `✅` : ""}</td>
</tr>
);
})}
Expand Down
4 changes: 3 additions & 1 deletion admin/src/i18n/generated_locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@
"table": {
"completion": "Completion",
"correctly_answered": "Correctly answered questions",
"quiz": "Quiz"
"max_achieved": "max achieved",
"quiz": "Quiz",
"status": "Status"
}
},
"member_tasks": {
Expand Down
4 changes: 3 additions & 1 deletion admin/src/i18n/generated_locales/sv.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
4 changes: 2 additions & 2 deletions admin/src/i18n/locales.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand All @@ -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)
Expand Down
10 changes: 6 additions & 4 deletions api/src/dispatch_emails.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions api/src/i18n/en.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
4 changes: 2 additions & 2 deletions api/src/i18n/locales.py
Original file line number Diff line number Diff line change
Expand Up @@ -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: ...
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions api/src/i18n/sv.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
40 changes: 40 additions & 0 deletions api/src/migrations/0045_quiz_attempts.sql
Original file line number Diff line number Diff line change
@@ -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;
8 changes: 7 additions & 1 deletion api/src/quiz/entities.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
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,
default_sort_column=None,
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,
Expand Down
20 changes: 20 additions & 0 deletions api/src/quiz/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -60,13 +78,15 @@ 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())
updated_at = Column(DateTime, server_default=func.now())
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})"
Expand Down
Loading
Loading