From 6eaba084bb8ed9e92eaaf4ab57f63fbd87d83a12 Mon Sep 17 00:00:00 2001 From: Neal Geilen Date: Tue, 8 Nov 2022 09:07:15 +0100 Subject: [PATCH] Release v1.0.0 (#41) * Change formats type to `string` and split with comma separator * Use root configuration instead of configuration section * Fix exceptions not properly displaying in the console * Add all module item types according to documentation * Update application demo GIF * Create code-analysis.yml * Remove comments and enable additional queries * Removed push from code-analysis.yml workflow * Typo code-analysis.yml * Added support for a Word export option * Added footer Epsilon Credits * Created helper class with reformat functions * Reformat code * Improved helper functions, Found that they can be added to records :-) * Reformat files * string interpolation * Feature/cleanup (#40) * Remove obsolete Epsilon.Http.Abstractions project * Fix invalid serializable implementation * Disable unused method return value hint * Change exception to more appropriate one * Update Grade score description * Prevent null grades from exporting * Reduce cognitive complexity to acceptable level * Move logging call * Reduce nesting * Move project name and repository uri to constants * Fix nullability warnings * Remove unused class * Use ?: operator and move constants to top level of class * Reduce loop complexity * Use project name constant in output name export option * Update README.md application demo gif * Add supported formats to README.md Co-authored-by: Jelle Maas --- .../Export/ICanvasModuleExporter.cs | 2 +- Epsilon.Abstractions/Export/IExporter.cs | 2 +- .../Http}/HttpService.cs | 2 +- .../Http}/Json/HttpClientJsonExtensions.cs | 2 +- .../Json/HttpResponseMessageJsonExtensions.cs | 2 +- .../ICanvasModuleCollectionFetcher.cs | 2 +- Epsilon.Canvas.Abstractions/Model/Module.cs | 7 +- .../Model/ModuleOutcomeResultCollection.cs | 3 + Epsilon.Canvas.Abstractions/Model/Outcome.cs | 22 ++++- .../Model/OutcomeResult.cs | 18 +++- .../Model/OutcomeResultCollection.cs | 3 +- .../Model/OutcomeResultCollectionLink.cs | 10 +-- Epsilon.Canvas.Abstractions/Model/User.cs | 12 --- .../Service/IOutcomeHttpService.cs | 1 - .../CanvasModuleCollectionFetcher.cs | 35 +++----- .../CanvasServiceCollectionExtensions.cs | 1 + .../Converter/LinkHeaderConverter.cs | 2 +- Epsilon.Canvas/Epsilon.Canvas.csproj | 2 +- .../Service/AssignmentHttpService.cs | 6 +- Epsilon.Canvas/Service/ModuleHttpService.cs | 4 +- Epsilon.Canvas/Service/OutcomeHttpService.cs | 13 +-- .../Service/PaginatorHttpService.cs | 26 +++--- .../Service/SubmissionHttpService.cs | 2 +- Epsilon.Cli/Startup.cs | 10 ++- .../Epsilon.Http.Abstractions.csproj | 9 -- Epsilon.sln | 6 -- Epsilon/Constants.cs | 7 ++ Epsilon/Epsilon.csproj | 2 + .../Exceptions/NoExportersFoundException.cs | 12 ++- Epsilon/Export/ExportOptions.cs | 2 +- .../Export/Exporters/ConsoleModuleExporter.cs | 27 +++--- Epsilon/Export/Exporters/CsvModuleExporter.cs | 69 ++++++++------- .../Export/Exporters/ExcelModuleExporter.cs | 68 ++++---------- .../Export/Exporters/WordModuleExporter.cs | 88 +++++++++++++++++++ Epsilon/Export/ModuleExporterCollection.cs | 9 +- .../CoreServiceCollectionExtensions.cs | 1 + README.md | 11 ++- 37 files changed, 289 insertions(+), 211 deletions(-) rename {Epsilon.Http.Abstractions => Epsilon.Abstractions/Http}/HttpService.cs (78%) rename {Epsilon.Http.Abstractions => Epsilon.Abstractions/Http}/Json/HttpClientJsonExtensions.cs (97%) rename {Epsilon.Http.Abstractions => Epsilon.Abstractions/Http}/Json/HttpResponseMessageJsonExtensions.cs (96%) create mode 100644 Epsilon.Canvas.Abstractions/Model/ModuleOutcomeResultCollection.cs delete mode 100644 Epsilon.Canvas.Abstractions/Model/User.cs delete mode 100644 Epsilon.Http.Abstractions/Epsilon.Http.Abstractions.csproj create mode 100644 Epsilon/Constants.cs create mode 100644 Epsilon/Export/Exporters/WordModuleExporter.cs diff --git a/Epsilon.Abstractions/Export/ICanvasModuleExporter.cs b/Epsilon.Abstractions/Export/ICanvasModuleExporter.cs index c05b3d01..e717b09b 100644 --- a/Epsilon.Abstractions/Export/ICanvasModuleExporter.cs +++ b/Epsilon.Abstractions/Export/ICanvasModuleExporter.cs @@ -2,7 +2,7 @@ namespace Epsilon.Abstractions.Export; -public interface ICanvasModuleExporter : IExporter> +public interface ICanvasModuleExporter : IExporter> { } \ No newline at end of file diff --git a/Epsilon.Abstractions/Export/IExporter.cs b/Epsilon.Abstractions/Export/IExporter.cs index fd463eff..68d494f8 100644 --- a/Epsilon.Abstractions/Export/IExporter.cs +++ b/Epsilon.Abstractions/Export/IExporter.cs @@ -4,5 +4,5 @@ public interface IExporter { public IEnumerable Formats { get; } - void Export(T data, string format); + Task Export(T data, string format); } \ No newline at end of file diff --git a/Epsilon.Http.Abstractions/HttpService.cs b/Epsilon.Abstractions/Http/HttpService.cs similarity index 78% rename from Epsilon.Http.Abstractions/HttpService.cs rename to Epsilon.Abstractions/Http/HttpService.cs index 8a6ac3f2..c5643701 100644 --- a/Epsilon.Http.Abstractions/HttpService.cs +++ b/Epsilon.Abstractions/Http/HttpService.cs @@ -1,4 +1,4 @@ -namespace Epsilon.Http.Abstractions; +namespace Epsilon.Abstractions.Http; public abstract class HttpService { diff --git a/Epsilon.Http.Abstractions/Json/HttpClientJsonExtensions.cs b/Epsilon.Abstractions/Http/Json/HttpClientJsonExtensions.cs similarity index 97% rename from Epsilon.Http.Abstractions/Json/HttpClientJsonExtensions.cs rename to Epsilon.Abstractions/Http/Json/HttpClientJsonExtensions.cs index d079116c..b1810d7f 100644 --- a/Epsilon.Http.Abstractions/Json/HttpClientJsonExtensions.cs +++ b/Epsilon.Abstractions/Http/Json/HttpClientJsonExtensions.cs @@ -1,4 +1,4 @@ -namespace Epsilon.Http.Abstractions.Json; +namespace Epsilon.Abstractions.Http.Json; public static class HttpClientJsonExtensions { diff --git a/Epsilon.Http.Abstractions/Json/HttpResponseMessageJsonExtensions.cs b/Epsilon.Abstractions/Http/Json/HttpResponseMessageJsonExtensions.cs similarity index 96% rename from Epsilon.Http.Abstractions/Json/HttpResponseMessageJsonExtensions.cs rename to Epsilon.Abstractions/Http/Json/HttpResponseMessageJsonExtensions.cs index e890303a..be5064cc 100644 --- a/Epsilon.Http.Abstractions/Json/HttpResponseMessageJsonExtensions.cs +++ b/Epsilon.Abstractions/Http/Json/HttpResponseMessageJsonExtensions.cs @@ -1,6 +1,6 @@ using System.Text.Json; -namespace Epsilon.Http.Abstractions.Json; +namespace Epsilon.Abstractions.Http.Json; public static class HttpResponseMessageJsonExtensions { diff --git a/Epsilon.Canvas.Abstractions/ICanvasModuleCollectionFetcher.cs b/Epsilon.Canvas.Abstractions/ICanvasModuleCollectionFetcher.cs index c4d4469c..6b8b97f5 100644 --- a/Epsilon.Canvas.Abstractions/ICanvasModuleCollectionFetcher.cs +++ b/Epsilon.Canvas.Abstractions/ICanvasModuleCollectionFetcher.cs @@ -4,5 +4,5 @@ namespace Epsilon.Canvas.Abstractions; public interface ICanvasModuleCollectionFetcher { - public Task> GetAll(int courseId); + public IAsyncEnumerable GetAll(int courseId); } \ No newline at end of file diff --git a/Epsilon.Canvas.Abstractions/Model/Module.cs b/Epsilon.Canvas.Abstractions/Model/Module.cs index 3b53730b..901962b5 100644 --- a/Epsilon.Canvas.Abstractions/Model/Module.cs +++ b/Epsilon.Canvas.Abstractions/Model/Module.cs @@ -1,5 +1,4 @@ using System.Text.Json.Serialization; -using Epsilon.Canvas.Abstractions.Response; namespace Epsilon.Canvas.Abstractions.Model; @@ -8,8 +7,4 @@ public record Module( [property: JsonPropertyName("name")] string Name, [property: JsonPropertyName("items_count")] int Count, [property: JsonPropertyName("items")] IEnumerable? Items -) -{ - [JsonIgnore] - public OutcomeResultCollection Collection { get; set; } -} \ No newline at end of file +); \ No newline at end of file diff --git a/Epsilon.Canvas.Abstractions/Model/ModuleOutcomeResultCollection.cs b/Epsilon.Canvas.Abstractions/Model/ModuleOutcomeResultCollection.cs new file mode 100644 index 00000000..0ed006f7 --- /dev/null +++ b/Epsilon.Canvas.Abstractions/Model/ModuleOutcomeResultCollection.cs @@ -0,0 +1,3 @@ +namespace Epsilon.Canvas.Abstractions.Model; + +public record ModuleOutcomeResultCollection(Module Module, OutcomeResultCollection Collection); \ No newline at end of file diff --git a/Epsilon.Canvas.Abstractions/Model/Outcome.cs b/Epsilon.Canvas.Abstractions/Model/Outcome.cs index 49149490..26125e95 100644 --- a/Epsilon.Canvas.Abstractions/Model/Outcome.cs +++ b/Epsilon.Canvas.Abstractions/Model/Outcome.cs @@ -1,4 +1,5 @@ using System.Text.Json.Serialization; +using System.Text.RegularExpressions; namespace Epsilon.Canvas.Abstractions.Model; @@ -6,4 +7,23 @@ public record Outcome( [property: JsonPropertyName("id")] int Id, [property: JsonPropertyName("title")] string Title, [property: JsonPropertyName("description")] string Description -); \ No newline at end of file +) +{ + public string ShortDescription() + { + string description = RemoveHtml(); + //Function gives only the short English description back of the outcome. + var startPos = description.IndexOf(" EN ", StringComparison.Ordinal) + " EN ".Length; + var endPos = description.IndexOf(" NL ", StringComparison.Ordinal); + + return description.Substring(startPos, endPos - startPos); + } + + private string RemoveHtml() + { + var raw = Regex.Replace(Description, "<.*?>", " "); + var trimmed = Regex.Replace(raw, @"\s\s+", " "); + + return trimmed; + } +}; \ No newline at end of file diff --git a/Epsilon.Canvas.Abstractions/Model/OutcomeResult.cs b/Epsilon.Canvas.Abstractions/Model/OutcomeResult.cs index ec89c7c9..f275f162 100644 --- a/Epsilon.Canvas.Abstractions/Model/OutcomeResult.cs +++ b/Epsilon.Canvas.Abstractions/Model/OutcomeResult.cs @@ -3,7 +3,21 @@ namespace Epsilon.Canvas.Abstractions.Model; public record OutcomeResult( - [property: JsonPropertyName("mastery")] bool? Mastery, + [property: JsonPropertyName("mastery")] + bool? Mastery, [property: JsonPropertyName("score")] double? Score, [property: JsonPropertyName("links")] OutcomeResultLink Link -); \ No newline at end of file +) +{ + public string? Grade() + { + return Score switch + { + <= 2 => "Unsatisfactory", + 3 => "Satisfactory", + 4 => "Good", + 5 => "Outstanding", + _ => null, + }; + } +} \ No newline at end of file diff --git a/Epsilon.Canvas.Abstractions/Model/OutcomeResultCollection.cs b/Epsilon.Canvas.Abstractions/Model/OutcomeResultCollection.cs index d2012232..6443f307 100644 --- a/Epsilon.Canvas.Abstractions/Model/OutcomeResultCollection.cs +++ b/Epsilon.Canvas.Abstractions/Model/OutcomeResultCollection.cs @@ -1,7 +1,6 @@ using System.Text.Json.Serialization; -using Epsilon.Canvas.Abstractions.Model; -namespace Epsilon.Canvas.Abstractions.Response; +namespace Epsilon.Canvas.Abstractions.Model; public record OutcomeResultCollection( [property: JsonPropertyName("outcome_results")] IEnumerable OutcomeResults, diff --git a/Epsilon.Canvas.Abstractions/Model/OutcomeResultCollectionLink.cs b/Epsilon.Canvas.Abstractions/Model/OutcomeResultCollectionLink.cs index 10bed7a7..9c5e1a6e 100644 --- a/Epsilon.Canvas.Abstractions/Model/OutcomeResultCollectionLink.cs +++ b/Epsilon.Canvas.Abstractions/Model/OutcomeResultCollectionLink.cs @@ -3,15 +3,13 @@ namespace Epsilon.Canvas.Abstractions.Model; public record OutcomeResultCollectionLink( - [property: JsonPropertyName("outcomes")] IEnumerable? Outcomes, - [property: JsonPropertyName("alignments")] IEnumerable? Alignments + [property: JsonPropertyName("outcomes")] IEnumerable Outcomes, + [property: JsonPropertyName("alignments")] IEnumerable Alignments ) { - public IDictionary OutcomesDictionary => Outcomes - .DistinctBy(static o => o.Id) + public IDictionary OutcomesDictionary => Outcomes.DistinctBy(static o => o.Id) .ToDictionary(static o => o.Id.ToString(), static o => o); - public IDictionary AlignmentsDictionary => Alignments - .DistinctBy(static a => a.Id) + public IDictionary AlignmentsDictionary => Alignments.DistinctBy(static a => a.Id) .ToDictionary(static a => a.Id, static a => a); } \ No newline at end of file diff --git a/Epsilon.Canvas.Abstractions/Model/User.cs b/Epsilon.Canvas.Abstractions/Model/User.cs deleted file mode 100644 index b283ffd2..00000000 --- a/Epsilon.Canvas.Abstractions/Model/User.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Epsilon.Canvas.Abstractions.Model; - -public class User -{ - [JsonPropertyName("id")] - public int Id { get; set; } - - [JsonPropertyName("name")] - public string Name { get; set; } -} \ No newline at end of file diff --git a/Epsilon.Canvas.Abstractions/Service/IOutcomeHttpService.cs b/Epsilon.Canvas.Abstractions/Service/IOutcomeHttpService.cs index 01602bd1..dec04af8 100644 --- a/Epsilon.Canvas.Abstractions/Service/IOutcomeHttpService.cs +++ b/Epsilon.Canvas.Abstractions/Service/IOutcomeHttpService.cs @@ -1,5 +1,4 @@ using Epsilon.Canvas.Abstractions.Model; -using Epsilon.Canvas.Abstractions.Response; namespace Epsilon.Canvas.Abstractions.Service; diff --git a/Epsilon.Canvas/CanvasModuleCollectionFetcher.cs b/Epsilon.Canvas/CanvasModuleCollectionFetcher.cs index 3ff5a2fa..82aa0491 100644 --- a/Epsilon.Canvas/CanvasModuleCollectionFetcher.cs +++ b/Epsilon.Canvas/CanvasModuleCollectionFetcher.cs @@ -1,6 +1,6 @@ -using Epsilon.Canvas.Abstractions; +using System.Diagnostics; +using Epsilon.Canvas.Abstractions; using Epsilon.Canvas.Abstractions.Model; -using Epsilon.Canvas.Abstractions.Response; using Epsilon.Canvas.Abstractions.Service; using Microsoft.Extensions.Logging; @@ -8,7 +8,6 @@ namespace Epsilon.Canvas; public class CanvasModuleCollectionFetcher : ICanvasModuleCollectionFetcher { - private readonly ILogger _logger; private readonly IModuleHttpService _moduleService; private readonly IOutcomeHttpService _outcomeService; @@ -18,36 +17,30 @@ public CanvasModuleCollectionFetcher( IOutcomeHttpService outcomeService ) { - _logger = logger; _moduleService = moduleService; _outcomeService = outcomeService; } - public async Task> GetAll(int courseId) + public async IAsyncEnumerable GetAll(int courseId) { - _logger.LogInformation("Downloading results..."); - var response = await _outcomeService.GetResults(courseId, new[] { "outcomes", "alignments" }); - - var alignments = response.Links.Alignments - .DistinctBy(static a => a.Id) - .ToDictionary(static a => a.Id, static a => a); - - var outcomes = response.Links.Outcomes - .DistinctBy(static o => o.Id) - .ToDictionary(static o => o.Id.ToString(), static o => o); - var modules = await _moduleService.GetAll(courseId, new[] { "items" }); - foreach (var module in modules) + + Debug.Assert(response != null, nameof(response) + " != null"); + Debug.Assert(modules != null, nameof(modules) + " != null"); + + foreach (var module in modules.ToArray()) { + Debug.Assert(module.Items != null, "module.Items != null"); + var ids = module.Items.Select(static i => $"assignment_{i.ContentId}"); - module.Collection = new OutcomeResultCollection( + Debug.Assert(response.Links?.Alignments != null, "response.Links?.Alignments != null"); + + yield return new ModuleOutcomeResultCollection(module, new OutcomeResultCollection( response.OutcomeResults.Where(r => ids.Contains(r.Link.Alignment)), response.Links with { Alignments = response.Links.Alignments.Where(a => ids.Contains(a.Id)) } - ); + )); } - - return modules; } } \ No newline at end of file diff --git a/Epsilon.Canvas/CanvasServiceCollectionExtensions.cs b/Epsilon.Canvas/CanvasServiceCollectionExtensions.cs index f77b6274..9888c720 100644 --- a/Epsilon.Canvas/CanvasServiceCollectionExtensions.cs +++ b/Epsilon.Canvas/CanvasServiceCollectionExtensions.cs @@ -14,6 +14,7 @@ public static class CanvasServiceCollectionExtensions { private const string CanvasHttpClient = "CanvasHttpClient"; + // ReSharper disable once UnusedMethodReturnValue.Global public static IServiceCollection AddCanvas(this IServiceCollection services, IConfiguration config) { services.Configure(config); diff --git a/Epsilon.Canvas/Converter/LinkHeaderConverter.cs b/Epsilon.Canvas/Converter/LinkHeaderConverter.cs index d42566b2..ab318a5c 100644 --- a/Epsilon.Canvas/Converter/LinkHeaderConverter.cs +++ b/Epsilon.Canvas/Converter/LinkHeaderConverter.cs @@ -13,7 +13,7 @@ public LinkHeader ConvertFrom(HttpResponseMessage response) { if (!response.Headers.Contains("Link")) { - throw new ArgumentNullException(nameof(response.Headers), "Header does not contain link key"); + throw new KeyNotFoundException("Header does not contain link key"); } return ConvertFrom(response.Headers.GetValues("Link").First()); diff --git a/Epsilon.Canvas/Epsilon.Canvas.csproj b/Epsilon.Canvas/Epsilon.Canvas.csproj index 46691de6..f1687d2d 100644 --- a/Epsilon.Canvas/Epsilon.Canvas.csproj +++ b/Epsilon.Canvas/Epsilon.Canvas.csproj @@ -7,8 +7,8 @@ + - diff --git a/Epsilon.Canvas/Service/AssignmentHttpService.cs b/Epsilon.Canvas/Service/AssignmentHttpService.cs index 3d531f0f..b1ad80b8 100644 --- a/Epsilon.Canvas/Service/AssignmentHttpService.cs +++ b/Epsilon.Canvas/Service/AssignmentHttpService.cs @@ -1,7 +1,7 @@ -using Epsilon.Canvas.Abstractions.Model; +using Epsilon.Abstractions.Http; +using Epsilon.Abstractions.Http.Json; +using Epsilon.Canvas.Abstractions.Model; using Epsilon.Canvas.Abstractions.Service; -using Epsilon.Http.Abstractions; -using Epsilon.Http.Abstractions.Json; namespace Epsilon.Canvas.Service; diff --git a/Epsilon.Canvas/Service/ModuleHttpService.cs b/Epsilon.Canvas/Service/ModuleHttpService.cs index c7375c4e..4142c27f 100644 --- a/Epsilon.Canvas/Service/ModuleHttpService.cs +++ b/Epsilon.Canvas/Service/ModuleHttpService.cs @@ -1,8 +1,8 @@ using System.Text; +using Epsilon.Abstractions.Http; +using Epsilon.Abstractions.Http.Json; using Epsilon.Canvas.Abstractions.Model; using Epsilon.Canvas.Abstractions.Service; -using Epsilon.Http.Abstractions; -using Epsilon.Http.Abstractions.Json; namespace Epsilon.Canvas.Service; diff --git a/Epsilon.Canvas/Service/OutcomeHttpService.cs b/Epsilon.Canvas/Service/OutcomeHttpService.cs index fd3ec7da..c3b6ad3f 100644 --- a/Epsilon.Canvas/Service/OutcomeHttpService.cs +++ b/Epsilon.Canvas/Service/OutcomeHttpService.cs @@ -1,9 +1,8 @@ using System.Text; +using Epsilon.Abstractions.Http; +using Epsilon.Abstractions.Http.Json; using Epsilon.Canvas.Abstractions.Model; -using Epsilon.Canvas.Abstractions.Response; using Epsilon.Canvas.Abstractions.Service; -using Epsilon.Http.Abstractions; -using Epsilon.Http.Abstractions.Json; namespace Epsilon.Canvas.Service; @@ -30,11 +29,13 @@ public OutcomeHttpService(HttpClient client, IPaginatorHttpService paginator) : var query = $"?include[]={string.Join("&include[]=", include)}"; var responses = await _paginator.GetAllPages(HttpMethod.Get, url + query); + var responsesArray = responses.ToArray(); + return new OutcomeResultCollection( - responses.SelectMany(static r => r.OutcomeResults), + responsesArray.SelectMany(static r => r.OutcomeResults), new OutcomeResultCollectionLink( - responses.SelectMany(static r => r.Links.Outcomes), - responses.SelectMany(static r => r.Links.Alignments) + responsesArray.SelectMany(static r => r.Links?.Outcomes ?? Array.Empty()), + responsesArray.SelectMany(static r => r.Links?.Alignments ?? Array.Empty()) ) ); } diff --git a/Epsilon.Canvas/Service/PaginatorHttpService.cs b/Epsilon.Canvas/Service/PaginatorHttpService.cs index eb5675a7..5009c20f 100644 --- a/Epsilon.Canvas/Service/PaginatorHttpService.cs +++ b/Epsilon.Canvas/Service/PaginatorHttpService.cs @@ -1,13 +1,14 @@ using System.Web; +using Epsilon.Abstractions.Http; +using Epsilon.Abstractions.Http.Json; using Epsilon.Canvas.Abstractions.Converter; using Epsilon.Canvas.Abstractions.Service; -using Epsilon.Http.Abstractions; -using Epsilon.Http.Abstractions.Json; namespace Epsilon.Canvas.Service; public class PaginatorHttpService : HttpService, IPaginatorHttpService { + private const int Limit = 100; private readonly ILinkHeaderConverter _headerConverter; public PaginatorHttpService(HttpClient client, ILinkHeaderConverter headerConverter) : base(client) @@ -19,25 +20,20 @@ public async Task> GetAllPages(HttpMethod method, { var pages = new List(); var page = "1"; - const int limit = 100; - if (!uri.Contains('?')) - { - uri += "?"; - } - else - { - uri += "&"; - } + uri += !uri.Contains('?') ? "?" : "&"; do { - var offset = pages.Count * limit; - var request = new HttpRequestMessage(method, $"{uri}per_page={limit}&offset={offset}&page={page}"); + var offset = pages.Count * Limit; + var request = new HttpRequestMessage(method, $"{uri}per_page={Limit}&offset={offset}&page={page}"); var (response, value) = await Client.SendAsync(request); var links = _headerConverter.ConvertFrom(response); - pages.Add(value); + if (value != null) + { + pages.Add(value); + } if (links.NextLink == null) { @@ -46,7 +42,7 @@ public async Task> GetAllPages(HttpMethod method, var query = HttpUtility.ParseQueryString(new Uri(links.NextLink).Query); page = query["page"]; - } while (pages.Count * limit % limit == 0); + } while (pages.Count * Limit % Limit == 0); return pages; } diff --git a/Epsilon.Canvas/Service/SubmissionHttpService.cs b/Epsilon.Canvas/Service/SubmissionHttpService.cs index 434d5a64..7b301009 100644 --- a/Epsilon.Canvas/Service/SubmissionHttpService.cs +++ b/Epsilon.Canvas/Service/SubmissionHttpService.cs @@ -1,7 +1,7 @@ using System.Text; +using Epsilon.Abstractions.Http; using Epsilon.Canvas.Abstractions.Model; using Epsilon.Canvas.Abstractions.Service; -using Epsilon.Http.Abstractions; namespace Epsilon.Canvas.Service; diff --git a/Epsilon.Cli/Startup.cs b/Epsilon.Cli/Startup.cs index 6e2f0658..d8e488ae 100644 --- a/Epsilon.Cli/Startup.cs +++ b/Epsilon.Cli/Startup.cs @@ -63,17 +63,19 @@ private async Task ExecuteAsync() } _logger.LogInformation("Targeting Canvas course: {CourseId}, at {Url}", _canvasSettings.CourseId, _canvasSettings.ApiUrl); - var modules = await _collectionFetcher.GetAll(_canvasSettings.CourseId); + _logger.LogInformation("Downloading results, this may take a few seconds..."); + var items = _collectionFetcher.GetAll(_canvasSettings.CourseId); var formats = _exportOptions.Formats.Split(","); var exporters = _exporterCollection.DetermineExporters(formats).ToArray(); _logger.LogInformation("Attempting to use following formats: {Formats}", string.Join(", ", formats)); - + foreach (var (format, exporter) in exporters) { _logger.LogInformation("Exporting to {Format} using {Exporter}...", format, exporter.GetType().Name); - exporter.Export(modules, format); + // ReSharper disable once PossibleMultipleEnumeration + await exporter.Export(items, format); } } catch (Exception ex) @@ -91,7 +93,7 @@ private static IEnumerable Validate(object model) var results = new List(); var context = new ValidationContext(model); - var isValid = Validator.TryValidateObject(model, context, results, true); + Validator.TryValidateObject(model, context, results, true); return results; } diff --git a/Epsilon.Http.Abstractions/Epsilon.Http.Abstractions.csproj b/Epsilon.Http.Abstractions/Epsilon.Http.Abstractions.csproj deleted file mode 100644 index eb2460e9..00000000 --- a/Epsilon.Http.Abstractions/Epsilon.Http.Abstractions.csproj +++ /dev/null @@ -1,9 +0,0 @@ - - - - net6.0 - enable - enable - - - diff --git a/Epsilon.sln b/Epsilon.sln index c1f11b5a..7bb4b9c2 100644 --- a/Epsilon.sln +++ b/Epsilon.sln @@ -12,8 +12,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Epsilon", "Epsilon\Epsilon. EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Epsilon.Abstractions", "Epsilon.Abstractions\Epsilon.Abstractions.csproj", "{4AEA96B5-B30A-49C7-8871-29D18587936F}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Epsilon.Http.Abstractions", "Epsilon.Http.Abstractions\Epsilon.Http.Abstractions.csproj", "{4BC41253-DFBF-4C5F-94AE-F639F306207C}" -EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -44,9 +42,5 @@ Global {4AEA96B5-B30A-49C7-8871-29D18587936F}.Debug|Any CPU.Build.0 = Debug|Any CPU {4AEA96B5-B30A-49C7-8871-29D18587936F}.Release|Any CPU.ActiveCfg = Release|Any CPU {4AEA96B5-B30A-49C7-8871-29D18587936F}.Release|Any CPU.Build.0 = Release|Any CPU - {4BC41253-DFBF-4C5F-94AE-F639F306207C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {4BC41253-DFBF-4C5F-94AE-F639F306207C}.Debug|Any CPU.Build.0 = Debug|Any CPU - {4BC41253-DFBF-4C5F-94AE-F639F306207C}.Release|Any CPU.ActiveCfg = Release|Any CPU - {4BC41253-DFBF-4C5F-94AE-F639F306207C}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/Epsilon/Constants.cs b/Epsilon/Constants.cs new file mode 100644 index 00000000..9fdd106f --- /dev/null +++ b/Epsilon/Constants.cs @@ -0,0 +1,7 @@ +namespace Epsilon; + +public static class Constants +{ + public const string ProjectName = "Epsilon"; + public static readonly Uri RepositoryUri = new("https://github.com/Typiqally/epsilon"); +} \ No newline at end of file diff --git a/Epsilon/Epsilon.csproj b/Epsilon/Epsilon.csproj index 2030f0c7..dab09f5d 100644 --- a/Epsilon/Epsilon.csproj +++ b/Epsilon/Epsilon.csproj @@ -12,10 +12,12 @@ + + diff --git a/Epsilon/Export/Exceptions/NoExportersFoundException.cs b/Epsilon/Export/Exceptions/NoExportersFoundException.cs index f1e498e0..2cf7b1db 100644 --- a/Epsilon/Export/Exceptions/NoExportersFoundException.cs +++ b/Epsilon/Export/Exceptions/NoExportersFoundException.cs @@ -1,4 +1,6 @@ -namespace Epsilon.Export.Exceptions; +using System.Runtime.Serialization; + +namespace Epsilon.Export.Exceptions; [Serializable] public class NoExportersFoundException : Exception @@ -7,7 +9,13 @@ public NoExportersFoundException() { } - public NoExportersFoundException(IEnumerable formats) : base($"No exporters could be found with the given formats {string.Join(",", formats)}") + protected NoExportersFoundException(SerializationInfo info, StreamingContext context) + : base(info, context) + { + } + + public NoExportersFoundException(IEnumerable formats) + : base($"No exporters could be found with the given formats {string.Join(",", formats)}") { } } \ No newline at end of file diff --git a/Epsilon/Export/ExportOptions.cs b/Epsilon/Export/ExportOptions.cs index be4ed1cb..c8a329fb 100644 --- a/Epsilon/Export/ExportOptions.cs +++ b/Epsilon/Export/ExportOptions.cs @@ -2,7 +2,7 @@ public class ExportOptions { - public string OutputName { get; set; } = "Epsilon-Export-{DateTime}"; + public string OutputName { get; set; } = $"{Constants.ProjectName}-Export-{{DateTime}}"; public string Formats { get; set; } = "console"; diff --git a/Epsilon/Export/Exporters/ConsoleModuleExporter.cs b/Epsilon/Export/Exporters/ConsoleModuleExporter.cs index c6ad1ad5..d5424749 100644 --- a/Epsilon/Export/Exporters/ConsoleModuleExporter.cs +++ b/Epsilon/Export/Exporters/ConsoleModuleExporter.cs @@ -1,4 +1,5 @@ -using Epsilon.Abstractions.Export; +using System.Diagnostics; +using Epsilon.Abstractions.Export; using Epsilon.Canvas.Abstractions.Model; using Microsoft.Extensions.Logging; @@ -15,28 +16,26 @@ public ConsoleModuleExporter(ILogger logger) public IEnumerable Formats { get; } = new[] { "console", "logs" }; - public void Export(IEnumerable data, string format) + public async Task Export(IAsyncEnumerable data, string format) { - LogModule(data); - } - - private void LogModule(IEnumerable modules) - { - foreach (var module in modules) + await foreach (var item in data) { - _logger.LogInformation("Module: {Name}", module.Name); + _logger.LogInformation("Module: {Name}", item.Module.Name); - var links = module.Collection.Links; - var alignments = links.AlignmentsDictionary; - var outcomes = links.OutcomesDictionary; + var links = item.Collection.Links; + var alignments = links?.AlignmentsDictionary; + var outcomes = links?.OutcomesDictionary; + Debug.Assert(alignments != null, nameof(alignments) + " != null"); + Debug.Assert(outcomes != null, nameof(outcomes) + " != null"); + foreach (var alignment in alignments.Values) { _logger.LogInformation("Alignment: {Alignment}", alignment.Name); - foreach (var result in module.Collection.OutcomeResults.Where(o => o.Link.Alignment == alignment.Id)) + foreach (var result in item.Collection.OutcomeResults.Where(o => o.Link.Alignment == alignment.Id && o.Link.Outcome != null)) { - _logger.LogInformation("- {OutcomeName} {Score}", outcomes[result.Link.Outcome].Title, result.Score); + _logger.LogInformation("- {OutcomeName} {Score}", outcomes[result.Link.Outcome!].Title, result.Grade()); } } } diff --git a/Epsilon/Export/Exporters/CsvModuleExporter.cs b/Epsilon/Export/Exporters/CsvModuleExporter.cs index f58c2cb0..847d74ef 100644 --- a/Epsilon/Export/Exporters/CsvModuleExporter.cs +++ b/Epsilon/Export/Exporters/CsvModuleExporter.cs @@ -1,4 +1,5 @@ using System.Data; +using System.Diagnostics; using Epsilon.Abstractions.Export; using Epsilon.Canvas.Abstractions.Model; using Microsoft.Extensions.Options; @@ -16,9 +17,9 @@ public CsvModuleExporter(IOptions options) public IEnumerable Formats { get; } = new[] { "csv" }; - public void Export(IEnumerable data, string format) + public async Task Export(IAsyncEnumerable data, string format) { - var dt = CreateDataTable(data); + var dt = await CreateDataTable(data); var stream = new StreamWriter($"{_options.FormattedOutputName}.{format}", false); WriteHeader(stream, dt); @@ -27,7 +28,7 @@ public void Export(IEnumerable data, string format) stream.Close(); } - private static DataTable CreateDataTable(IEnumerable modules) + private static async Task CreateDataTable(IAsyncEnumerable items) { var dt = new DataTable(); @@ -38,23 +39,31 @@ private static DataTable CreateDataTable(IEnumerable modules) dt.Columns.Add("Score", typeof(string)); dt.Columns.Add("Module", typeof(string)); - foreach (var module in modules) + await foreach (var item in items) { - var links = module.Collection.Links; + var links = item.Collection.Links; - foreach (var result in module.Collection.OutcomeResults) + Debug.Assert(links?.OutcomesDictionary != null, "links?.OutcomesDictionary != null"); + Debug.Assert(links.AlignmentsDictionary != null, "links.AlignmentsDictionary != null"); + + foreach (var result in item.Collection.OutcomeResults) { - var outcome = links.OutcomesDictionary[result.Link.Outcome]; - var alignment = links.AlignmentsDictionary[result.Link.Alignment]; - - dt.Rows.Add( - outcome.Id, - alignment.Id, - alignment.Name, - outcome.Title, - result.Score.HasValue ? result.Score : "not achieved", - module.Name - ); + Debug.Assert(result.Link != null, "result.Link != null"); + var outcome = links.OutcomesDictionary[result.Link.Outcome!]; + var alignment = links.AlignmentsDictionary[result.Link.Alignment!]; + var grade = result.Grade(); + + if (grade != null) + { + dt.Rows.Add( + outcome.Id, + alignment.Id, + alignment.Name, + outcome.Title, + result.Grade(), + item.Module.Name + ); + } } } @@ -75,31 +84,27 @@ private static void WriteHeader(TextWriter writer, DataTable dt) writer.Write(writer.NewLine); } - // TODO: Fix code smell, cognitive complexity in if statement nesting private static void WriteRows(TextWriter writer, DataTable dt) { foreach (DataRow dr in dt.Rows) { - for (var i = 0; i < dt.Columns.Count; i++) + foreach (DataColumn dtColumn in dt.Columns) { - if (!Convert.IsDBNull(dr[i])) + var value = dr[dtColumn.Ordinal].ToString(); + if (value != null) { - var value = dr[i].ToString(); - if (value != null) + if (value.Contains(';')) + { + value = $"\"{value}\""; + writer.Write(value); + } + else { - if (value.Contains(';')) - { - value = $"\"{value}\""; - writer.Write(value); - } - else - { - writer.Write(dr[i].ToString()); - } + writer.Write(value); } } - if (i < dt.Columns.Count - 1) + if (dtColumn.Ordinal < dt.Columns.Count - 1) { writer.Write(";"); } diff --git a/Epsilon/Export/Exporters/ExcelModuleExporter.cs b/Epsilon/Export/Exporters/ExcelModuleExporter.cs index 8a5a2a4a..322fc2aa 100644 --- a/Epsilon/Export/Exporters/ExcelModuleExporter.cs +++ b/Epsilon/Export/Exporters/ExcelModuleExporter.cs @@ -1,5 +1,5 @@ -using System.Text; -using System.Text.RegularExpressions; +using System.Diagnostics; +using System.Text; using Epsilon.Abstractions.Export; using Epsilon.Canvas.Abstractions.Model; using ExcelLibrary.SpreadSheet; @@ -18,13 +18,13 @@ public ExcelModuleExporter(IOptions options) public IEnumerable Formats { get; } = new[] { "xls", "xlsx", "excel" }; - public void Export(IEnumerable modules, string format) + public async Task Export(IAsyncEnumerable data, string format) { var workbook = new Workbook(); - foreach (var module in modules.Where(static m => m.Collection.OutcomeResults.Any())) + await foreach (var item in data.Where(static m => m.Collection.OutcomeResults.Any())) { - var worksheet = new Worksheet(module.Name); + var worksheet = new Worksheet(item.Module.Name); //Because reasons @source https://stackoverflow.com/a/8127642 for (var i = 0; i < 100; i++) @@ -32,7 +32,10 @@ public void Export(IEnumerable modules, string format) worksheet.Cells[i, 0] = new Cell(""); } - var links = module.Collection.Links; + var links = item.Collection.Links; + + Debug.Assert(links != null, nameof(links) + " != null"); + var alignments = links.AlignmentsDictionary; var outcomes = links.OutcomesDictionary; @@ -46,27 +49,29 @@ public void Export(IEnumerable modules, string format) var index = 1; foreach (var (outcomeId, outcome) in outcomes) { - var assignmentIds = module.Collection.OutcomeResults - .Where(o => o.Link.Outcome == outcomeId) + var assignmentIds = item.Collection.OutcomeResults + .Where(o => o.Link.Outcome == outcomeId && o.Grade() != null) .Select(static o => o.Link.Assignment) .ToArray(); if (assignmentIds.Any()) { - worksheet.Cells[index, 0] = new Cell(outcome.Title + " " + ShortDescription(ConvertHtmlToRaw(outcome.Description))); + worksheet.Cells[index, 0] = new Cell($"{outcome.Title} {outcome.ShortDescription()}"); var cellValueBuilder = new StringBuilder(); - foreach (var (alignmentId, alignment) in alignments.Where(a => assignmentIds.Contains(a.Key))) + foreach (var (_, alignment) in alignments.Where(a => assignmentIds.Contains(a.Key))) { cellValueBuilder.AppendLine($"{alignment.Name} {alignment.Url}"); } + worksheet.Cells[index, 1] = new Cell(cellValueBuilder.ToString()); - + var cellValueOutComeResultsBuilder = new StringBuilder(); - foreach (var outcomeResult in module.Collection.OutcomeResults.Where(result => result.Link.Outcome == outcomeId)) + foreach (var outcomeResult in item.Collection.OutcomeResults.Where(result => + result.Link.Outcome == outcomeId)) { - cellValueOutComeResultsBuilder.AppendLine(OutcomeToText(outcomeResult.Score)); + cellValueOutComeResultsBuilder.AppendLine(outcomeResult.Grade()); } worksheet.Cells[index, 2] = new Cell(cellValueOutComeResultsBuilder.ToString()); @@ -84,41 +89,4 @@ public void Export(IEnumerable modules, string format) // We're forced to xls because of the older format workbook.Save($"{_options.FormattedOutputName}.xls"); } - - private static string ShortDescription(string description) - { - //Function gives only the short English description back of the outcome. - var startPos = description.IndexOf(" EN ", StringComparison.Ordinal) + " EN ".Length; - var endPos = description.IndexOf(" NL ", StringComparison.Ordinal); - - return description.Substring(startPos, endPos - startPos); - } - - private static string ConvertHtmlToRaw(string html) - { - var raw = Regex.Replace(html, "<.*?>", " "); - var trimmed = Regex.Replace(raw, @"\s\s+", " "); - - return trimmed; - } - - private string OutcomeToText(double? result) - { - switch (result) - { - default: - case 0: - return "Unsatisfactory"; - break; - case 3: - return "Satisfactory"; - break; - case 4: - return "Good"; - break; - case 5: - return "Outstanding"; - break; - } - } } \ No newline at end of file diff --git a/Epsilon/Export/Exporters/WordModuleExporter.cs b/Epsilon/Export/Exporters/WordModuleExporter.cs new file mode 100644 index 00000000..6a301a5b --- /dev/null +++ b/Epsilon/Export/Exporters/WordModuleExporter.cs @@ -0,0 +1,88 @@ +using System.Diagnostics; +using System.Drawing; +using System.Text; +using Epsilon.Abstractions.Export; +using Epsilon.Canvas.Abstractions.Model; +using Microsoft.Extensions.Options; +using Xceed.Document.NET; +using Xceed.Words.NET; + +namespace Epsilon.Export.Exporters; + +public class WordModuleExporter : ICanvasModuleExporter +{ + private readonly ExportOptions _options; + + public WordModuleExporter(IOptions options) + { + _options = options.Value; + } + + public IEnumerable Formats { get; } = new[] { "word" }; + + public async Task Export(IAsyncEnumerable data, string format) + { + using var document = DocX.Create($"{_options.FormattedOutputName}.docx"); + + document.AddFooters(); + var link = document.AddHyperlink(Constants.ProjectName, Constants.RepositoryUri); + + document.Footers.Odd + .InsertParagraph("Created with ") + .AppendHyperlink(link).Color(Color.Blue).UnderlineStyle(UnderlineStyle.singleLine); + + await foreach (var item in data.Where(static m => m.Collection.OutcomeResults.Any())) + { + var links = item.Collection.Links; + + Debug.Assert(links != null, nameof(links) + " != null"); + + var alignments = links.AlignmentsDictionary; + var outcomes = links.OutcomesDictionary; + + var table = document.AddTable(1, 3); + + table.Rows[0].Cells[0].Paragraphs[0].Append("KPI"); + table.Rows[0].Cells[1].Paragraphs[0].Append("Assignment(s)"); + table.Rows[0].Cells[2].Paragraphs[0].Append("Score"); + + foreach (var (outcomeId, outcome) in outcomes) + { + var assignmentIds = item.Collection.OutcomeResults + .Where(o => o.Link.Outcome == outcomeId && o.Grade() != null) + .Select(static o => o.Link.Assignment) + .ToArray(); + + if (assignmentIds.Any()) + { + var row = table.InsertRow(); + row.Cells[0].Paragraphs[0].Append(outcome.Title + " " + outcome.ShortDescription()); + + var cellValueBuilder = new StringBuilder(); + + foreach (var (_, alignment) in alignments.Where(a => assignmentIds.Contains(a.Key))) + { + cellValueBuilder.AppendLine($"{alignment.Name} {alignment.Url}"); + } + + row.Cells[1].Paragraphs[0].Append(cellValueBuilder.ToString()); + + var cellValueOutComeResultsBuilder = new StringBuilder(); + foreach (var outcomeResult in item.Collection.OutcomeResults.Where(result => + result.Link.Outcome == outcomeId)) + { + cellValueOutComeResultsBuilder.AppendLine(outcomeResult.Grade()); + } + + row.Cells[2].Paragraphs[0].Append(cellValueOutComeResultsBuilder.ToString()); + } + } + + var par = document.InsertParagraph(item.Module.Name); + par.FontSize(24); + par.InsertTableAfterSelf(table).InsertPageBreakAfterSelf(); + } + + document.Save(); + } +} \ No newline at end of file diff --git a/Epsilon/Export/ModuleExporterCollection.cs b/Epsilon/Export/ModuleExporterCollection.cs index bd065893..d494ba4c 100644 --- a/Epsilon/Export/ModuleExporterCollection.cs +++ b/Epsilon/Export/ModuleExporterCollection.cs @@ -19,17 +19,14 @@ public IEnumerable Formats() public IDictionary DetermineExporters(IEnumerable formats) { - var formatsArray = formats as string[] ?? formats.ToArray(); // To prevent multiple enumeration + var formatsArray = formats.ToArray(); // To prevent multiple enumeration var foundExporters = new Dictionary(); foreach (var exporter in _exporters) { - foreach (var format in formatsArray) + foreach (var format in formatsArray.Where(f => exporter.Formats.Contains(f.ToLower()))) { - if (exporter.Formats.Contains(format.ToLower())) - { - foundExporters.Add(format, exporter); - } + foundExporters.Add(format, exporter); } } diff --git a/Epsilon/Extensions/CoreServiceCollectionExtensions.cs b/Epsilon/Extensions/CoreServiceCollectionExtensions.cs index 97d307cf..03ff44b9 100644 --- a/Epsilon/Extensions/CoreServiceCollectionExtensions.cs +++ b/Epsilon/Extensions/CoreServiceCollectionExtensions.cs @@ -24,6 +24,7 @@ private static IServiceCollection AddExport(this IServiceCollection services, IC services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); diff --git a/README.md b/README.md index cb78a9c8..2bc7f3f7 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,16 @@ These students usually have a personal course within Canvas (from Instructure), During each semester, it is requested to take note of all KPI's which have been proven. To aid in these efforts, this application will gather all your mastered/proven [KPI's](https://hbo-i.nl/domeinbeschrijving/) and export your KPI's to a file format (e.g., JSON, Exel, CSV). -![Application demo](https://user-images.githubusercontent.com/12190745/176268592-e863e4c3-47b4-4af5-aeca-298d53a37c33.gif) +![Application demo](https://user-images.githubusercontent.com/12190745/200400486-a7c6a166-cb42-4da4-a6be-855bedf1bfc6.gif) + +## Supported formats + +Currently we support exporting assignment outcomes to the following formats: + +- Word +- Excel +- CSV +- Console ## Usage Read how to use the application in our Wiki located [here](https://github.com/Typiqally/epsilon/wiki/How-to-use).