diff --git a/app/Controllers/Http/ParticipantController.js b/app/Controllers/Http/ParticipantController.js index e072c90..842b7b9 100644 --- a/app/Controllers/Http/ParticipantController.js +++ b/app/Controllers/Http/ParticipantController.js @@ -512,9 +512,30 @@ class ParticipantController { .orderBy('position', 'asc') .firstOrFail() } catch (e) { - return response.requestedRangeNotSatisfiable({ - message: `No job could be fetched for participant with identifier ${identifier}.` - }) + // Check if study has looping enabled + if (study.loop_enabled) { + // Reset all job states for this participant in this study + await study.resetJobStates(ptcp.id) + + // Increment loop count for this participation + await ptcp.studies().pivotQuery() + .where('study_id', study.id) + .increment('loop_count', 1) + + // Try to fetch job again + job = await ptcp.jobs() + .where('study_id', study.id) + .whereInPivot('status_id', [1, 2]) + .withPivot(['status_id']) + .with('variables.dtype') + .orderBy('pivot_status_id', 'desc') + .orderBy('position', 'asc') + .firstOrFail() + } else { + return response.requestedRangeNotSatisfiable({ + message: `No job could be fetched for participant with identifier ${identifier}.` + }) + } } // Change the status of the job from pending to started @@ -597,6 +618,40 @@ class ParticipantController { .first() if (job === null) { + // Check if study has looping enabled + if (study.loop_enabled) { + // Reset all job states for this participant in this study + await study.resetJobStates(ptcp.id) + + // Increment loop count for this participation + await ptcp.studies().pivotQuery() + .where('study_id', study.id) + .increment('loop_count', 1) + + // Try to fetch job again + const newJob = await ptcp.jobs() + .where('study_id', study.id) + .whereInPivot('status_id', [1, 2]) + .withPivot(['status_id']) + .with('variables.dtype') + .orderBy('pivot_status_id', 'desc') + .orderBy('position', 'asc') + .first() + + if (newJob === null) { + return response.notFound({ + message: `There are no jobs available for participant with identifier ${identifier}.` + }) + } + + return { + data: { + study_id: newJob.study_id, + current_job_index: newJob.position + } + } + } + return response.notFound({ message: `There are no jobs available for participant with identifier ${identifier}.` }) diff --git a/app/Controllers/Http/StudyController.js b/app/Controllers/Http/StudyController.js index a5ede90..718e1b2 100644 --- a/app/Controllers/Http/StudyController.js +++ b/app/Controllers/Http/StudyController.js @@ -297,7 +297,7 @@ class StudyController { }) } - study.merge(request.only(['name', 'description', 'information', 'active'])) + study.merge(request.only(['name', 'description', 'information', 'active', 'loop_enabled'])) study.save() return transform.item(study, 'StudyTransformer') diff --git a/app/Models/Study.js b/app/Models/Study.js index ba1d7a4..224af8c 100644 --- a/app/Models/Study.js +++ b/app/Models/Study.js @@ -173,6 +173,40 @@ class Study extends Model { return this.hasMany('App/Models/Session') } + /** + * Check if study has looping enabled + * @method hasLoopEnabled + * @return {Boolean} + */ + async hasLoopEnabled () { + return this.loop_enabled === true + } + + /** + * Reset all job states for participants in this study (for looping) + * @method resetJobStates + * @param {Number} participantId + * @return {Number} Number of reset job states + */ + async resetJobStates (participantId) { + const studyJobs = await this.jobs().ids() + if (studyJobs.length === 0) { + return 0 + } + + const result = await Database + .table('job_states') + .where('participant_id', participantId) + .whereIn('job_id', studyJobs) + .where('status_id', 3) // Only reset completed jobs + .update({ + status_id: 1, // Reset to pending + updated_at: new Date() + }) + + return result + } + /** * Checks if the passed user has sufficient privileges to edit this study * @@ -354,8 +388,20 @@ class Study extends Model { })) } - // A job result is stored as JSON in the database and consists of 1) the actual submitted data, 2) some info about the participant, - // 3) some info about the job, 4) the variables data, 5) the study ID and name. + // Get loop_count for this participant in this study + const participation = await Database + .table('participations') + .where('study_id', this.id) + .where('participant_id', ptcpId) + .first() + const loopCount = participation?.loop_count || 0 + + // A job result is stored as JSON in the database and consists of + // 1) the actual submitted data + loop count, + // 2) some info about the participant, + // 3) some info about the job, + // 4) the variables data, + // 5) the study ID and name. return await this.jobResults().create({ study_id: this.Id, participant_id: ptcpId, @@ -367,7 +413,8 @@ class Study extends Model { job_id: job.id, job_position: job.position, study_id: this.id, - study_name: this.name, + study_name: this.name, + loop_count: loopCount, ...varData, ...data }) diff --git a/database/migrations/1776506812201_add_loop_fields_schema.js b/database/migrations/1776506812201_add_loop_fields_schema.js new file mode 100644 index 0000000..ab78415 --- /dev/null +++ b/database/migrations/1776506812201_add_loop_fields_schema.js @@ -0,0 +1,28 @@ +'use strict' + +/** @type {import('@adonisjs/lucid/src/Schema')} */ +const Schema = use('Schema') + +class AddLoopFieldsSchema extends Schema { + up () { + this.table('studies', (table) => { + table.boolean('loop_enabled').default(false) + }) + + this.table('participations', (table) => { + table.integer('loop_count').unsigned().notNullable().defaultTo(0) + }) + } + + down () { + this.table('studies', (table) => { + table.dropColumn('loop_enabled') + }) + + this.table('participations', (table) => { + table.dropColumn('loop_count') + }) + } +} + +module.exports = AddLoopFieldsSchema diff --git a/database/seeds/3-StudySeeder.js b/database/seeds/3-StudySeeder.js index 054d535..617434e 100644 --- a/database/seeds/3-StudySeeder.js +++ b/database/seeds/3-StudySeeder.js @@ -108,6 +108,14 @@ class StudySeeder { } console.log(` Study ${s + 1}: ${NUM_JOBS} jobs, ${jobVars.length} job_variable rows`) } + + // Backfill loop_count for all existing job_results + await Database.raw(` + UPDATE job_results + SET data = JSON_SET(data, '$.loop_count', 0) + WHERE JSON_EXTRACT(data, '$.loop_count') IS NULL OR JSON_EXTRACT(data, '$.loop_count') = 0 + `) + console.log(' Backfilled loop_count=0 for existing job_results') } } diff --git a/resources/components/Studies/page/StudyInfo/StudyInfo.vue b/resources/components/Studies/page/StudyInfo/StudyInfo.vue index 83162cf..1513f14 100644 --- a/resources/components/Studies/page/StudyInfo/StudyInfo.vue +++ b/resources/components/Studies/page/StudyInfo/StudyInfo.vue @@ -23,6 +23,12 @@ auto-grow label="Information" /> +
@@ -35,6 +41,15 @@
+ +
+ + {{ study.loop_enabled ? 'mdi-repeat' : 'mdi-repeat-off' }} + + + {{ study.loop_enabled ? $t('studies.loop_enabled') : $t('studies.loop_disabled') }} + +
@@ -60,22 +75,28 @@ export default { data () { return { editMode: false, - buffer: this.study?.information ?? '' + buffer: this.study?.information ?? '', + loopEnabled: this.study?.loop_enabled ?? false } }, watch: { study (newData, oldData) { if (newData?.information === oldData?.information) { return } this.buffer = newData.information + this.loopEnabled = newData?.loop_enabled ?? false } }, methods: { cancel () { this.buffer = this.study?.information ?? '' + this.loopEnabled = this.study?.loop_enabled ?? false this.editMode = false }, save () { - this.$emit('editted', { information: this.buffer }) + this.$emit('editted', { + information: this.buffer, + loop_enabled: this.loopEnabled + }) this.editMode = false } } diff --git a/resources/lang/en-US/studies.js b/resources/lang/en-US/studies.js index 8b5286c..a251ca6 100644 --- a/resources/lang/en-US/studies.js +++ b/resources/lang/en-US/studies.js @@ -96,5 +96,8 @@ export default { can_edit_short: 'Edit', search: 'Search user' } - } + }, + loop_enabled: 'Loop tasks', + loop_enabled_hint: 'When enabled, participants will restart from the first task after completing all jobs', + loop_disabled: 'Tasks do not loop' } diff --git a/resources/models/Participation.js b/resources/models/Participation.js index 0ce9af1..871771e 100644 --- a/resources/models/Participation.js +++ b/resources/models/Participation.js @@ -12,6 +12,7 @@ export default class Participation extends Model { priority: this.number(1), jobs_count: this.number(0), job_results_count: this.number(0), + loop_count: this.number(0), created_at: this.attr(null), updated_at: this.attr(null) } diff --git a/resources/models/Study.js b/resources/models/Study.js index 409f7a4..a34367f 100644 --- a/resources/models/Study.js +++ b/resources/models/Study.js @@ -30,6 +30,7 @@ export default class Study extends Model { description: this.string(''), information: this.string(''), active: this.boolean(true), + loop_enabled: this.boolean(false), created_at: this.attr(''), updated_at: this.attr(''), deleted_at: this.attr(''), diff --git a/test/functional/study-loop.spec.js b/test/functional/study-loop.spec.js new file mode 100644 index 0000000..4429092 --- /dev/null +++ b/test/functional/study-loop.spec.js @@ -0,0 +1,428 @@ +'use strict' + +const { test, trait } = use('Test/Suite')('Study Looping') +const Study = use('App/Models/Study') +const Participant = use('App/Models/Participant') +const Database = use('Database') + +trait('Test/ApiClient') + +function genIdentifier () { + return 'loop_' + Date.now() + '_' + Math.floor(Math.random() * 1000) +} + +async function cleanup (ptcpId) { + await Database.table('job_states').where('participant_id', ptcpId).delete() + await Database.table('participations').where('participant_id', ptcpId).delete() + await Database.table('job_results').where('participant_id', ptcpId).delete() +} + + +// test looping behavior +test('looping enabled: resets after all jobs finished + increments loop_count', async ({ client, assert }) => { + const identifier = genIdentifier() + + const study = await Study.create({ + name: 'Loop Study', + active: true, + loop_enabled: true + }) + + const jobs = await Promise.all([ + study.jobs().create({ position: 1 }), + study.jobs().create({ position: 2 }), + study.jobs().create({ position: 3 }) + ]) + + const ptcp = await Participant.create({ + name: 'Participant', + identifier, + active: true + }) + + await Database.table('participations').insert({ + participant_id: ptcp.id, + study_id: study.id, + status_id: 1 + }) + + await Database.table('job_states').insert( + jobs.map(j => ({ + participant_id: ptcp.id, + job_id: j.id, + status_id: 1, + created_at: new Date(), + updated_at: new Date() + })) + ) + + // mark all finished + await Database.table('job_states') + .where('participant_id', ptcp.id) + .update({ status_id: 3 }) + + const res = await client + .get(`/api/v1/participants/${identifier}/${study.id}/currentjob_idx`) + .end() + + res.assertStatus(200) + assert.equal(res.body.data.current_job_index, 1) + + const states = await Database.table('job_states') + .where('participant_id', ptcp.id) + .orderBy('job_id') + + states.forEach(s => assert.equal(s.status_id, 1)) + + const participation = await Database.table('participations') + .where('participant_id', ptcp.id) + .first() + + assert.equal(participation.loop_count, 1) + + await cleanup(ptcp.id) +}) + +test('does NOT reset if not all jobs are finished (looping enabled)', async ({ client, assert }) => { + const identifier = genIdentifier() + + const study = await Study.create({ + name: 'Partial Study', + active: true, + loop_enabled: true + }) + + const [j1, j2, j3] = await Promise.all([ + study.jobs().create({ position: 1 }), + study.jobs().create({ position: 2 }), + study.jobs().create({ position: 3 }) + ]) + + const ptcp = await Participant.create({ + name: 'Participant', + identifier, + active: true + }) + + await Database.table('participations').insert({ + participant_id: ptcp.id, + study_id: study.id, + status_id: 1 + }) + + await Database.table('job_states').insert([ + { participant_id: ptcp.id, job_id: j1.id, status_id: 3 }, + { participant_id: ptcp.id, job_id: j2.id, status_id: 2 }, + { participant_id: ptcp.id, job_id: j3.id, status_id: 1 } + ]) + + const res = await client + .get(`/api/v1/participants/${identifier}/${study.id}/currentjob`) + .end() + + res.assertStatus(200) + assert.equal(res.body.data.position, 2) + + const states = await Database.table('job_states') + .where('participant_id', ptcp.id) + .orderBy('job_id') + + assert.deepEqual(states.map(s => s.status_id), [3, 2, 1]) + + await cleanup(ptcp.id) +}) + +test('looping enabled: full cycle twice, loop_count = 2 and results persisted', async ({ client, assert }) => { + const identifier = genIdentifier() + + const study = await Study.create({ + name: 'Full Cycle', + active: true, + loop_enabled: true + }) + + const [job1, job2] = await Promise.all([ + study.jobs().create({ position: 1 }), + study.jobs().create({ position: 2 }) + ]) + + const ptcp = await Participant.create({ + name: 'Participant', + identifier, + active: true + }) + + await Database.table('participations').insert({ + participant_id: ptcp.id, + study_id: study.id, + status_id: 1 + }) + + await Database.table('job_states').insert([ + { participant_id: ptcp.id, job_id: job1.id, status_id: 1 }, + { participant_id: ptcp.id, job_id: job2.id, status_id: 1 } + ]) + + for (let loop = 1; loop <= 2; loop++) { + for (const job of [job1, job2]) { + await Database.table('job_states') + .where({ participant_id: ptcp.id, job_id: job.id }) + .update({ status_id: 3 }) + + await client + .patch(`/api/v1/participants/${identifier}/${job.id}/result`) + .send({ data: { correct: true } }) + .end() + } + + await client + .get(`/api/v1/participants/${identifier}/${study.id}/currentjob_idx`) + .end() + + const participation = await Database.table('participations') + .where('participant_id', ptcp.id) + .first() + + assert.equal(participation.loop_count, loop) + } + + const count = await Database.table('job_results') + .where('participant_id', ptcp.id) + .getCount() + + assert.equal(count, 4) + + await cleanup(ptcp.id) +}) + + +// test results behavior +test('job_result stores loop_count after reset', async ({ client, assert }) => { + const identifier = genIdentifier() + + const study = await Study.create({ + name: 'Result Study', + active: true, + loop_enabled: true + }) + + const job = await study.jobs().create({ position: 1 }) + + const ptcp = await Participant.create({ + name: 'Participant', + identifier, + active: true + }) + + await Database.table('participations').insert({ + participant_id: ptcp.id, + study_id: study.id, + status_id: 1 + }) + + await Database.table('job_states').insert({ + participant_id: ptcp.id, + job_id: job.id, + status_id: 3 + }) + + // trigger loop reset + await client + .get(`/api/v1/participants/${identifier}/${study.id}/currentjob_idx`) + .end() + + await client + .patch(`/api/v1/participants/${identifier}/${job.id}/result`) + .send({ data: { correct: true } }) + .end() + + const result = await Database.table('job_results') + .where('participant_id', ptcp.id) + .first() + + assert.equal(result.data.loop_count, 1) + + await cleanup(ptcp.id) +}) + + +test('looping enabled: only resets jobs for participants who finished all jobs', async ({ client, assert }) => { + const identifierA = 'loop_partA_' + Date.now(); + const identifierB = 'loop_partB_' + Date.now(); + + const study = await Study.create({ + name: 'Multi Participant Study', + active: true, + loop_enabled: true + }); + + const [job1, job2] = await Promise.all([ + study.jobs().create({ position: 1 }), + study.jobs().create({ position: 2 }) + ]); + + // Participant A: finishes all jobs + const ptcpA = await Participant.create({ + name: 'Participant A', + identifier: identifierA, + active: true + }); + + // Participant B: only finishes first job + const ptcpB = await Participant.create({ + name: 'Participant B', + identifier: identifierB, + active: true + }); + + // Set up participations + await Promise.all([ + Database.table('participations').insert({ + participant_id: ptcpA.id, + study_id: study.id, + status_id: 1 + }), + Database.table('participations').insert({ + participant_id: ptcpB.id, + study_id: study.id, + status_id: 1 + }) + ]); + + // Set up job states + await Database.table('job_states').insert([ + // ptcpA: both jobs finished + { participant_id: ptcpA.id, job_id: job1.id, status_id: 3 }, + { participant_id: ptcpA.id, job_id: job2.id, status_id: 3 }, + + // ptcpB: only job1 finished + { participant_id: ptcpB.id, job_id: job1.id, status_id: 3 }, + { participant_id: ptcpB.id, job_id: job2.id, status_id: 1 } + ]); + + // Trigger currentjob_idx for both participants + const resA = await client + .get(`/api/v1/participants/${identifierA}/${study.id}/currentjob_idx`) + .end(); + + const resB = await client + .get(`/api/v1/participants/${identifierB}/${study.id}/currentjob_idx`) + .end(); + + // Both should return 200 with job index 1 (after reset for A, unchanged for B) + resA.assertStatus(200); + assert.equal(resA.body.data.current_job_index, 1); + + resB.assertStatus(200); + assert.equal(resB.body.data.current_job_index, 2); + + // Verify job states: only ptcpA should be reset + const statesA = await Database.table('job_states') + .where('participant_id', ptcpA.id) + .orderBy('job_id'); + + const statesB = await Database.table('job_states') + .where('participant_id', ptcpB.id) + .orderBy('job_id'); + + // ptcpA: both jobs reset to pending (status_id: 1) + statesA.forEach(s => assert.equal(s.status_id, 1)); + + // ptcpB: job1 finished (3), job2 still pending (1) -- no change + assert.deepEqual(statesB.map(s => s.status_id), [3, 1]); + + // Verify loop counts: only ptcpA incremented + const participationA = await Database.table('participations') + .where('participant_id', ptcpA.id) + .first(); + + const participationB = await Database.table('participations') + .where('participant_id', ptcpB.id) + .first(); + + assert.equal(participationA.loop_count, 1); + assert.equal(participationB.loop_count, 0); // Should remain 0 + + await cleanup(ptcpA.id); + await cleanup(ptcpB.id); +}); + + +// Test error cases +test('404 when jobs finished but looping disabled', async ({ client, assert }) => { + const identifier = genIdentifier() + + const study = await Study.create({ + name: 'No Loop', + active: true, + loop_enabled: false + }) + + const job = await study.jobs().create({ position: 1 }) + + const ptcp = await Participant.create({ + name: 'Participant', + identifier, + active: true + }) + + await Database.table('participations').insert({ + participant_id: ptcp.id, + study_id: study.id, + status_id: 1 + }) + + await Database.table('job_states').insert({ + participant_id: ptcp.id, + job_id: job.id, + status_id: 3 + }) + + const res = await client + .get(`/api/v1/participants/${identifier}/${study.id}/currentjob_idx`) + .end() + + res.assertStatus(404) + assert.include(res.body.message, 'no jobs') + + await cleanup(ptcp.id) +}) + +test('404 when looping enabled but participant is inactive', async ({ client, assert }) => { + const identifier = genIdentifier() + + const study = await Study.create({ + name: 'Inactive Participant', + active: true, + loop_enabled: true + }) + + const job = await study.jobs().create({ position: 1 }) + + const ptcp = await Participant.create({ + name: 'Participant', + identifier, + active: false + }) + + await Database.table('participations').insert({ + participant_id: ptcp.id, + study_id: study.id, + status_id: 1 + }) + + await Database.table('job_states').insert({ + participant_id: ptcp.id, + job_id: job.id, + status_id: 3 + }) + + const res = await client + .get(`/api/v1/participants/${identifier}/${study.id}/currentjob_idx`) + .end() + + res.assertStatus(412) + assert.include(res.body.message, 'not active') + + await cleanup(ptcp.id) +})