This commit is contained in:
Lee Stott 2021-09-08 12:06:25 +01:00
Родитель 833b08d34e
Коммит eba5ce004d
159 изменённых файлов: 51387 добавлений и 258 удалений

437
.gitignore поставляемый
Просмотреть файл

@ -1,350 +1,291 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
**/obj
**/bin
# dependencies
/client/node_modules
/client/.pnp
/client/.pnp.js
# testing
/client/coverage
# production
/client/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
# Azure Functions localsettings file
/api/local.settings.json
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
/api/*.suo
/api/*.user
/api/*.userosscache
/api/*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
/api/*.userprefs
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
[Ll]ogs/
/api/[Dd]ebug/
/api/[Dd]ebugPublic/
/api/[Rr]elease/
/api/[Rr]eleases/
/api/x64/
/api/x86/
/api/bld/
/api/[Bb]in/
/api/[Oo]bj/
/api/[Ll]og/
# Visual Studio 2015/2017 cache/options directory
.vs/
# Visual Studio 2015 cache/options directory
/api/.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
/api/[Tt]est[Rr]esult*/
/api/[Bb]uild[Ll]og.*
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# NUNIT
/api/*.VisualState.xml
/api/TestResult.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
/api/[Dd]ebugPS/
/api/[Rr]eleasePS/
/api/dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# DNX
/api/project.lock.json
/api/project.fragment.lock.json
/api/artifacts/
# .NET Core
project.lock.json
project.fragment.lock.json
artifacts/
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
/api/*_i.c
/api/*_p.c
/api/*_i.h
/api/*.ilk
/api/*.meta
/api/*.obj
/api/*.pch
/api/*.pdb
/api/*.pgc
/api/*.pgd
/api/*.rsp
/api/*.sbr
/api/*.tlb
/api/*.tli
/api/*.tlh
/api/*.tmp
/api/*.tmp_proj
/api/*.log
/api/*.vspscc
/api/*.vssscc
/api/.builds
/api/*.pidb
/api/*.svclog
/api/*.scc
# Chutzpah Test files
_Chutzpah*
/api/_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
/api/ipch/
/api/*.aps
/api/*.ncb
/api/*.opendb
/api/*.opensdf
/api/*.sdf
/api/*.cachefile
/api/*.VC.db
/api/*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
/api/*.psess
/api/*.vsp
/api/*.vspx
/api/*.sap
# TFS 2012 Local Workspace
$tf/
/api/$tf/
# Guidance Automation Toolkit
*.gpState
/api/*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
/api/_ReSharper*/
/api/*.[Rr]e[Ss]harper
/api/*.DotSettings.user
# JustCode is a .NET coding add-in
/api/.JustCode
# TeamCity is a build add-in
_TeamCity*
/api/_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Visual Studio code coverage results
*.coverage
*.coveragexml
/api/*.dotCover
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
/api/_NCrunch_*
/api/.*crunch*.local.xml
/api/nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
/api/*.mm.*
/api/AutoTest.Net/
# Web workbench (sass)
.sass-cache/
/api/.sass-cache/
# Installshield output folder
[Ee]xpress/
/api/[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
/api/DocProject/buildhelp/
/api/DocProject/Help/*.HxT
/api/DocProject/Help/*.HxC
/api/DocProject/Help/*.hhc
/api/DocProject/Help/*.hhk
/api/DocProject/Help/*.hhp
/api/DocProject/Help/Html2
/api/DocProject/Help/html
# Click-Once directory
publish/
/api/publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
/api/*.[Pp]ublish.xml
/api/*.azurePubxml
# TODO: Comment the next line if you want to checkin your web deploy settings
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
#*.pubxml
/api/*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
/api/PublishScripts/
# NuGet Packages
*.nupkg
# NuGet Symbol Packages
*.snupkg
/api/*.nupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
/api/**/packages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
/api/!**/packages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
#!**/packages/repositories.config
# NuGet v3's project.json files produces more ignoreable files
/api/*.nuget.props
/api/*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
/api/csx/
/api/*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
/api/ecf/
/api/rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
/api/AppPackages/
/api/BundleArtifacts/
/api/Package.StoreAssociation.xml
/api/_pkginfo.txt
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
/api/*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
/api/!*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
/api/ClientBin/
/api/~$*
/api/*~
/api/*.dbmdl
/api/*.dbproj.schemaview
/api/*.jfm
/api/*.pfx
/api/*.publishsettings
/api/node_modules/
/api/orleans.codegen.cs
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
/api/#bower_components/
# RIA/Silverlight projects
Generated_Code/
/api/Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
*.rptproj.bak
/api/_UpgradeReport_Files/
/api/Backup*/
/api/UpgradeLog*.XML
/api/UpgradeLog*.htm
# SQL Server files
*.mdf
*.ldf
*.ndf
/api/*.mdf
/api/*.ldf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
/api/*.rdl.data
/api/*.bim.layout
/api/*.bim_*.settings
# Microsoft Fakes
FakesAssemblies/
/api/FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
/api/*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
/api/.ntvs_analysis.dat
# Visual Studio 6 build log
*.plg
/api/*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
/api/*.opt
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
/api/**/*.HTMLClient/GeneratedArtifacts
/api/**/*.DesktopClient/GeneratedArtifacts
/api/**/*.DesktopClient/ModelManifest.xml
/api/**/*.Server/GeneratedArtifacts
/api/**/*.Server/ModelManifest.xml
/api/_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
/api/.paket/paket.exe
/api/paket-files/
# FAKE - F# Make
.fake/
/api/.fake/
# CodeRush personal settings
.cr/personal
# JetBrains Rider
/api/.idea/
/api/*.sln.iml
# CodeRush
/api/.cr/
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
# Cake - Uncomment if you are using it
# tools/**
# !tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
.mfractor/
# Local History for Visual Studio
.localhistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
.ionide/
/api/__pycache__/
/api/*.pyc

Просмотреть файл

@ -1,14 +1,27 @@
# Project
# Azure LTI Assessment Application
> This repo has been populated by an initial template to help get you started. Please
> make sure to update the content to build a great experience for community-building.
## About
As the maintainer of this project, please make a few updates:
Most modern Learning Management systems (LMS), such as [Moodle](https://moodle.org/), [Blackboard](https://www.blackboard.com) and [Canvas](https://www.instructure.com/canvas), support extensions using [LTI protocol](https://www.imsglobal.org/activity/learning-tools-interoperability) - an education technology, which represents a method for a learning system to connect with external applications.
- Improving this README.MD file to provide a great experience
- Updating SUPPORT.MD with content about this project's support experience
- Understanding the security reporting process in SECURITY.MD
- Remove this section from the README
**Azure Assessment App** is an LTI extension, implemented as a web application, which can be integrated into LMS using LTI protocol to allow Educators to easily create and manage assessments.
The Assessment App aims to reduce time spent by Educators on assessment management because it works independently from any LMS, provides a unified user interface and eliminates the need to transfer the questions from one format to another when switching between different LMS.
The project was completed as a part of Microsoft and University College London Industry Exchange Network ([UCL IXN](https://www.ucl.ac.uk/computer-science/collaborate/ucl-industry-exchange-network-ucl-ixn)) [Victoria Demina](https://github.com/victoriademina) under supervision of Dr. Graham Roberts (UCL) and Lee Stott (Microsoft) building of the [Microsoft Learn LTI](http://github.com/microsoft/learn-lti) Open Source Solution
## Key Features:
* **Single Sign-On (SSO)** - to access the Assessment App, users only need to sign into their institutions LMS.
* **Participants and Grading** - the Assessment App securely retrieves the course participants from LMS and returns their grades back to LMS grade book.
* **Assessment Analytics** - illustrative insights for educators.
## Table of content:
1. [Deployment Guide](./docs/DEPLOYMENT_GUIDE.md)
2. [Configuration Guide](./docs/CONFIGURATION_GUIDE.md)
3. [Educator Guide](./docs/EDUCATOR_GUIDE.md)
4. [Student Guide](./docs/STUDENT_GUIDE.md)
## Contributing
@ -27,7 +40,7 @@ contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additio
## Trademarks
This project may contain trademarks or logos for projects, products, or services. Authorized use of Microsoft
trademarks or logos is subject to and must follow
[Microsoft's Trademark & Brand Guidelines](https://www.microsoft.com/en-us/legal/intellectualproperty/trademarks/usage/general).
trademarks or logos is subject to and must follow [Microsoft's Trademark & Brand Guidelines](https://www.microsoft.com/en-us/legal/intellectualproperty/trademarks/usage/general).
Use of Microsoft trademarks or logos in modified versions of this project must not cause confusion or imply Microsoft sponsorship.
Any use of third-party trademarks or logos are subject to those third-party's policies.

Просмотреть файл

@ -0,0 +1,26 @@
using Microsoft.Azure.Services.AppAuthentication;
using Microsoft.IdentityModel.KeyVaultExtensions;
using Microsoft.IdentityModel.Tokens;
namespace Assessment.App.Azure
{
public class AppKeyVaultClient
{
private readonly string _keyString;
public AppKeyVaultClient(string keyString)
{
_keyString = keyString;
}
public SigningCredentials GetSigningCredentials()
{
var azureServiceTokenProvider = new AzureServiceTokenProvider();
var keyVaultAuthCallback = new KeyVaultSecurityKey.AuthenticationCallback(azureServiceTokenProvider.KeyVaultTokenCallback);
var keyVaultSecurityKey = new KeyVaultSecurityKey(_keyString, keyVaultAuthCallback);
var cryptoProviderFactory = new CryptoProviderFactory { CustomCryptoProvider = new KeyVaultCryptoProvider() };
return new SigningCredentials(keyVaultSecurityKey, SecurityAlgorithms.RsaSha256) { CryptoProviderFactory = cryptoProviderFactory };
}
}
}

Просмотреть файл

@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netcoreapp3.1</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Azure.Services.AppAuthentication" Version="1.6.1" />
<PackageReference Include="Microsoft.IdentityModel.KeyVaultExtensions" Version="6.5.1" />
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="6.12.1" />
</ItemGroup>
</Project>

Просмотреть файл

@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netcoreapp3.1</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Azure.Cosmos" Version="3.20.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="3.1.5" />
</ItemGroup>
</Project>

Просмотреть файл

@ -0,0 +1,215 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Threading.Tasks;
using Assessment.App.Database.Model;
using Microsoft.Azure.Cosmos;
using Microsoft.Azure.Cosmos.Linq;
using Microsoft.Extensions.Logging;
namespace Assessment.App.Database
{
public class DatabaseClient
{
private const string PlatformItemId = "main";
private readonly ILogger<DatabaseClient> _log;
private readonly Container _platformContainer;
private readonly Container _questionsContainer;
private readonly Container _studentResponsesContainer;
private readonly Container _assessmentsContainer;
private readonly Container _questionBanksContainer;
public DatabaseClient(CosmosClient cosmosClient, ILogger<DatabaseClient> log)
{
_log = log;
var database = cosmosClient.GetDatabase("assessment-app-db");
_platformContainer = database.GetContainer("platform-registration-container");
_questionsContainer = database.GetContainer("Questions");
_studentResponsesContainer = database.GetContainer("StudentResponses");
_assessmentsContainer = database.GetContainer("Assessments");
_questionBanksContainer = database.GetContainer("QuestionBanks");
}
public async Task<PlatformInfoItem> GetPlatformInfo()
{
try
{
return await _platformContainer.ReadItemAsync<PlatformInfoItem>(PlatformItemId,
new PartitionKey(PlatformItemId));
}
catch (CosmosException e)
{
if (e.StatusCode == HttpStatusCode.NotFound)
{
return await _platformContainer.UpsertItemAsync(new PlatformInfoItem()
{
Id = PlatformItemId,
DisplayName = "",
Issuer = "",
JwkSetUrl = "",
AccessTokenUrl = "",
AuthorizationUrl = "",
ClientId = "",
InstitutionName = "",
LogoUrl = "",
});
}
throw;
}
}
public async Task<QuestionBankItem> GetQuestionBank(string questionBankId)
{
return await _questionBanksContainer.ReadItemAsync<QuestionBankItem>(questionBankId,
new PartitionKey(questionBankId));
}
public async Task<List<QuestionItem>> GetQuestions(List<string> questionIds)
{
var idsAndKeys = questionIds.ConvertAll(s => (s, new PartitionKey(s)));
var result = await _questionsContainer.ReadManyItemsAsync<QuestionItem>(idsAndKeys);
return result.ToList();
}
public async Task<StudentResponseItem?> GetStudentResponse(string assessmentId, string studentId)
{
StudentResponseItem? result = null;
var queryDefinition = new QueryDefinition(
"SELECT * FROM r WHERE r.AssessmentId = @assessmentId AND r.StudentId = @studentId")
.WithParameter("@assessmentId", assessmentId)
.WithParameter("@studentId", studentId);
using var feedIterator =
_studentResponsesContainer.GetItemQueryIterator<StudentResponseItem>(queryDefinition);
while (feedIterator.HasMoreResults)
{
foreach (var item in await feedIterator.ReadNextAsync())
{
if (result != null)
{
throw new ApplicationException("Found more than one student response");
}
result = item;
}
}
return result;
}
public async Task<List<StudentResponseItem>> GetAssessmentResponses(string assessmentId)
{
var result = new List<StudentResponseItem>();
var queryDefinition = new QueryDefinition(
"SELECT * FROM r WHERE r.AssessmentId = @assessmentId")
.WithParameter("@assessmentId", assessmentId);
using var feedIterator =
_studentResponsesContainer.GetItemQueryIterator<StudentResponseItem>(queryDefinition);
while (feedIterator.HasMoreResults)
{
foreach (var item in await feedIterator.ReadNextAsync())
{
result.Add(item);
}
}
return result;
}
public async Task DeleteQuestionBanks(IEnumerable<string> questionBankIds)
{
foreach (var questionBankId in questionBankIds)
{
var questionBank = await GetQuestionBank(questionBankId);
await DeleteQuestionsFromAssessments(questionBank.QuestionIds);
await _questionBanksContainer.DeleteItemAsync<QuestionItem>(questionBankId,
new PartitionKey(questionBankId));
await DeleteQuestionsItems(questionBank.QuestionIds);
}
}
public async Task DeleteQuestions(List<string> questionIds)
{
await DeleteQuestionsFromAssessments(questionIds);
await DeleteQuestionsFromQuestionBanks(questionIds);
await DeleteQuestionsItems(questionIds);
}
private async Task DeleteQuestionsItems(IEnumerable<string> questionIds)
{
foreach (var questionId in questionIds)
{
await _questionsContainer.DeleteItemAsync<QuestionItem>(questionId, new PartitionKey(questionId));
}
}
private async Task DeleteQuestionsFromQuestionBanks(List<string> questionIds)
{
var questionBanks = new Dictionary<string, QuestionBankItem>();
foreach (var questionId in questionIds)
{
var queryDefinition = new QueryDefinition(
"SELECT * FROM b WHERE ARRAY_CONTAINS(b.QuestionIds, @questionId)")
.WithParameter("@questionId", questionId);
using var feedIterator =
_questionBanksContainer.GetItemQueryIterator<QuestionBankItem>(queryDefinition);
while (feedIterator.HasMoreResults)
{
foreach (var item in await feedIterator.ReadNextAsync())
{
questionBanks[item.Id] = item;
}
}
}
foreach (var questionBank in questionBanks.Values)
{
foreach (var questionId in questionIds)
{
questionBank.QuestionIds.Remove(questionId);
}
await _questionBanksContainer.UpsertItemAsync(questionBank);
}
}
private async Task DeleteQuestionsFromAssessments(List<string> questionIds)
{
var assessments = new Dictionary<string, AssessmentItem>();
foreach (var questionId in questionIds)
{
var queryDefinition = new QueryDefinition(
"SELECT * FROM a WHERE ARRAY_CONTAINS(a.QuestionIds, @questionId)")
.WithParameter("@questionId", questionId);
using var feedIterator =
_assessmentsContainer.GetItemQueryIterator<AssessmentItem>(queryDefinition);
while (feedIterator.HasMoreResults)
{
foreach (var item in await feedIterator.ReadNextAsync())
{
assessments[item.Id] = item;
}
}
}
foreach (var assessment in assessments.Values)
{
foreach (var questionId in questionIds)
{
assessment.QuestionIds.Remove(questionId);
}
await _assessmentsContainer.UpsertItemAsync(assessment);
}
}
public async Task<StudentResponseItem> UpsertStudentResponse(StudentResponseItem item)
{
return await _studentResponsesContainer.UpsertItemAsync(item);
}
}
}

Просмотреть файл

@ -0,0 +1,37 @@
using System;
using System.Collections.Generic;
using Newtonsoft.Json;
namespace Assessment.App.Database.Model
{
public class AssessmentItem
{
[JsonProperty("id")] public string Id { get; set; }
public string LmsContextId { get; set; }
public string LmsResourceLinkId { get; set; }
public string ContextMembershipUrl { get; set; }
public string LineItemUrl { get; set; } = "";
public string CourseName { get; set; } = "";
public string Name { get; set; } = "";
public string Description { get; set; } = "";
public DateTime Deadline { get; set; } = DateTime.UtcNow.AddDays(1);
public TimeSpan Duration { get; set; } = TimeSpan.FromHours(1);
public DateTime LastModified { get; set; } = DateTime.UtcNow;
public string AssessmentType { get; set; } = "";
public string Status { get; set; } = "";
public List<string> QuestionIds { get; set; } = new List<string>();
public List<string> StudentIds { get; set; } = new List<string>();
}
}

Просмотреть файл

@ -0,0 +1,17 @@
using Newtonsoft.Json;
namespace Assessment.App.Database.Model
{
public class PlatformInfoItem
{
[JsonProperty("id")] public string Id { get; set; }
public string DisplayName { get; set; }
public string Issuer { get; set; }
public string JwkSetUrl { get; set; }
public string AccessTokenUrl { get; set; }
public string AuthorizationUrl { get; set; }
public string ClientId { get; set; }
public string InstitutionName { get; set; }
public string LogoUrl { get; set; }
}
}

Просмотреть файл

@ -0,0 +1,21 @@
using System;
using System.Collections.Generic;
using Newtonsoft.Json;
namespace Assessment.App.Database.Model
{
public class QuestionBankItem
{
[JsonProperty("id")] public string Id { get; set; }
public string Name { get; set; }
public string Description { get; set; }
public DateTime LastModified { get; set; }
public List<string> QuestionIds { get; set; }
public string AssessmentType { get; set; }
}
}

Просмотреть файл

@ -0,0 +1,21 @@
using System;
using System.Collections.Generic;
using Newtonsoft.Json;
namespace Assessment.App.Database.Model
{
public class QuestionItem
{
[JsonProperty("id")] public string Id { get; set; }
public string Name { get; set; }
public string Description { get; set; }
public DateTime LastModified { get; set; }
public List<string> Options { get; set; }
public int Answer { get; set; }
}
}

Просмотреть файл

@ -0,0 +1,7 @@
namespace Assessment.App.Database.Model
{
public class QuestionResponseInfo
{
public int ChosenOption { get; set; }
}
}

Просмотреть файл

@ -0,0 +1,27 @@
using System;
using System.Collections.Generic;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
namespace Assessment.App.Database.Model
{
public class StudentResponseItem
{
[JsonProperty("id")] public string Id { get; set; }
public string AssessmentId { get; set; }
public string StudentId { get; set; }
[JsonConverter(typeof(StringEnumConverter))]
public StudentResponseStatus Status { get; set; } = StudentResponseStatus.NotStarted;
public DateTime StartTime { get; set; } = DateTime.MaxValue;
public DateTime EndTime { get; set; } = DateTime.MaxValue;
public double Score { get; set; } = 0;
public Dictionary<string, QuestionResponseInfo> Responses { get; set; } =
new Dictionary<string, QuestionResponseInfo>();
}
}

Просмотреть файл

@ -0,0 +1,14 @@
using System.Runtime.Serialization;
namespace Assessment.App.Database.Model
{
public enum StudentResponseStatus
{
[EnumMember(Value = "Not started")]
NotStarted,
[EnumMember(Value = "In progress")]
InProgress,
[EnumMember(Value = "Complete")]
Complete,
}
}

Просмотреть файл

@ -0,0 +1,54 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netcoreapp3.1</TargetFramework>
<AzureFunctionsVersion>V3</AzureFunctionsVersion>
<_FunctionsSkipCleanOutput>true</_FunctionsSkipCleanOutput>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.Identity" Version="1.5.0-beta.2" />
<PackageReference Include="Azure.Security.KeyVault.Keys" Version="4.2.0" />
<PackageReference Include="IdentityModel" Version="3.10.5" />
<PackageReference Include="LtiAdvantage" Version="0.1.1-alpha.0" />
<PackageReference Include="LtiAdvantage.IdentityModel" Version="0.1.1-alpha.0" />
<PackageReference Include="Microsoft.ApplicationInsights" Version="2.18.0-beta3" />
<PackageReference Include="Microsoft.ApplicationInsights.AspNetCore" Version="2.18.0-beta3" />
<PackageReference Include="Microsoft.AspNetCore.Http" Version="2.2.2" />
<PackageReference Include="Microsoft.Azure.Cosmos" Version="3.20.1" />
<PackageReference Include="Microsoft.Azure.Functions.Extensions" Version="1.1.0" />
<PackageReference Include="Microsoft.Azure.KeyVault" Version="3.0.5" />
<PackageReference Include="Microsoft.Azure.Services.AppAuthentication" Version="1.6.1" />
<PackageReference Include="Microsoft.Azure.WebJobs.Extensions.CosmosDB" Version="3.0.10" />
<PackageReference Include="Microsoft.Azure.WebJobs.Extensions.DurableTask" Version="2.5.0" />
<PackageReference Include="Microsoft.Azure.WebJobs.Extensions.Http" Version="3.0.12" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="6.0.0-preview.6.21352.12" />
<PackageReference Include="Microsoft.Identity.Web" Version="1.16.0" />
<PackageReference Include="Microsoft.IdentityModel.KeyVaultExtensions" Version="6.5.1" />
<PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="6.12.1" />
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="6.12.1" />
<PackageReference Include="Microsoft.NET.Sdk.Functions" Version="3.0.11" />
<PackageReference Include="System.Collections.NonGeneric" Version="4.3.0" />
<PackageReference Include="System.Collections.Specialized" Version="4.3.0" />
<PackageReference Include="System.ComponentModel.Annotations" Version="6.0.0-preview.4.21253.7" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="6.12.1" />
<PackageReference Include="System.Security.Claims" Version="4.3.0" />
<PackageReference Include="System.Text.Json" Version="6.0.0-preview.6.21352.12" />
</ItemGroup>
<ItemGroup>
<None Update="host.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="proxies.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="local.settings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>Never</CopyToPublishDirectory>
</None>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Assessment.App.Azure\Assessment.App.Azure.csproj" />
<ProjectReference Include="..\Assessment.App.Database\Assessment.App.Database.csproj" />
<ProjectReference Include="..\Assessment.App.Lti\Assessment.App.Lti.csproj" />
<ProjectReference Include="..\Assessment.App.Utils\Assessment.App.Utils.csproj" />
</ItemGroup>
</Project>

Просмотреть файл

@ -0,0 +1,284 @@
using System;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
using System.Web;
using System.Web.Http;
using Assessment.App.Database.Model;
using Assessment.App.Lti;
using Assessment.App.Utils.Lti;
using Azure.Identity;
using Azure.Security.KeyVault.Keys;
using IdentityModel;
using LtiAdvantage.Lti;
using LtiAdvantage.NamesRoleProvisioningService;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Azure.Documents;
using Microsoft.Azure.Documents.Client;
using Microsoft.Azure.Documents.Linq;
using Microsoft.Azure.WebJobs;
using Microsoft.Azure.WebJobs.Extensions.DurableTask;
using Microsoft.Azure.WebJobs.Extensions.Http;
using Microsoft.Extensions.Logging;
using IMJsonWebKey = IdentityModel.Jwk.JsonWebKey;
using JsonWebAlgorithmsKeyTypes = IdentityModel.Jwk.JsonWebAlgorithmsKeyTypes;
using JsonWebKeySet = IdentityModel.Jwk.JsonWebKeySet;
namespace Assessment.App.Functions.Connect
{
public class ConnectApi
{
private static readonly string RedirectUrl = Environment.GetEnvironmentVariable("RedirectUrl").TrimEnd('/');
private static readonly string KeyString = Environment.GetEnvironmentVariable("EdnaLiteDevKey");
private readonly IHttpClientFactory _httpClientFactory;
private readonly LtiAssessmentClient.Factory _ltiAssessmentClientFactory;
private readonly ILogger<ConnectApi> _log;
public ConnectApi(IHttpClientFactory httpClientFactory, LtiAssessmentClient.Factory ltiAssessmentClientFactory, ILogger<ConnectApi> log)
{
_httpClientFactory = httpClientFactory;
_ltiAssessmentClientFactory = ltiAssessmentClientFactory;
_log = log;
}
[FunctionName(nameof(OidcLogin))]
public async Task<IActionResult> OidcLogin(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", "post", Route = "oidc-login")]
HttpRequest req,
[CosmosDB(
databaseName: "assessment-app-db",
collectionName: "platform-registration-container",
ConnectionStringSetting = "ReadPlatformData",
Id = "main",
PartitionKey = "main")]
PlatformInfoItem platformInfoItem
// [DurableClient] IDurableOrchestrationClient orchestrationClient
)
{
_log.LogInformation("=========== entering oidc-login");
var loginParams = await LoginParams.CreateFromRequest(req);
_log.LogInformation("=========== parsed login params");
var redirectQueryParams = GetRedirectQueryParams(platformInfoItem, loginParams);
_log.LogInformation("============ built redirect query params");
var nonce = Guid.NewGuid().ToString();
var state = Guid.NewGuid().ToString();
// string instanceId = await orchestrationClient.StartNewAsync(nameof(SaveState), (object)(nonce, state));
// await orchestrationClient.WaitForCompletionOrCreateCheckStatusResponseAsync(req, instanceId);
redirectQueryParams["nonce"] = nonce;
redirectQueryParams["state"] = state;
string queryParams = redirectQueryParams.ToString();
string redirectUrl = $"{platformInfoItem.AuthorizationUrl}?{queryParams}";
_log.LogInformation("================ build redirect url");
return new RedirectResult(redirectUrl);
}
[FunctionName(nameof(SaveState))]
public async Task SaveState([OrchestrationTrigger] IDurableOrchestrationContext context)
{
(string nonce, string state) = context.GetInput<(string, string)>();
EntityId nonceEntityId = new EntityId(nameof(Nonce), nonce);
await context.CallEntityAsync(nonceEntityId, nameof(Nonce.SetState), state);
}
[FunctionName(nameof(LtiAdvantageLaunch))]
public async Task<IActionResult> LtiAdvantageLaunch(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "lti-advantage-launch")]
HttpRequest req,
// [DurableClient] IDurableEntityClient entityClient,
[CosmosDB(
databaseName: "assessment-app-db",
collectionName: "platform-registration-container",
ConnectionStringSetting = "ReadPlatformData",
Id = "main",
PartitionKey = "main")]
PlatformInfoItem platformInfoItem,
[CosmosDB(
"assessment-app-db",
"Assessments",
ConnectionStringSetting = "ReadPlatformData")]
IDocumentClient assessmentsClient,
ILogger log)
{
LtiResourceLinkRequest ltiResourceLinkRequest = null;
try
{
ltiResourceLinkRequest = await req.GetLtiResourceLinkRequest(
_httpClientFactory, platformInfoItem.JwkSetUrl, platformInfoItem.ClientId, platformInfoItem.Issuer);
}
catch (Exception e)
{
log.LogError($"Could not validate request.\n{e}");
}
if (ltiResourceLinkRequest == null)
{
return new BadRequestErrorMessageResult("Could not validate request.");
}
log.LogInformation(ltiResourceLinkRequest.ToString());
string nonce = ltiResourceLinkRequest.Nonce;
string state = req.Form["state"].ToString();
// TODO: implement nonce validation
// bool isNonceValid = await ValidateNonce(nonce, state, entityClient, log);
// if (!isNonceValid)
// return new BadRequestErrorMessageResult("Could not validate nonce.");
var assessmentsUri = UriFactory.CreateDocumentCollectionUri("assessment-app-db", "Assessments");
var query = assessmentsClient.CreateDocumentQuery<AssessmentItem>(
assessmentsUri, $"SELECT * FROM a WHERE a.LmsContextId = \"{ltiResourceLinkRequest.Context.Id}\" AND a.LmsResourceLinkId = \"{ltiResourceLinkRequest.ResourceLink.Id}\"",
new FeedOptions() {EnableCrossPartitionQuery = true}).AsDocumentQuery();
var matchedAssessments = new List<AssessmentItem>();
while (query.HasMoreResults)
{
foreach (AssessmentItem item in await query.ExecuteNextAsync())
{
matchedAssessments.Add(item);
}
}
if (matchedAssessments.Count > 1)
{
return new BadRequestErrorMessageResult("Found more than one matching assessment.");
}
AssessmentItem assessmentItem;
if (matchedAssessments.Count == 0)
{
assessmentItem = new AssessmentItem()
{
Status = "Draft",
AssessmentType = "Quiz",
};
}
else
{
assessmentItem = matchedAssessments[0];
}
// TODO: return an error if AssignmentGradeServices is not configured.
var lineItemUri = new Uri(ltiResourceLinkRequest.AssignmentGradeServices.LineItemUrl);
var lineItem = lineItemUri.GetLeftPart(UriPartial.Path).TrimEnd('/');
assessmentItem.LmsContextId = ltiResourceLinkRequest.Context.Id;
assessmentItem.LmsResourceLinkId = ltiResourceLinkRequest.ResourceLink.Id;
assessmentItem.ContextMembershipUrl = ltiResourceLinkRequest.NamesRoleService.ContextMembershipUrl;
assessmentItem.LineItemUrl = lineItem;
assessmentItem.CourseName = ltiResourceLinkRequest.Context.Title;
assessmentItem.Name = ltiResourceLinkRequest.ResourceLink.Title;
var response = await assessmentsClient.UpsertDocumentAsync(assessmentsUri, assessmentItem);
assessmentItem.Id = response.Resource.Id;
var ltiAssessmentClient = _ltiAssessmentClientFactory.Create(platformInfoItem, assessmentItem);
var member = await ltiAssessmentClient.GetMemberById(ltiResourceLinkRequest.UserId);
log.LogInformation(member.Email);
log.LogInformation(member.Roles.ToString());
var urlWithParams = $"{RedirectUrl}/spa/assessment/{assessmentItem.Id}";
if (member.IsStudent())
{
urlWithParams = $"{RedirectUrl}/spa/student-welcome-page/{assessmentItem.Id}";
}
log.LogInformation($"Redirect to {urlWithParams}");
return new RedirectResult(urlWithParams);
}
[FunctionName(nameof(Jwks))]
public async Task<IActionResult> Jwks(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "jwks")]
HttpRequest req
// [LtiAdvantage] LtiToolPublicKey publicKey
)
{
var keyClient = new KeyClient(
new Uri("https://assessment-app-kv.vault.azure.net/"),
new DefaultAzureCredential());
// new AzureCliCredential());
KeyVaultKey key = await keyClient.GetKeyAsync("EdnaLiteDevKey");
var jwks = new JsonWebKeySet();
IMJsonWebKey publicKey = new IMJsonWebKey()
{
Kid = key.Key.Id,
Kty = JsonWebAlgorithmsKeyTypes.RSA,
Alg = Microsoft.IdentityModel.Tokens.SecurityAlgorithms.RsaSha256,
Use = Microsoft.IdentityModel.Tokens.JsonWebKeyUseNames.Sig,
E = Base64Url.Encode(key.Key.E),
N = Base64Url.Encode(key.Key.N)
};
jwks.Keys.Add(publicKey);
return new OkObjectResult(jwks);
}
private async Task<bool> ValidateNonce(string nonce, string state, IDurableEntityClient entityClient,
ILogger log)
{
EntityId nonceEntityId = new EntityId(nameof(Nonce), nonce);
EntityStateResponse<Nonce> nonceEntityResponse =
await GetEntityStateWithRetries<Nonce>(nonceEntityId, entityClient);
if (!nonceEntityResponse.EntityExists)
{
log.LogWarning($"Entity {nonceEntityId.EntityKey} does not exist.");
return false;
}
if (state != nonceEntityResponse.EntityState.State)
{
log.LogWarning("The form state does not match the nonce.");
return false;
}
await entityClient.SignalEntityAsync(nonceEntityId, nameof(Nonce.Delete));
return true;
}
private async Task<EntityStateResponse<T>> GetEntityStateWithRetries<T>(EntityId entityId,
IDurableEntityClient entityClient, int retriesNumber = 3)
{
EntityStateResponse<T> entityResponse = default;
for (int i = 0; i < retriesNumber; i++)
{
entityResponse = await entityClient.ReadEntityStateAsync<T>(entityId);
if (entityResponse.EntityExists)
break;
await Task.Delay(500);
}
return entityResponse;
}
private NameValueCollection GetRedirectQueryParams(PlatformInfoItem platformInfo, LoginParams loginParams)
{
var loginQueryParams = new NameValueCollection
{
["response_type"] = "id_token",
["response_mode"] = "form_post",
["redirect_uri"] = loginParams.TargetLinkUri,
["scope"] = "openid",
["login_hint"] = loginParams.LoginHint,
["prompt"] = "none",
["lti_message_hint"] = loginParams.LtiMessageHint
};
var httpCopiedValuesCollection = HttpUtility.ParseQueryString(string.Empty);
httpCopiedValuesCollection.Add(loginQueryParams);
httpCopiedValuesCollection["client_id"] = platformInfo.ClientId;
return httpCopiedValuesCollection;
}
}
}

Просмотреть файл

@ -0,0 +1,42 @@
using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
namespace Assessment.App.Functions.Connect
{
public class LoginParams
{
public string TargetLinkUri { get; set; }
public string LoginHint { get; set; }
public string LtiMessageHint { get; set; }
public static async Task<LoginParams> CreateFromRequest(HttpRequest httpRequest)
{
if (httpRequest.HasFormContentType)
{
var form = await (httpRequest?.ReadFormAsync() ?? Task.FromResult<IFormCollection>(null));
if (form == null)
throw new NullReferenceException("The HTTP form could not be fetched.");
return new LoginParams
{
TargetLinkUri = form["target_link_uri"].ToString(),
LoginHint = form["login_hint"].ToString(),
LtiMessageHint = form["lti_message_hint"]
};
}
else
{
var query = httpRequest?.Query;
if (query == null)
throw new NullReferenceException("The HTTP Query could not be fetched.");
return new LoginParams
{
TargetLinkUri = query["target_link_uri"].ToString(),
LoginHint = query["login_hint"].ToString(),
LtiMessageHint = query["lti_message_hint"].ToString()
};
}
}
}
}

Просмотреть файл

@ -0,0 +1,59 @@
using System;
using System.Collections.Generic;
using System.IdentityModel.Tokens.Jwt;
using System.Linq;
using System.Net.Http;
using System.Security.Claims;
using System.Threading.Tasks;
using LtiAdvantage.Lti;
using Microsoft.AspNetCore.Http;
using Microsoft.IdentityModel.Tokens;
using Newtonsoft.Json.Linq;
namespace Assessment.App.Functions.Connect
{
public static class LtiAdvantageExtensions
{
public static async Task<LtiResourceLinkRequest> GetLtiResourceLinkRequest(
this HttpRequest request,
IHttpClientFactory httpClientFactory,
string jwkSetUrl, string clientId, string issuer)
{
ClaimsPrincipal claimsPrincipal = await request.GetValidatedLtiLaunchClaims(
httpClientFactory, jwkSetUrl, clientId, issuer);
return new LtiResourceLinkRequest(claimsPrincipal.Claims);
}
public static async Task<ClaimsPrincipal> GetValidatedLtiLaunchClaims(
this HttpRequest request,
IHttpClientFactory httpClientFactory,
string jwkSetUrl,
string clientId,
string issuer)
{
if (!request.Form.TryGetValue("id_token", out var idTokenValue))
throw new NullReferenceException("No ID token is presented in the http request.");
HttpClient client = httpClientFactory.CreateClient();
string certsJsonString = await client.GetStringAsync(jwkSetUrl);
JObject certsJObject = JObject.Parse(certsJsonString);
JArray keysJToken = certsJObject["keys"] as JArray;
IEnumerable<JsonWebKey> keys = keysJToken?
.Select(key => key.ToString())
.Select(s => new JsonWebKey(s))
?? Enumerable.Empty<JsonWebKey>();
TokenValidationParameters validationParameters = new TokenValidationParameters
{
ValidAudience = clientId,
ValidIssuer = issuer,
IssuerSigningKeys = keys
};
JwtSecurityTokenHandler jwtSecurityTokenHandler = new JwtSecurityTokenHandler { InboundClaimTypeMap = { ["sub"] = "sub" } };
return jwtSecurityTokenHandler.ValidateToken(idTokenValue.ToString(), validationParameters, out _);
}
}
}

Просмотреть файл

@ -0,0 +1,19 @@
using System.Threading.Tasks;
using Microsoft.Azure.WebJobs;
using Microsoft.Azure.WebJobs.Extensions.DurableTask;
using Newtonsoft.Json;
namespace Assessment.App.Functions.Connect
{
public class Nonce
{
[JsonProperty("state")]
public string State { get; set; }
public void SetState(string state) => State = state;
public void Delete() => Entity.Current.DeleteState();
[FunctionName(nameof(Nonce))]
public static Task Run([EntityTrigger] IDurableEntityContext ctx) => ctx.DispatchAsync<Nonce>();
}
}

Просмотреть файл

@ -0,0 +1,20 @@
namespace Assessment.App.Functions.Platform.Dto
{
public class PlatformResponse
{
public string DisplayName { get; set; }
public string Issuer { get; set; }
public string JwkSetUrl { get; set; }
public string AccessTokenUrl { get; set; }
public string AuthorizationUrl { get; set; }
public string LoginUrl { get; set; }
public string LaunchUrl { get; set; }
public string DomainUrl { get; set; }
public string ClientId { get; set; }
public string PublicKey { get; set; }
public string PublicJwk { get; set; }
public string PublicJwkSetUrl { get; set; }
public string InstitutionName { get; set; }
public string LogoUrl { get; set; }
}
}

Просмотреть файл

@ -0,0 +1,14 @@
namespace Assessment.App.Functions.Platform.Dto
{
public class PlatformUpdateRequest
{
public string DisplayName { get; set; }
public string Issuer { get; set; }
public string JwkSetUrl { get; set; }
public string AccessTokenUrl { get; set; }
public string AuthorizationUrl { get; set; }
public string ClientId { get; set; }
public string InstitutionName { get; set; }
public string LogoUrl { get; set; }
}
}

Просмотреть файл

@ -0,0 +1,236 @@
using System;
using System.IO;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;
using Assessment.App.Database;
using Assessment.App.Database.Model;
using Assessment.App.Functions.Platform.Dto;
using Assessment.App.Utils.Http;
using Azure.Identity;
using Azure.Security.KeyVault.Keys;
using IdentityModel;
using IdentityModel.Jwk;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Azure.WebJobs;
using Microsoft.Azure.WebJobs.Extensions.Http;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using JsonSerializer = System.Text.Json.JsonSerializer;
using IMJsonWebKey = IdentityModel.Jwk.JsonWebKey;
namespace Assessment.App.Functions.Platform
{
public class PlatformApi
{
private static readonly string BaseApiUrl = Environment.GetEnvironmentVariable("BaseApiUrl").TrimEnd('/');
private static readonly string KeyVaultUrl = Environment.GetEnvironmentVariable("KeyVaultUrl").TrimEnd('/');
private readonly DatabaseClient _databaseClient;
public PlatformApi(DatabaseClient databaseClient)
{
_databaseClient = databaseClient;
}
[FunctionName("GetPlatformData")]
public async Task<IActionResult> GetPlatformData(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "platform")]
HttpRequest req,
ILogger log)
{
log.LogInformation("C# HTTP trigger function processed a request.");
var platformInfoItem = await _databaseClient.GetPlatformInfo();
var keyClient = new KeyClient(
new Uri(KeyVaultUrl),
// new Uri("https://assessment-app-kv.vault.azure.net/"),
// new Uri("https://assessment-app-key-vault.vault.azure.net"),
new DefaultAzureCredential());
// new AzureCliCredential());
KeyVaultKey key = await keyClient.GetKeyAsync("EdnaLiteDevKey");
IMJsonWebKey publicKey = new IMJsonWebKey()
{
Kid = key.Key.Id,
Kty = JsonWebAlgorithmsKeyTypes.RSA,
Alg = Microsoft.IdentityModel.Tokens.SecurityAlgorithms.RsaSha256,
Use = Microsoft.IdentityModel.Tokens.JsonWebKeyUseNames.Sig,
E = Base64Url.Encode(key.Key.E),
N = Base64Url.Encode(key.Key.N)
};
var response = new PlatformResponse()
{
DisplayName = platformInfoItem.DisplayName,
Issuer = platformInfoItem.Issuer,
JwkSetUrl = platformInfoItem.JwkSetUrl,
AccessTokenUrl = platformInfoItem.AccessTokenUrl,
AuthorizationUrl = platformInfoItem.AuthorizationUrl,
LoginUrl = $"{BaseApiUrl}/api/oidc-login",
LaunchUrl = $"{BaseApiUrl}/api/lti-advantage-launch",
DomainUrl = BaseApiUrl,
ClientId = platformInfoItem.ClientId,
PublicKey = ExportPublicKey(key.Key.ToRSA().ExportParameters(false)),
PublicJwk = JsonSerializer.Serialize(publicKey),
PublicJwkSetUrl = $"{BaseApiUrl}/api/jwks",
InstitutionName = platformInfoItem.InstitutionName,
LogoUrl = platformInfoItem.LogoUrl,
};
return new OkObjectResult(response);
}
[FunctionName("UpdatePlatformData")]
public async Task<IActionResult> UpdatePlatformData(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "platform")]
HttpRequest req,
[CosmosDB(
databaseName: "assessment-app-db",
collectionName: "platform-registration-container",
ConnectionStringSetting = "ReadWritePlatformData",
Id = "main")]
IAsyncCollector<PlatformInfoItem> databaseItemOut,
ILogger log)
{
log.LogInformation("C# HTTP trigger function processed a request.");
var requestData = await req.ReadJsonBody<PlatformUpdateRequest>();
var platformInfoItem = new PlatformInfoItem()
{
Id = "main",
DisplayName = requestData.DisplayName,
Issuer = requestData.Issuer,
JwkSetUrl = requestData.JwkSetUrl,
AccessTokenUrl = requestData.AccessTokenUrl,
AuthorizationUrl = requestData.AuthorizationUrl,
ClientId = requestData.ClientId,
InstitutionName = requestData.InstitutionName,
LogoUrl = requestData.LogoUrl,
};
await databaseItemOut.AddAsync(platformInfoItem);
return new OkObjectResult(null);
}
// https://stackoverflow.com/questions/28406888/c-sharp-rsa-public-key-output-not-correct
private static string ExportPublicKey(RSAParameters parameters)
{
StringBuilder resultStringBuilder = new StringBuilder();
using var stream = new MemoryStream();
using var writer = new BinaryWriter(stream);
writer.Write((byte) 0x30); // SEQUENCE
using (var innerStream = new MemoryStream())
{
var innerWriter = new BinaryWriter(innerStream);
innerWriter.Write((byte) 0x30); // SEQUENCE
EncodeLength(innerWriter, 13);
innerWriter.Write((byte) 0x06); // OBJECT IDENTIFIER
var rsaEncryptionOid = new byte[] {0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01};
EncodeLength(innerWriter, rsaEncryptionOid.Length);
innerWriter.Write(rsaEncryptionOid);
innerWriter.Write((byte) 0x05); // NULL
EncodeLength(innerWriter, 0);
innerWriter.Write((byte) 0x03); // BIT STRING
using (var bitStringStream = new MemoryStream())
{
var bitStringWriter = new BinaryWriter(bitStringStream);
bitStringWriter.Write((byte) 0x00); // # of unused bits
bitStringWriter.Write((byte) 0x30); // SEQUENCE
using (var paramsStream = new MemoryStream())
{
var paramsWriter = new BinaryWriter(paramsStream);
EncodeIntegerBigEndian(paramsWriter, parameters.Modulus); // Modulus
EncodeIntegerBigEndian(paramsWriter, parameters.Exponent); // Exponent
var paramsLength = (int) paramsStream.Length;
EncodeLength(bitStringWriter, paramsLength);
bitStringWriter.Write(paramsStream.GetBuffer(), 0, paramsLength);
}
var bitStringLength = (int) bitStringStream.Length;
EncodeLength(innerWriter, bitStringLength);
innerWriter.Write(bitStringStream.GetBuffer(), 0, bitStringLength);
}
var length = (int) innerStream.Length;
EncodeLength(writer, length);
writer.Write(innerStream.GetBuffer(), 0, length);
}
var base64 = Convert.ToBase64String(stream.GetBuffer(), 0, (int) stream.Length).ToCharArray();
using TextWriter outputStream = new StringWriter();
outputStream.WriteLine("-----BEGIN PUBLIC KEY-----");
for (var i = 0; i < base64.Length; i += 64)
{
outputStream.WriteLine(base64, i, Math.Min(64, base64.Length - i));
}
outputStream.WriteLine("-----END PUBLIC KEY-----");
return outputStream.ToString();
}
private static void EncodeLength(BinaryWriter stream, int length)
{
if (length < 0) throw new ArgumentOutOfRangeException(nameof(length), "Length must be non-negative");
if (length < 0x80)
{
// Short form
stream.Write((byte) length);
}
else
{
// Long form
var temp = length;
var bytesRequired = 0;
while (temp > 0)
{
temp >>= 8;
bytesRequired++;
}
stream.Write((byte) (bytesRequired | 0x80));
for (var i = bytesRequired - 1; i >= 0; i--)
{
stream.Write((byte) (length >> (8 * i) & 0xff));
}
}
}
private static void EncodeIntegerBigEndian(BinaryWriter stream, byte[] value, bool forceUnsigned = true)
{
stream.Write((byte) 0x02); // INTEGER
var prefixZeros = 0;
for (var i = 0; i < value.Length; i++)
{
if (value[i] != 0) break;
prefixZeros++;
}
if (value.Length - prefixZeros == 0)
{
EncodeLength(stream, 1);
stream.Write((byte) 0);
}
else
{
if (forceUnsigned && value[prefixZeros] > 0x7f)
{
// Add a prefix zero to force unsigned if the MSB is 1
EncodeLength(stream, value.Length - prefixZeros + 1);
stream.Write((byte) 0);
}
else
{
EncodeLength(stream, value.Length - prefixZeros);
}
for (var i = prefixZeros; i < value.Length; i++)
{
stream.Write(value[i]);
}
}
}
}
}

Просмотреть файл

@ -0,0 +1,38 @@
using System;
using Assessment.App.Azure;
using Assessment.App.Database;
using Assessment.App.Lti;
using Microsoft.Azure.Cosmos.Fluent;
using Microsoft.Azure.Functions.Extensions.DependencyInjection;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
[assembly: FunctionsStartup(typeof(Assessment.App.Functions.Startup))]
namespace Assessment.App.Functions
{
public class Startup : FunctionsStartup
{
public override void Configure(IFunctionsHostBuilder builder)
{
builder.Services.AddHttpClient();
builder.Services.AddLogging();
builder.Services.AddSingleton(s =>
{
var cosmosDbConnectionString =
builder.GetContext().Configuration.GetConnectionString("ReadWritePlatformData");
var cosmosClientBuilder = new CosmosClientBuilder(cosmosDbConnectionString);
return cosmosClientBuilder.Build();
});
builder.Services.AddSingleton<DatabaseClient>();
builder.Services.AddSingleton(s =>
{
var keyString = Environment.GetEnvironmentVariable("EdnaKeyString");
return new AppKeyVaultClient(keyString);
});
builder.Services.AddSingleton<LtiPlatformClient.Factory>();
builder.Services.AddSingleton<LtiAssessmentClient.Factory>();
}
}
}

Просмотреть файл

@ -0,0 +1,43 @@
using System;
using Assessment.App.Database.Model;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
namespace Assessment.App.Functions.Student.Dto
{
public class StudentAssessmentDto
{
[JsonProperty("id")] public string Id { get; set; }
public string CourseName { get; set; } = "";
public string Name { get; set; } = "";
public string Description { get; set; } = "";
public string AssessmentType { get; set; } = "";
public DateTime Deadline { get; set; } = DateTime.UtcNow.AddDays(1);
[JsonConverter(typeof(StringEnumConverter))]
public StudentResponseStatus Status { get; set; } = StudentResponseStatus.NotStarted;
public DateTime StartTime { get; set; } = DateTime.MaxValue;
public double DurationSeconds { get; set; } = TimeSpan.FromHours(1).TotalSeconds;
public int NumberOfQuestions { get; set; } = 0;
public static StudentAssessmentDto CreateFromItem(AssessmentItem item)
{
return new StudentAssessmentDto()
{
Id = item.Id,
CourseName = item.CourseName,
Name = item.Name,
Description = item.Description,
AssessmentType = item.AssessmentType,
Deadline = item.Deadline,
DurationSeconds = item.Duration.TotalSeconds,
NumberOfQuestions = item.QuestionIds.Count,
};
}
}
}

Просмотреть файл

@ -0,0 +1,11 @@
using System;
using System.Collections.Generic;
namespace Assessment.App.Functions.Student.Dto
{
public class StudentAssessmentQuestions
{
public StudentAssessmentDto Assessment { get; set; }
public List<StudentQuestionDto> Questions { get; set; } = new List<StudentQuestionDto>();
}
}

Просмотреть файл

@ -0,0 +1,14 @@
using System.Collections.Generic;
using Newtonsoft.Json;
namespace Assessment.App.Functions.Student.Dto
{
public class StudentQuestionDto
{
[JsonProperty("id")] public string Id { get; set; }
public string Name { get; set; }
public string Description { get; set; }
public List<string> Options { get; set; }
public int ChosenOption { get; set; }
}
}

Просмотреть файл

@ -0,0 +1,11 @@
using System.Collections.Generic;
using Assessment.App.Database.Model;
namespace Assessment.App.Functions.Student.Dto
{
public class SubmitStudentAssessmentRequest
{
public Dictionary<string, QuestionResponseInfo> Responses { get; set; } =
new Dictionary<string, QuestionResponseInfo>();
}
}

Просмотреть файл

@ -0,0 +1,225 @@
using System;
using System.IdentityModel.Tokens.Jwt;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Threading.Tasks;
using System.Web.Http;
using Assessment.App.Database;
using Assessment.App.Database.Model;
using Assessment.App.Functions.Student.Dto;
using Assessment.App.Lti;
using Assessment.App.Utils.Http;
using LtiAdvantage;
using LtiAdvantage.AssignmentGradeServices;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Azure.WebJobs;
using Microsoft.Azure.WebJobs.Extensions.Http;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
namespace Assessment.App.Functions.Student
{
public class StudentApi
{
private readonly DatabaseClient _databaseClient;
private readonly LtiAssessmentClient.Factory _ltiAssessmentClientFactory;
public StudentApi(DatabaseClient databaseClient, LtiAssessmentClient.Factory ltiAssessmentClientFactory)
{
_databaseClient = databaseClient;
_ltiAssessmentClientFactory = ltiAssessmentClientFactory;
}
[FunctionName(nameof(GetStudentAssessment))]
public async Task<IActionResult> GetStudentAssessment(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "get-student-assessment/{assessmentId}")]
HttpRequest req,
[CosmosDB(
databaseName: "assessment-app-db",
collectionName: "platform-registration-container",
ConnectionStringSetting = "ReadPlatformData",
Id = "main",
PartitionKey = "main")]
PlatformInfoItem platformInfoItem,
[CosmosDB(
"assessment-app-db",
"Assessments",
ConnectionStringSetting = "ReadPlatformData",
Id = "{assessmentId}",
PartitionKey = "{assessmentId}")]
AssessmentItem assessmentItem
)
{
var ltiAssessmentClient = _ltiAssessmentClientFactory.Create(platformInfoItem, assessmentItem);
var member = await ltiAssessmentClient.GetMemberByEmail(new[] {req.GetUserEmail()});
var studentResponse = await _databaseClient.GetStudentResponse(assessmentItem.Id, member.UserId);
var result = CreateStudentAssessment(assessmentItem, studentResponse);
return new OkObjectResult(result);
}
[FunctionName(nameof(GetStudentQuestions))]
public async Task<IActionResult> GetStudentQuestions(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "get-student-questions/{assessmentId}")]
HttpRequest req,
[CosmosDB(
"assessment-app-db",
"Assessments",
ConnectionStringSetting = "ReadPlatformData",
Id = "{assessmentId}",
PartitionKey = "{assessmentId}")]
AssessmentItem assessmentItem,
[CosmosDB(
databaseName: "assessment-app-db",
collectionName: "platform-registration-container",
ConnectionStringSetting = "ReadPlatformData",
Id = "main",
PartitionKey = "main")]
PlatformInfoItem platformInfoItem,
ILogger log
)
{
var ltiAssessmentClient = _ltiAssessmentClientFactory.Create(platformInfoItem, assessmentItem);
var member = await ltiAssessmentClient.GetMemberByEmail(new[] {req.GetUserEmail()});
var response = new StudentAssessmentQuestions();
if (assessmentItem.QuestionIds.Count == 0)
{
log.LogInformation("No questions in the assessment.");
return new OkObjectResult(response);
}
var studentResponse = await _databaseClient.GetStudentResponse(assessmentItem.Id, member.UserId);
if (studentResponse == null)
{
studentResponse = await _databaseClient.UpsertStudentResponse(
new StudentResponseItem()
{
Id = Guid.NewGuid().ToString(),
AssessmentId = assessmentItem.Id,
StudentId = member.UserId,
StartTime = DateTime.UtcNow,
Status = StudentResponseStatus.InProgress,
});
}
response.Assessment = CreateStudentAssessment(assessmentItem, studentResponse);
var questionItems =
(await _databaseClient.GetQuestions(assessmentItem.QuestionIds)).ToDictionary(q => q.Id);
foreach (var questionId in assessmentItem.QuestionIds)
{
var item = questionItems[questionId];
var chosenOption = -1;
if (studentResponse.Responses.TryGetValue(questionId, out var responseInfo))
{
chosenOption = responseInfo.ChosenOption;
}
response.Questions.Add(new StudentQuestionDto()
{
Id = questionId,
Name = item.Name,
Description = item.Description,
Options = item.Options,
ChosenOption = chosenOption,
});
}
return new OkObjectResult(response);
}
[FunctionName(nameof(SubmitStudentAssessment))]
public async Task<IActionResult> SubmitStudentAssessment(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "submit-student-assessment/{assessmentId}")]
HttpRequest req,
[CosmosDB(
"assessment-app-db",
"Assessments",
ConnectionStringSetting = "ReadPlatformData",
Id = "{assessmentId}",
PartitionKey = "{assessmentId}")]
AssessmentItem assessmentItem,
[CosmosDB(
databaseName: "assessment-app-db",
collectionName: "platform-registration-container",
ConnectionStringSetting = "ReadPlatformData",
Id = "main",
PartitionKey = "main")]
PlatformInfoItem platformInfoItem
)
{
var ltiAssessmentClient = _ltiAssessmentClientFactory.Create(platformInfoItem, assessmentItem);
var member = await ltiAssessmentClient.GetMemberByEmail(new[] {req.GetUserEmail()});
var previousResponseItem = await _databaseClient.GetStudentResponse(assessmentItem.Id, member.UserId);
if (previousResponseItem == null)
{
return new InternalServerErrorResult();
}
var requestData = await req.ReadJsonBody<SubmitStudentAssessmentRequest>();
var questionItems =
(await _databaseClient.GetQuestions(assessmentItem.QuestionIds)).ToDictionary(q => q.Id);
var correctAnswers = 0;
foreach (var questionId in assessmentItem.QuestionIds)
{
if (requestData.Responses.TryGetValue(questionId, out var responseInfo))
{
if (responseInfo.ChosenOption == questionItems[questionId].Answer)
{
correctAnswers += 1;
}
}
}
var score = new Score()
{
ActivityProgress = ActivityProgress.Completed,
GradingProgress = GradingProgess.FullyGraded,
ScoreGiven = 100.0 * correctAnswers / assessmentItem.QuestionIds.Count,
ScoreMaximum = 100.0,
TimeStamp = DateTime.UtcNow,
UserId = member.UserId,
};
var updatedResponseItem = new StudentResponseItem()
{
Id = previousResponseItem.Id,
AssessmentId = previousResponseItem.AssessmentId,
StudentId = previousResponseItem.StudentId,
StartTime = previousResponseItem.StartTime,
EndTime = DateTime.UtcNow,
Status = StudentResponseStatus.Complete,
Score = score.ScoreGiven,
Responses = requestData.Responses,
};
await _databaseClient.UpsertStudentResponse(updatedResponseItem);
await ltiAssessmentClient.SubmitScore(score);
return new OkResult();
}
private static StudentAssessmentDto CreateStudentAssessment(AssessmentItem assessmentItem,
StudentResponseItem studentResponse)
{
var result = StudentAssessmentDto.CreateFromItem(assessmentItem);
if (studentResponse != null)
{
result.Status = studentResponse.Status;
result.StartTime = studentResponse.StartTime;
}
return result;
}
}
}

Просмотреть файл

@ -0,0 +1,46 @@
using System;
using System.Collections.Generic;
using Assessment.App.Database.Model;
using Newtonsoft.Json;
namespace Assessment.App.Functions.Teacher.Dto
{
public class AssessmentDto
{
[JsonProperty("id")] public string Id { get; set; }
public string Name { get; set; } = "";
public string Description { get; set; } = "";
public DateTime LastModified { get; set; } = DateTime.UtcNow;
public string AssessmentType { get; set; } = "";
public string Status { get; set; } = "";
public DateTime Deadline { get; set; } = DateTime.UtcNow.AddDays(1);
public double DurationSeconds { get; set; } = TimeSpan.FromHours(1).TotalSeconds;
public List<string> QuestionIds { get; set; } = new List<string>();
public List<string> StudentIds { get; set; } = new List<string>();
public static AssessmentDto CreateFromItem(AssessmentItem item)
{
return new AssessmentDto()
{
Id = item.Id,
Name = item.Name,
Description = item.Description,
LastModified = item.LastModified,
AssessmentType = item.AssessmentType,
Status = item.Status,
Deadline = item.Deadline,
DurationSeconds = item.Duration.TotalSeconds,
QuestionIds = item.QuestionIds,
StudentIds = item.StudentIds,
};
}
}
}

Просмотреть файл

@ -0,0 +1,10 @@
using System.Collections.Generic;
using Assessment.App.Database.Model;
namespace Assessment.App.Functions.Teacher.Dto
{
public class AssessmentStatsResponse
{
public List<StudentResultDto> StudentResponses { get; set; } = new List<StudentResultDto>();
}
}

Просмотреть файл

@ -0,0 +1,7 @@
namespace Assessment.App.Functions.Teacher.Dto
{
public class CreateQuestionBankResponse
{
public string Id { get; set; }
}
}

Просмотреть файл

@ -0,0 +1,7 @@
namespace Assessment.App.Functions.Teacher.Dto
{
public class CreateQuestionResponse
{
public string Id { get; set; }
}
}

Просмотреть файл

@ -0,0 +1,9 @@
using System.Collections.Generic;
namespace Assessment.App.Functions.Teacher.Dto
{
public class DeleteQuestionBanksRequest
{
public List<string> QuestionBankIds { get; set; }
}
}

Просмотреть файл

@ -0,0 +1,9 @@
using System.Collections.Generic;
namespace Assessment.App.Functions.Teacher.Dto
{
public class DeleteQuestionsRequest
{
public List<string> QuestionIds { get; set; }
}
}

Просмотреть файл

@ -0,0 +1,12 @@
using System.Collections.Generic;
using Assessment.App.Database.Model;
namespace Assessment.App.Functions.Teacher.Dto
{
public class GetAssessmentResponse
{
public AssessmentDto Assessment { get; set; }
public List<QuestionItem> Questions { get; set; } = new List<QuestionItem>();
}
}

Просмотреть файл

@ -0,0 +1,12 @@
using System.Collections.Generic;
using Assessment.App.Database.Model;
namespace Assessment.App.Functions.Teacher.Dto
{
public class GetQuestionBankResponse
{
public QuestionBankItem QuestionBank { get; set; }
public List<QuestionItem> Questions { get; set; } = new List<QuestionItem>();
}
}

Просмотреть файл

@ -0,0 +1,9 @@
using System.Collections.Generic;
namespace Assessment.App.Functions.Teacher.Dto
{
public class GetStudentsResponse
{
public List<MemberDto> Students { get; set; } = new List<MemberDto>();
}
}

Просмотреть файл

@ -0,0 +1,9 @@
using System.Collections.Generic;
namespace Assessment.App.Functions.Teacher.Dto
{
public class ListAssessmentsResponse
{
public List<AssessmentDto> Assessments { get; set; } = new List<AssessmentDto>();
}
}

Просмотреть файл

@ -0,0 +1,10 @@
using System.Collections.Generic;
using Assessment.App.Database.Model;
namespace Assessment.App.Functions.Teacher.Dto
{
public class ListQuestionBanksResponse
{
public List<QuestionBankItem> QuestionBanks { get; set; } = new List<QuestionBankItem>();
}
}

Просмотреть файл

@ -0,0 +1,17 @@
using Newtonsoft.Json;
namespace Assessment.App.Functions.Teacher.Dto
{
public class MemberDto
{
[JsonProperty("id")] public string Id { get; set; }
public string Email { get; set; }
public string FamilyName { get; set; }
public string GivenName { get; set; }
public string Picture { get; set; }
}
}

Просмотреть файл

@ -0,0 +1,38 @@
using System;
using System.Collections.Generic;
using Assessment.App.Database.Model;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
namespace Assessment.App.Functions.Teacher.Dto
{
public class StudentResultDto
{
public string StudentId { get; set; }
[JsonConverter(typeof(StringEnumConverter))]
public StudentResponseStatus Status { get; set; } = StudentResponseStatus.NotStarted;
public DateTime StartTime { get; set; } = DateTime.MaxValue;
public DateTime EndTime { get; set; } = DateTime.MaxValue;
public double Score { get; set; } = 0;
public Dictionary<string, QuestionResponseInfo> Responses { get; set; } =
new Dictionary<string, QuestionResponseInfo>();
public static StudentResultDto CreateFromItem(StudentResponseItem item)
{
return new StudentResultDto()
{
StudentId = item.StudentId,
Status = item.Status,
StartTime = item.StartTime,
EndTime = item.EndTime,
Score = item.Score,
Responses = item.Responses,
};
}
}
}

Просмотреть файл

@ -0,0 +1,412 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
using Assessment.App.Database;
using Assessment.App.Database.Model;
using Assessment.App.Functions.Teacher.Dto;
using Assessment.App.Lti;
using Assessment.App.Utils.Http;
using LtiAdvantage.Lti;
using LtiAdvantage.NamesRoleProvisioningService;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Azure.Documents;
using Microsoft.Azure.Documents.Client;
using Microsoft.Azure.Documents.Linq;
using Microsoft.Azure.WebJobs;
using Microsoft.Azure.WebJobs.Extensions.Http;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
namespace Assessment.App.Functions.Teacher
{
public class TeacherApi
{
private readonly DatabaseClient _databaseClient;
private readonly LtiAssessmentClient.Factory _ltiAssessmentClientFactory;
public TeacherApi(DatabaseClient databaseClient, LtiAssessmentClient.Factory ltiAssessmentClientFactory)
{
_databaseClient = databaseClient;
_ltiAssessmentClientFactory = ltiAssessmentClientFactory;
}
[FunctionName(nameof(ListAssessments))]
public async Task<IActionResult> ListAssessments(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "list-assessments")]
HttpRequest req,
[CosmosDB(
"assessment-app-db",
"Assessments",
ConnectionStringSetting = "ReadPlatformData")]
IDocumentClient assessmentsClient
)
{
var assessmentsUri = UriFactory.CreateDocumentCollectionUri("assessment-app-db", "Assessments");
var query = assessmentsClient
.CreateDocumentQuery<AssessmentItem>(assessmentsUri, "SELECT * FROM a",
new FeedOptions() {EnableCrossPartitionQuery = true})
.AsDocumentQuery();
var response = new ListAssessmentsResponse();
while (query.HasMoreResults)
{
foreach (AssessmentItem item in await query.ExecuteNextAsync())
{
response.Assessments.Add(AssessmentDto.CreateFromItem(item));
}
}
return new OkObjectResult(response);
}
[FunctionName(nameof(ListQuestionBanks))]
public async Task<IActionResult> ListQuestionBanks(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "list-question-banks")]
HttpRequest req,
[CosmosDB(
"assessment-app-db",
"QuestionBanks",
ConnectionStringSetting = "ReadPlatformData")]
IDocumentClient questionBanksClient
)
{
var questionBanksUri = UriFactory.CreateDocumentCollectionUri("assessment-app-db", "QuestionBanks");
var query = questionBanksClient
.CreateDocumentQuery<QuestionBankItem>(questionBanksUri, "SELECT * FROM q",
new FeedOptions() {EnableCrossPartitionQuery = true})
.AsDocumentQuery();
var response = new ListQuestionBanksResponse();
while (query.HasMoreResults)
{
foreach (QuestionBankItem item in await query.ExecuteNextAsync())
{
response.QuestionBanks.Add(item);
}
}
return new OkObjectResult(response);
}
[FunctionName(nameof(GetQuestionBank))]
public async Task<IActionResult> GetQuestionBank(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "get-question-bank/{id}")]
HttpRequest req,
[CosmosDB(
"assessment-app-db",
"QuestionBanks",
ConnectionStringSetting = "ReadPlatformData",
Id = "{id}",
PartitionKey = "{id}")]
QuestionBankItem questionBankItem
)
{
var response = new GetQuestionBankResponse
{
QuestionBank = questionBankItem,
Questions = await _databaseClient.GetQuestions(questionBankItem.QuestionIds)
};
return new OkObjectResult(response);
}
[FunctionName(nameof(GetAssessment))]
public async Task<IActionResult> GetAssessment(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "get-assessment/{id}")]
HttpRequest req,
[CosmosDB(
"assessment-app-db",
"Assessments",
ConnectionStringSetting = "ReadPlatformData",
Id = "{id}",
PartitionKey = "{id}")]
AssessmentItem assessmentItem
)
{
var response = new GetAssessmentResponse
{
Assessment = AssessmentDto.CreateFromItem(assessmentItem),
Questions = await _databaseClient.GetQuestions(assessmentItem.QuestionIds)
};
return new OkObjectResult(response);
}
[FunctionName(nameof(GetQuestion))]
public async Task<IActionResult> GetQuestion(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "get-question/{id}")]
HttpRequest req,
[CosmosDB(
"assessment-app-db",
"Questions",
ConnectionStringSetting = "ReadPlatformData",
Id = "{id}",
PartitionKey = "{id}")]
QuestionItem questionItem
)
{
return new OkObjectResult(questionItem);
}
[FunctionName(nameof(CreateQuestion))]
public async Task<IActionResult> CreateQuestion(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "create-question")]
HttpRequest req,
[CosmosDB(
"assessment-app-db",
"Questions",
ConnectionStringSetting = "ReadWritePlatformData")]
IAsyncCollector<QuestionItem> questionItemCollector
)
{
var id = Guid.NewGuid().ToString();
string requestBody;
using (var streamReader = new StreamReader(req.Body))
{
requestBody = await streamReader.ReadToEndAsync();
}
var questionItem = JsonConvert.DeserializeObject<QuestionItem>(requestBody);
await questionItemCollector.AddAsync(new QuestionItem()
{
Id = id,
Answer = questionItem.Answer,
Description = questionItem.Description,
Name = questionItem.Name,
Options = questionItem.Options,
LastModified = DateTime.UtcNow,
});
return new OkObjectResult(new CreateQuestionResponse() {Id = id});
}
[FunctionName(nameof(UpdateQuestion))]
public async Task<IActionResult> UpdateQuestion(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "update-question")]
HttpRequest req,
[CosmosDB(
"assessment-app-db",
"Questions",
ConnectionStringSetting = "ReadWritePlatformData")]
IAsyncCollector<QuestionItem> questionItemCollector
)
{
string requestBody;
using (var streamReader = new StreamReader(req.Body))
{
requestBody = await streamReader.ReadToEndAsync();
}
var questionItem = JsonConvert.DeserializeObject<QuestionItem>(requestBody);
await questionItemCollector.AddAsync(new QuestionItem()
{
Id = questionItem.Id,
Answer = questionItem.Answer,
Description = questionItem.Description,
Name = questionItem.Name,
Options = questionItem.Options,
LastModified = DateTime.UtcNow,
});
return new OkResult();
}
[FunctionName(nameof(CreateQuestionBank))]
public async Task<IActionResult> CreateQuestionBank(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "create-question-bank")]
HttpRequest req,
[CosmosDB(
"assessment-app-db",
"QuestionBanks",
ConnectionStringSetting = "ReadWritePlatformData")]
IAsyncCollector<QuestionBankItem> questionBankItemCollector
)
{
var id = Guid.NewGuid().ToString();
string requestBody;
using (var streamReader = new StreamReader(req.Body))
{
requestBody = await streamReader.ReadToEndAsync();
}
var questionBankItem = JsonConvert.DeserializeObject<QuestionBankItem>(requestBody);
await questionBankItemCollector.AddAsync(new QuestionBankItem()
{
Id = id,
AssessmentType = questionBankItem.AssessmentType,
Description = questionBankItem.Description,
LastModified = DateTime.UtcNow,
Name = questionBankItem.Name,
QuestionIds = questionBankItem.QuestionIds,
});
return new OkObjectResult(new CreateQuestionBankResponse() {Id = id});
}
[FunctionName(nameof(UpdateQuestionBank))]
public async Task<IActionResult> UpdateQuestionBank(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "update-question-bank")]
HttpRequest req,
[CosmosDB(
"assessment-app-db",
"QuestionBanks",
ConnectionStringSetting = "ReadWritePlatformData")]
IAsyncCollector<QuestionBankItem> questionBankItemCollector
)
{
string requestBody;
using (var streamReader = new StreamReader(req.Body))
{
requestBody = await streamReader.ReadToEndAsync();
}
var questionBankItem = JsonConvert.DeserializeObject<QuestionBankItem>(requestBody);
await questionBankItemCollector.AddAsync(new QuestionBankItem()
{
Id = questionBankItem.Id,
AssessmentType = questionBankItem.AssessmentType,
Description = questionBankItem.Description,
LastModified = DateTime.UtcNow,
Name = questionBankItem.Name,
QuestionIds = questionBankItem.QuestionIds,
});
return new OkResult();
}
[FunctionName(nameof(UpdateAssessment))]
public async Task<IActionResult> UpdateAssessment(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "update-assessment")]
HttpRequest req,
[CosmosDB(
"assessment-app-db",
"Assessments",
ConnectionStringSetting = "ReadPlatformData")]
IDocumentClient assessmentsClient
)
{
string requestBody;
using (var streamReader = new StreamReader(req.Body))
{
requestBody = await streamReader.ReadToEndAsync();
}
var assessmentDto = JsonConvert.DeserializeObject<AssessmentDto>(requestBody);
var documentUri = UriFactory.CreateDocumentUri("assessment-app-db", "Assessments", assessmentDto.Id);
var options = new RequestOptions() {PartitionKey = new PartitionKey(assessmentDto.Id)};
var documentResponse = await assessmentsClient.ReadDocumentAsync<AssessmentItem>(
documentUri, options);
await assessmentsClient.UpsertDocumentAsync(
UriFactory.CreateDocumentCollectionUri("assessment-app-db", "Assessments"), new AssessmentItem()
{
Id = assessmentDto.Id,
LmsContextId = documentResponse.Document.LmsContextId,
LmsResourceLinkId = documentResponse.Document.LmsResourceLinkId,
ContextMembershipUrl = documentResponse.Document.ContextMembershipUrl,
AssessmentType = assessmentDto.AssessmentType,
Description = assessmentDto.Description,
LastModified = DateTime.UtcNow,
Deadline = assessmentDto.Deadline,
Duration = TimeSpan.FromSeconds(assessmentDto.DurationSeconds),
Name = assessmentDto.Name,
Status = assessmentDto.Status,
QuestionIds = assessmentDto.QuestionIds,
StudentIds = assessmentDto.StudentIds,
}
);
return new OkResult();
}
[FunctionName(nameof(GetStudents))]
public async Task<IActionResult> GetStudents(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "get-students/{assessmentId}")]
HttpRequest req,
[CosmosDB(
"assessment-app-db",
"Assessments",
ConnectionStringSetting = "ReadPlatformData",
Id = "{assessmentId}",
PartitionKey = "{assessmentId}")]
AssessmentItem assessmentItem,
[CosmosDB(
databaseName: "assessment-app-db",
collectionName: "platform-registration-container",
ConnectionStringSetting = "ReadPlatformData",
Id = "main",
PartitionKey = "main")]
PlatformInfoItem platformInfoItem,
ILogger log
)
{
var ltiAssessmentClient = _ltiAssessmentClientFactory.Create(platformInfoItem, assessmentItem);
var members = await ltiAssessmentClient.GetMembers();
var students = new List<MemberDto>();
foreach (var m in members)
{
if (!IsStudent(m))
{
continue;
}
var picture = "";
if (m.Picture != null)
{
picture = m.Picture.ToString();
}
students.Add(new MemberDto()
{
Id = m.UserId,
Email = m.Email,
FamilyName = m.FamilyName,
GivenName = m.GivenName,
Picture = picture,
});
}
return new OkObjectResult(new GetStudentsResponse() {Students = students});
}
[FunctionName(nameof(DeleteQuestions))]
public async Task<IActionResult> DeleteQuestions(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "delete-questions")]
HttpRequest req
)
{
var requestData = await req.ReadJsonBody<DeleteQuestionsRequest>();
await _databaseClient.DeleteQuestions(requestData.QuestionIds);
return new OkResult();
}
[FunctionName(nameof(DeleteQuestionBanks))]
public async Task<IActionResult> DeleteQuestionBanks(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "delete-question-banks")]
HttpRequest req
)
{
var requestData = await req.ReadJsonBody<DeleteQuestionBanksRequest>();
await _databaseClient.DeleteQuestionBanks(requestData.QuestionBankIds);
return new OkResult();
}
[FunctionName(nameof(GetAssessmentStats))]
public async Task<IActionResult> GetAssessmentStats(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "get-assessment-stats/{assessmentId}")]
HttpRequest req, string assessmentId)
{
var responses = await _databaseClient.GetAssessmentResponses(assessmentId);
return new OkObjectResult(new AssessmentStatsResponse()
{
StudentResponses = responses.Select(StudentResultDto.CreateFromItem).ToList(),
});
}
public static bool IsStudent(Member m)
{
return m.Roles.Contains(Role.ContextLearner) || m.Roles.Contains(Role.InstitutionLearner);
}
}
}

Просмотреть файл

@ -0,0 +1,8 @@
{
"version": "2.0",
"logging": {
"logLevel": {
"default": "Trace"
}
}
}

Просмотреть файл

@ -0,0 +1,27 @@
{
"IsEncrypted": false,
"Values": {
"FUNCTIONS_WORKER_RUNTIME": "dotnet",
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
"AzureWebJobs.HttpExample.Disabled": "true",
"BaseApiUrl": "http://localhost:7071",
"RedirectUrl": "http://localhost:4280",
"KeyVaultUrl": "https://assessment-app-kv.vault.azure.net",
"EdnaLiteDevKey": "https://assessment-app-kv.vault.azure.net/keys/EdnaLiteDevKey/9c107fa4d63f48ff8fa01fe75f295feb",
"EdnaKeyString": "https://assessment-app-kv.vault.azure.net/keys/EdnaLiteDevKey/9c107fa4d63f48ff8fa01fe75f295feb",
"AzureAd:Instance": "https://login.microsoftonline.com/",
"AzureAd:Domain": "viktoriiademinagmail.onmicrosoft.com",
"AzureAd:TenantId": "8e149a57-04b4-4215-9cff-5a23fc0a7fbf",
"AzureAd:ClientId": "6bf0ea7a-239d-414a-8e0f-c88bac05b6c8",
"AzureAd:ClientSecret": "C._b6T~_M6i2.77d6Hc_Se6n-87IW5tR5w"
},
"Host": {
"LocalHttpPort": 7071,
"CORS": "*",
"CORSCredentials": false
},
"ConnectionStrings": {
"ReadPlatformData": "AccountEndpoint=https://localhost:8081/;AccountKey=C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==",
"ReadWritePlatformData": "AccountEndpoint=https://localhost:8081/;AccountKey=C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw=="
}
}

Просмотреть файл

@ -0,0 +1,23 @@
{
"$schema": "http://json.schemastore.org/proxies",
"proxies": {
"SinglePageApp": {
"matchCondition": {
"route": "/spa/{*path}"
},
"backendUri": "https://%STATIC_WEB_HOST%/index.html"
},
"Login": {
"matchCondition": {
"route": "/login"
},
"backendUri": "https://%STATIC_WEB_HOST%/index.html"
},
"StaticContent": {
"matchCondition": {
"route": "/static/{*path}"
},
"backendUri": "https://%STATIC_WEB_HOST%/static/{path}"
}
}
}

Просмотреть файл

@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netcoreapp3.1</TargetFramework>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Assessment.App.Azure\Assessment.App.Azure.csproj" />
<ProjectReference Include="..\Assessment.App.Database\Assessment.App.Database.csproj" />
<ProjectReference Include="..\Assessment.App.Utils\Assessment.App.Utils.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="IdentityModel" Version="3.10.5" />
<PackageReference Include="LtiAdvantage" Version="0.1.1-alpha.0" />
<PackageReference Include="LtiAdvantage.IdentityModel" Version="0.1.1-alpha.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="2.2.0" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="6.12.1" />
</ItemGroup>
</Project>

Просмотреть файл

@ -0,0 +1,136 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Threading.Tasks;
using Assessment.App.Database.Model;
using Assessment.App.Utils.Http;
using IdentityModel.Client;
using LtiAdvantage;
using LtiAdvantage.AssignmentGradeServices;
using LtiAdvantage.NamesRoleProvisioningService;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
namespace Assessment.App.Lti
{
public class LtiAssessmentClient
{
private readonly AssessmentItem _assessmentItem;
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger<LtiAssessmentClient> _log;
public LtiPlatformClient PlatformClient { get; }
public class Factory
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger<LtiAssessmentClient> _log;
private readonly LtiPlatformClient.Factory _platformClientFactory;
public Factory(IHttpClientFactory httpClientFactory, ILogger<LtiAssessmentClient> log,
LtiPlatformClient.Factory platformClientFactory)
{
_httpClientFactory = httpClientFactory;
_log = log;
_platformClientFactory = platformClientFactory;
}
public LtiAssessmentClient Create(PlatformInfoItem platformInfo, AssessmentItem assessmentItem)
{
return new LtiAssessmentClient(
assessmentItem, _httpClientFactory, _log, _platformClientFactory.Create(platformInfo)
);
}
}
private LtiAssessmentClient(
AssessmentItem assessmentItem, IHttpClientFactory httpClientFactory, ILogger<LtiAssessmentClient> log,
LtiPlatformClient platformClient)
{
_assessmentItem = assessmentItem;
_httpClientFactory = httpClientFactory;
_log = log;
PlatformClient = platformClient;
}
public async Task<IEnumerable<Member>> GetMembers()
{
var httpClient = await CreateHttpClientWithAccessToken(Constants.LtiScopes.Nrps.MembershipReadonly);
httpClient.DefaultRequestHeaders.Accept.Clear();
httpClient.DefaultRequestHeaders.Accept.Add(
new MediaTypeWithQualityHeaderValue(Constants.MediaTypes.MembershipContainer));
_log.LogInformation("Getting members.");
using var response = await httpClient.GetAsync(_assessmentItem.ContextMembershipUrl);
if (!response.IsSuccessStatusCode)
{
_log.LogError("Could not get members.");
throw new Exception(response.ReasonPhrase);
}
var membership = await response.ReadJsonBody<MembershipContainer>();
return membership.Members
.OrderBy(m => m.FamilyName)
.ThenBy(m => m.GivenName);
}
public async Task<Member> GetMemberByEmail(IEnumerable<string> userEmails)
{
// Looks like LTI 1.3 doesn't support querying by member identifiers
var allMembers = await GetMembers();
return allMembers.FirstOrDefault(member => userEmails.Any(userEmail =>
(member.Email ?? string.Empty).Equals(userEmail, StringComparison.OrdinalIgnoreCase)));
}
public async Task<Member> GetMemberById(string userId)
{
// Looks like LTI 1.3 doesn't support querying by member identifiers
var allMembers = (await GetMembers()).ToList();
return allMembers.FirstOrDefault(member => member.UserId.Equals(userId));
}
public async Task SubmitScore(Score score)
{
var httpClient = await CreateHttpClientWithAccessToken(Constants.LtiScopes.Ags.Score);
httpClient.DefaultRequestHeaders.Accept.Add(
new MediaTypeWithQualityHeaderValue(Constants.MediaTypes.Score));
var scoreResponse = await httpClient.PostAsync(
_assessmentItem.LineItemUrl + "/scores",
new StringContent(JsonConvert.SerializeObject(score), Encoding.UTF8, Constants.MediaTypes.Score));
if (scoreResponse.IsSuccessStatusCode)
{
return;
}
var scoreResponseBody = await scoreResponse.Content.ReadAsStringAsync();
throw new Exception($"Unable to publish user score: {scoreResponse.StatusCode}, {scoreResponseBody}");
}
private async Task<HttpClient> CreateHttpClientWithAccessToken(string scope)
{
var accessTokenResponse =
await PlatformClient.GetAccessTokenAsync(scope);
if (accessTokenResponse.IsError)
{
throw accessTokenResponse.Exception ??
new Exception(
$"Internal exception in the authentication flow to LMS: {accessTokenResponse.Error}");
}
var httpClient = _httpClientFactory.CreateClient();
httpClient.SetBearerToken(accessTokenResponse.AccessToken);
return httpClient;
}
}
}

Просмотреть файл

@ -0,0 +1,89 @@
using System;
using System.IdentityModel.Tokens.Jwt;
using System.Net.Http;
using System.Security.Claims;
using System.Threading.Tasks;
using Assessment.App.Azure;
using Assessment.App.Database.Model;
using IdentityModel;
using IdentityModel.Client;
using LtiAdvantage.IdentityModel.Client;
using Microsoft.Extensions.Logging;
using Microsoft.IdentityModel.Tokens;
namespace Assessment.App.Lti
{
public class LtiPlatformClient
{
private readonly PlatformInfoItem _platformInfo;
private readonly AppKeyVaultClient _keyVaultClient;
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger<LtiPlatformClient> _log;
public class Factory
{
private readonly AppKeyVaultClient _keyVaultClient;
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger<LtiPlatformClient> _log;
public Factory(
AppKeyVaultClient keyVaultClient, IHttpClientFactory httpClientFactory, ILogger<LtiPlatformClient> log)
{
_keyVaultClient = keyVaultClient;
_httpClientFactory = httpClientFactory;
_log = log;
}
public LtiPlatformClient Create(PlatformInfoItem platformInfo)
{
return new LtiPlatformClient(platformInfo, _keyVaultClient, _httpClientFactory, _log);
}
}
private LtiPlatformClient(
PlatformInfoItem platformInfo, AppKeyVaultClient keyVaultClient, IHttpClientFactory httpClientFactory,
ILogger<LtiPlatformClient> log)
{
_platformInfo = platformInfo;
_keyVaultClient = keyVaultClient;
_httpClientFactory = httpClientFactory;
_log = log;
}
public async Task<TokenResponse> GetAccessTokenAsync(string scope)
{
// TokenResponse errorResponse = ValidateParameters((nameof(clientId), clientId), (nameof(accessTokenEndpoint), accessTokenEndpoint), (nameof(scope), scope), (nameof(keyVaultKeyString), keyVaultKeyString));
// if (errorResponse != null)
// return errorResponse;
// Use a signed JWT as client credentials.
var payload = new JwtPayload();
payload.AddClaim(new Claim(JwtRegisteredClaimNames.Iss, _platformInfo.ClientId));
payload.AddClaim(new Claim(JwtRegisteredClaimNames.Sub, _platformInfo.ClientId));
payload.AddClaim(new Claim(JwtRegisteredClaimNames.Aud, _platformInfo.AccessTokenUrl));
payload.AddClaim(new Claim(JwtRegisteredClaimNames.Iat, EpochTime.GetIntDate(DateTime.UtcNow).ToString(),
ClaimValueTypes.Integer64));
payload.AddClaim(new Claim(JwtRegisteredClaimNames.Nbf,
EpochTime.GetIntDate(DateTime.UtcNow.AddSeconds(-5)).ToString(), ClaimValueTypes.Integer64));
payload.AddClaim(new Claim(JwtRegisteredClaimNames.Exp,
EpochTime.GetIntDate(DateTime.UtcNow.AddMinutes(5)).ToString(), ClaimValueTypes.Integer64));
payload.AddClaim(new Claim(JwtRegisteredClaimNames.Jti, CryptoRandom.CreateUniqueId()));
var handler = new JwtSecurityTokenHandler();
var credentials = _keyVaultClient.GetSigningCredentials();
var jwt = handler.WriteToken(new JwtSecurityToken(new JwtHeader(credentials), payload));
var request = new JwtClientCredentialsTokenRequest
{
Address = _platformInfo.AccessTokenUrl,
ClientId = _platformInfo.ClientId,
Jwt = jwt,
Scope = scope,
};
return await _httpClientFactory
.CreateClient()
.RequestClientCredentialsTokenWithJwtAsync(request);
}
}
}

Просмотреть файл

@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netcoreapp3.1</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="LtiAdvantage" Version="0.1.1-alpha.0" />
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.2.0" />
<PackageReference Include="Newtonsoft.Json" Version="12.0.1" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="6.12.1" />
</ItemGroup>
</Project>

Просмотреть файл

@ -0,0 +1,95 @@
using System;
using System.IdentityModel.Tokens.Jwt;
using System.IO;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.IdentityModel.Protocols;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Microsoft.IdentityModel.Tokens;
using Newtonsoft.Json;
namespace Assessment.App.Utils.Http
{
public static class HttpExtensions
{
public static async Task<T> ReadJsonBody<T>(this HttpRequest request)
{
return await ReadJsonFromStream<T>(request.Body);
}
public static async Task<T> ReadJsonBody<T>(this HttpResponse response)
{
return await ReadJsonFromStream<T>(response.Body);
}
public static async Task<T> ReadJsonBody<T>(this HttpResponseMessage response)
{
return await ReadJsonFromStream<T>(await response.Content.ReadAsStreamAsync());
}
private static async Task<T> ReadJsonFromStream<T>(Stream s)
{
using var streamReader = new StreamReader(s);
var requestBody = await streamReader.ReadToEndAsync();
return JsonConvert.DeserializeObject<T>(requestBody);
}
public static string GetUserEmail(this HttpRequest request)
{
// var accessToken = GetAccessToken(req);
// var tokenHandler = new JwtSecurityTokenHandler();
// var jwtToken = tokenHandler.ReadJwtToken(accessToken);
// // jwtToken.
// foreach (var claim in jwtToken.Claims)
// {
// log.LogInformation($"Claim {claim.Type}: {claim.Value}");
// }
// var validationParameters = new TokenValidationParameters
// {
// // // App Id URI and AppId of this service application are both valid audiences.
// // ValidAudiences = new[] { audience, clientID },
// //
// // // Support Azure AD V1 and V2 endpoints.
// // ValidIssuers = validIssuers,
// // IssuerSigningKeys = config.SigningKeys
// };
// SecurityToken securityToken;
// var claimsPrincipal = tokenHandler.ValidateToken(accessToken, validationParameters, out securityToken);
// log.LogInformation(claimsPrincipal.Identity.Name);
// return new OkResult();
var accessToken = GetAccessToken(request);
var tokenHandler = new JwtSecurityTokenHandler();
var jwtToken = tokenHandler.ReadJwtToken(accessToken);
return GetEmailFromToken(jwtToken);
}
private static string GetAccessToken(HttpRequest req)
{
var authorizationHeader = req.Headers?["Authorization"];
var parts = authorizationHeader?.ToString().Split(null) ?? new string[0];
if (parts.Length == 2 && parts[0].Equals("Bearer"))
{
return parts[1];
}
return null;
}
private static string GetEmailFromToken(JwtSecurityToken jwtToken)
{
foreach (var claim in jwtToken.Claims)
{
if (claim.Type == "unique_name")
{
return claim.Value;
}
}
return "";
}
}
}

Просмотреть файл

@ -0,0 +1,19 @@
using System.Linq;
using LtiAdvantage.Lti;
using LtiAdvantage.NamesRoleProvisioningService;
namespace Assessment.App.Utils.Lti
{
public static class LtiExtensions
{
public static bool IsStudent(this Member m)
{
return m.Roles.Contains(Role.ContextLearner) || m.Roles.Contains(Role.InstitutionLearner);
}
public static string GetPicture(this Member m)
{
return m.Picture != null ? m.Picture.ToString() : "";
}
}
}

40
api/AssessmentAppApi.sln Normal file
Просмотреть файл

@ -0,0 +1,40 @@

Microsoft Visual Studio Solution File, Format Version 12.00
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Assessment.App.Functions", "Assessment.App.Functions\Assessment.App.Functions.csproj", "{ADD018DE-90ED-48AC-A9A3-477E805F20BA}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Assessment.App.Database", "Assessment.App.Database\Assessment.App.Database.csproj", "{FAAF3B3F-FC24-4532-A315-9F8E9D689C00}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Assessment.App.Utils", "Assessment.App.Utils\Assessment.App.Utils.csproj", "{FAACF6CB-5EFE-468D-8B29-4F6096CBC633}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Assessment.App.Azure", "Assessment.App.Azure\Assessment.App.Azure.csproj", "{29FA8279-D08E-4614-A4C6-2A3FE254FF22}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Assessment.App.Lti", "Assessment.App.Lti\Assessment.App.Lti.csproj", "{8722D4FA-6E0D-401D-8324-EE21133DE3AE}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{ADD018DE-90ED-48AC-A9A3-477E805F20BA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{ADD018DE-90ED-48AC-A9A3-477E805F20BA}.Debug|Any CPU.Build.0 = Debug|Any CPU
{ADD018DE-90ED-48AC-A9A3-477E805F20BA}.Release|Any CPU.ActiveCfg = Release|Any CPU
{ADD018DE-90ED-48AC-A9A3-477E805F20BA}.Release|Any CPU.Build.0 = Release|Any CPU
{FAAF3B3F-FC24-4532-A315-9F8E9D689C00}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{FAAF3B3F-FC24-4532-A315-9F8E9D689C00}.Debug|Any CPU.Build.0 = Debug|Any CPU
{FAAF3B3F-FC24-4532-A315-9F8E9D689C00}.Release|Any CPU.ActiveCfg = Release|Any CPU
{FAAF3B3F-FC24-4532-A315-9F8E9D689C00}.Release|Any CPU.Build.0 = Release|Any CPU
{FAACF6CB-5EFE-468D-8B29-4F6096CBC633}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{FAACF6CB-5EFE-468D-8B29-4F6096CBC633}.Debug|Any CPU.Build.0 = Debug|Any CPU
{FAACF6CB-5EFE-468D-8B29-4F6096CBC633}.Release|Any CPU.ActiveCfg = Release|Any CPU
{FAACF6CB-5EFE-468D-8B29-4F6096CBC633}.Release|Any CPU.Build.0 = Release|Any CPU
{29FA8279-D08E-4614-A4C6-2A3FE254FF22}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{29FA8279-D08E-4614-A4C6-2A3FE254FF22}.Debug|Any CPU.Build.0 = Debug|Any CPU
{29FA8279-D08E-4614-A4C6-2A3FE254FF22}.Release|Any CPU.ActiveCfg = Release|Any CPU
{29FA8279-D08E-4614-A4C6-2A3FE254FF22}.Release|Any CPU.Build.0 = Release|Any CPU
{8722D4FA-6E0D-401D-8324-EE21133DE3AE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{8722D4FA-6E0D-401D-8324-EE21133DE3AE}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8722D4FA-6E0D-401D-8324-EE21133DE3AE}.Release|Any CPU.ActiveCfg = Release|Any CPU
{8722D4FA-6E0D-401D-8324-EE21133DE3AE}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal

2
client/.env.development Normal file
Просмотреть файл

@ -0,0 +1,2 @@
REACT_APP_TENANT_ID='8593ef8d-30b0-4b72-b0fc-89422b2025c5'
REACT_APP_CLIENT_ID='534d7b60-6d28-47f5-890c-c00c617b6cf1'

2
client/.env.production Normal file
Просмотреть файл

@ -0,0 +1,2 @@
REACT_APP_TENANT_ID='8593ef8d-30b0-4b72-b0fc-89422b2025c5'
REACT_APP_CLIENT_ID='727c1e4a-9e4d-4d03-a836-88b83c8e309a'

1
client/.env.test Normal file
Просмотреть файл

@ -0,0 +1 @@
REACT_APP_ASSESSMENT_APP_URL='http://localhost:4280'

46
client/README.md Normal file
Просмотреть файл

@ -0,0 +1,46 @@
# Getting Started with Create React App
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
## Available Scripts
In the project directory, you can run:
### `npm start`
Runs the app in the development mode.\
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
The page will reload if you make edits.\
You will also see any lint errors in the console.
### `npm test`
Launches the test runner in the interactive watch mode.\
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
### `npm run build`
Builds the app for production to the `build` folder.\
It correctly bundles React in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.\
Your app is ready to be deployed!
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
### `npm run eject`
**Note: this is a one-way operation. Once you `eject`, you cant go back!**
If you arent satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point youre on your own.
You dont have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldnt feel obligated to use this feature. However we understand that this tool wouldnt be useful if you couldnt customize it when you are ready for it.
## Learn More
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
To learn React, check out the [React documentation](https://reactjs.org/).

43164
client/package-lock.json сгенерированный Normal file

Разница между файлами не показана из-за своего большого размера Загрузить разницу

63
client/package.json Normal file
Просмотреть файл

@ -0,0 +1,63 @@
{
"name": "client",
"version": "0.1.0",
"private": true,
"dependencies": {
"@azure/msal-browser": "^2.15.0",
"@azure/msal-react": "^1.0.1",
"@fluentui/example-data": "^8.2.4",
"@fluentui/react": "^8.22.3",
"@testing-library/jest-dom": "^5.14.1",
"@testing-library/react": "^11.2.7",
"@testing-library/user-event": "^12.8.3",
"@types/d3": "^7.0.0",
"@types/jest": "^26.0.24",
"@types/node": "^12.20.16",
"@types/react": "^17.0.14",
"@types/react-dom": "^17.0.9",
"axios": "^0.21.1",
"d3": "^7.0.1",
"file-saver": "^2.0.5",
"react": "^17.0.2",
"react-countdown-circle-timer": "^2.5.3",
"react-dom": "^17.0.2",
"react-grid-system": "^7.2.0",
"react-helmet": "^6.1.0",
"react-router-dom": "^5.2.0",
"typescript": "^4.3.5",
"web-vitals": "^1.1.2"
},
"devDependencies": {
"@testing-library/dom": "^8.2.0",
"@types/file-saver": "^2.0.3",
"@types/react-helmet": "^6.1.2",
"@types/react-router-dom": "^5.1.8",
"@types/selenium-webdriver": "^4.0.15",
"react-scripts": "4.0.3",
"selenium-webdriver": "^4.0.0-rc-1"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}

Двоичные данные
client/public/favicon.ico Normal file

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 3.8 KiB

43
client/public/index.html Normal file
Просмотреть файл

@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using create-react-app"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>React App</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

Двоичные данные
client/public/logo192.png Normal file

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 5.2 KiB

Двоичные данные
client/public/logo512.png Normal file

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 9.4 KiB

Просмотреть файл

@ -0,0 +1,25 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

3
client/public/robots.txt Normal file
Просмотреть файл

@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

38
client/src/App.css Normal file
Просмотреть файл

@ -0,0 +1,38 @@
.App {
text-align: center;
}
.App-logo {
height: 40vmin;
pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
.App-logo {
animation: App-logo-spin infinite 20s linear;
}
}
.App-header {
background-color: #282c34;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
color: white;
}
.App-link {
color: #61dafb;
}
@keyframes App-logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

89
client/src/App.tsx Normal file
Просмотреть файл

@ -0,0 +1,89 @@
import React, {useContext} from 'react';
import './App.css';
import {UnauthenticatedTemplate, useMsal} from "@azure/msal-react";
import {PlatformRegistration} from "./pages/platform/PlatformRegistration";
import {AssessmentPage} from "./pages/assessment/AssessmentPage";
import {HomePage} from "./pages/home/HomePage";
import {Repository} from "./model/Repository";
import {RepositoryContext} from "./context/RepositoryContext";
import {
BrowserRouter as Router,
Switch,
Route,
} from "react-router-dom";
import {QuestionBankPage} from './pages/questionBank/QuestionBankPage';
import {QuestionPage} from './pages/question/QuestionPage';
import {NewQuestionBank} from './pages/questionBank/NewQuestionBank';
import {NewQuestionPage} from './pages/question/NewQuestionPage';
import {StudentQuiz} from './pages/assessment/StudentQuiz';
import { StudentWelcomePage } from './pages/assessment/StudentWelcomePage';
import { StudentFinishedAssessment } from './pages/assessment/StudentFinishedAssessment';
// import { FakeRepository } from './model/FakeRepository';
const InternalApp = () => {
const repositoryContext = useContext(RepositoryContext);
if (repositoryContext === null || !repositoryContext.isReady()) {
return <h1>Loading...</h1>
}
console.log(`Internal app: ${repositoryContext.isReady()}`);
return (
<Router>
<Switch>
<Route path="/spa/platform">
<PlatformRegistration/>
</Route>
<Route path="/spa/assessment/:id">
<AssessmentPage/>
</Route>
<Route path="/spa/question-bank/:id">
<QuestionBankPage/>
</Route>
<Route path="/spa/question/:id">
<QuestionPage/>
</Route>
<Route path="/spa/new-question-bank">
<NewQuestionBank/>
</Route>
<Route path="/spa/new-question/bank=:bankId">
<NewQuestionPage/>
</Route>
<Route path="/spa/student-quiz/:id">
<StudentQuiz/>
</Route>
<Route path="/spa/student-welcome-page/:id">
<StudentWelcomePage/>
</Route>
<Route path="/spa/student-finished-assessment">
<StudentFinishedAssessment/>
</Route>
<Route path="/login"/>
<Route path="/">
<HomePage/>
</Route>
</Switch>
</Router>
)
}
const App = () => {
const { instance, accounts, inProgress } = useMsal();
return (
<div className="App">
<RepositoryContext.Provider value={new Repository(instance, accounts, inProgress)}>
{/*<RepositoryContext.Provider value={new FakeRepository()}>*/}
<InternalApp/>
<UnauthenticatedTemplate>
<p>No users are signed in!</p>
</UnauthenticatedTemplate>
</RepositoryContext.Provider>
</div>
);
}
export default App;

Просмотреть файл

@ -0,0 +1,33 @@
import * as React from 'react';
import { CommandBar, ICommandBarItemProps } from '@fluentui/react/lib/CommandBar';
import { IButtonProps } from '@fluentui/react/lib/Button';
const overflowProps: IButtonProps = { ariaLabel: 'More commands' };
interface AssessmentCommandBarProps {
onSave: () => void;
}
export const AssessmentCommandBar = (
{onSave}: AssessmentCommandBarProps
) => {
const _items: ICommandBarItemProps[] = [
{
key: 'saveChanges',
text: 'Save changes',
cacheKey: 'myCacheKey', // changing this key will invalidate this item's cache
iconProps: { iconName: 'Save' },
onClick: onSave,
},
];
return (
<div>
<CommandBar
items={_items}
overflowButtonProps={overflowProps}
ariaLabel="Use left and right arrow keys to navigate between commands"
/>
</div>
);
};

Просмотреть файл

@ -0,0 +1,246 @@
import React, {useState, useContext, useEffect, useRef} from "react";
import {Assessment} from "../model/Assessment";
import {RepositoryContext} from "../context/RepositoryContext";
import {AssessmentStatistics} from "../model/AssessmentStatistics";
import {Spinner, Text} from "@fluentui/react";
import * as d3 from "d3";
import {Selection} from "d3-selection";
import {Question} from "../model/Question";
interface AssessmentStatisticsComponentProps {
id: string,
savedAssessment: Assessment;
}
interface QuestionInfo {
name: string,
correct: number,
skipped: number,
incorrect: number,
}
export const AssessmentStatisticsComponent = (
{id, savedAssessment}: AssessmentStatisticsComponentProps
) => {
const [stats, setStats] = useState<AssessmentStatistics>();
const [questions, setQuestions] = useState<{ [id: string]: Question }>();
const scoreRef = useRef<SVGSVGElement | null>(null);
const questionsRef = useRef<SVGSVGElement | null>(null);
const repositoryContext = useContext(RepositoryContext);
useEffect(() => {
const fetchStats = async () => {
if (repositoryContext === null) {
return;
}
const newStats = await repositoryContext.getAssessmentStats(id);
setStats(newStats);
}
const fetchQuestions = async () => {
if (repositoryContext == null) {
return;
}
const result: { [id: string]: Question } = {};
for (let questionId of savedAssessment.questionIds) {
result[questionId] = await repositoryContext.getQuestionById(questionId);
}
setQuestions(result);
}
fetchStats();
fetchQuestions();
}, [id, repositoryContext, savedAssessment])
useEffect(() => {
if (stats === undefined) {
return;
}
const renderScoreChart = (svg: Selection<SVGSVGElement | null, unknown, null, any>) => {
const height = 300;
const width = 500;
const margin = {top: 20, right: 30, bottom: 30, left: 40};
const color = "steelblue";
const data = stats.studentResponses.map(r => r.score);
const minScore = Math.floor(Math.min(...data));
const maxScore = Math.ceil(Math.max(...data) + 1);
const bins = d3.bin().thresholds(d3.range(minScore, maxScore))(data);
console.log(bins)
const x = d3.scaleLinear()
.domain([minScore, maxScore + 1])
.range([margin.left, width - margin.right])
const y = d3.scaleLinear()
.domain([0, d3.max(bins, d => d.length) || 0]).nice()
.range([height - margin.bottom, margin.top])
const xAxis = (g: Selection<SVGGElement, any, any, any>) => {
g
.attr("transform", `translate(0,${height - margin.bottom})`)
.call(d3.axisBottom(x).ticks(width / 80).tickSizeOuter(0))
.call(g => g.append("text")
.attr("x", width - margin.right)
.attr("y", -4)
.attr("fill", "currentColor")
.attr("font-weight", "bold")
.attr("text-anchor", "end")
.text("Score"))
}
const yAxis = (g: Selection<SVGGElement, any, any, any>) => {
const ticks = y.ticks().filter(tick => Number.isInteger(tick));
const axis = d3.axisLeft(y).tickValues(ticks).tickFormat(d3.format('d')).ticks(height / 40);
g
.attr("transform", `translate(${margin.left},0)`)
.call(axis)
.call(g => g.select(".domain").remove())
.call(g => g.select(".tick:last-of-type text").clone()
.attr("x", 4)
.attr("text-anchor", "start")
.attr("font-weight", "bold")
.text("Students"))
}
const plot = (g: Selection<SVGGElement, any, any, any>) => {
g
.attr("fill", color)
.selectAll("rect")
.data(bins)
.join("rect")
.attr("x", d => x(d.x0 || 0) + 1)
.attr("width", d => Math.max(1, x(d.x1 || 0) - x(d.x0 || 0) - 1))
.attr("y", d => y(d.length))
.attr("height", d => y(0) - y(d.length));
}
svg.select<SVGGElement>(".x-axis").call(xAxis);
svg.select<SVGGElement>(".y-axis").call(yAxis);
svg.select<SVGGElement>(".plot-area").call(plot);
}
renderScoreChart(d3.select(scoreRef.current));
}, [stats, savedAssessment])
useEffect(() => {
if (stats === undefined) {
return;
}
const renderQuestionsChart = (svg: Selection<SVGSVGElement | null, unknown, null, any>) => {
console.log(questions);
if (!questions) {
return;
}
const data: { [key: string]: QuestionInfo } = {};
for (let response of stats.studentResponses) {
for (let [questionId, value] of Object.entries(response.responses)) {
const question = questions[questionId];
if (!(questionId in data)) {
data[questionId] = {
name: question.name,
correct: 0,
skipped: savedAssessment.studentIds.length,
incorrect: 0,
}
}
if (value.chosenOption === question.answer) {
data[questionId].correct += 1;
} else {
data[questionId].incorrect += 1;
}
data[questionId].skipped -= 1;
}
}
console.log(data);
const series = d3.stack<QuestionInfo>()
.keys(["correct", "skipped", "incorrect"])
(Object.values(data));
const margin = ({top: 30, right: 10, bottom: 0, left: 30});
const width = 500;
const height = savedAssessment.questionIds.length * 25 + margin.top + margin.bottom;
const x = d3.scaleLinear()
.domain([0, savedAssessment.studentIds.length])
.range([margin.left, width - margin.right])
const y = d3.scaleBand()
.domain(Object.values(data).map(d => d.name))
.range([margin.top, height - margin.bottom])
.padding(0.08)
const xAxis = (g: Selection<SVGGElement, any, any, any>) => {
const ticks = x.ticks().filter(tick => Number.isInteger(tick));
const axis = d3.axisTop(x).tickValues(ticks).tickFormat(d3.format('d'));
g
.attr("transform", `translate(0,${margin.top})`)
.call(axis)
.call(g => g.selectAll(".domain").remove())
}
const yAxis = (g: Selection<SVGGElement, any, any, any>) => {
g
.attr("transform", `translate(${width + margin.right},0)`)
.call(d3.axisRight(y).tickSizeOuter(0))
.call(g => g.selectAll(".domain").remove())
}
const plot = (g: Selection<SVGGElement, any, any, any>) => {
g
.selectAll("g")
.data(series)
// .enter().append("g")
.join("g")
.attr("fill", d => {
console.log(d);
if (d.key === "correct") {
return "#59a14f";
}
if (d.key === "skipped") {
return "#bab0ab";
}
return "#e15759";
})
.selectAll("rect")
.data(d => d)
.join("rect")
.attr("x", d => x(d[0]))
.attr("y", (d, i) => y(d.data.name) || 0)
.attr("width", d => x(d[1]) - x(d[0]))
.attr("height", y.bandwidth())
}
svg.select<SVGGElement>(".q-x-axis").call(xAxis);
svg.select<SVGGElement>(".q-y-axis").call(yAxis);
svg.select<SVGGElement>(".q-plot-area").call(plot);
}
renderQuestionsChart(d3.select(questionsRef.current));
}, [stats, questions, savedAssessment])
if (repositoryContext === null || stats === undefined) {
return <Spinner
label="Loading statistics..."
/>;
}
return <>
<Text variant="large">Scores</Text>
<svg
ref={scoreRef}
style={{
height: 300,
width: "100%",
marginRight: "0px",
marginLeft: "0px",
}}>
<g className="plot-area"/>
<g className="x-axis"/>
<g className="y-axis"/>
</svg>
<Text variant="large">Correct answers per question</Text>
<svg
ref={questionsRef}
style={{
height: 500,
width: "100%",
marginRight: "0px",
marginLeft: "0px",
}}>
<g className="q-plot-area"/>
<g className="q-x-axis"/>
<g className="q-y-axis"/>
</svg>
</>
}

Просмотреть файл

@ -0,0 +1,129 @@
import React from "react";
import {
ChoiceGroup,
IChoiceGroupOption,
IconButton,
ITextFieldStyles,
Label,
mergeStyles,
PrimaryButton,
TextField
} from "@fluentui/react";
import {Question} from "../model/Question";
import {Col, Container, Row} from "react-grid-system";
const optionRootClass = mergeStyles({display: 'flex', alignItems: 'baseline'});
const textFieldStyles: Partial<ITextFieldStyles> = {fieldGroup: {width: 350}};
interface EditQuestionComponentProps {
question: Question;
setQuestion: (f: (oldValue: Question) => Question) => void;
}
export const EditQuestionComponent = (
{question, setQuestion}: EditQuestionComponentProps
) => {
const createOptions = (): IChoiceGroupOption[] => {
const createOneOption = (optionId: number): IChoiceGroupOption => {
return {
key: optionId.toString(),
text: '',
onRenderField: (props, render) => {
return (
<div className={optionRootClass}>
{render!(props)}
<TextField
id={`question-option-${optionId}`}
styles={textFieldStyles}
value={question.options[optionId]}
onChange={(_: any, newValue?: string) =>
setQuestion(q => {
var result = {...q, options: [...q.options]};
result.options[optionId] = newValue || '';
return result;
})}
/>
<IconButton
iconProps={{iconName: "Delete"}}
onClick={() => {
setQuestion(q => {
let result = {...q, options: [...q.options]};
result.options.splice(optionId, 1);
return result;
});
}}
disabled={question.options.length <= 2}
/>
</div>
)
}
};
};
return question.options.map((_: any, index: number) => createOneOption(index));
};
const updateCorrectAnswer = (_: any, option?: IChoiceGroupOption) => {
var answer: number;
if (option == null) {
answer = -1;
} else {
answer = Number(option.key);
}
setQuestion(q => ({...q, answer: answer}))
}
return (<Container style={{margin: '30px', position: 'relative'}}>
<Row>
<Col md={2}>
<Label style={{textAlign: "left"}}>Name</Label>
</Col>
<Col md={6}>
<TextField
id="question-name-input"
value={question.name}
onChange={(_: any, newValue?: string) =>
setQuestion(q => ({...q, name: newValue || ''}))
}
/>
</Col>
</Row>
<br/>
<Row>
<Col md={2}>
<Label style={{textAlign: "left"}}>Description</Label>
</Col>
<Col md={6}>
<TextField
id="question-description-input"
multiline
rows={2}
value={question.description}
onChange={(_: any, newValue?: string) =>
setQuestion(q => ({...q, description: newValue || ''}))
}
/>
</Col>
</Row>
<br/>
<Row>
<Col md={2}>
<Label style={{textAlign: "left"}}>Options</Label>
</Col>
<Col md={6}>
<ChoiceGroup
options={createOptions()}
required={true}
selectedKey={`${question.answer}`}
onChange={updateCorrectAnswer}/>
</Col>
</Row>
<br/>
<Row>
<Col md={2}/>
<Col md={6}>
<PrimaryButton text="Add option" onClick={() => setQuestion(
q => ({...q, options: [...q.options, '']})
)}/>
</Col>
</Row>
</Container>
);
}

Просмотреть файл

@ -0,0 +1,134 @@
import React from 'react';
import {
styled,
Text,
Separator,
FontWeights,
Image,
IImageStyleProps,
IImageStyles,
AnimationClassNames,
getTheme, Link
} from '@fluentui/react';
import {SimpleComponentStyles, IStylesOnly, IThemeOnlyProps} from '../utils/FluentUI/typings.fluent-ui';
import {themedClassNames} from '../utils/FluentUI';
import {UserDetails} from './UserDetails';
import {useHistory} from 'react-router-dom';
interface HeaderProps {
mainHeader?: string;
secondaryHeader?: string;
logoUrl?: string;
}
type HeaderStyles = SimpleComponentStyles<'root' | 'mainContent' | 'mainHeader' | 'separator' | 'secondaryHeader' | 'userSection'>;
const HeaderInner = ({
mainHeader,
secondaryHeader,
logoUrl,
styles
}: IStylesOnly<HeaderStyles> & HeaderProps): JSX.Element => {
let history = useHistory();
const redirectHome = () => {
history.push('/')
};
const classes = themedClassNames(styles);
return (
<div className={classes.root} id="top-header">
<div className={classes.mainContent}>
{logoUrl && <Image src={logoUrl} styles={imageStyles} height={40}/>}
<Link style={{
textDecoration: "none"
}}>
<Text style={{color: 'white'}} variant="xLargePlus" className={classes.mainHeader}
onClick={redirectHome}>
{mainHeader}
</Text>
</Link>
{secondaryHeader && <Separator vertical className={classes.separator}/>}
<Text style={{color: 'white'}} variant="xLarge" className={classes.secondaryHeader}>
{secondaryHeader}
</Text>
</div>
<div className={classes.userSection}>
<UserDetails/>
</div>
</div>
);
};
const headerStyles = ({theme}: IThemeOnlyProps): HeaderStyles => ({
root: [
{
width: '100%',
backgroundColor: theme.palette.themePrimary,
color: theme.palette.white,
boxShadow: theme.effects.elevation8,
display: 'flex',
position: 'sticky',
zIndex: 2
}
],
mainContent: [
{
padding: `${theme.spacing.s2} ${theme.spacing.s1}`,
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center'
}
],
mainHeader: [
{
fontWeight: FontWeights.regular,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
marginLeft: theme.spacing.m
}
],
separator: [
{
margin: `0 ${theme.spacing.s1}`,
selectors: {
':after': {
backgroundColor: theme.palette.whiteTranslucent40
}
}
}
],
secondaryHeader: [
{
fontWeight: FontWeights.regular,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis'
}
],
userSection: [
{
marginLeft: 'auto'
}
]
});
const imageStyles = ({isLoaded}: IImageStyleProps): Partial<IImageStyles> => {
const theme = getTheme();
return {
root: [
isLoaded &&
(AnimationClassNames.slideRightIn40,
{
marginLeft: theme.spacing.s1
})
],
image: [
{
maxWidth: 150,
maxHeight: '100%',
objectFit: 'contain'
}
]
};
};
export const Header = styled(HeaderInner, headerStyles);

Просмотреть файл

@ -0,0 +1,31 @@
import * as React from 'react';
import { CommandBar, ICommandBarItemProps } from '@fluentui/react/lib/CommandBar';
import { IButtonProps } from '@fluentui/react/lib/Button';
import {useHistory} from "react-router-dom";
const overflowProps: IButtonProps = { ariaLabel: 'More commands' };
export const CommandBarBasicExample: React.FunctionComponent = () => {
const history = useHistory();
const redirectToNewQuestionBank = () => {
history.push("/spa/new-question-bank")}
const _items: ICommandBarItemProps[] = [
{
key: 'newItem',
text: 'New Question Bank',
cacheKey: 'myCacheKey', // changing this key will invalidate this item's cache
iconProps: { iconName: 'Add' },
onClick: () => {redirectToNewQuestionBank()},
},
];
return (
<div>
<CommandBar
items={_items}
overflowButtonProps={overflowProps}
ariaLabel="Use left and right arrow keys to navigate between commands"
/>
</div>
);
};

Просмотреть файл

@ -0,0 +1,83 @@
import React, {useContext, useState} from 'react';
import {Dropdown, IDropdownOption, IDropdownStyles, Label, PrimaryButton, TextField} from "@fluentui/react";
import {Col, Container, Row} from "react-grid-system";
import {Assessment} from '../model/Assessment';
import {RepositoryContext} from '../context/RepositoryContext';
import {AssessmentStatus} from '../model/AssessmentStatus';
const dropdownStyles: Partial<IDropdownStyles> = {
dropdown: {width: 250},
};
const options: IDropdownOption[] = [
{key: 'quiz', text: 'Quiz'},
{key: 'jupiter-notebook', text: 'Jupyter Notebook (coming soon)'},
];
interface InitialAssessmentSetupComponentProps {
id: string;
savedAssessment: Assessment;
setSavedAssessment: (f: (oldValue: Assessment) => Assessment) => void;
}
export const InitialAssessmentSetupComponent = (
{id, savedAssessment, setSavedAssessment}: InitialAssessmentSetupComponentProps
) => {
const [description, setDescription] = useState<string>(savedAssessment.description);
const [assessmentType, setAssessmentType] = useState<number>(0);
const repositoryContext = useContext(RepositoryContext);
if (repositoryContext == null) {
return <p>Assessment cannot be found</p>
}
const updateAssessmentType = (_1: any, _2: any, index?: number) => {
if (index == null) {
return;
}
setAssessmentType(index);
}
const confirmSelection = async () => {
if (repositoryContext == null) {
return;
}
const newAssessment = {
...savedAssessment,
description: description,
assessmentType: options[assessmentType].text,
status: AssessmentStatus.Draft,
}
await repositoryContext.updateAssessment(newAssessment);
setSavedAssessment((_) => newAssessment);
}
return (
<div style={{margin: '30px', textAlign: 'left'}}>
<Container>
<Row align='start'>
<Col md={2}><Label>Description</Label></Col>
<Col md={6}>
<TextField
multiline
rows={4}
value={description}
onChange={(_: any, newValue?: string) => setDescription(newValue || "")}
/>
</Col>
</Row>
<br/>
<Row align='start'>
<Col md={2}><Label>Assessment type</Label></Col>
<Col md={6}>
<Dropdown
placeholder="Select an Assessment type"
options={options}
styles={dropdownStyles}
selectedKey={options[assessmentType].key}
onChange={updateAssessmentType}
/>
</Col>
</Row>
<br/>
<PrimaryButton text="Confirm" allowDisabledFocus onClick={confirmSelection}/>
</Container>
</div>
);
}

Просмотреть файл

@ -0,0 +1,381 @@
import {
DetailsList,
DetailsRow,
GroupedList,
IColumn,
IGroup,
IObjectWithKey,
Label,
MarqueeSelection,
MessageBar,
MessageBarType,
Persona,
PersonaSize,
Pivot,
PivotItem,
Selection,
SelectionMode,
SelectionZone,
TextField
} from "@fluentui/react";
import React, {useState, useContext, useEffect} from "react";
import {Col, Container, Row} from "react-grid-system"
import {RepositoryContext} from "../context/RepositoryContext";
import {Assessment} from "../model/Assessment";
import {
DatePicker,
DayOfWeek,
defaultDatePickerStrings,
} from '@fluentui/react';
import {AssessmentCommandBar} from "./AssessmentCommandBar";
import {Member} from "../model/Member";
import {TimePicker} from "./TimePicker";
import {AssessmentStatisticsComponent} from "./AssessmentStatisticsComponent";
const COLUMNS: IColumn[] = [{
key: "name",
name: "Name",
fieldName: "name",
minWidth: 300,
isResizable: true,
}];
const STUDENTS_COLUMNS: IColumn[] = [
{
key: "student",
name: "Student",
fieldName: "student",
minWidth: 300,
maxWidth: 600,
isResizable: true,
onRender: item => (
<Persona
imageUrl={item.picture}
imageInitials={`${item.givenName.charAt(0)}${item.familyName.charAt(0)}`}
text={`${item.givenName} ${item.familyName}`}
size={PersonaSize.size32}
/>
)
},
{
key: "email",
name: "Email",
fieldName: "email",
minWidth: 300,
isResizable: true,
},
]
interface QuestionItem extends IObjectWithKey {
key: string,
name: string,
}
interface StudentItem extends Member, IObjectWithKey {}
const getSelectionKeys = (s: Selection) => {
return s.getSelection()
.map(i => i.key)
.filter(k => k !== undefined)
.map(k => (k || "").toString());
}
interface ParticipantsComponentProps {
savedAssessment: Assessment,
students: StudentItem[],
onSelectionChanged: (selectedIds: string[]) => void,
}
const ParticipantsComponent = ({
savedAssessment, students, onSelectionChanged
}: ParticipantsComponentProps) => {
const [selection] = useState<Selection>(() => {
let resultInitialized = false;
const result = new Selection({
onSelectionChanged: () => {
if (resultInitialized) {
const newKeys = getSelectionKeys(result);
console.log("selection changed");
console.log(newKeys);
onSelectionChanged(newKeys);
}
}
});
console.log("creating state");
console.log(students);
console.log(savedAssessment.studentIds);
result.setItems(students);
for (const studentId of savedAssessment.studentIds) {
result.setKeySelected(studentId, true, false);
}
resultInitialized = true;
return result;
});
console.log("rerendering participants")
console.log(students);
console.log(getSelectionKeys(selection))
return (
<MarqueeSelection selection={selection}>
<DetailsList
items={students}
columns={STUDENTS_COLUMNS}
selection={selection}
selectionMode={SelectionMode.multiple}
/>
</MarqueeSelection>
)
}
interface QuizzAssessmentComponentProps {
id: string;
savedAssessment: Assessment;
setSavedAssessment: (f: (oldValue: Assessment) => Assessment) => void;
}
export const QuizzAssessmentComponent = (
{id, savedAssessment, setSavedAssessment}: QuizzAssessmentComponentProps
) => {
const [description, setDescription] = useState<string>(savedAssessment.description);
const [deadlineDate, setDeadlineDate] = useState<Date>(new Date(
savedAssessment.deadline.getFullYear(), savedAssessment.deadline.getMonth(), savedAssessment.deadline.getDate()));
const [deadlineTime, setDeadlineTime] = useState({
hours: savedAssessment.deadline.getHours(),
minutes: savedAssessment.deadline.getMinutes(),
});
const [duration, setDuration] = useState({
hours: Math.floor(savedAssessment.durationSeconds / (60 * 60)),
minutes: Math.floor(savedAssessment.durationSeconds / 60) % 60,
});
const [questionsSelection, setQuestionsSelection] = useState<Selection>(new Selection());
const [groups, setGroups] = useState<IGroup[]>([]);
const [items, setItems] = useState<QuestionItem[]>([]);
const [students, setStudents] = useState<StudentItem[]>([]);
const [studentsLoaded, setStudentsLoaded] = useState<boolean>(false);
const [selectedStudents, setSelectedStudents] = useState<string[]>(savedAssessment.studentIds);
const [showSavedMsg, setShowSavedMsg] = useState(false);
console.log(deadlineDate);
const repositoryContext = useContext(RepositoryContext);
useEffect(() => {
const fetchQuestionBanks = async () => {
if (repositoryContext == null) {
return;
}
const questionBanks = await repositoryContext.getQuestionBanks();
const newGroups = [];
const newItems = [];
let startIndex = 0;
for (const bank of questionBanks) {
const questions = await repositoryContext.getQuestionsFromQuestionBank(bank.id);
if (questions.length === 0) {
continue;
}
newGroups.push({
count: questions.length,
key: bank.id,
name: bank.name,
startIndex: startIndex,
})
newItems.push(...questions.map(q => ({
key: q.id,
name: q.name,
})));
startIndex += questions.length;
}
setItems(newItems);
setGroups(newGroups);
const newQuestionsSelection = new Selection();
newQuestionsSelection.setItems(newItems);
for (const questionId of savedAssessment.questionIds) {
newQuestionsSelection.setKeySelected(questionId, true, false);
}
setQuestionsSelection(newQuestionsSelection);
}
const fetchStudents = async () => {
if (repositoryContext == null) {
return;
}
const newStudents = (await repositoryContext.getStudents(id)).map(s => ({
...s,
key: s.id,
}));
setStudents(newStudents);
setStudentsLoaded(true);
}
fetchQuestionBanks();
fetchStudents();
}, [id, savedAssessment, repositoryContext])
if (repositoryContext == null) {
return <p>Assessment cannot be found</p>
}
const onRenderCell = (
nestingDepth?: number,
item?: QuestionItem,
itemIndex?: number,
group?: IGroup,
): React.ReactNode => {
return item && typeof itemIndex === 'number' && itemIndex > -1 ? (
<DetailsRow
columns={COLUMNS}
groupNestingDepth={nestingDepth}
item={item}
itemIndex={itemIndex}
selection={questionsSelection}
selectionMode={SelectionMode.multiple}
compact={false}
group={group}
/>
) : null;
};
const saveChanges = async () => {
setShowSavedMsg(false);
console.log("save changes");
console.log(getSelectionKeys(questionsSelection));
console.log(selectedStudents);
console.log(duration);
const durationSeconds = duration.hours * 60 * 60 + duration.minutes * 60;
const updatedAssessment = {
...savedAssessment,
description: description,
deadline: new Date(
deadlineDate.getFullYear(),
deadlineDate.getMonth(),
deadlineDate.getDate(),
deadlineTime.hours,
deadlineTime.minutes,
),
durationSeconds: durationSeconds,
questionIds: getSelectionKeys(questionsSelection),
studentIds: selectedStudents,
}
await repositoryContext.updateAssessment(updatedAssessment);
setShowSavedMsg(true);
setSavedAssessment(_ => updatedAssessment);
}
let participantsComponent;
if (studentsLoaded) {
participantsComponent = <ParticipantsComponent
savedAssessment={savedAssessment}
students={students}
onSelectionChanged={setSelectedStudents}
/>
} else {
participantsComponent = <p>Loading...</p>
}
return (
<>
{showSavedMsg && <MessageBar
messageBarType={MessageBarType.success}
onDismiss={() => setShowSavedMsg(false)}
isMultiline={false}
>
Changes were saved successfully!
</MessageBar>}
<br/>
<AssessmentCommandBar
onSave={saveChanges}
/>
<div style={{
margin: '30px',
textAlign: 'left',
}}>
<Pivot aria-label="Large Link Size Pivot Example" linkSize="large">
<PivotItem headerText="General">
<br/>
<div style={{textAlign: 'left'}}>
<Container>
<Row align='start'>
<Col md={2}><Label>Description</Label></Col>
<Col md={6}>
<TextField
multiline
rows={4}
value={description}
onChange={(_: any, newValue?: string) => setDescription(newValue || "")}
/>
</Col>
</Row>
<br/>
<Row align='start'>
<Col md={2}><Label>Assessment type</Label></Col>
<Col md={6}><TextField disabled={true}
defaultValue={savedAssessment.assessmentType}/>
</Col>
</Row>
<br/>
<Row align='start'>
<Col md={2}><Label>Deadline</Label></Col>
<Col md={4}>
<DatePicker
firstDayOfWeek={DayOfWeek.Monday}
placeholder="Select a date..."
ariaLabel="Select a date"
value={deadlineDate}
minDate={new Date()}
onSelectDate={newDate => {
console.log(newDate);
if (newDate) {
setDeadlineDate(newDate);
}
}}
// DatePicker uses English strings by default. For localized apps, you must override this prop.
strings={defaultDatePickerStrings}
/>
<TimePicker
value={deadlineTime}
onValueChanged={setDeadlineTime}
/>
</Col>
</Row>
<br/>
<Row align='start'>
<Col md={2}><Label>Duration</Label></Col>
<Col md={4}>
<TimePicker
value={duration}
onValueChanged={setDuration}
/>
</Col>
</Row>
<br/>
</Container>
</div>
</PivotItem>
<PivotItem headerText="Choose questions">
<div style={{width: '60%'}}>
<SelectionZone selection={questionsSelection} selectionMode={SelectionMode.multiple}>
<GroupedList
items={items}
groups={groups}
onRenderCell={onRenderCell}
selection={questionsSelection}
selectionMode={SelectionMode.multiple}
/>
</SelectionZone>
</div>
</PivotItem>
<PivotItem headerText="Participants">
<div style={{width: '60%'}}>
{participantsComponent}
</div>
</PivotItem>
<PivotItem headerText="Analytics">
<AssessmentStatisticsComponent
id={id}
savedAssessment={savedAssessment}
/>
</PivotItem>
</Pivot>
</div>
</>
)
}

Просмотреть файл

@ -0,0 +1,51 @@
import React from "react";
import {StudentQuestion} from '../model/StudentQuestion';
import {ChoiceGroup, IChoiceGroupOption} from '@fluentui/react/lib/ChoiceGroup';
import { Label } from "@fluentui/react";
import { getTheme } from '@fluentui/react';
interface StudentQuestionComponentProps {
question: StudentQuestion;
selectedOption: number;
setSelectedOption: (choice: number) => void;
}
export const StudentQuestionComponent = (
{question, selectedOption, setSelectedOption}: StudentQuestionComponentProps
) => {
const theme = getTheme();
const options: IChoiceGroupOption[] = question.options.map((value, index) => ({
key: index.toString(),
text: value,
}));
return (
<>
<br/>
<div style={{
backgroundColor: '#faf9f8',
width: '50%',
margin: 'auto',
padding: '10px',
boxShadow: theme.effects.elevation8
}}>
<br/>
<div style={{margin: '30px', textAlign: 'left'}}>
<Label style={{textAlign: 'left', fontSize: '25px'}}>Question</Label>
<p>{question.description}</p>
<ChoiceGroup
selectedKey={selectedOption.toString()}
onChange={(_: any, option) => {
if (option) {
setSelectedOption(Number(option.key));
}
}}
options={options}
/>
</div>
<br/>
<br/>
</div>
</>
)
}

Просмотреть файл

@ -0,0 +1,64 @@
import {MaskedTextField} from "@fluentui/react";
import React from "react";
interface TimeSpan {
hours: number,
minutes: number,
}
interface TimePickerProps {
value: TimeSpan,
onValueChanged: (newValue: TimeSpan) => void,
}
const timeFormat = /^(\d{2}):(\d{2})$/
const checkValueFormat = (value: string) => {
const result = timeFormat[Symbol.match](value);
if (!result) {
return "Invalid time format";
}
const hours = Number(result[0]);
if (hours >= 24) {
return "Should be between 0 and 23 hours";
}
const minutes = Number(result[1]);
if (minutes >= 60) {
return "Should be between 0 and 59 minutes";
}
}
export const TimePicker = (
{value, onValueChanged}: TimePickerProps
) => {
const onChange = (_: any, newValue?: string) => {
if (!newValue) {
return;
}
const result = timeFormat[Symbol.match](newValue);
if (!result) {
return;
}
const hours = Number(result[1]);
if (hours >= 24) {
return;
}
const minutes = Number(result[2]);
if (minutes >= 60) {
return;
}
onValueChanged({
hours: hours,
minutes: minutes,
})
}
return (
<MaskedTextField
value={`${value.hours.toString().padStart(2, '0')}:${value.minutes.toString().padStart(2, '0')}`}
onGetErrorMessage={checkValueFormat}
onChange={onChange}
mask="99:99"
maskChar='0'
/>
)
}

Просмотреть файл

@ -0,0 +1,92 @@
import {DefaultButton, Dialog, DialogFooter, DialogType, PrimaryButton, Spinner} from "@fluentui/react";
import * as React from "react";
import {useMemo, useState} from "react";
import {RepositoryContext} from "../context/RepositoryContext";
const modalPropsStyles = { main: { maxWidth: 600 } };
interface UploadQuestionBanksComponentProps {
hidden: boolean,
onFinish: (done: boolean) => void,
}
const dialogContentProps = {
type: DialogType.normal,
title: 'Upload question banks',
}
export const UploadQuestionBanksComponent = (
{hidden, onFinish}: UploadQuestionBanksComponentProps
) => {
const [selectedFile, setSelectedFile] = useState<Blob | null>(null);
const [inProgress, setInProgress] = useState(false);
const repositoryContext = React.useContext(RepositoryContext);
const dialogModalProps = useMemo(() => ({
isBlocking: true,
styles: modalPropsStyles,
}), []);
if (repositoryContext == null) {
return <></>;
}
const changeHandler = (event: any) => {
setSelectedFile(event.target.files[0]);
};
const doUpload = async () => {
if (selectedFile === null) {
return;
}
setInProgress(true);
setSelectedFile(null);
const reader = new FileReader();
reader.onload = async (e) => {
const text = e.target?.result;
if (!text) {
return;
}
const rawData = JSON.parse(text.toString());
for (let rawBank of rawData) {
const bank = await repositoryContext.createNewQuestionBank({
id: "",
name: rawBank.name,
description: rawBank.description,
lastModified: new Date(),
questionIds: [],
assessmentType: rawBank.assessmentType,
});
for (let rawQuestion of rawBank.questions) {
await repositoryContext.saveNewQuestion(bank.id, {
id: "",
name: rawQuestion.name,
description: rawQuestion.description,
lastModified: new Date(),
options: rawQuestion.options,
answer: rawQuestion.answer,
})
}
}
onFinish(true);
setInProgress(false);
}
reader.readAsText(selectedFile);
};
return(
<Dialog
hidden={hidden}
onDismiss={() => onFinish(false)}
dialogContentProps={dialogContentProps}
modalProps={dialogModalProps}
>
<input type="file" name="file" onChange={changeHandler} />
<DialogFooter>
{inProgress && <Spinner
label="Uploading..."
/>}
<PrimaryButton text="Upload" disabled={selectedFile === null} onClick={doUpload}/>
<DefaultButton text="Cancel" onClick={() => onFinish(false)}/>
</DialogFooter>
</Dialog>
)
}

Просмотреть файл

@ -0,0 +1,19 @@
import React from 'react';
import {Persona, PersonaSize} from '@fluentui/react';
import {useMsal} from "@azure/msal-react";
export const UserDetails = () => {
const {accounts} = useMsal();
if (accounts.length === 0) {
return <p>No accounts found!</p>
}
const current = accounts[0];
return <div style={{margin: "5px", color: "white"}}>
<Persona
imageInitials={current.name?.charAt(0)}
text={current.name}
size={PersonaSize.size32}
/>
</div>
};

Просмотреть файл

@ -0,0 +1,4 @@
import React from "react";
import { IRepository } from "../model/IRepository";
export const RepositoryContext = React.createContext<IRepository | null>(null);

13
client/src/index.css Normal file
Просмотреть файл

@ -0,0 +1,13 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}

46
client/src/index.tsx Normal file
Просмотреть файл

@ -0,0 +1,46 @@
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
import { initializeIcons } from '@fluentui/react/lib/Icons';
import {MsalAuthenticationTemplate, MsalProvider} from "@azure/msal-react";
import {Configuration, InteractionType, PublicClientApplication} from "@azure/msal-browser";
initializeIcons();
// MSAL configuration
const configuration: Configuration = {
auth: {
// clientId: '6bf0ea7a-239d-414a-8e0f-c88bac05b6c8',
// authority: 'https://login.microsoftonline.com/8e149a57-04b4-4215-9cff-5a23fc0a7fbf',
clientId: `${process.env.REACT_APP_CLIENT_ID}`,
authority: `https://login.microsoftonline.com/${process.env.REACT_APP_TENANT_ID}`,
redirectUri: `${window.location.origin}/login`,
}
};
const pca = new PublicClientApplication(configuration);
const LoadingComponent = () => <p>Authentication in progress...</p>;
// Component
const AppProvider = () => (
<MsalProvider instance={pca}>
<React.StrictMode>
<MsalAuthenticationTemplate
interactionType={InteractionType.Redirect}
loadingComponent={LoadingComponent}>
<App />
</MsalAuthenticationTemplate>
</React.StrictMode>
</MsalProvider>
);
ReactDOM.render(
<AppProvider/>,
document.getElementById('root')
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

Просмотреть файл

@ -0,0 +1,14 @@
import { AssessmentStatus } from "./AssessmentStatus";
export interface Assessment {
id: string,
name: string,
description: string,
lastModified: Date,
deadline: Date,
durationSeconds: number,
assessmentType: string,
status: AssessmentStatus,
questionIds: string[],
studentIds: string[],
}

Просмотреть файл

@ -0,0 +1,5 @@
import {StudentResult} from "./StudentResult";
export interface AssessmentStatistics {
studentResponses: StudentResult[],
}

Просмотреть файл

@ -0,0 +1,7 @@
export enum AssessmentStatus {
InitialSetup = "Initial setup",
Draft = "Draft",
Published = "Published",
Ongoing = "Ongoing",
Complete = "Complete",
}

Просмотреть файл

@ -0,0 +1,370 @@
import {IRepository} from "./IRepository";
import {Assessment} from "./Assessment";
import {QuestionBank} from "./QuestionBank";
import {Question} from "./Question";
import {AssessmentStatus} from "./AssessmentStatus";
import { Member } from "./Member";
import { StudentQuestion } from "./StudentQuestion";
import {StudentAssessment} from "./StudentAssessment";
import {StudentAssessmentQuestions} from "./StudentAssessmentQuestions";
import {AssessmentStatistics} from "./AssessmentStatistics";
export class FakeRepository implements IRepository {
assessments: { [id: string]: Assessment };
questionBanks: { [id: string]: QuestionBank };
questions: { [id: string]: Question };
members: { [id: string]: Member };
constructor() {
this.members = {
'0': {
id: '0',
email: 'ivanivanov@ucl.ac.uk',
familyName: 'Ivanov',
givenName: 'Ivan',
picture: '',
},
'1': {
id: '1',
email: 'svetaivanova@ucl.ac.uk',
familyName: 'Ivanova',
givenName: 'Sveta',
picture: '',
},
'2': {
id: '2',
email: 'mashaivanova@ucl.ac.uk',
familyName: 'Ivanova',
givenName: 'Masha',
picture: '',
}
}
this.assessments = {
'0': {
id: '0',
name: "Introductory Programming",
description: "",
lastModified: new Date(),
deadline: new Date(),
durationSeconds: 60 * 60,
assessmentType: "Quiz",
status: AssessmentStatus.Draft,
questionIds: ['6', '5', '2', '3', '4'],
studentIds: ['1', '2'],
},
'1': {
id: '1',
name: "Functional Programming",
description: "",
lastModified: new Date(),
deadline: new Date(),
durationSeconds: 60 * 60,
assessmentType: "Quiz",
status: AssessmentStatus.Complete,
questionIds: ['0', '1', '5', '3', '7'],
studentIds: ['0', '1', '2'],
},
'2': {
id: '2',
name: "Databases",
description: "",
lastModified: new Date(),
deadline: new Date(),
durationSeconds: 60 * 60,
assessmentType: "Quiz",
status: AssessmentStatus.InitialSetup,
questionIds: ['5', '1', '2', '6', '4'],
studentIds: ['0', '1', '2'],
},
'3': {
id: '3',
name: "Machine Learning for Domain Specialists",
description: "",
lastModified: new Date(),
deadline: new Date(),
durationSeconds: 60 * 60,
assessmentType: "Quiz",
status: AssessmentStatus.Ongoing,
questionIds: ['0', '5', '2', '6', '4'],
studentIds: ['0', '1', '2'],
},
'4': {
id: '4',
name: "Software Engineering",
description: "",
lastModified: new Date(),
deadline: new Date(),
durationSeconds: 60 * 60,
assessmentType: "Quiz",
status: AssessmentStatus.Published,
questionIds: ['6', '5', '4', '3', '2'],
studentIds: ['0', '1'],
},
'5': {
id: '5',
name: "Architecture and Hardware",
description: "",
lastModified: new Date(),
deadline: new Date(),
durationSeconds: 60 * 60,
assessmentType: "Quiz",
status: AssessmentStatus.Draft,
questionIds: ['5', '1', '7', '3', '2'],
studentIds: ['1', '2'],
},
'6': {
id: '6',
name: "App Engineering",
description: "",
lastModified: new Date(),
deadline: new Date(),
durationSeconds: 60 * 60,
assessmentType: "Quiz",
status: AssessmentStatus.Draft,
questionIds: ['5', '7', '2', '6', '4'],
studentIds: ['0', '1', '2'],
},
'7': {
id: '7',
name: "Algorithmics",
description: "",
lastModified: new Date(),
deadline: new Date(),
durationSeconds: 60 * 60,
assessmentType: "Quiz",
status: AssessmentStatus.InitialSetup,
questionIds: ['5', '6', '2', '3', '7'],
studentIds: ['0', '2'],
},
}
this.questionBanks = {
'0': {
id: '0',
name: "Introduction to Machine Learning: Pre-Lecture Quiz",
description: "",
lastModified: new Date(),
questionIds: ['0', '1', '2'],
assessmentType: "Quiz",
},
'1': {
id: '1',
name: "Software Engineering MCQ",
description: "",
lastModified: new Date(),
questionIds: ['3', '4', '5', '6', '7', '8'],
assessmentType: "Quiz",
},
'2': {
id: '2',
name: "COMP0066",
description: "",
lastModified: new Date(),
questionIds: [],
assessmentType: "Quiz",
},
'3': {
id: '3',
name: "COMP0068",
description: "",
lastModified: new Date(),
questionIds: [],
assessmentType: "Quiz",
},
'4': {
id: '4',
name: "COMP0147",
description: "",
lastModified: new Date(),
questionIds: [],
assessmentType: "Quiz",
},
}
this.questions = {
'0': {
id: '0',
name: "Applications of machine learning",
description: "Applications of machine learning are all around us",
lastModified: new Date(),
options: ["True", "False"],
answer: 0,
},
'1': {
id: '1',
name: "Technical difference between classical ML and deep learning",
description: "What is the technical difference between classical ML and deep learning?",
lastModified: new Date(),
options: [
"Classical ML was invented first",
"The use of neural networks",
"Deep learning is used in robots",
],
answer: 1,
},
'2': {
id: '2',
name: "Why might a business want to use ML strategies?",
description: "Why might a business want to use ML strategies?",
lastModified: new Date(),
options: [
"To automate the solving of multi-dimensional problems",
"To customise a shopping experience based on the type of the customer",
"Both of the above",
],
answer: 2,
},
'3': {
id: '3',
name: "Software is considered to be collection of:",
description: "Software is considered to be collection of:",
lastModified: new Date(),
options: [
"Programming code",
"Associated libraries",
"Documentations",
"All of the above",
],
answer: 3,
},
'4': {
id: '4',
name: "The process of developing a software product",
description: "The process of developing a software product using software engineering principles and methods is referred to as:",
lastModified: new Date(),
options: [
"Software Engineering",
"Software Evolution",
"System Models",
"Software Models",
],
answer: 1,
},
'5': {
id: '5',
name: "Software evolution",
description: "Lehman has given laws for software evolution and he divided the software into ___ different categories",
lastModified: new Date(),
options: [
"6",
"2",
"3",
"5",
],
answer: 2,
},
'6': {
id: '6',
name: "E-Type software evolution",
description: "Which of the following is not consider laws for E-Type software evolution?",
lastModified: new Date(),
options: [
"Continuing quality",
"Continuing change",
"Increasing complexity",
"Self-regulation",
],
answer: 0,
},
'7': {
id: '7',
name: "Characteristics of good software",
description: "Which of the following is the Characteristics of good software?",
lastModified: new Date(),
options: [
"Transitional",
"Operational",
"Maintenance",
"All of the above",
],
answer: 3,
},
'8': {
id: '8',
name: "Need of Software Engineering",
description: "Where there is a need of Software Engineering?",
lastModified: new Date(),
options: [
"For Large Software",
"To reduce Cost",
"Software Quality Management",
"All of the above",
],
answer: 3,
},
}
}
public async getAssessments(): Promise<Assessment[]> {
return Object.values(this.assessments);
}
public async getAssessmentById(id: string): Promise<Assessment> {
return this.assessments[id];
}
public async getQuestionBanks(): Promise<QuestionBank[]> {
return Object.values(this.questionBanks);
}
public async getQuestionBankById(id: string): Promise<QuestionBank> {
return this.questionBanks[id];
}
public async getQuestionsFromQuestionBank(bankId: string): Promise<Question[]> {
return this.questionBanks[bankId].questionIds.map((id) => this.questions[id]);
}
public async getQuestionById(id: string): Promise<Question> {
return this.questions[id];
}
public async updateAssessment(a: Assessment) {
this.assessments[a.id] = {...a};
}
public async updateQuestion(q: Question) {
this.questions[q.id] = {...q};
}
public async updateQuestionBank(b: QuestionBank) {
this.questionBanks[b.id] = {...b}
}
public async saveNewQuestion(bankId: string, q: Question): Promise<Question> {
const nextQuestionId = Object.keys(this.questions).length.toString();
const questionToSave = {...q, id: nextQuestionId};
this.questions[nextQuestionId] = questionToSave;
this.questionBanks[bankId].questionIds.push(nextQuestionId);
return questionToSave;
}
public async createNewQuestionBank(bank: QuestionBank): Promise<QuestionBank> {
const nextQuestionBankId = Object.keys(this.questionBanks).length.toString();
const newQuestionBank = {
...bank,
id: nextQuestionBankId,
}
this.questionBanks[nextQuestionBankId] = newQuestionBank;
return newQuestionBank;
}
public async getStudents(id: string): Promise<Member[]> {
return Object.values(this.members);
}
public async getAssessmentStats(assessmentId: string): Promise<AssessmentStatistics> {
throw new Error("Not implemented");
}
public async getStudentAssessment(assessmentId:string): Promise<StudentAssessment> {
throw new Error("Not implemented");
}
public async getStudentQuestions(assessmentId: string): Promise<StudentAssessmentQuestions> {
throw new Error("Not implemented");
}
public async submitStudentAssessment(assessmentId: string, chosenOptions: { [id: string]: number }) {}
public isReady(): boolean {
return true;
}
public async deleteQuestions(questionIds: string[]) {
throw new Error("Not implemented");
}
public async deleteQuestionBanks(questionBankIds: string[]) {
throw new Error("Not implemented");
}
}

Просмотреть файл

@ -0,0 +1,29 @@
import {Assessment} from "./Assessment";
import {QuestionBank} from "./QuestionBank";
import {Question} from "./Question";
import { Member } from "./Member";
import {StudentAssessment} from "./StudentAssessment";
import {StudentAssessmentQuestions} from "./StudentAssessmentQuestions";
import {AssessmentStatistics} from "./AssessmentStatistics";
export interface IRepository {
getAssessments(): Promise<Assessment[]>;
getAssessmentById(id: string): Promise<Assessment>;
getQuestionBanks(): Promise<QuestionBank[]>;
getQuestionBankById(id: string): Promise<QuestionBank>;
getQuestionsFromQuestionBank(bankId: string): Promise<Question[]>;
getQuestionById(id: string): Promise<Question>;
updateAssessment(a: Assessment): void;
updateQuestion(q: Question): void;
updateQuestionBank(b: QuestionBank): void;
saveNewQuestion(bankId: string, q: Question): Promise<Question>;
createNewQuestionBank(bank: QuestionBank): Promise<QuestionBank>;
getStudents(assessmentId: string): Promise<Member[]>;
getAssessmentStats(assessmentId: string): Promise<AssessmentStatistics>;
getStudentAssessment(assessmentId: string): Promise<StudentAssessment>;
getStudentQuestions(assessmentId: string): Promise<StudentAssessmentQuestions>;
submitStudentAssessment(assessmentId: string, chosenOptions: { [id: string]: number }): void;
deleteQuestions(questionIds: string[]): void;
deleteQuestionBanks(questionBankIds: string[]): void;
isReady(): boolean;
}

Просмотреть файл

@ -0,0 +1,7 @@
export interface Member {
id: string,
email: string,
familyName: string,
givenName: string,
picture: string,
}

Просмотреть файл

@ -0,0 +1,8 @@
export interface Question {
id: string,
name: string,
description: string,
lastModified: Date,
options: string[],
answer: number,
}

Просмотреть файл

@ -0,0 +1,8 @@
export interface QuestionBank {
id: string,
name: string,
description: string,
lastModified: Date,
questionIds: string[],
assessmentType: string,
}

Просмотреть файл

@ -0,0 +1,3 @@
export interface QuestionResponseInfo {
chosenOption: number,
}

Просмотреть файл

@ -0,0 +1,276 @@
import {Assessment} from "./Assessment";
import {QuestionBank} from "./QuestionBank";
import {Question} from "./Question";
import axios from "axios";
import {IRepository} from "./IRepository";
import {Member} from "./Member";
import {StudentAssessment} from "./StudentAssessment";
import {
IPublicClientApplication,
AccountInfo,
InteractionRequiredAuthError,
InteractionStatus
} from "@azure/msal-browser";
import {StudentAssessmentQuestions} from "./StudentAssessmentQuestions";
import {QuestionResponseInfo} from "./QuestionResponseInfo";
import {AssessmentStatistics} from "./AssessmentStatistics";
interface ListAssessmentsResponse {
assessments: Assessment[],
}
interface ListQuestionBanksResponse {
questionBanks: QuestionBank[],
}
interface GetAssessmentResponse {
assessment: Assessment,
questions: Question[],
}
interface GetQuestionBankResponse {
questionBank: QuestionBank,
questions: Question[],
}
interface CreateQuestionResponse {
id: string,
}
interface CreateQuestionBankResponse {
id: string,
}
interface GetStudentsResponse {
students: Member[],
}
interface SubmitStudentAssessmentRequest {
responses: { [id: string]: QuestionResponseInfo },
}
// For more details see
// https://stackoverflow.com/questions/65692061/casting-dates-properly-from-an-api-response-in-typescript
const isoDateFormat = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2}(?:\.\d*)?)((-(\d{2}):(\d{2})|Z)?)$/;
function isIsoDateString(value: any): boolean {
return value && typeof value === "string" && isoDateFormat.test(value);
}
export function handleDates(body: any) {
if (body === null || body === undefined || typeof body !== "object") {
return body;
}
for (const key of Object.keys(body)) {
const value = body[key];
if (isIsoDateString(value)) {
body[key] = new Date(value);
} else if (typeof value === "object") {
handleDates(value);
}
}
}
axios.interceptors.response.use(originalResponse => {
handleDates(originalResponse.data);
return originalResponse;
});
export class Repository implements IRepository {
msalInstance: IPublicClientApplication;
accounts: AccountInfo[];
inProgress: InteractionStatus;
assessments?: { [id: string]: Assessment };
questionBanks?: { [id: string]: QuestionBank };
students: { [id: string]: Member[] } = {};
questions: { [id: string]: Question } = {};
constructor(msalInstance: IPublicClientApplication, accounts: AccountInfo[], inProgress: InteractionStatus) {
this.msalInstance = msalInstance;
this.accounts = accounts;
this.inProgress = inProgress;
}
private async getAssessmentsDict(): Promise<{ [id: string]: Assessment }> {
const response = await axios.get<ListAssessmentsResponse>("/api/list-assessments");
console.log(response)
this.assessments = response.data.assessments.reduce((a, e) => ({...a, [e.id]: e}), {});
return this.assessments;
}
private cacheQuestions(questionsToCache: Question[]) {
for (const q of questionsToCache) {
this.questions[q.id] = q;
}
}
public async getAssessments(): Promise<Assessment[]> {
return Object.values(await this.getAssessmentsDict());
}
public async getAssessmentById(id: string): Promise<Assessment> {
const assessments = await this.getAssessmentsDict();
const response = await axios.get<GetAssessmentResponse>(`/api/get-assessment/${id}`);
assessments[response.data.assessment.id] = response.data.assessment;
this.cacheQuestions(response.data.questions);
return assessments[id];
}
private async getQuestionBanksDict(): Promise<{ [id: string]: QuestionBank }> {
const response = await axios.get<ListQuestionBanksResponse>("/api/list-question-banks");
this.questionBanks = response.data.questionBanks.reduce((a, e) => ({...a, [e.id]: e}), {});
return this.questionBanks;
}
public async getQuestionBanks(): Promise<QuestionBank[]> {
return Object.values(await this.getQuestionBanksDict());
}
public async getQuestionBankById(id: string): Promise<QuestionBank> {
const questionBanks = await this.getQuestionBanksDict();
const response = await axios.get<GetQuestionBankResponse>(`/api/get-question-bank/${id}`);
questionBanks[response.data.questionBank.id] = response.data.questionBank;
this.cacheQuestions(response.data.questions);
return questionBanks[id];
}
public async getQuestionsFromQuestionBank(bankId: string): Promise<Question[]> {
const questionBank = await this.getQuestionBankById(bankId);
return questionBank.questionIds.map((id) => this.questions[id]);
}
public async getQuestionById(id: string): Promise<Question> {
if (!(id in this.questions)) {
const response = await axios.get<Question>(`/api/get-question/${id}`);
this.questions[response.data.id] = response.data;
}
return this.questions[id];
}
public async updateAssessment(a: Assessment) {
await axios.post(`/api/update-assessment`, a);
if (this.assessments != null) {
this.assessments[a.id] = {...a};
}
}
public async updateQuestion(q: Question) {
await axios.post(`/api/update-question`, q);
this.questions[q.id] = {...q};
}
public async updateQuestionBank(b: QuestionBank) {
await axios.post("/api/update-question-bank", b);
if (this.questionBanks != null) {
this.questionBanks[b.id] = {...b};
}
}
public async saveNewQuestion(bankId: string, q: Question): Promise<Question> {
const response = await axios.post<CreateQuestionResponse>("/api/create-question", q);
const questionBank = await this.getQuestionBankById(bankId);
questionBank.questionIds.push(response.data.id);
await this.updateQuestionBank(questionBank);
const result = {...q, id: response.data.id};
this.questions[result.id] = result;
return result;
}
public async createNewQuestionBank(bank: QuestionBank): Promise<QuestionBank> {
const response = await axios.post<CreateQuestionBankResponse>("/api/create-question-bank", bank);
const result = {...bank, id: response.data.id};
if (this.questionBanks != null) {
this.questionBanks[result.id] = result;
}
return result;
}
public async getStudents(assessmentId: string): Promise<Member[]> {
if (!(assessmentId in this.students)) {
const response = await axios.get<GetStudentsResponse>(`/api/get-students/${assessmentId}`);
this.students[assessmentId] = response.data.students;
}
return this.students[assessmentId];
}
public async getAssessmentStats(assessmentId: string): Promise<AssessmentStatistics> {
const response = await axios.get<AssessmentStatistics>(`/api/get-assessment-stats/${assessmentId}`);
return response.data;
}
public async getStudentAssessment(assessmentId: string): Promise<StudentAssessment> {
const accessToken = await this.getAccessToken();
const response = await axios.get<StudentAssessment>(`/api/get-student-assessment/${assessmentId}`, {
headers: {
"Authorization": `Bearer ${accessToken}`,
}
});
return response.data;
}
public async getStudentQuestions(assessmentId: string): Promise<StudentAssessmentQuestions> {
const accessToken = await this.getAccessToken();
const response = await axios.get<StudentAssessmentQuestions>(`/api/get-student-questions/${assessmentId}`, {
headers: {
"Authorization": `Bearer ${accessToken}`,
}
});
return response.data;
}
public async submitStudentAssessment(assessmentId: string, chosenOptions: { [id: string]: number }) {
const accessToken = await this.getAccessToken();
const request: SubmitStudentAssessmentRequest = {
responses: Object.entries(chosenOptions).reduce((a, item) => ({
...a,
[item[0]]: { chosenOption: item[1] }
}), {}),
};
await axios.post(`/api/submit-student-assessment/${assessmentId}`, request, {
headers: {
"Authorization": `Bearer ${accessToken}`,
},
});
}
public async deleteQuestions(questionIds: string[]) {
await axios.post("/api/delete-questions", {
questionIds: questionIds,
});
}
public async deleteQuestionBanks(questionBankIds: string[]) {
await axios.post(`/api/delete-question-banks`, {
questionBankIds: questionBankIds,
});
}
public isReady(): boolean {
return this.inProgress === "none" && this.accounts.length > 0;
}
private async getAccessToken(): Promise<string> {
if (this.isReady()) {
// Retrieve an access token
const accessTokenRequest = {
account: this.accounts[0],
scopes: ["user.read"],
};
const response = await this.msalInstance.acquireTokenSilent(accessTokenRequest).catch((error) => {
if (error instanceof InteractionRequiredAuthError) {
this.msalInstance.acquireTokenRedirect(accessTokenRequest);
}
console.log(error);
});
console.log(response)
if (!response) {
throw new Error("Unable to get access token.");
}
return response.accessToken;
}
console.log(`Inside repo: ${this.isReady()}`);
throw new Error("Unable to get access token.");
}
}

Просмотреть файл

@ -0,0 +1,14 @@
import { StudentResponseStatus } from "./StudentResponseStatus";
export interface StudentAssessment {
id: string,
courseName: string,
name: string,
description: string,
assessmentType: string,
status: StudentResponseStatus,
startTime: Date,
deadline: Date,
durationSeconds: number,
numberOfQuestions: number,
}

Просмотреть файл

@ -0,0 +1,7 @@
import {StudentAssessment} from "./StudentAssessment";
import {StudentQuestion} from "./StudentQuestion";
export interface StudentAssessmentQuestions {
assessment: StudentAssessment,
questions: StudentQuestion[],
}

Просмотреть файл

@ -0,0 +1,7 @@
export interface StudentQuestion {
id: string,
name: string,
description: string,
options: string[],
chosenOption: number,
}

Просмотреть файл

@ -0,0 +1,5 @@
export enum StudentResponseStatus {
NotStarted = "Not started",
InProgress = "In progress",
Complete = "Complete",
}

Просмотреть файл

@ -0,0 +1,11 @@
import {StudentResponseStatus} from "./StudentResponseStatus";
import {QuestionResponseInfo} from "./QuestionResponseInfo";
export interface StudentResult {
studentId: string,
status: StudentResponseStatus,
startTime: Date,
endTime: Date,
score: number,
responses: { [id: string]: QuestionResponseInfo },
}

Просмотреть файл

@ -0,0 +1,76 @@
import {Header} from '../../components/Header';
import {AssessmentPageTitle} from "./AssessmentPageTitle";
import React, {useContext, useEffect, useState} from 'react';
import {RepositoryContext} from '../../context/RepositoryContext';
import {useParams} from "react-router-dom";
import {Assessment} from '../../model/Assessment';
import {AssessmentStatus} from '../../model/AssessmentStatus';
import {InitialAssessmentSetupComponent} from '../../components/InitialAssessmentSetupComponent';
import {QuizzAssessmentComponent} from '../../components/QuizzAssessmentComponent';
import {Spinner} from "@fluentui/react";
type AssessmentPageParams = {
id: string;
};
export const AssessmentPage = () => {
const {id} = useParams<AssessmentPageParams>();
const repositoryContext = useContext(RepositoryContext);
const [savedAssessment, setSavedAssessment] = useState<Assessment>({
id: "",
name: "",
description: "",
lastModified: new Date(),
deadline: new Date(),
durationSeconds: 0,
assessmentType: "",
status: AssessmentStatus.InitialSetup,
questionIds: [],
studentIds: [],
});
const [isLoaded, setIsLoaded] = useState(false);
useEffect(() => {
const fetchAssessmentData = async () => {
if (repositoryContext == null) {
return;
}
const assessmentData = await repositoryContext.getAssessmentById(id);
setSavedAssessment(assessmentData);
setIsLoaded(true);
}
fetchAssessmentData();
}, [id, repositoryContext]);
if (repositoryContext == null) {
return <p>Assessment cannot be found</p>
}
let internalComponent = (<h1>Unknown state</h1>);
if (!isLoaded) {
internalComponent = (<Spinner
label="Loading assessment data..."
/>)
} else if (savedAssessment.status === AssessmentStatus.InitialSetup) {
internalComponent = (
<InitialAssessmentSetupComponent
id={id}
savedAssessment={savedAssessment}
setSavedAssessment={setSavedAssessment}
/>
);
} else if (savedAssessment.assessmentType === "Quiz") {
internalComponent = (<QuizzAssessmentComponent
id={id}
savedAssessment={savedAssessment}
setSavedAssessment={setSavedAssessment}
/>);
}
return (
<>
<AssessmentPageTitle/>
<Header
mainHeader="Assessment App"
secondaryHeader={`${savedAssessment.name}`}
/>
{internalComponent}
</>
);
}

Просмотреть файл

@ -0,0 +1,14 @@
import React from 'react'
import { Helmet } from 'react-helmet'
const title = 'Assessment App | Assessment'
export const AssessmentPageTitle = () => {
return (
<>
<Helmet>
<title>{ title }</title>
</Helmet>
</>
)
}

Просмотреть файл

@ -0,0 +1,33 @@
import {Label, PrimaryButton } from '@fluentui/react';
import React from 'react';
import { Header } from '../../components/Header';
import { getTheme } from '@fluentui/react';
export const StudentFinishedAssessment = () => {
const theme = getTheme();
return (
<>
<Header mainHeader="Assessment App" secondaryHeader="Quiz"/>
<br/>
<div style={{
backgroundColor: '#faf9f8',
width: '60%',
margin: 'auto',
padding: '10px',
boxShadow: theme.effects.elevation8
}}>
<Label style={{fontSize: '25px'}}>Thank you!</Label>
<div style={{margin: '40px', textAlign: 'left'}}>
<p><b>Module:</b> COMP0066 Introductory Programming</p>
<p><b>Assessment name:</b> Introduction to Python</p>
<p><b>Assessment type:</b> Quiz</p>
<p><b>Assessment status:</b> Finished</p>
</div>
<PrimaryButton
text='Return to Learning Management System'
onClick={event => window.location.href='https://moodle.ucl.ac.uk/'}
/>
</div>
</>
)
}

Просмотреть файл

@ -0,0 +1,110 @@
import {PrimaryButton, Spinner} from '@fluentui/react';
import * as React from 'react';
import {useContext, useEffect, useState} from 'react';
import {StudentQuestionComponent} from '../../components/StudentQuestionComponent';
import {StudentQuestion} from '../../model/StudentQuestion';
import {Header} from '../../components/Header';
import {useHistory, useParams} from 'react-router-dom';
import {RepositoryContext} from "../../context/RepositoryContext";
import {StudentAssessment} from "../../model/StudentAssessment";
import {CountdownCircleTimer} from "react-countdown-circle-timer";
interface StudentQuizParams {
id: string,
}
export const StudentQuiz = () => {
const {id} = useParams<StudentQuizParams>();
const repositoryContext = useContext(RepositoryContext);
const [questions, setQuestions] = useState<StudentQuestion[]>([])
const [chosenOptions, setChosenOptions] = useState<{ [id: string]: number }>({});
const [isLoaded, setIsLoaded] = useState(false);
const [studentAssessment, setStudentAssessment] = useState<StudentAssessment | null>(null);
const history = useHistory();
useEffect(() => {
const fetchStudentAssessment = async () => {
if (repositoryContext == null) {
return;
}
const questionsData = await repositoryContext.getStudentQuestions(id);
setChosenOptions(Object.assign({}, ...questionsData.questions.map(q => ({[q.id]: q.chosenOption}))));
setQuestions(questionsData.questions);
setStudentAssessment(questionsData.assessment);
setIsLoaded(true);
}
fetchStudentAssessment();
}, [id, repositoryContext]);
if (repositoryContext === null || !isLoaded || studentAssessment === null) {
return <>
<Header mainHeader="Assessment App" secondaryHeader="Quiz"/>
<br/>
<Spinner
label="Loading questions..."
/>
</>
}
const finishAssessment = async () => {
await repositoryContext.submitStudentAssessment(id, chosenOptions);
history.push(`/spa/student-welcome-page/${id}`);
}
const now = Date.now();
const durationLeft = studentAssessment.durationSeconds - (now - studentAssessment.startTime.getTime()) / 1000;
const deadlineLeft = (studentAssessment.deadline.getTime() - now) / 1000;
const timeLeft = Math.min(durationLeft, deadlineLeft);
console.log(timeLeft)
console.log(studentAssessment.durationSeconds);
const createQuestionComponent = (q: StudentQuestion) => {
return <StudentQuestionComponent
key={q.id}
question={q}
selectedOption={chosenOptions[q.id]}
setSelectedOption={option => setChosenOptions(prev => ({...prev, [q.id]: option}))}
/>
}
const questionComponents = questions.map(createQuestionComponent);
return (
<>
<Header mainHeader="Assessment App" secondaryHeader="Quiz"/>
{questionComponents}
<PrimaryButton
text="Submit"
onClick={finishAssessment}
/>
<div style={{
position: 'sticky',
bottom: 10,
left: 0,
margin: '10px',
}}>
<CountdownCircleTimer
isPlaying
onComplete={() => {
history.push("/spa/student-finished-assessment");
}}
duration={studentAssessment.durationSeconds}
initialRemainingTime={timeLeft}
colors="#218380"
size={120}
strokeWidth={6}
children={({remainingTime}) => {
if (remainingTime === undefined) {
return "unknown";
}
const hours = Math.floor(remainingTime / 3600);
const minutes = Math.floor((remainingTime % 3600) / 60);
const seconds = remainingTime % 60;
const minutesSeconds = `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
if (hours > 0) {
return `${hours}:${minutesSeconds}`
}
return minutesSeconds;
}}
/>
</div>
</>
)
}

Просмотреть файл

@ -0,0 +1,120 @@
import {getTheme, Label, PrimaryButton, Spinner} from '@fluentui/react';
import React, {useContext, useEffect, useState} from 'react';
import {Header} from '../../components/Header';
import {useHistory, useParams} from 'react-router-dom';
import {RepositoryContext} from "../../context/RepositoryContext";
import {StudentAssessment} from "../../model/StudentAssessment";
import {StudentResponseStatus} from "../../model/StudentResponseStatus";
interface StudentWelcomePageParams {
id: string,
}
export const StudentWelcomePage = () => {
const {id} = useParams<StudentWelcomePageParams>();
const repositoryContext = useContext(RepositoryContext);
const [studentAssessment, setStudentAssessment] = useState<StudentAssessment>();
const history = useHistory();
useEffect(() => {
const fetchStudentAssessment = async () => {
if (repositoryContext == null) {
return;
}
const assessmentData = await repositoryContext.getStudentAssessment(id);
setStudentAssessment(assessmentData);
}
fetchStudentAssessment();
}, [id, repositoryContext]);
const redirectToAssessment = () =>{
let path = `/spa/student-quiz/${id}`;
history.push(path);
}
const theme = getTheme();
if (repositoryContext === null || studentAssessment === undefined) {
return <>
<Header mainHeader="Assessment App" secondaryHeader="Quiz"/>
<br/>
<Spinner
label="Loading assessment data..."
/>
</>
}
const timeIsOver = () => {
const now = Date.now();
if (studentAssessment.deadline.getTime() < now) {
return true;
}
const durationLeft = studentAssessment.durationSeconds - (now - studentAssessment.startTime.getTime()) / 1000;
return durationLeft <= 0;
}
const durationHours = Math.floor(studentAssessment.durationSeconds / (60 * 60));
const durationMinutes = Math.floor(studentAssessment.durationSeconds / 60) % 60;
const basicInfo = <>
<p><b>Module:</b> {studentAssessment.courseName}</p>
<p><b>Assessment name:</b> {studentAssessment.name}</p>
<p><b>Assessment type:</b> {studentAssessment.assessmentType}</p>
<p><b>Questions:</b> {studentAssessment.numberOfQuestions}</p>
<p><b>Status:</b> {studentAssessment.status}</p>
</>
let infoComponent = <h1>Unknown assessment status.</h1>
if (studentAssessment.status === StudentResponseStatus.Complete) {
infoComponent = (<>
<div style={{margin: '40px', textAlign: 'left'}}>
{basicInfo}
</div>
</>)
} else if (timeIsOver()) {
infoComponent = (<>
<div style={{margin: '40px', textAlign: 'left'}}>
<p><b>Module:</b> {studentAssessment.courseName}</p>
<p><b>Assessment name:</b> {studentAssessment.name}</p>
<p><b>Assessment type:</b> {studentAssessment.assessmentType}</p>
<p><b>Questions:</b> {studentAssessment.numberOfQuestions}</p>
<p><b>Status:</b> Time is over</p>
</div>
</>)
} else if (studentAssessment.status === StudentResponseStatus.NotStarted) {
infoComponent = (<>
<div style={{margin: '40px', textAlign: 'left'}}>
{basicInfo}
<p><b>Deadline:</b> {studentAssessment.deadline.toLocaleDateString()} {studentAssessment.deadline.toLocaleTimeString()}</p>
<p><b>Duration:</b> {durationHours} hours, {durationMinutes} minutes</p>
</div>
<PrimaryButton
text='Start'
onClick={redirectToAssessment}
/>
</>)
} else if (studentAssessment.status === StudentResponseStatus.InProgress) {
infoComponent = (<>
<div style={{margin: '40px', textAlign: 'left'}}>
{basicInfo}
<p><b>Deadline:</b> {studentAssessment.deadline.toLocaleDateString()} {studentAssessment.deadline.toLocaleTimeString()}</p>
<p><b>Duration:</b> {durationHours} hours, {durationMinutes} minutes</p>
</div>
<PrimaryButton
text='Continue'
onClick={redirectToAssessment}
/>
</>)
}
return (
<>
<Header mainHeader="Assessment App" secondaryHeader="Quiz"/>
<br/>
<div style={{
backgroundColor: '#faf9f8',
width: '60%',
margin: 'auto',
padding: '10px',
boxShadow: theme.effects.elevation8
}}>
<Label style={{fontSize: '25px'}}>Welcome to Assessment App!</Label>
{infoComponent}
</div>
</>
)
}

Просмотреть файл

@ -0,0 +1,396 @@
import * as React from 'react';
import {DetailsList, SelectionMode, DetailsListLayoutMode, IColumn} from '@fluentui/react/lib/DetailsList';
import {RepositoryContext} from '../../context/RepositoryContext';
import {Header} from '../../components/Header';
import {Link, useHistory} from "react-router-dom";
import {HomePageTitle} from "./HomePageTitle";
import {
DefaultButton,
Dialog,
DialogFooter, DialogType,
MarqueeSelection, MessageBar, MessageBarType,
Pivot,
PivotItem,
PrimaryButton,
Selection
} from '@fluentui/react';
import {useEffect, useMemo, useState} from "react";
import {AssessmentStatus} from "../../model/AssessmentStatus";
import {CommandBar, ICommandBarItemProps} from "@fluentui/react/lib/CommandBar";
import {IButtonProps} from "@fluentui/react/lib/Button";
import FileSaver from "file-saver";
import {UploadQuestionBanksComponent} from "../../components/UploadQuestionBanksComponent";
const COLUMNS_QUESTION_BANKS: IColumn[] = [
{
key: "name",
name: "Name",
fieldName: "name",
minWidth: 100,
maxWidth: 500,
isResizable: true,
onRender: item => (
<Link to={`/spa/question-bank/${item.key}`} style={{
color: "black"
}}>
{item.name}
</Link>
)
},
{
key: "lastModified",
name: "Modified",
fieldName: "lastModified",
minWidth: 100,
maxWidth: 200,
isResizable: true
},
{
key: "assessmentType",
name: "Type",
fieldName: "assessmentType",
minWidth: 10,
maxWidth: 200,
isResizable: true
},
{
key: "fileSize",
name: "File Size",
fieldName: "fileSize",
minWidth: 10,
maxWidth: 200,
isResizable: true
},
];
const COLUMNS_ASSESSMENTS: IColumn[] = [
{
key: "name",
name: "Name",
fieldName: "name",
minWidth: 100,
maxWidth: 500,
isResizable: true,
onRender: item => (
<Link to={`/spa/assessment/${item.key}`} style={{
color: "black"
}}>
{item.name}
</Link>
)
},
{
key: "lastModified",
name: "Modified",
fieldName: "lastModified",
minWidth: 100,
maxWidth: 200,
isResizable: true
},
{
key: "assessmentType",
name: "Type",
fieldName: "assessmentType",
minWidth: 10,
maxWidth: 200,
isResizable: true
},
{
key: "status",
name: "Status",
fieldName: "status",
minWidth: 10,
maxWidth: 200,
isResizable: true
}
];
const overflowProps: IButtonProps = { ariaLabel: 'More commands' };
const modalPropsStyles = { main: { maxWidth: 450 } };
const deleteDialogContentProps = {
type: DialogType.normal,
title: 'Deleting question banks',
subText: 'Are you sure you want to delete the selected question banks?'
}
interface AssessmentListItem {
key: string,
name: string,
lastModified: string,
assessmentType: string,
status: AssessmentStatus,
}
interface QuestionBankListItem {
key: string,
name: string
lastModified: string,
assessmentType: string,
}
enum TabKey {
assessments = 'Assessments',
questionBanks = 'Question Banks',
}
export const HomePage = () => {
const history = useHistory();
const [assessments, setAssessments] = useState<AssessmentListItem[]>([]);
const [questionBanks, setQuestionBanks] = useState<QuestionBankListItem[]>([]);
const [selectedTab, setSelectedTab] = useState<string>(TabKey.assessments);
const [selectionCount, setSelectionCount] = useState(0);
const [selection] = useState(new Selection({
onSelectionChanged: () => {
setSelectionCount(selection.getSelectedCount());
}
}));
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [showDeleteMsg, setShowDeleteMsg] = useState(false);
const deleteDialogModalProps = useMemo(() => ({
isBlocking: true,
styles: modalPropsStyles,
}), []);
const [hiddenUploadDialog, setHiddenUploadDialog] = useState(true);
const [showUploadSuccessMsg, setShowUploadSuccessMsg] = useState(false);
const repositoryContext = React.useContext(RepositoryContext);
useEffect(() => {
const fetchState = async () => {
if (repositoryContext == null) {
return;
}
const fetchedAssessments = await repositoryContext.getAssessments();
const fetchedQuestionBanks = await repositoryContext.getQuestionBanks();
setAssessments(fetchedAssessments.map(
a => ({
key: a.id,
name: a.name,
lastModified: a.lastModified.toDateString(),
assessmentType: a.assessmentType,
status: a.status,
})
));
setQuestionBanks(fetchedQuestionBanks.map(
q => ({
key: q.id,
name: q.name,
lastModified: q.lastModified.toDateString(),
assessmentType: q.assessmentType,
fileSize: `${q.questionIds.length} items`,
})
));
}
fetchState();
}, [repositoryContext])
if (repositoryContext == null) {
return <p>No assignments so far</p>
}
const redirectToAssessment = (item: any) => {
history.push(`/spa/assessment/${item.id}`)
}
const redirectToQuestionBank = (item: any) => {
history.push(`/spa/question-bank/${item.id}`)
}
const redirectToNewQuestionBank = () => {
history.push("/spa/new-question-bank")
}
const reloadData = async () => {
const fetchedAssessments = await repositoryContext.getAssessments();
const fetchedQuestionBanks = await repositoryContext.getQuestionBanks();
setAssessments(fetchedAssessments.map(
a => ({
key: a.id,
name: a.name,
lastModified: a.lastModified.toDateString(),
assessmentType: a.assessmentType,
status: a.status,
})
));
setQuestionBanks(fetchedQuestionBanks.map(
q => ({
key: q.id,
name: q.name,
lastModified: q.lastModified.toDateString(),
assessmentType: q.assessmentType,
fileSize: `${q.questionIds.length} items`,
})
));
}
const deleteSelectedQuestionBanks = async () => {
const selectedKeys = selection.getSelection()
.map(item => item.key)
.filter(key => key !== undefined && key !== null)
.map(key => key || "")
.map(key => key.toString());
await repositoryContext.deleteQuestionBanks(selectedKeys);
setShowDeleteDialog(false);
await reloadData();
setShowDeleteMsg(true);
}
const downloadSelectedQuestionBanks = async () => {
const questionBankIds = selection.getSelection().map(i => i.key?.toString());
const result = [];
for (let questionBankId of questionBankIds) {
if (questionBankId) {
const bank = await repositoryContext.getQuestionBankById(questionBankId);
const questions = [];
for (let questionId of bank.questionIds) {
const question = await repositoryContext.getQuestionById(questionId);
questions.push({
name: question.name,
description: question.description,
options: question.options,
answer: question.answer,
})
}
result.push({
name: bank.name,
description: bank.description,
questions: questions,
assessmentType: bank.assessmentType,
})
}
}
const date = new Date();
const file = new File([JSON.stringify(result)], `question_banks_${date.toUTCString()}.json`, {type: "application/json;charset=utf-8"});
FileSaver.saveAs(file);
}
const _items: ICommandBarItemProps[] = [
{
key: 'newItem',
text: 'New Question Bank',
cacheKey: 'myCacheKey', // changing this key will invalidate this item's cache
iconProps: { iconName: 'Add' },
className: "new-question-bank-button",
onClick: redirectToNewQuestionBank,
},
{
key: 'upload',
text: 'Upload',
iconProps: { iconName: 'Upload' },
onClick: () => {
setShowUploadSuccessMsg(false);
setHiddenUploadDialog(false);
}
},
{
key: 'download',
text: 'Download',
iconProps: { iconName: 'Download' },
disabled: selectionCount === 0,
onClick: () => { downloadSelectedQuestionBanks() },
},
{
key: 'delete',
text: 'Delete',
disabled: selectionCount === 0,
className: 'delete-question-bank-button',
onClick: () => {
setShowDeleteMsg(false);
setShowDeleteDialog(true);
},
iconProps: { iconName: 'Delete' }
},
];
console.log(assessments);
return (
<>
<div>
<HomePageTitle/>
<Header
mainHeader="Assessment App"
secondaryHeader="Home"
/>
{showDeleteMsg && <MessageBar
messageBarType={MessageBarType.success}
onDismiss={() => setShowDeleteMsg(false)}
isMultiline={false}
>
Question Banks were deleted successfully!
</MessageBar>}
{showUploadSuccessMsg && <MessageBar
messageBarType={MessageBarType.success}
onDismiss={() => setShowUploadSuccessMsg(false)}
isMultiline={false}
>
Question Banks were uploaded successfully!
</MessageBar>}
<br/>
<CommandBar
items={_items}
overflowButtonProps={overflowProps}
// farItems={_farItems}
ariaLabel="Use left and right arrow keys to navigate between commands"
/>
<Dialog
hidden={!showDeleteDialog}
onDismiss={() => setShowDeleteDialog(false)}
dialogContentProps={deleteDialogContentProps}
modalProps={deleteDialogModalProps}
>
<DialogFooter>
<PrimaryButton
onClick={deleteSelectedQuestionBanks}
text="Delete"
id="delete-question-bank-confirm-button"
/>
<DefaultButton onClick={() => setShowDeleteDialog(false)} text="Cancel"/>
</DialogFooter>
</Dialog>
<UploadQuestionBanksComponent
hidden={hiddenUploadDialog}
onFinish={async (done) => {
if (done) {
await reloadData();
setShowUploadSuccessMsg(true);
}
setHiddenUploadDialog(true);
}}
/>
<div style={{margin: '50px', textAlign: 'left'}}>
<Pivot
id="home-page-tabs"
linkSize="large"
selectedKey={selectedTab}
onLinkClick={(item) => {
if (item && item.props.itemKey) {
setSelectedTab(item.props.itemKey);
}
}}
>
<PivotItem headerText="Assessments" itemKey={TabKey.assessments}>
<DetailsList
items={assessments}
columns={COLUMNS_ASSESSMENTS}
selectionMode={SelectionMode.none}
layoutMode={DetailsListLayoutMode.justified}
onItemInvoked={redirectToAssessment}
/>
</PivotItem>
<PivotItem headerText="Question Banks" itemKey={TabKey.questionBanks}>
<MarqueeSelection selection={selection}>
<DetailsList
items={questionBanks}
columns={COLUMNS_QUESTION_BANKS}
selection={selection}
selectionMode={SelectionMode.multiple}
layoutMode={DetailsListLayoutMode.justified}
onItemInvoked={redirectToQuestionBank}
/>
</MarqueeSelection>
</PivotItem>
</Pivot>
</div>
</div>
</>
)
}

Некоторые файлы не были показаны из-за слишком большого количества измененных файлов Показать больше