Group sessions now show current data in a scenario format.

Remote users are now informed when session is closed.
This commit is contained in:
neviyn 2019-02-28 15:07:29 +00:00
parent 6f7e5596c3
commit 6f4da05ddc
6 changed files with 499 additions and 388 deletions

View File

@ -147,7 +147,19 @@ data class Scenario(
@OneToOne(cascade = [CascadeType.ALL]) @OneToOne(cascade = [CascadeType.ALL])
val knowledge: RatingComponent val knowledge: RatingComponent
) ) {
fun ratingsAllValid(): Boolean {
val limitation = 1..5
return monitoring.rating in limitation &&
controlProcedural.rating in limitation &&
control.rating in limitation &&
conservatism.rating in limitation &&
teamworkCommunications.rating in limitation &&
teamworkLeadership.rating in limitation &&
teamworkWorkload.rating in limitation &&
knowledge.rating in limitation
}
}
@Entity @Entity
data class Person( data class Person(

View File

@ -15,7 +15,7 @@ object GroupSessionManager {
var tutors: List<Long>? = null var tutors: List<Long>? = null
var trainingType: TrainingType? = null var trainingType: TrainingType? = null
var scenarioTitles: List<String>? = null var scenarioTitles: List<String>? = null
var observations: MutableList<Observation> = mutableListOf() var observations: MutableMap<String, GroupObservation> = mutableMapOf()
fun startNewSession(site: Site, tutors: Set<Tutor>, trainingType: TrainingType, scenarioTitles: List<String>): Int { fun startNewSession(site: Site, tutors: Set<Tutor>, trainingType: TrainingType, scenarioTitles: List<String>): Int {
logger.info("Starting new Group Session") logger.info("Starting new Group Session")
@ -29,11 +29,19 @@ object GroupSessionManager {
this.tutors = tutors.map { it.id } this.tutors = tutors.map { it.id }
this.trainingType = trainingType this.trainingType = trainingType
this.scenarioTitles = scenarioTitles this.scenarioTitles = scenarioTitles
observations = mutableListOf() observations = mutableMapOf()
logger.debug("Group session initialized") logger.debug("Group session initialized")
return sessionId return sessionId
} }
fun invalidate() {
this.site = null
this.tutors = null
this.trainingType = null
this.scenarioTitles = null
this.observations = mutableMapOf()
}
fun isValid(sessionId: Int): Boolean { fun isValid(sessionId: Int): Boolean {
return isValid() && sessionId == this.sessionId return isValid() && sessionId == this.sessionId
} }
@ -42,7 +50,30 @@ object GroupSessionManager {
return site != null && tutors != null && trainingType != null && scenarioTitles != null return site != null && tutors != null && trainingType != null && scenarioTitles != null
} }
fun addObservation(observation: Observation) { fun asScenarioView(): MutableMap<String, MutableList<Scenario>> {
observations.add(observation) val output: MutableMap<String, MutableList<Scenario>> = mutableMapOf()
scenarioTitles?.forEach { title ->
output[title] = mutableListOf()
}
observations.forEach { k, v ->
v.scenarios.forEach {
output[it.title]?.add(it.copy(title = k))
}
}
return output
}
fun dataComplete(): Boolean {
observations.values.forEach {
it.scenarios.forEach { x ->
if (!x.ratingsAllValid())
return false
}
}
return true
}
fun updateObservationData(input: GroupObservation) {
observations[input.person] = input
} }
} }

View File

@ -40,7 +40,7 @@ class CustomWebSecurityConfigurerAdapter : WebSecurityConfigurerAdapter() {
@Throws(Exception::class) @Throws(Exception::class)
override fun configure(http: HttpSecurity) { override fun configure(http: HttpSecurity) {
http.csrf().disable().authorizeRequests() http.csrf().disable().authorizeRequests()
.antMatchers(HttpMethod.POST, "/api/site", "/api/tutor", "/api/observation", "/api/grpob/start").authenticated() .antMatchers(HttpMethod.POST, "/api/site", "/api/tutor", "/api/observation", "/api/grpob/start", "/api/grpob/complete").authenticated()
.anyRequest().permitAll() .anyRequest().permitAll()
.and() .and()
.httpBasic() .httpBasic()

View File

@ -73,7 +73,7 @@ class GroupSessionController {
fun reconnectToGroupObservation(): Map<String, Any> { fun reconnectToGroupObservation(): Map<String, Any> {
if (GroupSessionManager.isValid()) { if (GroupSessionManager.isValid()) {
logger.info("Previous group observation requested, current id ${GroupSessionManager.sessionId}") logger.info("Previous group observation requested, current id ${GroupSessionManager.sessionId}")
return getConnectionDetails().plus(mapOf("id" to GroupSessionManager.sessionId.toString(), "observations" to GroupSessionManager.observations)) return getConnectionDetails().plus(mapOf("id" to GroupSessionManager.sessionId.toString(), "scenarios" to GroupSessionManager.asScenarioView()))
} }
logger.info("Tried to recover a session but no session is active") logger.info("Tried to recover a session but no session is active")
return mapOf("error" to "No session currently active") return mapOf("error" to "No session currently active")
@ -119,29 +119,41 @@ class GroupSessionController {
*/ */
@PostMapping("/submit") @PostMapping("/submit")
fun addGroupObservation(@Valid @RequestBody observationData: GroupObservation) { fun addGroupObservation(@Valid @RequestBody observationData: GroupObservation) {
GroupSessionManager.updateObservationData(observationData)
websocketMessenger.convertAndSend("/ws/scenarios", mapOf("scenarios" to GroupSessionManager.asScenarioView()))
}
@PostMapping("/complete")
fun pushObservationsToDatabase(): Map<String, String> {
if (GroupSessionManager.isValid() && GroupSessionManager.dataComplete()) {
val tutors = tutorRepository.findAllById(GroupSessionManager.tutors!!).toSet() val tutors = tutorRepository.findAllById(GroupSessionManager.tutors!!).toSet()
if (GroupSessionManager.isValid()) { GroupSessionManager.observations.values.forEach { x ->
var observation = Observation( saveObservation(Observation(
site = GroupSessionManager.site!!, site = GroupSessionManager.site!!,
date = LocalDate.now(), date = LocalDate.now(),
type = GroupSessionManager.trainingType!!, type = GroupSessionManager.trainingType!!,
observed = observationData.scenarios.joinToString { it.title }, observed = x.scenarios.joinToString { it.title },
monitoring = observationData.scenarios.map { it.monitoring.rating }.average(), monitoring = x.scenarios.map { it.monitoring.rating }.average(),
conservatism = observationData.scenarios.map { it.conservatism.rating }.average(), conservatism = x.scenarios.map { it.conservatism.rating }.average(),
controlProcedural = observationData.scenarios.map { it.controlProcedural.rating }.average(), controlProcedural = x.scenarios.map { it.controlProcedural.rating }.average(),
control = observationData.scenarios.map { it.control.rating }.average(), control = x.scenarios.map { it.control.rating }.average(),
teamworkCommunications = observationData.scenarios.map { it.teamworkCommunications.rating }.average(), teamworkCommunications = x.scenarios.map { it.teamworkCommunications.rating }.average(),
teamworkLeadership = observationData.scenarios.map { it.teamworkLeadership.rating }.average(), teamworkLeadership = x.scenarios.map { it.teamworkLeadership.rating }.average(),
teamworkWorkload = observationData.scenarios.map { it.teamworkWorkload.rating }.average(), teamworkWorkload = x.scenarios.map { it.teamworkWorkload.rating }.average(),
knowledge = observationData.scenarios.map { it.knowledge.rating }.average(), knowledge = x.scenarios.map { it.knowledge.rating }.average(),
scenarios = observationData.scenarios, scenarios = x.scenarios,
tutors = tutors, tutors = tutors,
person = personRepository.findFirstByNameLike(observationData.person.toUpperCase()) ?: personRepository.save(Person(name = observationData.person.toUpperCase())) person = personRepository.findFirstByNameLike(x.person.toUpperCase())
) ?: personRepository.save(Person(name = x.person.toUpperCase()))
observation = saveObservation(observation) ))
GroupSessionManager.addObservation(observation)
websocketMessenger.convertAndSend("/ws/observations", mapOf("observations" to GroupSessionManager.observations))
} }
GroupSessionManager.invalidate()
websocketMessenger.convertAndSend("/ws/status", mapOf("status" to "complete"))
return mapOf("success" to "The submission was successfully completed.")
} else if (!GroupSessionManager.dataComplete()) {
return mapOf("error" to "Data is incomplete")
}
return mapOf("error" to "Session was not valid")
} }
/** /**

View File

@ -1,17 +1,47 @@
<template> <template>
<b-container> <b-container fluid>
<b-row v-if="active"> <b-row v-if="active">
<b-col cols="3"> <b-col cols="12" md="3">
<vue-qrcode v-model="qrdata" :options="{ width: 250 }"></vue-qrcode> <vue-qrcode v-model="qrdata" :options="{ width: 250 }"></vue-qrcode>
<p>Scan the code or navigate to <p>Scan the code or navigate to
<br> <br>
{{ qrdata }} {{ qrdata }}
</p> </p>
</b-col> </b-col>
<b-col cols="12" md="9">
<b-card-group v-for="(item, index) in data" v-bind:key="index">
<b-card border-variant="secondary" header-border-variant="secondary" :header="index">
<b-card-text>
<b-table :fields="tableFields" :items="item"></b-table>
</b-card-text>
</b-card>
</b-card-group>
</b-col>
<b-col> <b-col>
<b-list-group v-for="(item, index) in data" v-bind:key="index"> <b-button size="lg" variant="primary" v-on:click="showCompletionModal()">Finish Session</b-button>
<b-list-group-item>{{ item.person.name }}</b-list-group-item> </b-col>
</b-list-group> </b-row>
<b-row v-else-if="complete">
<b-col>
<b-row>
<b-col>
<h2>Session Complete</h2>
</b-col>
</b-row>
<b-row>
<b-col>
<p>Observation data for this session is now saved in the database.</p>
</b-col>
</b-row>
<b-row>
<b-col>
<b-button
size="lg"
variant="primary"
v-on:click="window.location.reload()"
>Start a New Session</b-button>
</b-col>
</b-row>
</b-col> </b-col>
</b-row> </b-row>
<b-row v-else> <b-row v-else>
@ -99,7 +129,7 @@
<b-modal <b-modal
id="submissionModal" id="submissionModal"
ref="submissionModal" ref="submissionModal"
title="Enter password to confirm submission" title="Enter password to confirm session start"
@ok="handleOk" @ok="handleOk"
@shown="clearPassword" @shown="clearPassword"
> >
@ -107,6 +137,17 @@
<b-form-input type="password" placeholder="Enter password" v-model="submitPassword"></b-form-input> <b-form-input type="password" placeholder="Enter password" v-model="submitPassword"></b-form-input>
</form> </form>
</b-modal> </b-modal>
<b-modal
id="completionModal"
ref="completionModal"
title="Enter password to confirm submission"
@ok="handleSubmitComplete"
@shown="clearPassword"
>
<form @submit.stop.prevent="handleSubmit">
<b-form-input type="password" placeholder="Enter password" v-model="submitPassword"></b-form-input>
</form>
</b-modal>
</b-container> </b-container>
</template> </template>
<script> <script>
@ -122,15 +163,30 @@ export default {
return { return {
active: false, active: false,
stompclient: null, stompclient: null,
complete: false,
qrdata: "N/A", qrdata: "N/A",
data: [{person:{name:"No data yet received."}}], data: [{ person: { name: "No data yet received." } }],
site: null, site: null,
tutors: null, tutors: null,
siteOptions: [], siteOptions: [],
tutorOptions: [], tutorOptions: [],
scenarioTitles: [{ data: "" }, { data: "" }, { data: "" }], scenarioTitles: [{ data: "" }, { data: "" }, { data: "" }],
type: null, type: null,
submitPassword: null submitPassword: null,
tableFields: [
{ key: "title", label: "Name" },
{ key: "monitoring.rating", label: "Monitoring" },
{ key: "controlProcedural.rating", label: "Control Procedural" },
{ key: "control.rating", label: "Control" },
{ key: "conservatism.rating", label: "Conservatism" },
{
key: "teamworkCommunications.rating",
label: "Teamwork Communications"
},
{ key: "teamworkLeadership.rating", label: "Teamwork Leadership" },
{ key: "teamworkWorkload.rating", label: "Teamwork Workload" },
{ key: "knowledge.rating", label: "Knowledge" }
]
}; };
}, },
watch: { watch: {
@ -183,27 +239,7 @@ export default {
console.log(response); console.log(response);
if ("error" in response.data) { if ("error" in response.data) {
} else { } else {
let rdata = response.data; self.setupSession(response.data);
self.qrdata = `http://${rdata.ip}:${rdata.port}/#/groupsession/${
rdata.id
}`;
self.active = true;
self.stompclient = webstomp.over(
new SockJS("http://127.0.0.1:8080/websocket", {
heartbeat: true
})
);
self.stompclient.connect([], function() {
self.stompclient.subscribe("/ws/observations", function(
incomingData
) {
self.data = JSON.parse(incomingData.body).observations;
console.log("data #")
console.log(self.data)
});
});
self.$refs.submissionModal.hide();
self.clearPassword();
} }
}) })
.catch(function(error) { .catch(function(error) {
@ -219,28 +255,7 @@ export default {
console.log(response); console.log(response);
if ("error" in response.data) { if ("error" in response.data) {
} else { } else {
let rdata = response.data; self.setupSession(response.data);
self.data = rdata.observations;
self.qrdata = `http://${rdata.ip}:${rdata.port}/#/groupsession/${
rdata.id
}`;
console.log(self.active);
self.active = true;
console.log(self.active);
self.stompclient = webstomp.over(
new SockJS("http://127.0.0.1:8080/websocket", {
heartbeat: false
})
);
self.stompclient.connect([], function() {
self.stompclient.subscribe("/ws/observations", function(
incomingData
) {
self.data = JSON.parse(incomingData.body).observations;
});
});
self.$refs.submissionModal.hide();
self.clearPassword();
} }
}) })
.catch(function(error) { .catch(function(error) {
@ -250,6 +265,27 @@ export default {
} }
}); });
}, },
setupSession: function(rdata) {
console.log("rdata");
console.log(rdata);
var self = this;
self.qrdata = `http://${rdata.ip}:${rdata.port}/#/groupsession/${
rdata.id
}`;
self.active = true;
self.stompclient = webstomp.over(
new SockJS("http://127.0.0.1:8080/websocket", {
heartbeat: false
})
);
self.stompclient.connect([], function() {
self.stompclient.subscribe("/ws/scenarios", function(incomingData) {
self.data = JSON.parse(incomingData.body).scenarios;
});
});
self.$refs.submissionModal.hide();
self.clearPassword();
},
getTutors: function() { getTutors: function() {
if (this.site != null) { if (this.site != null) {
Vue.axios Vue.axios
@ -277,6 +313,9 @@ export default {
showModal() { showModal() {
this.$refs.submissionModal.show(); this.$refs.submissionModal.show();
}, },
showCompletionModal() {
this.$refs.completionModal.show();
},
clearPassword() { clearPassword() {
this.submitPassword = null; this.submitPassword = null;
}, },
@ -286,6 +325,31 @@ export default {
if (this.submitPassword !== null) { if (this.submitPassword !== null) {
this.startSession(); this.startSession();
} }
},
handleSubmitComplete(evt) {
// Prevent modal from closing
var self = this;
evt.preventDefault();
if (this.submitPassword !== null) {
let axiosConfig = {
auth: {
username: "admin",
password: this.submitPassword
}
};
Vue.axios
.post("/grpob/complete", {}, axiosConfig)
.then(function(response) {
console.log(response.data);
if ("success" in response.data) {
self.complete = true;
self.active = false;
}
})
.catch(function(error) {
console.log(error);
});
}
} }
}, },
beforeDestroy() { beforeDestroy() {

View File

@ -7,8 +7,8 @@
</b-container> </b-container>
<b-container v-else-if="complete"> <b-container v-else-if="complete">
<h2>Submission Complete</h2> <h2>Submission Complete</h2>
<p>Thank you. Your observation data has been submitted.</p> <p>Thank you.</p>
<p>Your observation summary will shortly appear on the session display.</p> <p>This observation session is now closed and your data submitted to the database.</p>
</b-container> </b-container>
<b-container v-else-if="!valid"> <b-container v-else-if="!valid">
<p>Getting session data from server</p> <p>Getting session data from server</p>
@ -17,7 +17,7 @@
<p>No scenarios defined for this session, please setup a new group session</p> <p>No scenarios defined for this session, please setup a new group session</p>
</b-container> </b-container>
<b-container v-else fluid> <b-container v-else fluid>
<b-form @submit="onSubmit" id="submission-form" novalidate> <b-form>
<b-row align-h="center"> <b-row align-h="center">
<b-col> <b-col>
<b-form-group label="Participant"> <b-form-group label="Participant">
@ -35,12 +35,7 @@
<b-col> <b-col>
<b-row> <b-row>
<b-col> <b-col>
<b-form-input <b-form-input v-model="item.title" type="text" readonly></b-form-input>
v-model="item.title"
type="text"
placeholder="Enter scenario description."
readonly
></b-form-input>
</b-col> </b-col>
</b-row> </b-row>
<b-row> <b-row>
@ -50,7 +45,7 @@
<h5>Monitoring</h5> <h5>Monitoring</h5>
<score-selector <score-selector
:score-value="item.monitoring.rating" :score-value="item.monitoring.rating"
v-on:newselection="item.monitoring.rating = $event;" v-on:newselection="item.monitoring.rating = $event; actuallySubmit()"
></score-selector> ></score-selector>
</b-col> </b-col>
</b-row> </b-row>
@ -81,7 +76,7 @@
<h5>Control Procedural</h5> <h5>Control Procedural</h5>
<score-selector <score-selector
:score-value="item.controlProcedural.rating" :score-value="item.controlProcedural.rating"
v-on:newselection="item.controlProcedural.rating = $event;" v-on:newselection="item.controlProcedural.rating = $event; actuallySubmit()"
></score-selector> ></score-selector>
</b-col> </b-col>
</b-row> </b-row>
@ -112,7 +107,7 @@
<h5>Control</h5> <h5>Control</h5>
<score-selector <score-selector
:score-value="item.control.rating" :score-value="item.control.rating"
v-on:newselection="item.control.rating = $event;" v-on:newselection="item.control.rating = $event; actuallySubmit()"
></score-selector> ></score-selector>
</b-col> </b-col>
</b-row> </b-row>
@ -143,7 +138,7 @@
<h5>Conservatism</h5> <h5>Conservatism</h5>
<score-selector <score-selector
:score-value="item.conservatism.rating" :score-value="item.conservatism.rating"
v-on:newselection="item.conservatism.rating = $event;" v-on:newselection="item.conservatism.rating = $event; actuallySubmit()"
></score-selector> ></score-selector>
</b-col> </b-col>
</b-row> </b-row>
@ -174,7 +169,7 @@
<h5>Teamwork Communications</h5> <h5>Teamwork Communications</h5>
<score-selector <score-selector
:score-value="item.teamworkCommunications.rating" :score-value="item.teamworkCommunications.rating"
v-on:newselection="item.teamworkCommunications.rating = $event;" v-on:newselection="item.teamworkCommunications.rating = $event; actuallySubmit()"
></score-selector> ></score-selector>
</b-col> </b-col>
</b-row> </b-row>
@ -205,7 +200,7 @@
<h5>Teamwork Leadership</h5> <h5>Teamwork Leadership</h5>
<score-selector <score-selector
:score-value="item.teamworkLeadership.rating" :score-value="item.teamworkLeadership.rating"
v-on:newselection="item.teamworkLeadership.rating = $event;" v-on:newselection="item.teamworkLeadership.rating = $event; actuallySubmit()"
></score-selector> ></score-selector>
</b-col> </b-col>
</b-row> </b-row>
@ -236,7 +231,7 @@
<h5>Teamwork Workload</h5> <h5>Teamwork Workload</h5>
<score-selector <score-selector
:score-value="item.teamworkWorkload.rating" :score-value="item.teamworkWorkload.rating"
v-on:newselection="item.teamworkWorkload.rating = $event;" v-on:newselection="item.teamworkWorkload.rating = $event; actuallySubmit()"
></score-selector> ></score-selector>
</b-col> </b-col>
</b-row> </b-row>
@ -267,7 +262,7 @@
<h5>Knowledge</h5> <h5>Knowledge</h5>
<score-selector <score-selector
:score-value="item.knowledge.rating" :score-value="item.knowledge.rating"
v-on:newselection="item.knowledge.rating = $event;" v-on:newselection="item.knowledge.rating = $event; actuallySubmit()"
></score-selector> ></score-selector>
</b-col> </b-col>
</b-row> </b-row>
@ -296,20 +291,16 @@
</b-col> </b-col>
</b-row> </b-row>
<br> <br>
<b-button type="submit" variant="primary" v-on:click="onSubmit()">Submit</b-button> <b-button variant="primary" v-on:click="actuallySubmit()">Update</b-button>
</b-form> </b-form>
</b-container> </b-container>
<b-modal id="submissionModal" ref="submissionModal" title="Confirm Submission" @ok="actuallySubmit()">
<form @submit.stop.prevent="handleSubmit">
<p>Once submitted, data cannot be changed.</p>
<p>Are you sure you wish to submit?</p>
</form>
</b-modal>
</b-container> </b-container>
</template> </template>
<script> <script>
import Vue from "vue"; import Vue from "vue";
import ScoreSelector from "../components/ScoreSelector.vue"; import ScoreSelector from "../components/ScoreSelector.vue";
import webstomp from "webstomp-client";
import SockJS from "sockjs-client";
export default { export default {
name: "groupsessioninput", name: "groupsessioninput",
title: "Group Session - Input", title: "Group Session - Input",
@ -321,7 +312,8 @@ export default {
participant: null, participant: null,
valid: false, valid: false,
complete: false, complete: false,
error: null error: null,
stompclient: null
}; };
}, },
mounted() { mounted() {
@ -335,6 +327,7 @@ export default {
self.addAnotherObservation(x); self.addAnotherObservation(x);
}); });
self.valid = true; self.valid = true;
self.setupSession()
} }
}) })
.catch(function(error) { .catch(function(error) {
@ -352,80 +345,79 @@ export default {
this.scenarios.push({ this.scenarios.push({
title: newTitle, title: newTitle,
monitoring: { monitoring: {
rating: null, rating: 0,
strengths: "", strengths: "",
improvements: "" improvements: ""
}, },
controlProcedural: { controlProcedural: {
rating: null, rating: 0,
strengths: "", strengths: "",
improvements: "" improvements: ""
}, },
control: { control: {
rating: null, rating: 0,
strengths: "", strengths: "",
improvements: "" improvements: ""
}, },
conservatism: { conservatism: {
rating: null, rating: 0,
strengths: "", strengths: "",
improvements: "" improvements: ""
}, },
teamworkCommunications: { teamworkCommunications: {
rating: null, rating: 0,
strengths: "", strengths: "",
improvements: "" improvements: ""
}, },
teamworkLeadership: { teamworkLeadership: {
rating: null, rating: 0,
strengths: "", strengths: "",
improvements: "" improvements: ""
}, },
teamworkWorkload: { teamworkWorkload: {
rating: null, rating: 0,
strengths: "", strengths: "",
improvements: "" improvements: ""
}, },
knowledge: { knowledge: {
rating: null, rating: 0,
strengths: "", strengths: "",
improvements: "" improvements: ""
} }
}); });
}, },
onSubmit: function(e) {
e.preventDefault();
e.stopPropagation();
var form = document.getElementById("submission-form");
if (form.checkValidity()) {
this.showModal();
}
form.classList.add("was-validated");
},
showModal() {
this.$refs.submissionModal.show();
},
actuallySubmit() { actuallySubmit() {
var self = this; var self = this;
var payload = { var payload = {
person: self.participant, person: self.participant,
scenarios: self.scenarios scenarios: self.scenarios
} };
var form = document.getElementById("submission-form");
if (form.checkValidity()) {
console.log(payload)
Vue.axios Vue.axios
.post("/grpob/submit", payload) .post("/grpob/submit", payload)
.then(function(response) { .then(function(response) {
self.complete = true;
console.log(response); console.log(response);
}) })
.catch(function(error) { .catch(function(error) {
self.error = error; self.error = error;
console.log(error); console.log(error);
}); });
},
setupSession: function() {
var self = this;
self.stompclient = webstomp.over(
new SockJS(`http://${window.location.host}/websocket`, {
heartbeat: false
})
);
self.stompclient.connect([], function() {
self.stompclient.subscribe("/ws/status", function(incomingData) {
var data = JSON.parse(incomingData.body);
if (data.status === "complete") {
self.complete = true;
self.stompclient.disconnect()
} }
form.classList.add("was-validated"); });
});
} }
} }
}; };