Skip to content

Commit

Permalink
Feature/integer duration (#238)
Browse files Browse the repository at this point in the history
* changed experiment duration from free-text field to minutes (integer)

* appointment end should be determined by start + duration

* appeasing the linter

* computed end time in AppointmentForm + updated tests
  • Loading branch information
bbonf authored Nov 15, 2024
1 parent 0e73b50 commit d1d55bd
Show file tree
Hide file tree
Showing 24 changed files with 145 additions and 99 deletions.
1 change: 0 additions & 1 deletion babex-vue/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,6 @@ class GenericApiPart<T> extends ApiPart {

interface AppointmentCreate {
start: Date,
end: Date,
experiment: number,
leader: number,
participant: number,
Expand Down
6 changes: 4 additions & 2 deletions babex-vue/src/components/agenda/AgendaCalendar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
// optional experiment id for limiting feeds
experiment?: number,
scheduling?: boolean,
duration?: number,
}>();
// from https://stackoverflow.com/a/64090995
Expand Down Expand Up @@ -130,7 +130,9 @@
allDaySlot: false,
slotMinTime: "07:00:00",
slotMaxTime: "20:00:00",
slotDuration: props.scheduling ? "00:15:00" : "00:30:00",
slotDuration: {minutes: props.duration ?? 30},
defaultTimedEventDuration: {minutes: props.duration},
forceEventDuration: props.duration ? true : false,
eventTimeFormat: {
hour: '2-digit',
minute: '2-digit',
Expand Down
10 changes: 6 additions & 4 deletions babex-vue/src/components/agenda/AppointmentForm.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script lang="ts" setup>
import {defineEmits, defineProps, ref} from 'vue';
import {computed, defineEmits, defineProps, ref} from 'vue';
import {babexApi} from '../../api';
import {Location} from '../../types';
import { _ } from '@/util';
Expand All @@ -18,7 +18,6 @@
const form = ref({
start: props.event.start,
end: props.event.end,
comment: props.event ? props.event.extendedProps.comment : null,
location: props.event.extendedProps.location,
leader: props.event.extendedProps.leader,
Expand Down Expand Up @@ -64,6 +63,9 @@
function isCanceled() {
return props.event.extendedProps.outcome == 'CANCELED';
}
const eventEnd = computed(
() => new Date(form.value!.start.getTime() + 60 * 1000 * props.event.extendedProps.experiment.session_duration));
</script>

<template>
Expand Down Expand Up @@ -101,7 +103,7 @@
<DateTimePicker class="appointment-start" v-model="form.start" :readonly="hasOutcome()" />

<div>{{ _('To:') }}</div>
<DateTimePicker class="appointment-end" v-model="form.end" :readonly="hasOutcome()" />
<DateTimePicker class="appointment-end" v-model="eventEnd" :readonly="true" />

<div>
<label>{{ _('Comments:') }}</label>
Expand All @@ -121,7 +123,7 @@
</div>
</div>

<div class="mt-4"><button class="btn btn-primary save" :disabled="form.end <= form.start">{{ _('Save') }}</button></div>
<div class="mt-4"><button class="btn btn-primary save">{{ _('Save') }}</button></div>
</form>

<div v-if="!isPast() && !isCanceled()">
Expand Down
20 changes: 9 additions & 11 deletions babex-vue/src/components/invite/CallHome.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<script lang="ts" setup>
import { _ } from '@/util';
import {defineProps, ref} from 'vue';
import {defineProps, ref, computed} from 'vue';
import AgendaCalendar from '../agenda/AgendaCalendar.vue';
import {babexApi} from '../../api';
import {Call} from '../../types';
Expand All @@ -10,7 +10,7 @@
const props = defineProps<{
participant: {id: number, name: string},
experiment: {id: number, name: string},
experiment: {id: number, name: string, session_duration: number},
leaders: {id: number, name: string}[],
statuses: { [id: string]: string},
call: Call,
Expand All @@ -32,7 +32,7 @@
// event start and end times saved as separate refs because
// our DateTimePicker doesn't play nicely with fullcalendar's event object
const eventStart = ref<Date|null>(null);
const eventEnd = ref<Date|null>(null);
const eventEnd = computed(() => new Date(eventStart.value!.getTime() + 60 * 1000 * props.experiment.session_duration));
const callStatus = ref<string|null>(null);
const comment = ref('');
Expand Down Expand Up @@ -72,13 +72,12 @@
}
function confirm() {
if(!event.value || !eventStart.value || !eventEnd.value) {
if(!event.value || !eventStart.value) {
return;
}
babexApi.call.appointment.create({
start: eventStart.value,
end: eventEnd.value,
experiment: props.experiment.id,
participant: props.participant.id,
leader: confirmationForm.value.leader,
Expand Down Expand Up @@ -112,11 +111,9 @@
}
event.value = calendar.value?.calendar.getApi().addEvent({
start: selectionInfo.start,
end: selectionInfo.end,
startEditable: true
});
eventStart.value = selectionInfo.start;
eventEnd.value = selectionInfo.end;
eventStart.value = event.value!.start;
}
else {
step.value = 1;
Expand Down Expand Up @@ -170,6 +167,7 @@
function complete() {
location.href = props.completeUrl;
}
</script>

<template>
Expand Down Expand Up @@ -204,15 +202,15 @@
<div class="modal-dialog modal-dialog-centered modal-lg">
<div v-if="step < 2" class="modal-content">
<div class="modal-body">
<AgendaCalendar ref="calendar" @select="onSelect" :start="start" :end="end" :experiment="experiment.id" :scheduling="true"></AgendaCalendar>
<AgendaCalendar ref="calendar" @select="onSelect" :start="start" :end="end" :experiment="experiment.id" :duration="experiment.session_duration"></AgendaCalendar>
</div>
<div class="modal-footer">
<button @click="reset()" type="button" class="btn btn-secondary" :disabled="step < 1">{{ _('Back') }}</button>
<button @click="step = 2" type="button" class="btn btn-primary" :disabled="event==null">{{ _('Next') }}</button>
<button @click="modalVisible = false" type="button" class="btn btn-secondary">{{ _('Cancel') }}</button>
</div>
</div>
<div v-if="step === 2 && eventStart && eventEnd" class="modal-content">
<div v-if="step === 2 && eventStart" class="modal-content">
<div class="modal-body">
<h2>{{ _('Appointment details') }}</h2>
<table class="table mt-3">
Expand All @@ -225,7 +223,7 @@
</tr>
<tr>
<th>{{ _('To') }}</th>
<td><DateTimePicker v-model="eventEnd" /></td>
<td><DateTimePicker :readonly="true" v-model="eventEnd" /></td>
</tr>
</table>

Expand Down
17 changes: 8 additions & 9 deletions integration_tests/test_invite.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,18 +33,17 @@ def test_cancel_appointment_from_email(apps, participant, mailbox, link_from_mai
DefaultCriteria = apps.lab.get_model('experiments', 'DefaultCriteria')
User = apps.lab.get_model('main', 'User')
Appointment = apps.lab.get_model('experiments', 'Appointment')
experiment = Experiment.objects.create(defaultcriteria=DefaultCriteria.objects.create())
experiment = Experiment.objects.create(duration=10, session_duration=20)

# somewhat abusing the get_model() calls above to setup django for the following to work
from experiments.models import make_appointment
from utils.appointment_mail import send_appointment_mail, prepare_appointment_mail

leader = User.objects.first() # admin
start = timezone.now()
end = start + timedelta(hours=1)
experiment.leaders.add(leader)
experiment.save()
appointment = make_appointment(experiment, participant, leader, start, end)
appointment = make_appointment(experiment, participant, leader, start)

send_appointment_mail(appointment, prepare_appointment_mail(appointment))

Expand All @@ -71,18 +70,18 @@ def test_appointment_in_parent_overview(apps, participant, mailbox, page, login_
Experiment = apps.lab.get_model('experiments', 'Experiment')
DefaultCriteria = apps.lab.get_model('experiments', 'DefaultCriteria')
User = apps.lab.get_model('main', 'User')
experiment = Experiment.objects.create(defaultcriteria=DefaultCriteria.objects.create(),
experiment = Experiment.objects.create(duration=10,
session_duration=20,
name='Test Experiment')

# somewhat abusing the get_model() calls above to setup django for the following to work
from experiments.models import make_appointment

leader = User.objects.first() # admin
start = timezone.now()
end = start + timedelta(hours=1)
experiment.leaders.add(leader)
experiment.save()
appointment = make_appointment(experiment, participant, leader, start, end)
appointment = make_appointment(experiment, participant, leader, start)

login_as(participant.email)
try:
Expand All @@ -99,18 +98,18 @@ def test_past_appointment_not_in_parent_overview(apps, participant, mailbox, pag
Experiment = apps.lab.get_model('experiments', 'Experiment')
DefaultCriteria = apps.lab.get_model('experiments', 'DefaultCriteria')
User = apps.lab.get_model('main', 'User')
experiment = Experiment.objects.create(defaultcriteria=DefaultCriteria.objects.create(),
experiment = Experiment.objects.create(duration=10,
session_duration=20,
name='Test Experiment')

# somewhat abusing the get_model() calls above to setup django for the following to work
from experiments.models import make_appointment

leader = User.objects.first() # admin
start = timezone.now() - timedelta(days=30)
end = start + timedelta(hours=1)
experiment.leaders.add(leader)
experiment.save()
appointment = make_appointment(experiment, participant, leader, start, end)
appointment = make_appointment(experiment, participant, leader, start)

login_as(participant.email)
try:
Expand Down
2 changes: 1 addition & 1 deletion lab/agenda/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ def perform_update(self, serializer):
updated_timeslot = updated.timeslot

# check if we should inform the participant about changed time
if original_timeslot.start != updated_timeslot.start or original_timeslot.end != updated_timeslot.end:
if original_timeslot.start != updated_timeslot.start:
send_appointment_mail(updated, prepare_appointment_mail(updated))


Expand Down
2 changes: 1 addition & 1 deletion lab/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

@pytest.fixture
def sample_experiment(admin_user, db):
yield admin_user.experiments.create(defaultcriteria=DefaultCriteria.objects.create())
yield admin_user.experiments.create(duration=15, session_duration=30)


@pytest.fixture
Expand Down
4 changes: 2 additions & 2 deletions lab/experiments/email.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,8 @@ def __init__(self, *args, **kwargs):
</p>
<p>
<strong>Het experiment</strong><br/>
Het experiment duurt maximaal {{experiment_duration}}.
Omdat we ook de procedure uitleggen en er achteraf tijd is voor vragen, zult u ongeveer {{session_duration}} kwijt zijn
Het experiment duurt maximaal {{experiment_duration}} minuten.
Omdat we ook de procedure uitleggen en er achteraf tijd is voor vragen, zult u ongeveer {{session_duration}} minuten kwijt zijn
aan uw bezoek aan het Babylab. In de bijlage van deze mail vindt u meer informatie over het experiment en onze werkwijze.
</p>
<p>
Expand Down
2 changes: 0 additions & 2 deletions lab/experiments/forms/experiment_forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,6 @@ class Meta:
exclude = ("defaultcriteria",)
widgets = {
"name": forms.TextInput,
"duration": forms.TextInput,
"session_duration": forms.TextInput,
"task_description": forms.Textarea(
{
"rows": 7,
Expand Down
5 changes: 4 additions & 1 deletion lab/experiments/locale/en/LC_MESSAGES/django.po
Original file line number Diff line number Diff line change
Expand Up @@ -917,4 +917,7 @@ msgid "default_criteria:attribute:min_age"
msgstr "Min. age"

msgid "default_criteria:attribute:max_age"
msgstr "Max. age"
msgstr "Max. age"

msgid "experiments:duration:minutes"
msgstr "minutes"
5 changes: 4 additions & 1 deletion lab/experiments/locale/nl/LC_MESSAGES/django.po
Original file line number Diff line number Diff line change
Expand Up @@ -949,4 +949,7 @@ msgstr "Geëxcludeerd"

#: experiments/models/appointment_models.py:38
msgid "experiments:appointment:outcome:canceled"
msgstr "Geannuleerd"
msgstr "Geannuleerd"

msgid "experiments:duration:minutes"
msgstr "minuten"
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Generated by Django 4.2.11 on 2024-11-14 09:09

from django.db import migrations, models
import experiments.email


class Migration(migrations.Migration):

dependencies = [
("experiments", "0022_alter_experiment_confirmation_email"),
]

operations = [
migrations.AlterField(
model_name="experiment",
name="confirmation_email",
field=models.TextField(
default='<p>Beste {{parent_name}},</p>\n <p>\n U heeft een afspraak gemaakt om mee te doen met het experiment:\n <strong>{{experiment_name}}</strong><br/><br/>\n We verwachten u en {{participant_name}} op:<br/><br/>\n Datum: <strong>{{date}}</strong><br/>\n Tijd: <strong>{{time}} uur</strong><br/>\n Locatie: <strong>Janskerkhof 13a</strong> (let op: dit is de groene voordeur met de helling ervoor).<br/>\n </p>\n <p>\n <strong>Gezondheidsklachten</strong><br/>\n Wij vragen u de afspraak te verzetten wanneer uw kind vlak voor de afspraak gehoorproblemen\n en/of oorontsteking heeft en dus mogelijk minder goed hoort.\n </p>\n <p>\n <strong>Aankomst in het Babylab</strong><br/>\n Als u aanbelt bij Janskerkhof 13a en via de intercom zegt dat u voor het Babylab komt, dan wordt de deur op afstand voor u geopend.\n Wanneer u binnenkomt, kunt u gelijk na de hal met de lift (of met de trap) naar beneden.\n Daar vindt u aan uw rechterhand de wachtkamer, waar u plaats kunt nemen. De onderzoeksassistent zal u daar komen ophalen.\n </p>\n <p>\n <strong>Het experiment</strong><br/>\n Het experiment duurt maximaal {{experiment_duration}} minuten.\n Omdat we ook de procedure uitleggen en er achteraf tijd is voor vragen, zult u ongeveer {{session_duration}} minuten kwijt zijn\n aan uw bezoek aan het Babylab. In de bijlage van deze mail vindt u meer informatie over het experiment en onze werkwijze.\n </p>\n <p>\n Het is belangrijk voor ons onderzoek dat er geen broertje of zusje meekomt tijdens het bezoek aan het lab.\n Als u hierover van tevoren een andere afspraak heeft gemaakt met de assistent van het Babylab, dan geldt uiteraard die afspraak.\n </p>\n <p>\n <strong>Afspraak verzetten/afzeggen</strong><br/>\n Als u deze afspraak wilt afzeggen, kunt u dat doen via <a href="{{cancel_link}}">deze link</a>.\n Doe dat a.u.b. minstens 24 uur van tevoren. Als u vlak van tevoren ontdekt dat u verhinderd bent,\n neem dan contact op met de testleider ({{leader_name}}, email: [email protected]).\n </p>\n <p>\n Meer informatie over het Babylab, bijvoorbeeld de routebeschrijving, kunt u vinden op de\n <a href="https://babylab.wp.hum.uu.nl/">website van het Babylab</a>.\n Wij danken u alvast hartelijk voor uw medewerking. Zonder uw deelname kunnen wij geen onderzoek doen!\n </p>\n <p>\n Vriendelijke groet,<br/><br/>\n Het team van het Babylab voor Taalonderzoek\n </p>',
help_text=experiments.email.AppointmentConfirmEmail.help_text,
verbose_name="experiment:attribute:confirmation_email",
),
),
migrations.AlterField(
model_name="experiment",
name="duration",
field=models.IntegerField(verbose_name="experiment:attribute:duration"),
),
migrations.AlterField(
model_name="experiment",
name="session_duration",
field=models.IntegerField(verbose_name="experiment:attribute:session_duration"),
),
]
10 changes: 8 additions & 2 deletions lab/experiments/models/appointment_models.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from datetime import datetime
from datetime import datetime, timedelta

from cdh.mail.classes import TemplateEmail
from django.db import models
Expand Down Expand Up @@ -69,7 +69,11 @@ def save(self, *args, **kwargs):
# verify that the leader is valid
if self.leader not in self.experiment.leaders.all():
raise ValueError("Leader {} is not part of experiment {}".format(self.leader, self.experiment))

# make sure the end time is correct
self.end = self.start + timedelta(minutes=self.experiment.session_duration)
self.timeslot.save()

super().save(*args, **kwargs)

def __str__(self):
Expand Down Expand Up @@ -108,10 +112,12 @@ def is_canceled(self):
return self.outcome == Appointment.Outcome.CANCELED


def make_appointment(experiment: Experiment, participant: Participant, leader: User, start: datetime, end: datetime):
def make_appointment(experiment: Experiment, participant: Participant, leader: User, start: datetime):
if leader not in experiment.leaders.all():
raise ValueError(f'{leader} is not a leader in the experiment "{experiment}"')

# appointment end is determined by the experiment's session duration
end = start + timedelta(minutes=experiment.session_duration)
timeslot = TimeSlot.objects.create(start=start, end=end, experiment=experiment)
appointment = Appointment.objects.create(
participant=participant, timeslot=timeslot, experiment=experiment, leader=leader
Expand Down
4 changes: 2 additions & 2 deletions lab/experiments/models/experiment_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,9 @@ def _get_dt_2_hours_ago() -> datetime:
class Experiment(models.Model):
name = models.TextField(_("experiment:attribute:name"))

duration = models.TextField(_("experiment:attribute:duration"))
duration = models.IntegerField(_("experiment:attribute:duration"))

session_duration = models.TextField(_("experiment:attribute:session_duration"))
session_duration = models.IntegerField(_("experiment:attribute:session_duration"))

# how many participants are aimed for
recruitment_target = models.PositiveIntegerField(
Expand Down
7 changes: 4 additions & 3 deletions lab/experiments/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ class AppointmentSerializer(serializers.ModelSerializer):
class AppointmentExperimentSerializer(serializers.ModelSerializer):
class Meta:
model = Experiment
fields = ["id", "name", "leaders"]
fields = ["id", "name", "leaders", "session_duration"]

leaders = ExperimentLeadersSerializer(read_only=True, many=True)

Expand Down Expand Up @@ -51,7 +51,7 @@ class Meta:
contact_phone = serializers.ReadOnlyField(source="leader.phonenumber")

start = serializers.DateTimeField()
end = serializers.DateTimeField()
end = serializers.ReadOnlyField()

session_duration = serializers.ReadOnlyField(source="experiment.session_duration")

Expand All @@ -70,6 +70,7 @@ class Meta:
"id",
"name",
"duration",
"session_duration",
"task_description",
"location",
"leaders",
Expand Down Expand Up @@ -104,6 +105,6 @@ class Meta:
contact_phone = serializers.ReadOnlyField(source="leader.phonenumber")

start = serializers.DateTimeField()
end = serializers.DateTimeField()
end = serializers.ReadOnlyField()

session_duration = serializers.ReadOnlyField(source="experiment.session_duration")
Loading

0 comments on commit d1d55bd

Please sign in to comment.