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)
+})