diff --git a/Epsilon.Abstractions/Component/KpiTable.cs b/Epsilon.Abstractions/Component/KpiTable.cs new file mode 100644 index 0000000..147947a --- /dev/null +++ b/Epsilon.Abstractions/Component/KpiTable.cs @@ -0,0 +1,131 @@ +using DocumentFormat.OpenXml; +using DocumentFormat.OpenXml.Packaging; +using DocumentFormat.OpenXml.Wordprocessing; + +namespace Epsilon.Abstractions.Component; + +public record KpiTable( + IDictionary KpiTableEntries +) : IWordCompetenceComponent +{ + public void AddToWordDocument(MainDocumentPart mainDocumentPart) + { + var body = new Body(); + + // Create a table to display outcomes, assignments, and grades + var table = new Table(); + + // Define column header texts + var columnsHeaders = new Dictionary { { "KPI", "3000" }, { "Assignments", "5000" }, { "Grades", "1000" }, }; + + // Create the table header row + var headerRow = new TableRow(); + + // Create the header cells + foreach (var columnHeader in columnsHeaders) + { + headerRow.AppendChild(CreateTableCellWithBorders(columnHeader.Value, new Paragraph(new Run(new Text(columnHeader.Key))))); + } + + // Add the header row to the table + table.AppendChild(headerRow); + + // Create the table body rows and cells + foreach (var entry in KpiTableEntries.ToList().OrderByDescending(static e => e.Value.Kpi)) + { + var tableRow = new TableRow(); + + // Outcome (KPI) column + tableRow.AppendChild(CreateTableCellWithBorders("3000", new Paragraph(new Run(new Text(entry.Value.Kpi))))); + + // Assignments column + var assignmentsParagraph = new Paragraph(); + var assignmentsRun = assignmentsParagraph.AppendChild(new Run()); + + foreach (var assignment in entry.Value.Assignments) + { + var rel = mainDocumentPart.AddHyperlinkRelationship(assignment.Link, true); + var relationshipId = rel.Id; + + var runProperties = new RunProperties( + new Underline { Val = UnderlineValues.Single, }); + + assignmentsRun.AppendChild(new Hyperlink(new Run(runProperties, new Text(assignment.Name))) + { + History = OnOffValue.FromBoolean(true), + Id = relationshipId, + }); + + assignmentsRun.AppendChild(new Break()); + } + + tableRow.AppendChild(CreateTableCellWithBorders("5000", assignmentsParagraph)); + + // Grades column + var grades = entry.Value.Assignments.Select(static a => a.Grade); + var gradesParagraph = new Paragraph(); + var gradesRun = gradesParagraph.AppendChild(new Run()); + + foreach (var grade in grades) + { + gradesRun.AppendChild(new Text(grade)); + gradesRun.AppendChild(new Break()); + } + + tableRow.AppendChild(CreateTableCellWithBorders("1000", gradesParagraph)); + + // Add the row to the table + table.AppendChild(tableRow); + } + + // Newline to separate the table from the rest of the document + body.Append(new Paragraph(new Run(new Text("")))); + + // Add the table to the document + body.AppendChild(table); + + mainDocumentPart.Document.AppendChild(body); + } + + private static TableCell CreateTableCellWithBorders(string? width, params OpenXmlElement[] elements) + { + var cell = new TableCell(); + var cellProperties = new TableCellProperties(); + var borders = new TableCellBorders( + new LeftBorder + { + Val = BorderValues.Single, + }, + new RightBorder + { + Val = BorderValues.Single, + }, + new TopBorder + { + Val = BorderValues.Single, + }, + new BottomBorder + { + Val = BorderValues.Single, + }); + + foreach (var element in elements) + { + cell.Append(element); + } + + if (width != null) + { + cellProperties.Append(new TableCellWidth + { + Type = TableWidthUnitValues.Dxa, + Width = width, + }); + } + + cellProperties.Append(borders); + cell.PrependChild(cellProperties); + + return cell; + } +} \ No newline at end of file diff --git a/Epsilon.Abstractions/Component/KpiTableEntry.cs b/Epsilon.Abstractions/Component/KpiTableEntry.cs new file mode 100644 index 0000000..613c403 --- /dev/null +++ b/Epsilon.Abstractions/Component/KpiTableEntry.cs @@ -0,0 +1,9 @@ +using Epsilon.Abstractions.Model; + +namespace Epsilon.Abstractions.Component; + +public record KpiTableEntry( + string Kpi, + MasteryLevel MasteryLevel, + IEnumerable Assignments +); diff --git a/Epsilon.Abstractions/Component/KpiTableEntryAssignment.cs b/Epsilon.Abstractions/Component/KpiTableEntryAssignment.cs new file mode 100644 index 0000000..00ffbe8 --- /dev/null +++ b/Epsilon.Abstractions/Component/KpiTableEntryAssignment.cs @@ -0,0 +1,7 @@ +namespace Epsilon.Abstractions.Component; + +public record KpiTableEntryAssignment( + string Name, + string Grade, + Uri Link +); \ No newline at end of file diff --git a/Epsilon.Canvas.Abstractions/Model/GraphQl/AssessmentRating.cs b/Epsilon.Canvas.Abstractions/Model/GraphQl/AssessmentRating.cs index 8d570e6..6fef714 100644 --- a/Epsilon.Canvas.Abstractions/Model/GraphQl/AssessmentRating.cs +++ b/Epsilon.Canvas.Abstractions/Model/GraphQl/AssessmentRating.cs @@ -8,4 +8,13 @@ public record AssessmentRating( ) { public bool IsMastery => Points >= Criterion?.MasteryPoints; + + public string? Grade => Points switch + { + >= 5.0 => "Outstanding", + >= 4.0 => "Good", + >= 3.0 => "Sufficient", + >= 0.0 => "Insufficient", + _ => null, + }; } \ No newline at end of file diff --git a/Epsilon.Canvas.Abstractions/Model/GraphQl/Assignment.cs b/Epsilon.Canvas.Abstractions/Model/GraphQl/Assignment.cs index ebc2038..50fed18 100644 --- a/Epsilon.Canvas.Abstractions/Model/GraphQl/Assignment.cs +++ b/Epsilon.Canvas.Abstractions/Model/GraphQl/Assignment.cs @@ -4,6 +4,7 @@ namespace Epsilon.Canvas.Abstractions.Model.GraphQl; public record Assignment( [property: JsonPropertyName("name")] string? Name, + [property: JsonPropertyName("htmlUrl")] Uri? HtmlUrl, [property: JsonPropertyName("modules")] IEnumerable? Modules , [property: JsonPropertyName("rubric")] Rubric? Rubric ); \ No newline at end of file diff --git a/Epsilon.Host.Frontend/src/components/KpiTable.vue b/Epsilon.Host.Frontend/src/components/KpiTable.vue new file mode 100644 index 0000000..a8184b6 --- /dev/null +++ b/Epsilon.Host.Frontend/src/components/KpiTable.vue @@ -0,0 +1,68 @@ + + + + + diff --git a/Epsilon.Host.Frontend/src/views/PerformanceDashboard.vue b/Epsilon.Host.Frontend/src/views/PerformanceDashboard.vue index 2dd89b9..1e49d6a 100644 --- a/Epsilon.Host.Frontend/src/views/PerformanceDashboard.vue +++ b/Epsilon.Host.Frontend/src/views/PerformanceDashboard.vue @@ -82,6 +82,11 @@ onMounted(() => { height: 64px; } +.loading-icon { + width: 64px; + height: 64px; +} + @media screen and (min-width: 580px) { .performance-dashboard { display: grid; diff --git a/Epsilon.Host.WebApi/Program.cs b/Epsilon.Host.WebApi/Program.cs index 9733d07..abffd5c 100644 --- a/Epsilon.Host.WebApi/Program.cs +++ b/Epsilon.Host.WebApi/Program.cs @@ -21,6 +21,7 @@ }); builder.Services.AddControllers(); + builder.Services.AddRouting(static options => options.LowercaseUrls = true); // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle @@ -37,12 +38,14 @@ { "persona_page", services.GetRequiredService>() }, { "competence_profile", services.GetRequiredService>() }, { "kpi_matrix", services.GetRequiredService>() }, + { "kpi_table", services.GetRequiredService>() }, } )); builder.Services.AddScoped, PersonaPageComponentFetcher>(); builder.Services.AddScoped, CompetenceProfileComponentFetcher>(); builder.Services.AddScoped, KpiMatrixComponentFetcher>(); +builder.Services.AddScoped, KpiTableComponentFetcher>(); var app = builder.Build(); @@ -66,7 +69,6 @@ }); }); - app.UseHttpsRedirection(); app.UseCors(); diff --git a/Epsilon/Component/CompetenceProfileComponentFetcher.cs b/Epsilon/Component/CompetenceProfileComponentFetcher.cs index 012a0ba..015cf2b 100644 --- a/Epsilon/Component/CompetenceProfileComponentFetcher.cs +++ b/Epsilon/Component/CompetenceProfileComponentFetcher.cs @@ -73,19 +73,17 @@ IHboIDomain domain if (queryResponse.Data != null) { - foreach (var course in queryResponse.Data.Courses) + foreach (var course in queryResponse.Data.Courses!) { - foreach (var submissionsConnection in course.SubmissionsConnection.Nodes) + foreach (var submission in course.SubmissionsConnection!.Nodes.Select(static sm => sm.SubmissionsHistories.Nodes + .Where(static h => h.RubricAssessments.Nodes.Any()) + .MaxBy(static h => h.Attempt))) { - var submission = submissionsConnection.SubmissionsHistories.Nodes - .Where(static h => h.RubricAssessments.Nodes.Any()) - .MaxBy(static h => h.Attempt); - if (submission != null) { - var rubricAssessments = submission.RubricAssessments.Nodes; + var rubricAssessments = submission.RubricAssessments?.Nodes; - foreach (var assessmentRating in rubricAssessments.SelectMany(static rubricAssessment => rubricAssessment.AssessmentRatings.Where(static ar => + foreach (var assessmentRating in rubricAssessments?.SelectMany(static rubricAssessment => rubricAssessment.AssessmentRatings.Where(static ar => ar is { Points: not null, Criterion.MasteryPoints: not null, Criterion.Outcome: not null, } && ar.Points >= ar.Criterion.MasteryPoints))) { if (FhictConstants.ProfessionalTasks.TryGetValue(assessmentRating.Criterion.Outcome.Id, out var professionalTask)) diff --git a/Epsilon/Component/KpiMatrixComponentFetcher.cs b/Epsilon/Component/KpiMatrixComponentFetcher.cs index 22161df..1e3ab73 100644 --- a/Epsilon/Component/KpiMatrixComponentFetcher.cs +++ b/Epsilon/Component/KpiMatrixComponentFetcher.cs @@ -72,14 +72,12 @@ public override async Task Fetch(DateTime startDate, DateTi var assignments = new List(); - foreach (var course in outcomes.Data!.Courses!) + foreach (var course in outcomes!.Data!.Courses!) { - foreach (var submissionsConnection in course.SubmissionsConnection!.Nodes) + foreach (var submission in course.SubmissionsConnection!.Nodes.Select(sm => sm.SubmissionsHistories.Nodes + .Where(sub => sub.SubmittedAt > startDate && sub.SubmittedAt < endDate) + .MaxBy(static h => h.Attempt))) { - var submission = submissionsConnection.SubmissionsHistories.Nodes - .Where(sub => sub.SubmittedAt > startDate && sub.SubmittedAt < endDate) - .MaxBy(static h => h.Attempt); - if (submission is { Assignment.Rubric: not null, diff --git a/Epsilon/Component/KpiTableComponentFetcher.cs b/Epsilon/Component/KpiTableComponentFetcher.cs new file mode 100644 index 0000000..602c345 --- /dev/null +++ b/Epsilon/Component/KpiTableComponentFetcher.cs @@ -0,0 +1,163 @@ +using Epsilon.Abstractions.Component; +using Epsilon.Abstractions.Model; +using Epsilon.Canvas.Abstractions.Model.GraphQl; +using Epsilon.Canvas.Abstractions.Service; +using Microsoft.Extensions.Configuration; + +namespace Epsilon.Component; + +public class KpiTableComponentFetcher : CompetenceComponentFetcher +{ + private const string GetUserKpis = @" + query GetUserKpis { + allCourses { + submissionsConnection(studentIds: $studentIds) { + nodes { + submissionHistoriesConnection { + nodes { + rubricAssessmentsConnection { + nodes { + assessmentRatings { + criterion { + outcome { + _id + title + } + masteryPoints + } + points + } + } + } + attempt + submittedAt + assignment { + name + htmlUrl + rubric { + criteria { + outcome { + title + _id + masteryPoints + } + } + } + } + } + } + postedAt + } + } + } + } + "; + + private readonly IConfiguration _configuration; + private readonly IGraphQlHttpService _graphQlService; + + public KpiTableComponentFetcher( + IGraphQlHttpService graphQlService, + IConfiguration configuration + ) + { + _graphQlService = graphQlService; + _configuration = configuration; + } + + public override async Task Fetch(DateTime startDate, DateTime endDate) + { + var studentId = _configuration["Canvas:StudentId"]; + var outcomesQuery = GetUserKpis.Replace("$studentIds", studentId, StringComparison.InvariantCultureIgnoreCase); + var outcomes = await _graphQlService.Query(outcomesQuery); + + var kpiTableEntries = new Dictionary() { }; + + foreach (var course in outcomes.Data!.Courses!) + { + foreach (var submission in course.SubmissionsConnection!.Nodes.Select(sm => sm.SubmissionsHistories.Nodes + .Where(sub => sub.SubmittedAt > startDate && sub.SubmittedAt < endDate) + .MaxBy(static h => h.Attempt))) + { + if (submission is + { + Assignment.Rubric: not null, + RubricAssessments.Nodes: not null, + }) + { + var rubricCriteria = submission.Assignment.Rubric.Criteria?.ToArray(); + + if (rubricCriteria is not null) + { + foreach (var outcome in GetValidOutcomes(rubricCriteria)) + { + var assessmentRatings = submission.RubricAssessments.Nodes.FirstOrDefault()?.AssessmentRatings; + + var grade = assessmentRatings?.FirstOrDefault(ar => ar?.Criterion?.Outcome?.Id == outcome?.Id)?.Grade; + + if (grade != null) + { + var assessmentRating = assessmentRatings?.FirstOrDefault(ar => ar?.Criterion?.Outcome?.Id == outcome?.Id); + + if (assessmentRating is not null) + { + + if (kpiTableEntries.ContainsKey(outcome.Id)) + { + UpdateKpiTableEntry(kpiTableEntries, outcome, submission.Assignment, grade); + } + else + { + AddKpiTableEntry(kpiTableEntries, outcome, submission.Assignment, grade); + } + } + } + } + } + } + } + } + + return new KpiTable(kpiTableEntries); + } + + private static IEnumerable GetValidOutcomes(IEnumerable rubricCriteria) + { + return rubricCriteria + .Select(static criteria => criteria.Outcome) + .Where(static outcome => outcome is not null && + (FhictConstants.ProfessionalTasks.ContainsKey(outcome.Id) || + FhictConstants.ProfessionalSkills.ContainsKey(outcome.Id))); + } + + private static void UpdateKpiTableEntry(Dictionary kpiTableEntries, Outcome outcome, Assignment assignment, string gradeStatus) + { + var kpiEntry = kpiTableEntries.GetValueOrDefault(outcome.Id); + var updatedAssignments = kpiEntry.Assignments.Append( + new KpiTableEntryAssignment(assignment.Name, gradeStatus, assignment.HtmlUrl) + ); + var updatedEntry = kpiEntry with { Assignments = updatedAssignments, }; + + kpiTableEntries.Remove(outcome.Id); + kpiTableEntries.Add(outcome.Id, updatedEntry); + } + + private static void AddKpiTableEntry(Dictionary kpiTableEntries, Outcome outcome, Assignment assignment, string gradeStatus) + { + MasteryLevel? masteryLevel = null; + ProfessionalSkillLevel? professionalSkill = null; + + if (FhictConstants.ProfessionalTasks.TryGetValue(outcome.Id, out var professionalTask) + || FhictConstants.ProfessionalSkills.TryGetValue(outcome.Id, out professionalSkill)) + { + masteryLevel = new HboIDomain2018().MasteryLevels.FirstOrDefault(ml => ml.Id == (professionalTask?.MasteryLevel ?? professionalSkill?.MasteryLevel)); + } + + if (masteryLevel != null) + { + var kpiAssignment = new KpiTableEntryAssignment(assignment.Name, gradeStatus, assignment.HtmlUrl); + var kpiTableEntry = new KpiTableEntry(outcome.Title, masteryLevel, new List { kpiAssignment, }); + kpiTableEntries.Add(outcome.Id, kpiTableEntry); + } + } +} \ No newline at end of file