Inital Commit
This commit is contained in:
Родитель
833b08d34e
Коммит
eba5ce004d
|
@ -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
|
33
README.md
33
README.md
|
@ -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 institution’s 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() : "";
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
|
@ -0,0 +1,2 @@
|
|||
REACT_APP_TENANT_ID='8593ef8d-30b0-4b72-b0fc-89422b2025c5'
|
||||
REACT_APP_CLIENT_ID='534d7b60-6d28-47f5-890c-c00c617b6cf1'
|
|
@ -0,0 +1,2 @@
|
|||
REACT_APP_TENANT_ID='8593ef8d-30b0-4b72-b0fc-89422b2025c5'
|
||||
REACT_APP_CLIENT_ID='727c1e4a-9e4d-4d03-a836-88b83c8e309a'
|
|
@ -0,0 +1 @@
|
|||
REACT_APP_ASSESSMENT_APP_URL='http://localhost:4280'
|
|
@ -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 can’t go back!**
|
||||
|
||||
If you aren’t 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 you’re on your own.
|
||||
|
||||
You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t 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/).
|
Разница между файлами не показана из-за своего большого размера
Загрузить разницу
|
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
Двоичный файл не отображается.
После Ширина: | Высота: | Размер: 3.8 KiB |
|
@ -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>
|
Двоичный файл не отображается.
После Ширина: | Высота: | Размер: 5.2 KiB |
Двоичный файл не отображается.
После Ширина: | Высота: | Размер: 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"
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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);
|
|
@ -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;
|
||||
}
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
Некоторые файлы не были показаны из-за слишком большого количества измененных файлов Показать больше
Загрузка…
Ссылка в новой задаче