From 95b913ec05d095e6bcd54af6f28f87cf9c89dcaf Mon Sep 17 00:00:00 2001 From: Alex Mickelson Date: Mon, 11 Dec 2023 17:58:19 -0700 Subject: [PATCH] adding matching suppiort --- Management.Test/Markdown/QuizMarkdownTests.cs | 40 +++++++++++ .../Markdown/QuizQuestionMarkdownTests.cs | 60 ++++++++++++++++ .../AssignmentForm/AssignmentForm.razor | 2 +- .../Models/Local/Quiz/LocalQuizQuestion.cs | 71 ++++++++++++------- .../Local/Quiz/LocalQuizQuestionAnswer.cs | 14 +++- build.sh | 9 ++- docker-compose.yml | 8 ++- requests/quiz.http | 40 ++++++++++- 8 files changed, 206 insertions(+), 38 deletions(-) diff --git a/Management.Test/Markdown/QuizMarkdownTests.cs b/Management.Test/Markdown/QuizMarkdownTests.cs index 57ba1cd..2a0e036 100644 --- a/Management.Test/Markdown/QuizMarkdownTests.cs +++ b/Management.Test/Markdown/QuizMarkdownTests.cs @@ -314,6 +314,46 @@ short_answer"; }; var quizMarkdown = quiz.ToMarkdown(); + var parsedQuiz = LocalQuiz.ParseMarkdown(quizMarkdown); + parsedQuiz.Should().BeEquivalentTo(quiz); + } + [Test] + public void SerializationIsDeterministic_Matching() + { + var quiz = new LocalQuiz() + { + Name = "Test Quiz", + Description = "quiz description", + LockAt = new DateTime(2022, 10, 3, 12, 5, 0), + DueAt = new DateTime(2022, 10, 3, 12, 5, 0), + ShuffleAnswers = true, + OneQuestionAtATime = true, + LocalAssignmentGroupName = "Assignments", + Questions = new LocalQuizQuestion[] + { + new () + { + Text = "test matching", + QuestionType = QuestionType.MATCHING, + Points = 1, + Answers = new LocalQuizQuestionAnswer[] + { + new() { + Correct = true, + Text="yes", + MatchedText = "testing yes" + }, + new() { + Correct = true, + Text="no", + MatchedText = "testing no" + } + } + } + } + }; + var quizMarkdown = quiz.ToMarkdown(); + var parsedQuiz = LocalQuiz.ParseMarkdown(quizMarkdown); parsedQuiz.Should().BeEquivalentTo(quiz); } diff --git a/Management.Test/Markdown/QuizQuestionMarkdownTests.cs b/Management.Test/Markdown/QuizQuestionMarkdownTests.cs index 75cd1d5..a0d1cd2 100644 --- a/Management.Test/Markdown/QuizQuestionMarkdownTests.cs +++ b/Management.Test/Markdown/QuizQuestionMarkdownTests.cs @@ -96,6 +96,7 @@ oneline question "; markdown.Should().Contain(expectedQuestionString); } + [Test] public void CanParseQuestionWithMultipleAnswers() { @@ -158,6 +159,7 @@ essay firstQuestion.QuestionType.Should().Be(QuestionType.ESSAY); firstQuestion.Text.Should().NotContain("essay"); } + [Test] public void CanParseShortAnswer() { @@ -183,6 +185,7 @@ short answer firstQuestion.QuestionType.Should().Be(QuestionType.SHORT_ANSWER); firstQuestion.Text.Should().NotContain("short answer"); } + [Test] public void ShortAnswerToMarkdown_IsCorrect() { @@ -239,4 +242,61 @@ Which events are triggered when the user clicks on an input field? essay"; questionMarkdown.Should().Contain(expectedMarkdown); } + + [Test] + public void CanParseMatchingQuestion() + { + var rawMarkdownQuiz = @" +Name: Test Quiz +ShuffleAnswers: true +OneQuestionAtATime: false +DueAt: 2023-08-21T23:59:00 +LockAt: 2023-08-21T23:59:00 +AssignmentGroup: Assignments +AllowedAttempts: -1 +Description: +--- +Match the following terms & definitions + +^ statement - a single command to be executed +^ identifier - name of a variable +^ keyword - reserved word that has special meaning in a program (e.g. class, void, static, etc.) +"; + + var quiz = LocalQuiz.ParseMarkdown(rawMarkdownQuiz); + var firstQuestion = quiz.Questions.First(); + firstQuestion.QuestionType.Should().Be(QuestionType.MATCHING); + firstQuestion.Text.Should().NotContain("statement"); + firstQuestion.Answers.First().MatchedText.Should().Be("a single command to be executed"); + } + [Test] + public void CanCreateMarkdownForMatchingQuesiton() + { + var rawMarkdownQuiz = @" +Name: Test Quiz +ShuffleAnswers: true +OneQuestionAtATime: false +DueAt: 2023-08-21T23:59:00 +LockAt: 2023-08-21T23:59:00 +AssignmentGroup: Assignments +AllowedAttempts: -1 +Description: +--- +Match the following terms & definitions + +^ statement - a single command to be executed +^ identifier - name of a variable +^ keyword - reserved word that has special meaning in a program (e.g. class, void, static, etc.) +"; + + var quiz = LocalQuiz.ParseMarkdown(rawMarkdownQuiz); + var questionMarkdown = quiz.Questions.First().ToMarkdown(); + var expectedMarkdown = @"Points: 1 +Match the following terms & definitions + +^ statement - a single command to be executed +^ identifier - name of a variable +^ keyword - reserved word that has special meaning in a program (e.g. class, void, static, etc.)"; + questionMarkdown.Should().Contain(expectedMarkdown); + } } \ No newline at end of file diff --git a/Management.Web/Shared/Components/AssignmentForm/AssignmentForm.razor b/Management.Web/Shared/Components/AssignmentForm/AssignmentForm.razor index 65413de..a311af1 100644 --- a/Management.Web/Shared/Components/AssignmentForm/AssignmentForm.razor +++ b/Management.Web/Shared/Components/AssignmentForm/AssignmentForm.razor @@ -165,7 +165,7 @@ @assignmentContext.Assignment?.Name -
+
@if (assignmentContext.Assignment != null) { diff --git a/Management/Models/Local/Quiz/LocalQuizQuestion.cs b/Management/Models/Local/Quiz/LocalQuizQuestion.cs index 904511c..4a78f9a 100644 --- a/Management/Models/Local/Quiz/LocalQuizQuestion.cs +++ b/Management/Models/Local/Quiz/LocalQuizQuestion.cs @@ -13,25 +13,7 @@ public record LocalQuizQuestion Enumerable.Empty(); public string ToMarkdown() { - var answerArray = Answers.Select((answer, i) => - { - var questionLetter = (char)(i + 97); - var isMultipleChoice = QuestionType == "multiple_choice"; - - var correctIndicator = answer.Correct ? "*" : isMultipleChoice ? "" : " "; - - - var questionTypeIndicator = isMultipleChoice - ? $"{correctIndicator}{questionLetter}) " - : $"[{correctIndicator}] "; - - // var textWithSpecificNewline = answer.Text.Replace(Environment.NewLine, Environment.NewLine + " "); - - var multilineMarkdownCompatibleText = answer.Text.StartsWith("```") - ? Environment.NewLine + answer.Text - : answer.Text; - return $"{questionTypeIndicator}{multilineMarkdownCompatibleText}"; - }); + var answerArray = Answers.Select(getAnswerMarkdown); var answersText = string.Join(Environment.NewLine, answerArray); var questionTypeIndicator = QuestionType == "essay" || QuestionType == "short_answer" ? QuestionType : ""; @@ -40,7 +22,34 @@ public record LocalQuizQuestion {answersText}{questionTypeIndicator}"; } - private static readonly string[] validFirstAnswerDelimiters = new string[] { "*a)", "a)", "[ ]", "[*]" }; + private string getAnswerMarkdown(LocalQuizQuestionAnswer answer, int index) + { + var multilineMarkdownCompatibleText = answer.Text.StartsWith("```") + ? Environment.NewLine + answer.Text + : answer.Text; + + if (QuestionType == "multiple_answers") + { + var correctIndicator = answer.Correct ? "*" : " "; + var questionTypeIndicator = $"[{correctIndicator}] "; + + return $"{questionTypeIndicator}{multilineMarkdownCompatibleText}"; + } + else if(QuestionType == "matching") + { + return $"^ {answer.Text} - {answer.MatchedText}"; + } + else + { + var questionLetter = (char)(index + 97); + var correctIndicator = answer.Correct ? "*" : ""; + var questionTypeIndicator = $"{correctIndicator}{questionLetter}) "; + + return $"{questionTypeIndicator}{multilineMarkdownCompatibleText}"; + } + } + + private static readonly string[] _validFirstAnswerDelimiters = ["*a)", "a)", "[ ]", "[*]", "^"]; public static LocalQuizQuestion ParseMarkdown(string input, int questionIndex) { @@ -59,7 +68,7 @@ public record LocalQuizQuestion var linesWithoutAnswers = linesWithoutPoints .TakeWhile( (line, index) => - !validFirstAnswerDelimiters.Any(prefix => line.TrimStart().StartsWith(prefix)) + !_validFirstAnswerDelimiters.Any(prefix => line.TrimStart().StartsWith(prefix)) ) .ToArray(); @@ -67,6 +76,7 @@ public record LocalQuizQuestion var questionType = getQuestionType(linesWithoutPoints, questionIndex); var questionTypesWithoutAnswers = new string[] { "essay", "short answer", "short_answer" }; + var descriptionLines = questionTypesWithoutAnswers.Contains(questionType.ToLower()) ? linesWithoutAnswers .TakeWhile( @@ -78,9 +88,9 @@ public record LocalQuizQuestion - var typesWithAnswers = new string[] { "multiple_choice", "multiple_answers" }; + var typesWithAnswers = new string[] { "multiple_choice", "multiple_answers", "matching" }; var answers = typesWithAnswers.Contains(questionType) - ? getAnswers(linesWithoutPoints, questionIndex) + ? getAnswers(linesWithoutPoints, questionIndex, questionType) : []; return new LocalQuizQuestion() @@ -118,6 +128,10 @@ public record LocalQuizQuestion if (isMultipleAnswer) return "multiple_answers"; + var isMatching = answerLines.First().StartsWith("^"); + if (isMatching) + return "matching"; + return ""; } @@ -126,7 +140,7 @@ public record LocalQuizQuestion var indexOfAnswerStart = linesWithoutPoints .ToList() .FindIndex( - l => validFirstAnswerDelimiters.Any(prefix => l.TrimStart().StartsWith(prefix)) + l => _validFirstAnswerDelimiters.Any(prefix => l.TrimStart().StartsWith(prefix)) ); if (indexOfAnswerStart == -1) { @@ -136,7 +150,7 @@ public record LocalQuizQuestion var answerLinesRaw = linesWithoutPoints[indexOfAnswerStart..]; - var answerStartPattern = @"^(\*?[a-z]\))|\[\s*\]|\[\*\]"; + var answerStartPattern = @"^(\*?[a-z]\))|\[\s*\]|\[\*\]|\^"; var answerLines = answerLinesRaw.Aggregate(new List(), (acc, line) => { var isNewAnswer = Regex.IsMatch(line, answerStartPattern); @@ -156,12 +170,12 @@ public record LocalQuizQuestion return answerLines; } - private static LocalQuizQuestionAnswer[] getAnswers(string[] linesWithoutPoints, int questionIndex) + private static LocalQuizQuestionAnswer[] getAnswers(string[] linesWithoutPoints, int questionIndex, string questionType) { var answerLines = getAnswersGroupedByLines(linesWithoutPoints, questionIndex); var answers = answerLines - .Select((a, i) => LocalQuizQuestionAnswer.ParseMarkdown(a)) + .Select((a, i) => LocalQuizQuestionAnswer.ParseMarkdown(a, questionType)) .ToArray(); return answers; @@ -174,6 +188,7 @@ public static class QuestionType public static readonly string MULTIPLE_CHOICE = "multiple_choice"; public static readonly string ESSAY = "essay"; public static readonly string SHORT_ANSWER = "short_answer"; + public static readonly string MATCHING = "matching"; // possible support for: calculated, file_upload, fill_in_multiple_blanks, matching, multiple_dropdowns, numerical, text_only, true_false, public static readonly IEnumerable AllTypes = new string[] @@ -182,5 +197,7 @@ public static class QuestionType MULTIPLE_CHOICE, ESSAY, SHORT_ANSWER, + MATCHING + }; } diff --git a/Management/Models/Local/Quiz/LocalQuizQuestionAnswer.cs b/Management/Models/Local/Quiz/LocalQuizQuestionAnswer.cs index 72b6a1e..c02f644 100644 --- a/Management/Models/Local/Quiz/LocalQuizQuestionAnswer.cs +++ b/Management/Models/Local/Quiz/LocalQuizQuestionAnswer.cs @@ -8,14 +8,24 @@ public record LocalQuizQuestionAnswer public bool Correct { get; init; } public string Text { get; init; } = string.Empty; + public string? MatchedText { get; init; } + public string HtmlText => Markdig.Markdown.ToHtml(Text); - public static LocalQuizQuestionAnswer ParseMarkdown(string input) + public static LocalQuizQuestionAnswer ParseMarkdown(string input, string questionType) { var isCorrect = input[0] == '*' || input[1] == '*'; - string startingQuestionPattern = @"^(\*?[a-z]\))|\[\s*\]|\[\*\] "; + string startingQuestionPattern = @"^(\*?[a-z]\))|\[\s*\]|\[\*\]|\^ "; var text = Regex.Replace(input, startingQuestionPattern, string.Empty).Trim(); + if(questionType == QuestionType.MATCHING) + return new LocalQuizQuestionAnswer() + { + Correct = true, + Text = text.Split('-')[0].Trim(), + MatchedText = string.Join("", text.Split('-')[1..]).Trim(), + }; + return new LocalQuizQuestionAnswer() { Correct = isCorrect, diff --git a/build.sh b/build.sh index e22ae14..fb694b7 100755 --- a/build.sh +++ b/build.sh @@ -1,20 +1,25 @@ #!/bin/bash -VERSION="1.1" +MAJOR_VERSION="1" +MINOR_VERSION="2" +VERSION="$MAJOR_VERSION.$MINOR_VERSION" dotnet publish Management.Web/ \ --os linux \ --arch x64 \ /t:PublishContainer \ -c Release \ - -p ContainerImageTags="\"$VERSION;latest\"" \ + -p ContainerImageTags="\"$MAJOR_VERSION;$VERSION;latest\"" \ -p ContainerRepository="canvas_management" echo "to push run: " +echo "" echo "docker image tag canvas_management:$VERSION alexmickelson/canvas_management:$VERSION" +echo "docker image tag canvas_management:$MAJOR_VERSION alexmickelson/canvas_management:$MAJOR_VERSION" echo "docker image tag canvas_management:latest alexmickelson/canvas_management:latest" echo "docker push alexmickelson/canvas_management:$VERSION" +echo "docker push alexmickelson/canvas_management:$MAJOR_VERSION" echo "docker push alexmickelson/canvas_management:latest" \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index cf88c6c..ba0a592 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,6 @@ services: canvas_manager: - image: alexmickelson/canvas_management:1.0 + image: alexmickelson/canvas_management:1 user: "1000:1000" ports: - 8080:8080 @@ -9,6 +9,8 @@ services: environment: - storageDirectory=/app/storage volumes: - - ./storage:/app/storage + # - ./storage:/app/storage # - ~/projects/faculty/1410/2023-fall-alex/modules:/app/storage/1410 - # - ~/projects/faculty/1810/2024-spring-alex/modules:/app/storage/web_intro \ No newline at end of file + - ~/projects/faculty/1810/2024-spring-alex/modules:/app/storage/web_intro + - ~/projects/faculty/1400/2024_spring_alex/modules:/app/storage/1400 + - ~/projects/faculty/1405/2024_spring_alex/modules:/app/storage/1405 \ No newline at end of file diff --git a/requests/quiz.http b/requests/quiz.http index 856f63e..0fc7b5e 100644 --- a/requests/quiz.http +++ b/requests/quiz.http @@ -1,13 +1,13 @@ -GET https://snow.instructure.com/api/v1/courses/871954/quizzes +GET https://snow.instructure.com/api/v1/courses/958185/quizzes Authorization: Bearer {{$dotenv CANVAS_TOKEN}} ### -GET https://snow.instructure.com/api/v1/courses/871954/assignments +GET https://snow.instructure.com/api/v1/courses/958185/assignments Authorization: Bearer {{$dotenv CANVAS_TOKEN}} ### -POST https://snow.instructure.com/api/v1/courses/871954/quizzes/3243305/questions +POST https://snow.instructure.com/api/v1/courses/958185/quizzes/3358912/questions Authorization: Bearer {{$dotenv CANVAS_TOKEN}} Content-Type: application/json @@ -43,4 +43,38 @@ Content-Type: application/json } ] } +} +### +POST https://snow.instructure.com/api/v1/courses/958185/quizzes/3358912/questions +Authorization: Bearer {{$dotenv CANVAS_TOKEN}} +Content-Type: application/json + +{ + "question": { + "question_text": "dummy matching via the api", + "question_type": "matching_question", + "points_possible": 5, + "answers": [ + { + "correct": true, + "answer_match_left": "statement", + "answer_match_right": "a single command to be executed" + }, + { + "correct": true, + "answer_match_left": "identifier", + "answer_match_right": "name of a variable" + }, + { + "correct": true, + "answer_match_left": "keyword", + "answer_match_right": "reserved word that has special meaning in a program (e.g. class, void, static, etc.)" + }, + { + "correct": true, + "answer_match_left": "source file", + "answer_match_right": "the .cs text file containing your source code" + } + ] + } } \ No newline at end of file