From 3ceda1820e832699ca030af224e9a33eee547777 Mon Sep 17 00:00:00 2001 From: "Blazej M. Baczkowski" Date: Sat, 18 Apr 2026 14:35:01 +0200 Subject: [PATCH 1/2] feat: add UI option and functionality to loop participants jobs When a participant finished the last job and client requests the next job, the system checks if looping is enabled. If so, it resets all job states for that participant back to "pending" Increments the loop_count in the participations table: loop_count is stored in job_results (change study data model) Participant starts from the first job again Only new job_results include loop_count metadata Relates to #215 --- app/Controllers/Http/ParticipantController.js | 61 ++++++++++++++++++- app/Controllers/Http/StudyController.js | 2 +- app/Models/Study.js | 53 +++++++++++++++- .../1776506812201_add_loop_fields_schema.js | 28 +++++++++ database/seeds/3-StudySeeder.js | 8 +++ .../Studies/page/StudyInfo/StudyInfo.vue | 25 +++++++- resources/lang/en-US/studies.js | 5 +- resources/models/Participation.js | 1 + resources/models/Study.js | 1 + 9 files changed, 174 insertions(+), 10 deletions(-) create mode 100644 database/migrations/1776506812201_add_loop_fields_schema.js 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(''), From f92dd65c9fc91a6648d0f6b46b36e81628a3f636 Mon Sep 17 00:00:00 2001 From: "Blazej M. Baczkowski" Date: Fri, 24 Apr 2026 21:56:43 +0200 Subject: [PATCH 2/2] add tests for looping behavior Relates to #215 --- test/functional/study-loop.spec.js | 428 +++++++++++++++++++++++++++++ 1 file changed, 428 insertions(+) create mode 100644 test/functional/study-loop.spec.js 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) +})