Compare commits

..

58 Commits

Author SHA1 Message Date
Valentin Deniaud 21cae6a31f manager: allow separate arrvial/departure check for partial bookings (#80047)
gitea/chrono/pipeline/head This commit looks good Details
2023-10-11 15:15:22 +02:00
Valentin Deniaud a25a8e6ef1 translation update
gitea/chrono/pipeline/head This commit looks good Details
2023-10-09 10:58:28 +02:00
Valentin Deniaud 2a56ba5432 manager: allow adding second check to partial booking (#80371)
gitea/chrono/pipeline/head This commit looks good Details
2023-10-09 10:48:50 +02:00
Valentin Deniaud 2e22706c08 manager: add separate view to update booking check (#80371) 2023-10-09 10:48:50 +02:00
Valentin Deniaud 81e93dd4c5 agendas: allow multiple checks by booking (#80371) 2023-10-09 10:48:50 +02:00
Valentin Deniaud 3cb80d478a agendas: store computed start/end times on booking check (#80371) 2023-10-09 10:48:50 +02:00
Valentin Deniaud ec497c66d9 all: use new BookingCheck model (#80371) 2023-10-09 10:48:50 +02:00
Valentin Deniaud bcae843c0d agendas: migrate booking check data into new model (#80371) 2023-10-09 10:48:50 +02:00
Valentin Deniaud 6d31c85dd7 agendas: add BookingCheck model (#80371) 2023-10-09 10:48:50 +02:00
Frédéric Péters 17cddfbd4a tox: keep on testing drf 3.12 only for now (#81946)
gitea/chrono/pipeline/head This commit looks good Details
2023-10-05 22:04:24 +02:00
Frédéric Péters 57a67073e3 misc: replace serializers.NullBooleanField (#81946) 2023-10-05 22:04:24 +02:00
Frédéric Péters ca32dc3a36 setup: allow djangorestframework 3.14 (#81946) 2023-10-05 22:04:24 +02:00
Valentin Deniaud fb7d928206 all: do not write booking check info in secondary bookings (#81986)
gitea/chrono/pipeline/head This commit looks good Details
2023-10-05 11:06:53 +02:00
Valentin Deniaud 9b315f4be3 api: count secondary booking presence from primary booking in stats (#81986) 2023-10-05 11:06:53 +02:00
Valentin Deniaud 1fd95681fe manager: allow checking partial bookings separately (#81370)
gitea/chrono/pipeline/head Build queued... Details
2023-10-05 10:56:41 +02:00
Frédéric Péters fc86701ab2 translation update
gitea/chrono/pipeline/head This commit looks good Details
2023-10-04 14:32:49 +02:00
Valentin Deniaud f34af55592 ants_hub: differentiate "place" translation (#81980)
gitea/chrono/pipeline/head This commit looks good Details
2023-10-04 11:59:07 +02:00
Valentin Deniaud 2cae3b7724 translation update
gitea/chrono/pipeline/head This commit looks good Details
2023-10-03 11:56:38 +02:00
Valentin Deniaud 23a1b70dd7 manager: hide unused settings for partial bookings agendas (#80465)
gitea/chrono/pipeline/head This commit looks good Details
2023-10-03 10:41:33 +02:00
Valentin Deniaud a13003cdec api: allow different hours per day in partial bookings recurring fillslots (#78086)
gitea/chrono/pipeline/head This commit looks good Details
2023-10-03 09:34:18 +02:00
Frédéric Péters 6aa243817e ci: keep on using pylint 2 while pylint-django is not ready (#81905)
gitea/chrono/pipeline/head This commit looks good Details
2023-10-03 06:45:34 +02:00
Lauréline Guérin 15b2b26c08
misc: fix test for partial bookings running after 18h (#80877)
gitea/chrono/pipeline/head This commit looks good Details
2023-10-02 15:48:17 +02:00
Lauréline Guérin 918903fc8c
agendas: store computed times for partial bookings (#80877) 2023-10-02 15:48:17 +02:00
Valentin Deniaud 33e53a694a manager: forbid checking arrival after departure for partial bookings (#81619)
gitea/chrono/pipeline/head This commit looks good Details
2023-10-02 10:11:54 +02:00
Valentin Deniaud ec86a9bbcc manager: partial bookings, allow user check without booking (#80369)
gitea/chrono/pipeline/head This commit looks good Details
2023-10-02 10:11:32 +02:00
Lauréline Guérin 28c3641d50
manager: need to be staff to duplicate an agenda (#81583)
gitea/chrono/pipeline/head This commit looks good Details
2023-09-26 16:23:51 +02:00
Frédéric Péters 655ffeb610 translation update
gitea/chrono/pipeline/head This commit looks good Details
2023-09-21 20:12:47 +02:00
Thomas Jund bdce64d56e partial booking manager: change #main-content overflow (#80356)
gitea/chrono/pipeline/head This commit looks good Details
to allow sticky hours list
2023-09-21 12:02:37 +02:00
Thomas Jund e6be5342e6 partial booking manager: move end time at right (#80356) 2023-09-21 11:59:10 +02:00
Lauréline Guérin 60de169359 manager: disable check for partial bookings if check locked (#80983)
gitea/chrono/pipeline/head This commit looks good Details
2023-09-21 10:47:42 +02:00
Lauréline Guérin e231d27751 manager: mark event as checked for partial bookings (#80983) 2023-09-21 10:47:42 +02:00
Valentin Deniaud 42cc548a33 api: allow getting all user bookings as ICS (#80685)
gitea/chrono/pipeline/head This commit looks good Details
2023-09-21 10:34:45 +02:00
Thomas Jund 8b924ef670 manager css: allow multiple partial bookings on the same line (#80050)
gitea/chrono/pipeline/head This commit looks good Details
2023-09-19 17:00:19 +02:00
Valentin Deniaud 7c34e4fe7f manager: allow multiple partial bookings for one user on the same day (#80050) 2023-09-19 16:59:12 +02:00
Valentin Deniaud 11ef5b4bd2 api: allow partial booking in all event fillslot endpoints (#80050) 2023-09-19 16:57:23 +02:00
Valentin Deniaud 9b340a01d6 manager: add button to prefill partial booking check hours (#80045)
gitea/chrono/pipeline/head This commit looks good Details
2023-09-18 15:33:53 +02:00
Frédéric Péters 9a841fc31e translation update
gitea/chrono/pipeline/head This commit looks good Details
2023-09-15 10:44:42 +02:00
Serghei Mihai 5fe881fdb5 manager: don't show booking colours of cancelled bookings (#81110)
gitea/chrono/pipeline/head This commit looks good Details
2023-09-15 09:33:53 +02:00
Lauréline Guérin 93081c6e46
manager: partial bookings, events redirect to day view (#80982)
gitea/chrono/pipeline/head This commit looks good Details
2023-09-15 08:57:21 +02:00
Lauréline Guérin c8d71aa997
api: events check endpoint, return also times and minutes (#80973)
gitea/chrono/pipeline/head Build queued... Details
2023-09-15 08:57:01 +02:00
Lauréline Guérin 2b288340b6
manager: display computed period for partial bookings (#80842)
gitea/chrono/pipeline/head This commit looks good Details
2023-09-15 08:54:09 +02:00
Lauréline Guérin 16e3602391
agendas: methods to compute start and end times from check times (#80842) 2023-09-15 08:54:09 +02:00
Lauréline Guérin df0223abf2
manager: configure invoicing options for partial bookings (#80842) 2023-09-15 08:54:09 +02:00
Lauréline Guérin 0fe3933ed1
manager: fix wording for checked period in day view (#80842) 2023-09-15 08:54:09 +02:00
Lauréline Guérin 0b6ca9d5d2
agendas: fix event_overlaps method with recurrences (#80851)
gitea/chrono/pipeline/head This commit looks good Details
2023-09-08 12:08:55 +02:00
Lauréline Guérin d16b35067e
manager: fix day view for partial bookings and recurring event (#80851) 2023-09-08 12:08:55 +02:00
Lauréline Guérin fbe2deea93
api: add partial_bookings field in agenda details (#81002)
gitea/chrono/pipeline/head This commit looks good Details
2023-09-08 12:01:12 +02:00
Valentin Deniaud 7e946138ac agendas: import/export all exception sources (#80219)
gitea/chrono/pipeline/head This commit looks good Details
2023-09-04 16:55:43 +02:00
Benjamin Dauvergne a68026e839 ants_hub: fix typo in order_by (#80590)
gitea/chrono/pipeline/head This commit looks good Details
2023-08-28 13:15:11 +02:00
Benjamin Dauvergne 5fbbe0e984 ants_hub: allow multiple identifiant_predemande (#80592)
gitea/chrono/pipeline/head This commit looks good Details
2023-08-28 11:30:55 +02:00
Frédéric Péters 0f81147829 translation update
gitea/chrono/pipeline/head This commit looks good Details
2023-08-17 21:46:35 +02:00
Valentin Deniaud 7859f0558e manager: use proper widget for agenda minimal booking time (#75884)
gitea/chrono/pipeline/head This commit looks good Details
2023-08-17 09:52:46 +02:00
Valentin Deniaud e2d70795b1 tests: add missing ordering in test_recurring_events (#80402)
gitea/chrono/pipeline/head This commit looks good Details
2023-08-16 14:24:22 +02:00
Valentin Deniaud 84463c84bf api: remove legacy fillslots views (#80352)
gitea/chrono/pipeline/head This commit looks good Details
2023-08-16 11:13:38 +02:00
Valentin Deniaud 8127fbff66 manager: report all errors at once in CSV import (#70523)
gitea/chrono/pipeline/head This commit looks good Details
2023-08-16 09:46:26 +02:00
Valentin Deniaud b9c02c20bd manager: split methods of CSV import (#70523) 2023-08-16 09:46:26 +02:00
Valentin Deniaud 60f31525ee api: allow changing bookings from date in recurring fillslots (#78921)
gitea/chrono/pipeline/head This commit looks good Details
2023-08-16 09:46:01 +02:00
Valentin Deniaud f371341d7d api: allow weekday name in recurring event display template (#80042)
gitea/chrono/pipeline/head Build queued... Details
2023-08-03 17:42:17 +02:00
50 changed files with 3458 additions and 1705 deletions

View File

@ -18,7 +18,7 @@ class Migration(migrations.Migration):
blank=True,
default=datetime.time(0, 0),
null=True,
help_text='Ex.: 08:00:00. If left empty, available events will be those that are later than the current time.',
help_text='If left empty, available events will be those that are later than the current time.',
verbose_name='Booking opening time',
),
),

View File

@ -12,11 +12,11 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='booking',
name='user_check_end_time',
field=models.TimeField(null=True, blank=True, verbose_name='Departure'),
field=models.TimeField(null=True, verbose_name='Departure'),
),
migrations.AddField(
model_name='booking',
name='user_check_start_time',
field=models.TimeField(null=True, blank=True, verbose_name='Arrival'),
field=models.TimeField(null=True, verbose_name='Arrival'),
),
]

View File

@ -0,0 +1,33 @@
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('agendas', '0158_partial_booking_check_fields'),
]
operations = [
migrations.AddField(
model_name='agenda',
name='invoicing_tolerance',
field=models.PositiveSmallIntegerField(
default=0, validators=[django.core.validators.MaxValueValidator(59)], verbose_name='Tolerance'
),
),
migrations.AddField(
model_name='agenda',
name='invoicing_unit',
field=models.CharField(
choices=[
('hour', 'Per hour'),
('half_hour', 'Per half hour'),
('quarter', 'Per quarter-hour'),
('minute', 'Per minute'),
],
default='hour',
max_length=10,
verbose_name='Invoicing',
),
),
]

View File

@ -0,0 +1,20 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('agendas', '0159_partial_bookings_invoicing'),
]
operations = [
migrations.AddField(
model_name='booking',
name='computed_end_time',
field=models.TimeField(null=True),
),
migrations.AddField(
model_name='booking',
name='computed_start_time',
field=models.TimeField(null=True),
),
]

View File

@ -0,0 +1,37 @@
# Generated by Django 3.2.21 on 2023-10-04 13:51
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('agendas', '0160_computed_times'),
]
operations = [
migrations.CreateModel(
name='BookingCheck',
fields=[
(
'id',
models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
('presence', models.BooleanField()),
('start_time', models.TimeField(null=True, blank=True, verbose_name='Arrival')),
('end_time', models.TimeField(null=True, blank=True, verbose_name='Departure')),
('computed_end_time', models.TimeField(null=True)),
('computed_start_time', models.TimeField(null=True)),
('type_slug', models.CharField(blank=True, max_length=160, null=True)),
('type_label', models.CharField(blank=True, max_length=150, null=True)),
(
'booking',
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name='user_check',
to='agendas.booking',
),
),
],
),
]

View File

@ -0,0 +1,62 @@
# Generated by Django 3.2.18 on 2023-08-22 15:47
from django.db import migrations
def migrate_booking_check_data(apps, schema_editor):
Booking = apps.get_model('agendas', 'Booking')
BookingCheck = apps.get_model('agendas', 'BookingCheck')
booking_checks = []
bookings = list(Booking.objects.filter(user_was_present__isnull=False))
for booking in bookings:
booking_check = BookingCheck(
booking=booking,
presence=booking.user_was_present,
start_time=booking.user_check_start_time,
end_time=booking.user_check_end_time,
computed_start_time=booking.computed_start_time,
computed_end_time=booking.computed_end_time,
type_slug=booking.user_check_type_slug,
type_label=booking.user_check_type_label,
)
booking_checks.append(booking_check)
BookingCheck.objects.bulk_create(booking_checks)
def reverse_migrate_booking_check_data(apps, schema_editor):
Booking = apps.get_model('agendas', 'Booking')
bookings = list(Booking.objects.filter(user_check__isnull=False).select_related('user_check'))
for booking in bookings:
booking.user_was_present = booking.user_check.presence
booking.user_check_start_time = booking.user_check.start_time
booking.user_check_end_time = booking.user_check.end_time
booking.computed_start_time = booking.computed_start_time
booking.computed_end_time = booking.computed_end_time
booking.user_check_type_slug = booking.user_check.type_slug
booking.user_check_type_label = booking.user_check.type_label
Booking.objects.bulk_update(
bookings,
fields=[
'user_was_present',
'user_check_start_time',
'user_check_end_time',
'computed_start_time',
'computed_end_time',
'user_check_type_slug',
'user_check_type_label',
],
)
class Migration(migrations.Migration):
dependencies = [
('agendas', '0161_add_booking_check_model'),
]
operations = [
migrations.RunPython(migrate_booking_check_data, reverse_migrate_booking_check_data),
]

View File

@ -0,0 +1,40 @@
# Generated by Django 3.2.18 on 2023-08-22 15:51
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('agendas', '0162_migrate_booking_check_data'),
]
operations = [
migrations.RemoveField(
model_name='booking',
name='user_check_end_time',
),
migrations.RemoveField(
model_name='booking',
name='user_check_start_time',
),
migrations.RemoveField(
model_name='booking',
name='user_check_type_label',
),
migrations.RemoveField(
model_name='booking',
name='user_check_type_slug',
),
migrations.RemoveField(
model_name='booking',
name='user_was_present',
),
migrations.RemoveField(
model_name='booking',
name='computed_end_time',
),
migrations.RemoveField(
model_name='booking',
name='computed_start_time',
),
]

View File

@ -0,0 +1,20 @@
# Generated by Django 3.2.21 on 2023-10-05 11:41
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('agendas', '0163_remove_booking_check_fields'),
]
operations = [
migrations.AlterField(
model_name='bookingcheck',
name='booking',
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, related_name='user_checks', to='agendas.booking'
),
),
]

View File

@ -14,6 +14,7 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import base64
import collections
import copy
import dataclasses
@ -35,6 +36,7 @@ from django.contrib.auth.models import Group
from django.contrib.humanize.templatetags.humanize import ordinal
from django.contrib.postgres.fields import ArrayField
from django.core.exceptions import ValidationError
from django.core.files.base import ContentFile
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import connection, models, transaction
from django.db.models import (
@ -284,13 +286,27 @@ class Agenda(models.Model):
minimal_booking_time = models.TimeField(
verbose_name=_('Booking opening time'),
default=datetime.time(0, 0, 0), # booking is possible starting and finishin at 00:00
help_text=_(
'Ex.: 08:00:00. If left empty, available events will be those that are later than the current time.'
),
help_text=_('If left empty, available events will be those that are later than the current time.'),
null=True,
blank=True,
)
partial_bookings = models.BooleanField(default=False)
invoicing_unit = models.CharField(
verbose_name=_('Invoicing'),
max_length=10,
choices=[
('hour', _('Per hour')),
('half_hour', _('Per half hour')),
('quarter', _('Per quarter-hour')),
('minute', _('Per minute')),
],
default='hour',
)
invoicing_tolerance = models.PositiveSmallIntegerField(
verbose_name=_('Tolerance'),
default=0,
validators=[MaxValueValidator(59)],
)
class Meta:
ordering = ['label']
@ -1121,7 +1137,10 @@ class Agenda(models.Model):
return True
def event_overlaps(self, start_datetime, recurrence_days, recurrence_end_date, instance=None):
qs = self.event_set
qs = self.event_set.filter(
# exclude recurrences, check only recurring and normal events
primary_event__isnull=True
)
if hasattr(instance, 'pk'):
qs = qs.exclude(pk=instance.pk)
@ -1567,6 +1586,44 @@ class Agenda(models.Model):
free_time += desk_free_time
return free_time
def async_refresh_booking_computed_times(self):
if self.kind != 'events' or not self.partial_bookings:
return
if 'uwsgi' in sys.modules:
from chrono.utils.spooler import refresh_booking_computed_times_from_agenda
tenant = getattr(connection, 'tenant', None)
transaction.on_commit(
lambda: refresh_booking_computed_times_from_agenda.spool(
agenda_id=str(self.pk), domain=getattr(tenant, 'domain_url', None)
)
)
return
self.refresh_booking_computed_times()
def refresh_booking_computed_times(self):
bookings_queryset = Booking.objects.filter(
event__agenda__kind='events',
event__agenda__partial_bookings=True,
event__agenda=self,
event__check_locked=False,
event__invoiced=False,
event__cancelled=False,
cancellation_datetime__isnull=True,
)
booking_checks = BookingCheck.objects.filter(booking__in=bookings_queryset).select_related(
'booking', 'booking__event__agenda'
)
to_update = []
for booking_check in booking_checks:
changed = booking_check.refresh_computed_times()
if changed:
to_update.append(booking_check)
if to_update:
BookingCheck.objects.bulk_update(to_update, ['computed_start_time', 'computed_end_time'])
class VirtualMember(models.Model):
"""Trough model to link virtual agendas to their real agendas.
@ -2084,7 +2141,8 @@ class Event(models.Model):
booking_qs = self.booking_set.filter(
cancellation_datetime__isnull=True,
in_waiting_list=False,
user_was_present__isnull=True,
user_checks__isnull=True,
primary_booking__isnull=True,
)
if booking_qs.exists():
return
@ -2107,17 +2165,17 @@ class Event(models.Model):
self.notify_checked()
def notify_checked(self):
for booking in self.booking_set.filter(user_was_present__isnull=False):
if booking.user_was_present is True and booking.presence_callback_url:
for booking in self.booking_set.filter(user_checks__isnull=False).prefetch_related('user_checks'):
if booking.user_check.presence is True and booking.presence_callback_url:
url = booking.presence_callback_url
elif booking.user_was_present is False and booking.absence_callback_url:
elif booking.user_check.presence is False and booking.absence_callback_url:
url = booking.absence_callback_url
else:
continue
payload = {
'user_was_present': booking.user_was_present,
'user_check_type_slug': booking.user_check_type_slug,
'user_check_type_label': booking.user_check_type_label,
'user_was_present': booking.user_check.presence,
'user_check_type_slug': booking.user_check.type_slug,
'user_check_type_label': booking.user_check.type_label,
}
try:
response = requests_wrapper.post(url, json=payload, remote_service='auto', timeout=15)
@ -2130,6 +2188,45 @@ class Event(models.Model):
except Exception as e: # noqa pylint: disable=broad-except
logging.error('error (%s) notifying checked booking (%s)', e, booking.id)
def async_refresh_booking_computed_times(self):
if self.agenda.kind != 'events' or not self.agenda.partial_bookings:
return
if self.check_locked or self.invoiced or self.cancelled:
return
if 'uwsgi' in sys.modules:
from chrono.utils.spooler import refresh_booking_computed_times_from_event
tenant = getattr(connection, 'tenant', None)
transaction.on_commit(
lambda: refresh_booking_computed_times_from_event.spool(
event_id=str(self.pk), domain=getattr(tenant, 'domain_url', None)
)
)
return
self.refresh_booking_computed_times()
def refresh_booking_computed_times(self):
bookings_queryset = Booking.objects.filter(
event__agenda__kind='events',
event__agenda__partial_bookings=True,
event=self,
event__check_locked=False,
event__invoiced=False,
event__cancelled=False,
cancellation_datetime__isnull=True,
)
booking_checks = BookingCheck.objects.filter(booking__in=bookings_queryset).select_related('booking')
to_update = []
for booking_check in booking_checks:
booking_check.booking.event = self # to avoid lots of querysets
changed = booking_check.refresh_computed_times()
if changed:
to_update.append(booking_check)
if to_update:
BookingCheck.objects.bulk_update(to_update, ['computed_start_time', 'computed_end_time'])
def in_bookable_period(self, bypass_delays=False):
if self.publication_datetime and now() < self.publication_datetime:
return False
@ -2181,7 +2278,7 @@ class Event(models.Model):
'booking',
filter=Q(
booking__cancellation_datetime__isnull=True,
booking__user_was_present=False,
booking__user_checks__presence=False,
booking__user_external_id=user_external_id,
),
),
@ -2300,10 +2397,14 @@ class Event(models.Model):
.order_by()
.values('event')
)
present_count = bookings.filter(user_was_present=True).annotate(count=Count('event')).values('count')
absent_count = bookings.filter(user_was_present=False).annotate(count=Count('event')).values('count')
present_count = (
bookings.filter(user_checks__presence=True).annotate(count=Count('event')).values('count')
)
absent_count = (
bookings.filter(user_checks__presence=False).annotate(count=Count('event')).values('count')
)
notchecked_count = (
bookings.filter(user_was_present__isnull=True).annotate(count=Count('event')).values('count')
bookings.filter(user_checks__isnull=True).annotate(count=Count('event')).values('count')
)
return qs.annotate(
present_count=Coalesce(Subquery(present_count, output_field=IntegerField()), Value(0)),
@ -2699,11 +2800,6 @@ class Booking(models.Model):
user_first_name = models.CharField(max_length=250, blank=True)
user_email = models.EmailField(blank=True)
user_phone_number = models.CharField(max_length=30, blank=True)
user_was_present = models.BooleanField(null=True)
user_check_type_slug = models.CharField(max_length=160, blank=True, null=True)
user_check_type_label = models.CharField(max_length=150, blank=True, null=True)
user_check_start_time = models.TimeField(_('Arrival'), null=True, blank=True)
user_check_end_time = models.TimeField(_('Departure'), null=True, blank=True)
out_of_min_delay = models.BooleanField(default=False)
extra_emails = ArrayField(models.EmailField(), default=list)
@ -2723,6 +2819,15 @@ class Booking(models.Model):
def user_name(self):
return ('%s %s' % (self.user_first_name, self.user_last_name)).strip()
@cached_property
def user_check(self): # pylint: disable=method-hidden
user_checks = list(self.user_checks.all())
if len(user_checks) > 1:
raise AttributeError('booking has multiple checks')
return user_checks[0] if user_checks else None
@cached_property
def emails(self):
emails = set(self.extra_emails)
@ -2737,6 +2842,11 @@ class Booking(models.Model):
phone_numbers.add(self.user_phone_number)
return list(phone_numbers)
def refresh_from_db(self, *args, **kwargs):
if hasattr(self, 'user_check'):
del self.user_check
return super().refresh_from_db(*args, **kwargs)
def cancel(self, trigger_callback=False):
timestamp = now()
with transaction.atomic():
@ -2760,39 +2870,41 @@ class Booking(models.Model):
self.save()
def reset_user_was_present(self):
self.user_check_type_slug = None
self.user_check_type_label = None
self.user_was_present = None
with transaction.atomic():
self.secondary_booking_set.update(user_check_type_slug=None)
self.secondary_booking_set.update(user_check_type_label=None)
self.secondary_booking_set.update(user_was_present=None)
self.save()
if self.user_check:
self.user_check.delete()
self.user_check = None
self.event.checked = False
self.event.save(update_fields=['checked'])
def mark_user_absence(self, check_type_slug=None, check_type_label=None):
self.user_check_type_slug = check_type_slug
self.user_check_type_label = check_type_label
self.user_was_present = False
def mark_user_absence(self, check_type_slug=None, check_type_label=None, start_time=None, end_time=None):
if not self.user_check:
self.user_check = BookingCheck(booking=self)
self.user_check.presence = False
self.user_check.type_slug = check_type_slug
self.user_check.type_label = check_type_label
self.user_check.start_time = start_time
self.user_check.end_time = end_time
self.cancellation_datetime = None
with transaction.atomic():
self.secondary_booking_set.update(user_check_type_slug=check_type_slug)
self.secondary_booking_set.update(user_check_type_label=check_type_label)
self.secondary_booking_set.update(user_was_present=False)
self.user_check.save()
self.secondary_booking_set.update(cancellation_datetime=None)
self.save()
self.event.set_is_checked()
def mark_user_presence(self, check_type_slug=None, check_type_label=None):
self.user_check_type_slug = check_type_slug
self.user_check_type_label = check_type_label
self.user_was_present = True
def mark_user_presence(self, check_type_slug=None, check_type_label=None, start_time=None, end_time=None):
if not self.user_check:
self.user_check = BookingCheck(booking=self)
self.user_check.presence = True
self.user_check.type_slug = check_type_slug
self.user_check.type_label = check_type_label
self.user_check.start_time = start_time
self.user_check.end_time = end_time
self.cancellation_datetime = None
with transaction.atomic():
self.secondary_booking_set.update(user_check_type_slug=check_type_slug)
self.secondary_booking_set.update(user_check_type_label=check_type_label)
self.secondary_booking_set.update(user_was_present=True)
self.user_check.save()
self.secondary_booking_set.update(cancellation_datetime=None)
self.save()
self.event.set_is_checked()
@ -2833,9 +2945,7 @@ class Booking(models.Model):
anonymization_datetime=now(),
)
def get_ics(self, request=None):
ics = vobject.iCalendar()
ics.add('prodid').value = '-//Entr\'ouvert//NON SGML Publik'
def get_vevent_ics(self, request=None):
vevent = vobject.newFromBehavior('vevent')
vevent.add('uid').value = '%s-%s-%s' % (
self.event.start_datetime.isoformat(),
@ -2863,6 +2973,12 @@ class Booking(models.Model):
field_value = request and request.GET.get(field) or (self.extra_data or {}).get(field)
if field_value:
vevent.add(field).value = field_value
return vevent
def get_ics(self, request=None):
ics = vobject.iCalendar()
ics.add('prodid').value = '-//Entr\'ouvert//NON SGML Publik'
vevent = self.get_vevent_ics(request)
ics.add(vevent)
return ics.serialize()
@ -2885,6 +3001,100 @@ class Booking(models.Model):
return translate_from_publik_url(self.backoffice_url)
class BookingCheck(models.Model):
booking = models.ForeignKey(Booking, on_delete=models.CASCADE, related_name='user_checks')
presence = models.BooleanField()
start_time = models.TimeField(_('Arrival'), null=True, blank=True)
end_time = models.TimeField(_('Departure'), null=True, blank=True)
computed_start_time = models.TimeField(null=True)
computed_end_time = models.TimeField(null=True)
type_slug = models.CharField(max_length=160, blank=True, null=True)
type_label = models.CharField(max_length=150, blank=True, null=True)
def _get_previous_and_next_slots(self, _time):
minutes = {
'hour': 60,
'half_hour': 30,
'quarter': 15,
}[self.booking.event.agenda.invoicing_unit]
time_minutes = _time.hour * 60 + _time.minute
previous_slot_minutes = math.trunc(time_minutes / minutes) * minutes
previous_slot = datetime.time(*divmod(previous_slot_minutes, 60))
next_slot = datetime.time(*divmod(previous_slot_minutes + minutes, 60))
return previous_slot, next_slot
def get_computed_start_time(self):
if self.start_time is None:
return None
start_time = self.start_time
if self.booking.start_time:
start_time = min(self.start_time, self.booking.start_time)
if self.booking.event.agenda.invoicing_unit == 'minute':
return start_time
tolerance = self.booking.event.agenda.invoicing_tolerance
# compute previous and next slot
previous_slot, next_slot = self._get_previous_and_next_slots(start_time)
# in tolerance ? take next_slot
if (next_slot.minute or 60 - start_time.minute) <= tolerance:
return next_slot
# else take previous_slot
return previous_slot
def get_computed_end_time(self):
if self.end_time is None:
return None
end_time = self.end_time
if self.booking.end_time:
end_time = max(self.end_time, self.booking.end_time)
if self.booking.event.agenda.invoicing_unit == 'minute':
return end_time
tolerance = self.booking.event.agenda.invoicing_tolerance
# compute previous and next slot
previous_slot, next_slot = self._get_previous_and_next_slots(end_time)
# in tolerance ? take previous_slot
if (end_time.minute - previous_slot.minute) <= tolerance:
return previous_slot
# else take next_slot
return next_slot
def refresh_computed_times(self):
old_computed_start_time = self.computed_start_time
old_computed_end_time = self.computed_end_time
self.computed_start_time = self.get_computed_start_time()
self.computed_end_time = self.get_computed_end_time()
# return True if changed, else False
if (
old_computed_start_time == self.computed_start_time
and old_computed_end_time == self.computed_end_time
):
return False
return True
def overlaps_existing_check(self, start_time, end_time):
booking_checks = BookingCheck.objects.filter(booking=self.booking).exclude(pk=self.pk)
if not booking_checks:
return False
if len(booking_checks) > 1:
raise ValueError('too many booking checks') # should not happen
return bool(start_time < booking_checks[0].end_time and end_time > booking_checks[0].start_time)
OpeningHour = collections.namedtuple('OpeningHour', ['begin', 'end'])
@ -2936,14 +3146,15 @@ class Desk(models.Model):
desk.unavailability_calendars.add(target_calendar)
def export_json(self):
time_period_exceptions = self.timeperiodexception_set.filter(source__settings_slug__isnull=True)
time_period_exception_sources = self.timeperiodexceptionsource_set.filter(settings_slug__isnull=False)
time_period_exceptions = self.timeperiodexception_set.filter(source__isnull=True)
return {
'label': self.label,
'slug': self.slug,
'timeperiods': [time_period.export_json() for time_period in self.timeperiod_set.filter()],
'exceptions': [exception.export_json() for exception in time_period_exceptions],
'exception_sources': [source.export_json() for source in time_period_exception_sources],
'exception_sources': [
source.export_json() for source in self.timeperiodexceptionsource_set.all()
],
'unavailability_calendars': [{'slug': x.slug} for x in self.unavailability_calendars.all()],
}
@ -3367,15 +3578,31 @@ class TimePeriodExceptionSource(models.Model):
@classmethod
def import_json(cls, data):
data = clean_import_data(cls, data)
if data.get('ics_file'):
data['ics_file'] = ContentFile(base64.b64decode(data['ics_file']), name=data['ics_filename'])
desk = data.pop('desk')
settings_slug = data.pop('settings_slug')
source, _ = cls.objects.update_or_create(desk=desk, settings_slug=settings_slug, defaults=data)
if source.enabled:
source.enable()
ics_url = data.pop('ics_url', None)
ics_filename = data.pop('ics_filename', None)
source, _ = cls.objects.update_or_create(
desk=desk, settings_slug=settings_slug, ics_filename=ics_filename, ics_url=ics_url, defaults=data
)
if settings_slug:
if source.enabled:
source.enable()
else:
try:
source.refresh_timeperiod_exceptions_from_ics()
except ICSError:
pass
def export_json(self):
'''Export only sources from settings.'''
return {
'ics_filename': self.ics_filename,
'ics_file': base64.b64encode(self.ics_file.read()).decode() if self.ics_file else None,
'ics_url': self.ics_url,
'settings_slug': self.settings_slug,
'settings_label': self.settings_label,
'enabled': self.enabled,

View File

@ -95,6 +95,18 @@ class FillSlotSerializer(serializers.Serializer):
required=False, child=serializers.CharField(max_length=16, allow_blank=False)
)
check_overlaps = serializers.BooleanField(default=False)
start_time = serializers.TimeField(required=False, allow_null=True)
end_time = serializers.TimeField(required=False, allow_null=True)
def validate(self, attrs):
super().validate(attrs)
use_partial_bookings = any(agenda.partial_bookings for agenda in self.context.get('agendas', []))
if use_partial_bookings:
if not attrs.get('start_time') or not attrs.get('end_time'):
raise ValidationError(_('must include start_time and end_time for partial bookings agenda'))
if attrs['start_time'] > attrs['end_time']:
raise ValidationError(_('start_time must be before end_time'))
return attrs
class SlotsSerializer(serializers.Serializer):
@ -188,18 +200,6 @@ class RecurringFillslotsSerializer(MultipleAgendasEventsFillSlotsSerializer):
check_overlaps = CommaSeparatedStringField(
required=False, child=serializers.SlugField(max_length=160, allow_blank=False)
)
start_time = serializers.TimeField(required=False)
end_time = serializers.TimeField(required=False)
def validate(self, attrs):
super().validate(attrs)
use_partial_bookings = any(agenda.partial_bookings for agenda in self.context['agendas'])
if use_partial_bookings:
if not attrs.get('start_time') or not attrs.get('end_time'):
raise ValidationError(_('must include start_time and end_time for partial bookings agenda'))
if attrs['start_time'] > attrs['end_time']:
raise ValidationError(_('start_time must be before end_time'))
return attrs
def validate_slots(self, value):
super().validate_slots(value)
@ -229,7 +229,60 @@ class RecurringFillslotsSerializer(MultipleAgendasEventsFillSlotsSerializer):
return slots
class RecurringFillslotsByDaySerializer(FillSlotSerializer):
weekdays = {
'monday': 1,
'tuesday': 2,
'wednesday': 3,
'thursday': 4,
'friday': 5,
'saturday': 6,
'sunday': 7,
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
for weekday in self.weekdays:
self.fields[weekday] = CommaSeparatedStringField(
child=serializers.TimeField(), required=False, min_length=2, max_length=2, allow_null=True
)
setattr(self, 'validate_%s' % weekday, self.validate_hour_range)
def validate_hour_range(self, value):
if not value:
return None
start_time, end_time = value
if start_time >= end_time:
raise ValidationError(_('Start hour must be before end hour.'))
return value
def validate(self, attrs):
agendas = self.context['agendas']
if len(agendas) > 1:
raise ValidationError('Multiple agendas are not supported.')
agenda = agendas[0]
if not agenda.partial_bookings:
raise ValidationError('Agenda kind must be partial bookings.')
attrs['hours_by_days'] = hours_by_days = {}
for weekday, weekday_index in self.weekdays.items():
if attrs.get(weekday):
hours_by_days[weekday_index] = attrs[weekday]
days_by_event = collections.defaultdict(list)
for event in agenda.get_open_recurring_events():
for day in event.recurrence_days:
if day in hours_by_days:
days_by_event[event.slug].append(day)
attrs['slots'] = {agenda.slug: days_by_event}
return attrs
class BookingSerializer(serializers.ModelSerializer):
user_was_present = serializers.BooleanField(required=False, allow_null=True)
user_absence_reason = serializers.CharField(required=False, allow_blank=True, allow_null=True)
user_presence_reason = serializers.CharField(required=False, allow_blank=True, allow_null=True)
use_color_for = serializers.CharField(required=False, allow_blank=True, allow_null=True, source='color')
@ -278,12 +331,39 @@ class BookingSerializer(serializers.ModelSerializer):
ret.pop('user_absence_reason', None)
ret.pop('user_presence_reason', None)
else:
user_was_present = self.instance.user_check.presence if self.instance.user_check else None
ret['user_was_present'] = user_was_present
ret['user_absence_reason'] = (
self.instance.user_check_type_slug if self.instance.user_was_present is False else None
self.instance.user_check.type_slug if user_was_present is False else None
)
ret['user_presence_reason'] = (
self.instance.user_check_type_slug if self.instance.user_was_present is True else None
self.instance.user_check.type_slug if user_was_present is True else None
)
if self.instance.event.agenda.kind == 'events' and self.instance.event.agenda.partial_bookings:
self.instance.user_check_start_time = self.instance.user_check.start_time
self.instance.user_check_end_time = self.instance.user_check.end_time
self.instance.computed_start_time = self.instance.user_check.computed_start_time
self.instance.computed_end_time = self.instance.user_check.computed_end_time
for key in ['', 'user_check_', 'computed_']:
start_key, end_key, minutes_key = (
'%sstart_time' % key,
'%send_time' % key,
'%sduration' % key,
)
ret[start_key] = getattr(self.instance, start_key)
ret[end_key] = getattr(self.instance, end_key)
ret[minutes_key] = None
if (
getattr(self.instance, start_key) is not None
and getattr(self.instance, end_key) is not None
):
start_minutes = (
getattr(self.instance, start_key).hour * 60 + getattr(self.instance, start_key).minute
)
end_minutes = (
getattr(self.instance, end_key).hour * 60 + getattr(self.instance, end_key).minute
)
ret[minutes_key] = end_minutes - start_minutes
return ret
def _validate_check_type(self, kind, value):
@ -446,7 +526,7 @@ class AgendaOrSubscribedSlugsSerializer(AgendaOrSubscribedSlugsMixin, DateRangeM
class RecurringFillslotsQueryStringSerializer(AgendaOrSubscribedSlugsSerializer):
action = serializers.ChoiceField(required=True, choices=['update', 'book', 'unbook'])
action = serializers.ChoiceField(required=True, choices=['update', 'update-from-date', 'book', 'unbook'])
class RecurringEventsListSerializer(AgendaOrSubscribedSlugsSerializer):
@ -495,11 +575,12 @@ class EventSerializer(serializers.ModelSerializer):
field_classes = {
'text': serializers.CharField,
'textarea': serializers.CharField,
'bool': serializers.NullBooleanField,
'bool': serializers.BooleanField,
}
field_options = {
'text': {'allow_blank': True},
'textarea': {'allow_blank': True},
'bool': {'allow_null': True},
}
for custom_field in self.instance.agenda.events_type.get_custom_fields():
field_class = field_classes[custom_field['field_type']]

View File

@ -23,6 +23,11 @@ urlpatterns = [
path('agendas/datetimes/', views.agendas_datetimes, name='api-agendas-datetimes'),
path('agendas/recurring-events/', views.recurring_events_list, name='api-agenda-recurring-events'),
path('agendas/recurring-events/fillslots/', views.recurring_fillslots, name='api-recurring-fillslots'),
path(
'agendas/recurring-events/fillslots-by-day/',
views.recurring_fillslots_by_day,
name='api-recurring-fillslots-by-day',
),
path(
'agendas/events/',
views.agendas_events,
@ -57,9 +62,6 @@ urlpatterns = [
views.fillslot,
name='api-fillslot',
),
re_path(
r'^agenda/(?P<agenda_identifier>[\w-]+)/fillslots/$', views.fillslots, name='api-agenda-fillslots'
),
re_path(
r'^agenda/(?P<agenda_identifier>[\w-]+)/events/fillslots/$',
views.events_fillslots,
@ -127,6 +129,7 @@ urlpatterns = [
name='api-agenda-subscription',
),
path('bookings/', views.bookings, name='api-bookings'),
path('bookings/ics/', views.bookings_ics, name='api-bookings-ics'),
path('booking/<int:booking_pk>/', views.booking, name='api-booking'),
path('booking/<int:booking_pk>/cancel/', views.cancel_booking, name='api-cancel-booking'),
path('booking/<int:booking_pk>/accept/', views.accept_booking, name='api-accept-booking'),

View File

@ -20,9 +20,10 @@ import datetime
import json
import uuid
import vobject
from django.conf import settings
from django.db import IntegrityError, transaction
from django.db.models import BooleanField, Count, ExpressionWrapper, F, Func, Prefetch, Q
from django.db.models import BooleanField, Case, Count, ExpressionWrapper, F, Func, Prefetch, Q, When
from django.db.models.expressions import RawSQL
from django.db.models.functions import TruncDay
from django.http import Http404, HttpResponse
@ -45,6 +46,7 @@ from chrono.agendas.models import (
ISO_WEEKDAYS,
Agenda,
Booking,
BookingCheck,
BookingColor,
Desk,
Event,
@ -96,6 +98,8 @@ def get_agenda_detail(request, agenda, check_events=False):
if check_events:
agenda_detail['opened_events_available'] = bool(agenda.get_open_events().filter(full=False))
agenda_detail['booking_form_url'] = agenda.get_booking_form_url()
if settings.PARTIAL_BOOKINGS_ENABLED:
agenda_detail['partial_bookings'] = agenda.partial_bookings
elif agenda.accept_meetings():
agenda_detail['api'] = {
'meetings_url': request.build_absolute_uri(
@ -113,9 +117,6 @@ def get_agenda_detail(request, agenda, check_events=False):
),
}
)
agenda_detail['api']['fillslots_url'] = request.build_absolute_uri(
reverse('api-agenda-fillslots', kwargs={'agenda_identifier': agenda.slug})
)
agenda_detail['api']['backoffice_url'] = request.build_absolute_uri(
reverse('chrono-manager-agenda-view', kwargs={'pk': agenda.pk})
)
@ -176,6 +177,10 @@ def is_event_disabled(
def get_event_text(event, agenda, day=None):
event_text = force_str(event)
if day is not None:
event.weekday = ISO_WEEKDAYS[day].capitalize()
if agenda.event_display_template:
try:
event_text = Template(agenda.event_display_template).render(
@ -190,7 +195,7 @@ def get_event_text(event, agenda, day=None):
)
elif day is not None:
event_text = _('%(weekday)s: %(event)s') % {
'weekday': ISO_WEEKDAYS[day].capitalize(),
'weekday': event.weekday,
'event': event_text,
}
return event_text
@ -1175,20 +1180,14 @@ class AgendaResourceList(APIView):
agenda_resource_list = AgendaResourceList.as_view()
class EventsAgendaFillslots(APIView):
class EventsAgendaFillslot(APIView):
permission_classes = (permissions.IsAuthenticated,)
serializer_class = serializers.FillSlotsSerializer
serializer_class = serializers.FillSlotSerializer
def post(self, request, agenda, slots):
if not settings.LEGACY_FILLSLOTS_ENABLED and slots is None:
raise APIErrorBadRequest(N_('deprecated call'))
return self.fillslot(request=request, agenda=agenda, slots=slots)
def fillslot(self, request, agenda, slots=None, retry=False):
slots = slots or []
multiple_booking = bool(not slots)
def post(self, request, agenda, slot):
return self.fillslot(request=request, agenda=agenda, slot=slot)
def fillslot(self, request, agenda, slot, retry=False):
known_body_params = set(request.query_params).intersection(
{'label', 'user_name', 'backoffice_url', 'user_display_label'}
)
@ -1198,14 +1197,12 @@ class EventsAgendaFillslots(APIView):
N_('parameters "%s" must be included in request body, not query'), params
)
serializer = self.serializer_class(data=request.data, partial=True)
context = {'agendas': [agenda]}
serializer = self.serializer_class(data=request.data, partial=True, context=context)
if not serializer.is_valid():
raise APIErrorBadRequest(N_('invalid payload'), errors=serializer.errors)
payload = serializer.validated_data
if 'slots' in payload:
slots = payload['slots']
if 'count' in payload:
places_count = payload['count']
elif 'count' in request.query_params:
@ -1249,29 +1246,28 @@ class EventsAgendaFillslots(APIView):
extra_data = get_extra_data(request, serializer.validated_data)
events = get_events_from_slots(slots, request, agenda, payload)
event = get_events_from_slots([slot], request, agenda, payload)[0]
# search free places. Switch to waiting list if necessary.
in_waiting_list = False
for event in events:
if event.start_datetime > now():
if payload.get('force_waiting_list') and not event.waiting_list_places:
raise APIError(N_('no waiting list'))
if event.start_datetime > now():
if payload.get('force_waiting_list') and not event.waiting_list_places:
raise APIError(N_('no waiting list'))
if event.waiting_list_places:
if (
payload.get('force_waiting_list')
or (event.booked_places + places_count) > event.places
or event.booked_waiting_list_places
):
# if this is full or there are people waiting, put new bookings
# in the waiting list.
in_waiting_list = True
if (event.booked_waiting_list_places + places_count) > event.waiting_list_places:
raise APIError(N_('sold out'))
else:
if (event.booked_places + places_count) > event.places:
if event.waiting_list_places:
if (
payload.get('force_waiting_list')
or (event.booked_places + places_count) > event.places
or event.booked_waiting_list_places
):
# if this is full or there are people waiting, put new bookings
# in the waiting list.
in_waiting_list = True
if (event.booked_waiting_list_places + places_count) > event.waiting_list_places:
raise APIError(N_('sold out'))
else:
if (event.booked_places + places_count) > event.places:
raise APIError(N_('sold out'))
try:
with transaction.atomic():
@ -1281,18 +1277,17 @@ class EventsAgendaFillslots(APIView):
# now we have a list of events, book them.
primary_booking = None
for event in events:
for dummy in range(places_count):
new_booking = make_booking(
event=event,
payload=payload,
extra_data=extra_data,
primary_booking=primary_booking,
in_waiting_list=in_waiting_list,
)
new_booking.save()
if primary_booking is None:
primary_booking = new_booking
for dummy in range(places_count):
new_booking = make_booking(
event=event,
payload=payload,
extra_data=extra_data,
primary_booking=primary_booking,
in_waiting_list=in_waiting_list,
)
new_booking.save()
if primary_booking is None:
primary_booking = new_booking
except IntegrityError as e:
if 'tstzrange_constraint' in str(e):
# "optimistic concurrency control", between our availability
@ -1306,14 +1301,14 @@ class EventsAgendaFillslots(APIView):
# of fillslot().
if retry:
raise APIError(N_('no more desk available'))
return self.fillslot(request=request, agenda=agenda, slots=slots, retry=True)
return self.fillslot(request=request, agenda=agenda, slot=slot, retry=True)
raise
response = {
'err': 0,
'in_waiting_list': in_waiting_list,
'booking_id': primary_booking.id,
'datetime': format_response_datetime(events[0].start_datetime),
'datetime': format_response_datetime(event.start_datetime),
'agenda': {
'label': primary_booking.event.agenda.label,
'slug': primary_booking.event.agenda.slug,
@ -1341,47 +1336,26 @@ class EventsAgendaFillslots(APIView):
}
if to_cancel_booking:
response['cancelled_booking_id'] = cancelled_booking_id
if not multiple_booking:
event = events[0]
# event.full is not up to date, it might have been changed by previous new_booking.save().
event.refresh_from_db()
response['places'] = get_event_places(event)
if event.end_datetime:
response['end_datetime'] = format_response_datetime(event.end_datetime)
else:
response['end_datetime'] = None
# event.full is not up to date, it might have been changed by previous new_booking.save().
event.refresh_from_db()
response['places'] = get_event_places(event)
if event.end_datetime:
response['end_datetime'] = format_response_datetime(event.end_datetime)
else:
response['events'] = [
{
'slug': x.slug,
'text': str(x),
'datetime': format_response_datetime(x.start_datetime),
'end_datetime': format_response_datetime(x.end_datetime) if x.end_datetime else None,
'description': x.description,
}
for x in events
]
response['end_datetime'] = None
return Response(response)
class EventsAgendaFillslot(EventsAgendaFillslots):
class MeetingsAgendaFillslot(APIView):
permission_classes = (permissions.IsAuthenticated,)
serializer_class = serializers.FillSlotSerializer
def post(self, request, agenda, slot):
return self.fillslot(request=request, agenda=agenda, timeslot_id=slot)
class MeetingsAgendaFillslots(APIView):
permission_classes = (permissions.IsAuthenticated,)
serializer_class = serializers.FillSlotsSerializer
def post(self, request, agenda, slots):
if not settings.LEGACY_FILLSLOTS_ENABLED and slots is None:
raise APIErrorBadRequest(N_('deprecated call'))
return self.fillslot(request=request, agenda=agenda, slots=slots)
def fillslot(self, request, agenda, slots=None, retry=False):
slots = slots or []
def fillslot(self, request, agenda, timeslot_id, retry=False):
known_body_params = set(request.query_params).intersection(
{'label', 'user_name', 'backoffice_url', 'user_display_label'}
)
@ -1396,9 +1370,6 @@ class MeetingsAgendaFillslots(APIView):
raise APIErrorBadRequest(N_('invalid payload'), errors=serializer.errors)
payload = serializer.validated_data
if 'slots' in payload:
slots = payload['slots']
to_cancel_booking = None
cancel_booking_id = None
if payload.get('cancel_booking_id'):
@ -1426,23 +1397,15 @@ class MeetingsAgendaFillslots(APIView):
user_external_id = payload.get('user_external_id') or None
exclude_user = payload.get('exclude_user')
# slots are actually timeslot ids (meeting_type:start_datetime), not events ids.
# split them back to get both parts
meeting_type_id = slots[0].split(':')[0]
datetimes = set()
for slot in slots:
try:
meeting_type_id_, datetime_str = slot.split(':')
except ValueError:
raise APIErrorBadRequest(N_('invalid slot: %s'), slot)
if meeting_type_id_ != meeting_type_id:
raise APIErrorBadRequest(
N_('all slots must have the same meeting type id (%s)'), meeting_type_id
)
try:
datetimes.add(make_aware(datetime.datetime.strptime(datetime_str, '%Y-%m-%d-%H%M')))
except ValueError:
raise APIErrorBadRequest(N_('bad datetime format: %s'), datetime_str)
try:
meeting_type_id, datetime_str = timeslot_id.split(':')
except ValueError:
raise APIErrorBadRequest(N_('invalid timeslot_id: %s'), timeslot_id)
try:
slot_datetime = make_aware(datetime.datetime.strptime(datetime_str, '%Y-%m-%d-%H%M'))
except ValueError:
raise APIErrorBadRequest(N_('bad datetime format: %s'), datetime_str)
resources = get_resources_from_request(request, agenda)
@ -1460,8 +1423,8 @@ class MeetingsAgendaFillslots(APIView):
meeting_type,
resources=resources,
user_external_id=user_external_id if exclude_user else None,
start_datetime=min(datetimes),
end_datetime=max(datetimes) + datetime.timedelta(minutes=meeting_type.duration),
start_datetime=slot_datetime,
end_datetime=slot_datetime + datetime.timedelta(minutes=meeting_type.duration),
),
key=lambda slot: slot.start_datetime,
)
@ -1496,18 +1459,15 @@ class MeetingsAgendaFillslots(APIView):
# select a desk on the agenda with min fill_rate on the given date
for available_desk_id in sorted(datetimes_by_desk.keys()):
if datetimes.issubset(datetimes_by_desk[available_desk_id]):
if slot_datetime in datetimes_by_desk[available_desk_id]:
desk = Desk.objects.get(id=available_desk_id)
if available_desk is None:
available_desk = desk
available_desk_rate = 0
for dt in datetimes:
available_desk_rate += fill_rates[available_desk.agenda][dt.date()]['fill_rate']
available_desk_rate = fill_rates[available_desk.agenda][slot_datetime.date()][
'fill_rate'
]
else:
for dt in datetimes:
desk_rate = 0
for dt in datetimes:
desk_rate += fill_rates[desk.agenda][dt.date()]['fill_rate']
desk_rate = fill_rates[desk.agenda][slot_datetime.date()]['fill_rate']
if desk_rate < available_desk_rate:
available_desk = desk
available_desk_rate = desk_rate
@ -1516,36 +1476,28 @@ class MeetingsAgendaFillslots(APIView):
# meeting agenda
# search first desk where all requested slots are free
for available_desk_id in sorted(datetimes_by_desk.keys()):
if datetimes.issubset(datetimes_by_desk[available_desk_id]):
if slot_datetime in datetimes_by_desk[available_desk_id]:
available_desk = Desk.objects.get(id=available_desk_id)
break
if available_desk is None:
raise APIError(N_('no more desk available'))
# all datetimes are free, book them in order
datetimes = list(datetimes)
datetimes.sort()
# get a real meeting_type for virtual agenda
if agenda.kind == 'virtual':
meeting_type = MeetingType.objects.get(agenda=available_desk.agenda, slug=meeting_type.slug)
# booking requires real Event objects (not lazy Timeslots);
# create them now, with data from the slots and the desk we found.
events = []
for start_datetime in datetimes:
events.append(
Event(
agenda=available_desk.agenda,
slug=str(uuid.uuid4()), # set slug to avoid queries during slug generation
meeting_type=meeting_type,
start_datetime=start_datetime,
full=False,
places=1,
desk=available_desk,
)
)
# booking requires real Event object (not lazy Timeslot);
# create it now, with data from the slot and the desk we found.
event = Event(
agenda=available_desk.agenda,
slug=str(uuid.uuid4()), # set slug to avoid queries during slug generation
meeting_type=meeting_type,
start_datetime=slot_datetime,
full=False,
places=1,
desk=available_desk,
)
try:
with transaction.atomic():
@ -1553,23 +1505,17 @@ class MeetingsAgendaFillslots(APIView):
cancelled_booking_id = to_cancel_booking.pk
to_cancel_booking.cancel()
# now we have a list of events, book them.
primary_booking = None
for event in events:
if agenda.accept_meetings():
event.save()
if resources:
event.resources.add(*resources)
new_booking = make_booking(
event=event,
payload=payload,
extra_data=extra_data,
primary_booking=primary_booking,
color=color,
)
new_booking.save()
if primary_booking is None:
primary_booking = new_booking
# book event
event.save()
if resources:
event.resources.add(*resources)
booking = make_booking(
event=event,
payload=payload,
extra_data=extra_data,
color=color,
)
booking.save()
except IntegrityError as e:
if 'tstzrange_constraint' in str(e):
# "optimistic concurrency control", between our availability
@ -1583,33 +1529,33 @@ class MeetingsAgendaFillslots(APIView):
# of fillslot().
if retry:
raise APIError(N_('no more desk available'))
return self.fillslot(request=request, agenda=agenda, slots=slots, retry=True)
return self.fillslot(request=request, agenda=agenda, timeslot_id=timeslot_id, retry=True)
raise
response = {
'err': 0,
'booking_id': primary_booking.id,
'datetime': format_response_datetime(events[0].start_datetime),
'booking_id': booking.id,
'datetime': format_response_datetime(event.start_datetime),
'agenda': {
'label': primary_booking.event.agenda.label,
'slug': primary_booking.event.agenda.slug,
'label': booking.event.agenda.label,
'slug': booking.event.agenda.slug,
},
'end_datetime': format_response_datetime(events[-1].end_datetime),
'duration': (events[-1].end_datetime - events[-1].start_datetime).seconds // 60,
'end_datetime': format_response_datetime(event.end_datetime),
'duration': (event.end_datetime - event.start_datetime).seconds // 60,
'resources': [r.slug for r in resources],
'desk': {'label': available_desk.label, 'slug': available_desk.slug},
'api': {
'booking_url': request.build_absolute_uri(
reverse('api-booking', kwargs={'booking_pk': primary_booking.id})
reverse('api-booking', kwargs={'booking_pk': booking.id})
),
'cancel_url': request.build_absolute_uri(
reverse('api-cancel-booking', kwargs={'booking_pk': primary_booking.id})
reverse('api-cancel-booking', kwargs={'booking_pk': booking.id})
),
'ics_url': request.build_absolute_uri(
reverse('api-booking-ics', kwargs={'booking_pk': primary_booking.id})
reverse('api-booking-ics', kwargs={'booking_pk': booking.id})
),
'anonymize_url': request.build_absolute_uri(
reverse('api-anonymize-booking', kwargs={'booking_pk': primary_booking.id})
reverse('api-anonymize-booking', kwargs={'booking_pk': booking.id})
),
},
}
@ -1619,13 +1565,10 @@ class MeetingsAgendaFillslots(APIView):
return Response(response)
class MeetingsAgendaFillslot(MeetingsAgendaFillslots):
class Fillslot(APIView):
serializer_class = serializers.FillSlotSerializer
class Fillslots(APIView):
def dispatch(self, request, *args, **kwargs):
agenda_identifier = kwargs['agenda_identifier']
def dispatch(self, request, agenda_identifier, event_identifier):
try:
agenda = Agenda.objects.get(slug=agenda_identifier)
except Agenda.DoesNotExist:
@ -1635,31 +1578,11 @@ class Fillslots(APIView):
except (ValueError, Agenda.DoesNotExist):
raise Http404()
if kwargs.get('slots'):
if agenda.accept_meetings():
api_view = MeetingsAgendaFillslot()
else:
api_view = EventsAgendaFillslot()
if agenda.accept_meetings():
api_view = MeetingsAgendaFillslot()
else:
if agenda.accept_meetings():
api_view = MeetingsAgendaFillslots()
else:
api_view = EventsAgendaFillslots()
return api_view.dispatch(request=request, agenda=agenda, slots=kwargs.get('slots'))
fillslots = Fillslots.as_view()
class Fillslot(Fillslots):
serializer_class = serializers.FillSlotSerializer
def dispatch(self, request, agenda_identifier=None, event_identifier=None):
return super().dispatch(
request=request,
agenda_identifier=agenda_identifier,
slots=[event_identifier], # fill a "list on one slot"
)
api_view = EventsAgendaFillslot()
return api_view.dispatch(request=request, agenda=agenda, slot=event_identifier)
fillslot = Fillslot.as_view()
@ -1704,6 +1627,16 @@ class RecurringFillslots(APIView):
guardian_external_id,
)
events_to_unbook = self.get_events_to_unbook(agendas, events_to_book)
elif data['action'] == 'update-from-date':
events_to_book = self.get_event_recurrences(
agendas,
payload['slots'],
start_datetime,
end_datetime,
user_external_id,
guardian_external_id,
)
events_to_unbook = self.get_events_to_unbook(agendas, events_to_book, start_datetime)
elif data['action'] == 'book':
events_to_book = self.get_event_recurrences(
agendas,
@ -1771,7 +1704,7 @@ class RecurringFillslots(APIView):
# don't reload agendas and events types
for event in events_to_book:
event.agenda = agendas_by_id[event.agenda_id]
bookings = [make_booking(event, payload, extra_data) for event in events_to_book]
bookings = self.make_bookings(events_to_book, payload, extra_data)
bookings_to_cancel = Booking.objects.filter(
user_external_id=user_external_id, event__in=events_to_unbook, cancellation_datetime__isnull=True
@ -1866,7 +1799,7 @@ class RecurringFillslots(APIView):
return events
def get_events_to_unbook(self, agendas, events_to_book):
def get_events_to_unbook(self, agendas, events_to_book, start_datetime=None):
events_to_book_ids = set(events_to_book.values_list('pk', flat=True))
events_to_unbook = [
e
@ -1877,6 +1810,7 @@ class RecurringFillslots(APIView):
and e.pk not in events_to_book_ids
and (not agenda.minimal_booking_delay or e.start_datetime >= agenda.min_booking_datetime)
and (not agenda.maximal_booking_delay or e.start_datetime <= agenda.max_booking_datetime)
and (not start_datetime or e.start_datetime >= start_datetime)
]
return events_to_unbook
@ -1904,14 +1838,32 @@ class RecurringFillslots(APIView):
% ', '.join(sorted('%s / %s' % (x, y) for x, y in overlaps))
)
def make_bookings(self, events, payload, extra_data):
return [make_booking(event, payload, extra_data) for event in events]
recurring_fillslots = RecurringFillslots.as_view()
class RecurringFillslotsByDay(RecurringFillslots):
serializer_class = serializers.RecurringFillslotsByDaySerializer
def make_bookings(self, events, payload, extra_data):
bookings = []
for event in events:
payload['start_time'], payload['end_time'] = payload['hours_by_days'][
event.start_datetime.isoweekday()
]
bookings.append(make_booking(event, payload, extra_data))
return bookings
recurring_fillslots_by_day = RecurringFillslotsByDay.as_view()
class EventsFillslots(APIView):
permission_classes = (permissions.IsAuthenticated,)
serializer_class = serializers.EventsFillSlotsSerializer
serializer_extra_context = None
multiple_agendas = False
def post(self, request, agenda_identifier):
@ -2072,7 +2024,11 @@ class EventsFillslots(APIView):
],
'cancelled_booking_count': cancelled_count,
'cancelled_events': cancelled_events,
'bookings_ics_url': request.build_absolute_uri(reverse('api-bookings-ics'))
+ '?user_external_id=%s' % user_external_id,
}
if not self.multiple_agendas:
response['bookings_ics_url'] += '&agenda=%s' % self.agenda.slug
return Response(response)
def get_events(self, request, payload, start_datetime, end_datetime):
@ -2086,6 +2042,10 @@ class EventsFillslots(APIView):
def get_agendas_by_ids(self):
return {self.agenda.pk: self.agenda}
@property
def serializer_extra_context(self):
return {'agendas': [self.agenda]}
events_fillslots = EventsFillslots.as_view()
@ -2165,7 +2125,7 @@ class MultipleAgendasEventsFillslots(EventsFillslots):
@property
def serializer_extra_context(self):
return {'allowed_agenda_slugs': self.agenda_slugs}
return {'allowed_agenda_slugs': self.agenda_slugs, 'agendas': self.agendas}
agendas_events_fillslots = MultipleAgendasEventsFillslots.as_view()
@ -2244,7 +2204,7 @@ class MultipleAgendasEventsCheckStatus(APIView):
booking_queryset = Booking.objects.filter(
event__in=events,
user_external_id=user_external_id,
)
).prefetch_related('user_checks')
bookings_by_event_id = collections.defaultdict(list)
for booking in booking_queryset:
bookings_by_event_id[booking.event_id].append(booking)
@ -2265,12 +2225,12 @@ class MultipleAgendasEventsCheckStatus(APIView):
booking.event = event # prevent db calls
if booking.cancellation_datetime is not None:
check_status = {'status': 'cancelled'}
elif booking.user_was_present is None:
elif not booking.user_check:
check_status = {'status': 'error', 'error_reason': 'booking-not-checked'}
else:
check_status = {
'status': 'presence' if booking.user_was_present else 'absence',
'check_type': booking.user_check_type_slug,
'status': 'presence' if booking.user_check.presence else 'absence',
'check_type': booking.user_check.type_slug,
}
data.append(
{
@ -2308,6 +2268,9 @@ class MultipleAgendasEventsCheckLock(APIView):
start_datetime__lt=date_end,
)
events.update(check_locked=check_locked)
if check_locked is False:
for event in events:
event.async_refresh_booking_computed_times()
return Response({'err': 0})
@ -2547,6 +2510,7 @@ class BookingFilter(filters.FilterSet):
category = filters.CharFilter(field_name='event__agenda__category__slug', lookup_expr='exact')
date_start = filters.DateFilter(field_name='event__start_datetime', lookup_expr='gte')
date_end = filters.DateFilter(field_name='event__start_datetime', lookup_expr='lt')
user_was_present = filters.CharFilter(method='filter_user_was_present')
user_absence_reason = filters.CharFilter(method='filter_user_absence_reason')
user_presence_reason = filters.CharFilter(method='filter_user_presence_reason')
@ -2554,16 +2518,19 @@ class BookingFilter(filters.FilterSet):
# we want to include bookings of event recurrences
return queryset.filter(Q(event__slug=value) | Q(event__primary_event__slug=value))
def filter_user_was_present(self, queryset, name, value):
return queryset.filter(user_checks__presence=value)
def filter_user_absence_reason(self, queryset, name, value):
return queryset.filter(
Q(user_check_type_slug=value) | Q(user_check_type_label=value),
user_was_present=False,
Q(user_checks__type_slug=value) | Q(user_checks__type_label=value),
user_checks__presence=False,
)
def filter_user_presence_reason(self, queryset, name, value):
return queryset.filter(
Q(user_check_type_slug=value) | Q(user_check_type_label=value),
user_was_present=True,
Q(user_checks__type_slug=value) | Q(user_checks__type_label=value),
user_checks__presence=True,
)
class Meta:
@ -2608,6 +2575,7 @@ class BookingsAPI(ListAPIView):
return (
Booking.objects.filter(primary_booking__isnull=True, cancellation_datetime__isnull=True)
.select_related('event', 'event__agenda', 'event__desk')
.prefetch_related('user_checks')
.order_by('event__start_datetime', 'event__slug', 'event__agenda__slug', 'pk')
)
@ -2615,6 +2583,29 @@ class BookingsAPI(ListAPIView):
bookings = BookingsAPI.as_view()
class BookingsICS(BookingsAPI):
def get(self, request, *args, **kwargs):
if not request.GET.get('user_external_id'):
raise APIError(N_('missing param user_external_id'))
try:
bookings = self.filter_queryset(self.get_queryset())
except ValidationError as e:
raise APIErrorBadRequest(N_('invalid payload'), errors=e.detail)
ics = vobject.iCalendar()
ics.add('prodid').value = '-//Entr\'ouvert//NON SGML Publik'
for booking in bookings:
vevent = booking.get_vevent_ics()
ics.add(vevent)
return HttpResponse(ics.serialize(), content_type='text/calendar')
bookings_ics = BookingsICS.as_view()
class BookingAPI(APIView):
permission_classes = (permissions.IsAuthenticated,)
serializer_class = serializers.BookingSerializer
@ -2672,7 +2663,10 @@ class BookingAPI(APIView):
):
raise APIErrorBadRequest(N_('event is marked as checked'), err=5)
user_was_present = serializer.validated_data.get('user_was_present', self.booking.user_was_present)
user_was_present = serializer.validated_data.get(
'user_was_present',
self.booking.user_check.presence if self.booking.user_check else None,
)
if user_was_present is True and 'user_absence_reason' in request.data:
raise APIErrorBadRequest(N_('user is marked as present, can not set absence reason'), err=6)
if user_was_present is False and 'user_presence_reason' in request.data:
@ -2685,9 +2679,23 @@ class BookingAPI(APIView):
self.booking.extra_data.update(extra_data)
self.booking.save()
if user_was_present is None:
if self.booking.user_check:
self.booking.user_check.delete()
self.booking.user_check = None
else:
if not self.booking.user_check:
self.booking.user_check = BookingCheck(booking=self.booking)
self.booking.user_check.presence = user_was_present
self.booking.user_check.save()
if 'user_check_type_slug' in serializer.validated_data and self.booking.user_check:
self.booking.user_check.type_slug = serializer.validated_data['user_check_type_slug']
self.booking.user_check.type_label = serializer.validated_data['user_check_type_label']
self.booking.user_check.save()
secondary_bookings_update = {}
for key in [
'user_was_present',
'user_first_name',
'user_last_name',
'user_email',
@ -2697,9 +2705,6 @@ class BookingAPI(APIView):
secondary_bookings_update[key] = getattr(self.booking, key)
if 'use_color_for' in request.data:
secondary_bookings_update['color'] = self.booking.color
if 'user_absence_reason' in request.data or 'user_presence_reason' in request.data:
secondary_bookings_update['user_check_type_slug'] = self.booking.user_check_type_slug
secondary_bookings_update['user_check_type_label'] = self.booking.user_check_type_label
if extra_data:
secondary_bookings_update['extra_data'] = self.booking.extra_data
if secondary_bookings_update:
@ -3253,9 +3258,17 @@ class BookingsStatistics(APIView):
if not isinstance(group_by, list): # legacy support
group_by = [group_by]
lookups = [
'extra_data__%s' % field if field != 'user_was_present' else field for field in group_by
]
lookups = []
if 'user_was_present' in group_by:
bookings = bookings.annotate(
presence=Case(
When(primary_booking__isnull=True, then=F('user_checks__presence')),
When(primary_booking__isnull=False, then=F('primary_booking__user_checks__presence')),
)
)
lookups.append('presence')
lookups += ['extra_data__%s' % field for field in group_by if field != 'user_was_present']
bookings = bookings.values('day', *lookups).annotate(total=Count('id')).order_by('day')
days = bookings_by_day = collections.OrderedDict(

View File

@ -22,6 +22,7 @@ from django.conf import settings
from django.db import models, transaction
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from django.utils.translation import pgettext_lazy
from chrono.agendas.models import Agenda, Booking
from chrono.utils.timezone import localtime, now
@ -228,22 +229,27 @@ class Place(models.Model):
.values_list(
'extra_data__ants_identifiant_predemande', 'event__start_datetime', 'cancellation_datetime'
)
.order_by('event__state_datetime')
.order_by('event__start_datetime')
)
for identifiant_predemande, start_datetime, cancellation_datetime in bookings:
if not isinstance(identifiant_predemande, str):
for identifiant_predemande_data, start_datetime, cancellation_datetime in bookings:
if not isinstance(identifiant_predemande_data, str):
continue
rdv = {
'id': identifiant_predemande,
'date': start_datetime.isoformat(),
}
if cancellation_datetime is not None:
rdv['annule'] = True
yield rdv
# split data on commas, and remove trailing whitespaces
identifiant_predemandes = filter(
None, (part.strip() for part in identifiant_predemande_data.split(','))
)
for identifiant_predemande in identifiant_predemandes:
rdv = {
'id': identifiant_predemande,
'date': start_datetime.isoformat(),
}
if cancellation_datetime is not None:
rdv['annule'] = True
yield rdv
class Meta:
verbose_name = _('place')
verbose_name_plural = _('places')
verbose_name = pgettext_lazy('location', 'place')
verbose_name_plural = pgettext_lazy('location', 'places')
unique_together = [
('city', 'name'),
]

View File

@ -7,8 +7,8 @@ msgid ""
msgstr ""
"Project-Id-Version: chrono 0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-07-18 18:13+0200\n"
"PO-Revision-Date: 2023-07-18 18:16+0200\n"
"POT-Creation-Date: 2023-10-09 10:54+0200\n"
"PO-Revision-Date: 2023-10-09 10:58+0200\n"
"Last-Translator: Frederic Peters <fpeters@entrouvert.com>\n"
"Language: French\n"
"MIME-Version: 1.0\n"
@ -260,12 +260,40 @@ msgstr "Horaire douverture des réservations"
#: agendas/models.py
msgid ""
"Ex.: 08:00:00. If left empty, available events will be those that are later "
"than the current time."
"If left empty, available events will be those that are later than the "
"current time."
msgstr ""
"Exemple : 08:00:00. Si ce champ est laissé vide, les évènements ou rendez-"
"vous disponibles à la réservation seront ceux qui commencent après lheure "
"actuelle, en prenant en compte les délais de réservation minimal et maximal."
"Si ce champ est laissé vide, les évènements ou rendez-vous disponibles à la "
"réservation seront ceux qui commencent après lheure actuelle, en prenant en "
"compte les délais de réservation minimal et maximal."
#: agendas/models.py
msgid "Invoicing"
msgstr "Facturation"
#: agendas/models.py
msgid "Per hour"
msgstr "À lheure"
#: agendas/models.py
msgid "Per half hour"
msgstr "À la demi-heure"
#: agendas/models.py
msgid "Per quarter-hour"
msgstr "Au quart dheure"
#: agendas/models.py
msgid "Per minute"
msgstr "À la minute"
#: agendas/models.py
msgid "Tolerance"
msgstr "Tolérance"
#: agendas/models.py manager/forms.py
msgid "Partial bookings"
msgstr "Plages libres"
#: agendas/models.py
#, python-format
@ -980,6 +1008,15 @@ msgstr "Armistice 1945"
msgid "Whit Monday"
msgstr "Lundi de Pentecôte"
#: api/serializers.py
msgid "must include start_time and end_time for partial bookings agenda"
msgstr ""
"start_time et end_time doivent être inclus pour les agendas plages libres"
#: api/serializers.py
msgid "start_time must be before end_time"
msgstr "start_time doit précéder end_time"
#: api/serializers.py manager/forms.py
msgid "This field is required."
msgstr "Le champ est obligatoire."
@ -1009,15 +1046,6 @@ msgid "Events from the following agendas cannot be booked: %s"
msgstr "Les évènements de ces agendas ne peuvent pas être réservés : %s"
#: api/serializers.py
msgid "must include start_time and end_time for partial bookings agenda"
msgstr ""
"start_time et end_time doivent être inclus pour les agendas plages libres"
#: api/serializers.py
msgid "start_time must be before end_time"
msgstr "start_time doit précéder end_time"
#: api/serializers.py api/views.py
#, python-format
msgid "invalid slot: %s"
msgstr "créneau invalide : %s"
@ -1028,6 +1056,10 @@ msgid "event %(event_slug)s of agenda %(agenda_slug)s is not bookable"
msgstr ""
"lévènement %(event_slug)s de lagenda %(agenda_slug)s nest pas réservable"
#: api/serializers.py
msgid "Start hour must be before end hour."
msgstr "Lheure darrivée doit précéder lheure de départ."
#: api/serializers.py
msgid "unknown absence reason"
msgstr "motif dabsence inconnu"
@ -1187,10 +1219,6 @@ msgstr ""
msgid "it is not possible to change kind value"
msgstr "il nest pas possible de modifier le type"
#: api/views.py
msgid "deprecated call"
msgstr "appel déprécié"
#: api/views.py
#, python-format
msgid "parameters \"%s\" must be included in request body, not query"
@ -1236,8 +1264,8 @@ msgstr "plus de guichet disponible"
#: api/views.py
#, python-format
msgid "all slots must have the same meeting type id (%s)"
msgstr "tous les créneaux doivent être pour le même type dévènement (%s)"
msgid "invalid timeslot_id: %s"
msgstr "timeslot_id invalide : %s"
#: api/views.py
#, python-format
@ -1512,13 +1540,14 @@ msgid "Full synchronization"
msgstr "Synchronisation complète"
#: apps/ants_hub/models.py
msgctxt "location"
msgid "place"
msgstr "lieu"
#: apps/ants_hub/models.py manager/templates/chrono/manager_event_check.html
#: manager/templates/chrono/manager_event_check_booking_fragment.html
#: apps/ants_hub/models.py
msgctxt "location"
msgid "places"
msgstr "lieus"
msgstr "lieux"
#: apps/ants_hub/models.py
msgid "CNI"
@ -1781,10 +1810,6 @@ msgstr "%(mt_label)s (%(mt_duration)s minutes)"
msgid "Synchronization has been launched."
msgstr "La synchronisation a été lancée."
#: manager/forms.py
msgid "Partial bookings"
msgstr "Plages libres"
#: manager/forms.py
msgid "Desk 1"
msgstr "Guichet 1"
@ -1934,6 +1959,22 @@ msgstr "Statut"
msgid "Type"
msgstr "Type"
#: manager/forms.py
msgid "Fill with booking start time"
msgstr "Prendre lheure de début de la réservation"
#: manager/forms.py
msgid "Fill with booking end time"
msgstr "Prendre lheure de fin de la réservation"
#: manager/forms.py
msgid "Arrival must be before departure."
msgstr "Lheure darrivée doit précéder lheure de départ."
#: manager/forms.py
msgid "Booking check hours overlap existing check."
msgstr "Les heures de pointage chevauchent un pointage existant."
#: manager/forms.py
msgid "Start date"
msgstr "Date de début"
@ -2077,58 +2118,49 @@ msgstr ""
msgid "Invalid file format."
msgstr "Format de fichier invalide."
#: manager/forms.py
#, python-brace-format
msgid "Invalid file format. ({event_no} event)"
msgstr "Format de fichier invalide. ({event_no} évènement)"
#: manager/forms.py manager/templates/chrono/manager_sample_events.txt
#: manager/views.py
msgid "date"
msgstr "date"
#: manager/forms.py
#, python-brace-format
msgid "Invalid file format. (date/time format, {event_no} event)"
msgstr "Format de fichier invalide. (format date/heure, {event_no} évènement)"
msgid "Invalid file format:"
msgstr "Format de fichier invalide :"
#: manager/forms.py
#, python-brace-format
msgid "Invalid file format. (number of places, {event_no} event)"
msgstr "Format de fichier invalide. (nombre de places, {event_no} évènement)"
msgid "Not enough columns."
msgstr "Pas assez de colonnes."
#: manager/forms.py
#, python-brace-format
msgid ""
"Invalid file format. (number of places in waiting list, {event_no} event)"
msgid "Wrong start date/time format."
msgstr "Format de date/heure invalide."
#: manager/forms.py
msgid "Number of places must be an integer."
msgstr "Le nombre de places doit être un nombre entier."
#: manager/forms.py
msgid "Number of places in waiting list must be an integer."
msgstr ""
"Format de fichier invalide. (nombre de places sur la liste dattente, "
"{event_no} évènement)"
"Le nombre de places dans la liste dattente doit être un nombre entier."
#: manager/forms.py
#, python-brace-format
msgid "Invalid file format. (missing end_time, {event_no} event)"
msgstr "Format de fichier invalide. (end_time manquant, {event_no} évènement)"
msgid "Wrong publication date/time format."
msgstr "Format de date/heure de publication invalide."
#: manager/forms.py
#, python-brace-format
msgid "Invalid file format. (duration, {event_no} event)"
msgstr "Format de fichier invalide. (durée, {event_no} évènement)"
msgid "Missing end_time."
msgstr "end_time absent."
#: manager/forms.py
msgid "Invalid file format:\n"
msgstr "Format de fichier invalide :\n"
msgid "Duration must be an integer."
msgstr "La durée doit être un nombre entier."
#: manager/forms.py
#, python-format
msgid "%s: "
msgstr "%s : "
#: manager/forms.py
#, python-format
msgid "%(errors)s (line %(line)d)"
msgstr "%(errors)s (ligne %(line)d)"
#: manager/forms.py
msgid "ICS File"
msgstr "Fichier ICS"
@ -2362,16 +2394,19 @@ msgstr "Complet"
#: manager/templates/chrono/manager_agenda_event_fragment.html
#: manager/templates/chrono/manager_event_detail.html
#: manager/templates/chrono/manager_partial_bookings_day_view.html
msgid "Invoiced"
msgstr "Facturé"
#: manager/templates/chrono/manager_agenda_event_fragment.html
#: manager/templates/chrono/manager_event_detail.html
#: manager/templates/chrono/manager_partial_bookings_day_view.html
msgid "Check locked"
msgstr "Pointage verrouillé"
#: manager/templates/chrono/manager_agenda_event_fragment.html
#: manager/templates/chrono/manager_event_detail.html
#: manager/templates/chrono/manager_partial_bookings_day_view.html
msgid "Checked"
msgstr "Pointé"
@ -2804,6 +2839,7 @@ msgid "Bookings (%(booked_places)s/%(places)s)"
msgstr "Réservations (%(booked_places)s/%(places)s)"
#: manager/templates/chrono/manager_event_check.html
#: manager/templates/chrono/manager_partial_bookings_day_view.html
msgid "Mark the event as checked"
msgstr "Marquer lévènement comme étant pointé"
@ -2816,13 +2852,18 @@ msgstr "Marquer toutes les réservations non précisées :"
msgid "Waiting List (%(booked_places)s/%(places)s)"
msgstr "Liste dattente (%(booked_places)s/%(places)s)"
#: manager/templates/chrono/manager_event_check.html
#: manager/templates/chrono/manager_event_check_booking_fragment.html
msgid "places"
msgstr "places"
#: manager/templates/chrono/manager_event_check_booking_fragment.html
msgid "Not booked"
msgstr "Non réservé"
#: manager/templates/chrono/manager_event_check_booking_fragment.html
msgid "Present,Absent,-"
msgstr "Présence,Absence,-"
msgid "Present,Absent"
msgstr "Présence,Absence"
#: manager/templates/chrono/manager_event_check_booking_fragment.html
msgctxt "check"
@ -2923,6 +2964,10 @@ msgstr "Paramètres daffichage"
msgid "Booking check options"
msgstr "Paramètres du pointage"
#: manager/templates/chrono/manager_events_agenda_settings.html
msgid "Invoicing options"
msgstr "Paramètres de facturation"
#: manager/templates/chrono/manager_events_agenda_settings.html
msgid "Management notifications"
msgstr "Notifications dadministration"
@ -3004,6 +3049,21 @@ msgstr "Activer le pointage des réservations sur les évènements à venir :"
msgid "Extra user block template:"
msgstr "Gabarit supplémentaire pour les données de lusager :"
#: manager/templates/chrono/manager_events_agenda_settings.html
msgid "Invoicing:"
msgstr "Facturation :"
#: manager/templates/chrono/manager_events_agenda_settings.html
msgid "Tolerance:"
msgstr "Tolérance :"
#: manager/templates/chrono/manager_events_agenda_settings.html
#, python-format
msgid "%(num)s minute"
msgid_plural "%(num)s minutes"
msgstr[0] "%(num)s minute"
msgstr[1] "%(num)s minutes"
#: manager/templates/chrono/manager_events_agenda_settings.html
#, python-format
msgid "%(label)s: %(display_value)s will be notified."
@ -3263,6 +3323,10 @@ msgstr "Malheureusement il ny en a pour le moment aucun accessible."
msgid "Check booking"
msgstr "Pointage"
#: manager/templates/chrono/manager_partial_booking_form.html
msgid "Add second booking check"
msgstr "Ajouter un deuxième pointage"
#: manager/templates/chrono/manager_partial_bookings_day_view.html
msgid "Booked period"
msgstr "Période réservée"
@ -3275,6 +3339,14 @@ msgstr "Période réservée :"
msgid "Checked period:"
msgstr "Période pointée :"
#: manager/templates/chrono/manager_partial_bookings_day_view.html
msgid "Computed period"
msgstr "Période calculée"
#: manager/templates/chrono/manager_partial_bookings_day_view.html
msgid "Computed period:"
msgstr "Période calculée :"
#: manager/templates/chrono/manager_replace_exceptions.html
msgid "Replace exceptions"
msgstr "Remplacer les exceptions"
@ -3706,6 +3778,10 @@ msgstr "Configurer les paramètres daffichage"
msgid "Configure booking check options"
msgstr "Configurer les paramètres de pointage"
#: manager/views.py
msgid "Configure invoicing options"
msgstr "Configurer les paramètres de facturation"
#: manager/views.py
msgid "Event successfully duplicated."
msgstr "Lévènement a bien été dupliqué."

View File

@ -47,6 +47,7 @@ from chrono.agendas.models import (
AgendaNotificationsSettings,
AgendaReminderSettings,
Booking,
BookingCheck,
Category,
Desk,
Event,
@ -94,6 +95,7 @@ class AgendaAddForm(forms.ModelForm):
self.cleaned_data['kind'] = 'events'
self.instance.partial_bookings = True
self.instance.default_view = 'day'
self.instance.enable_check_for_future_events = True
def save(self, *args, **kwargs):
create = self.instance.pk is None
@ -145,6 +147,9 @@ class AgendaBookingDelaysForm(forms.ModelForm):
'maximal_booking_delay',
'minimal_booking_time',
]
widgets = {
'minimal_booking_time': widgets.TimeWidget,
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@ -526,15 +531,15 @@ class BookingCheckFilterSet(django_filters.FilterSet):
if value == 'booked':
return queryset
if value == 'not-checked':
return queryset.filter(user_was_present__isnull=True)
return queryset.filter(user_checks__isnull=True)
if value == 'presence':
return queryset.filter(user_was_present=True)
return queryset.filter(user_checks__presence=True)
if value == 'absence':
return queryset.filter(user_was_present=False)
return queryset.filter(user_checks__presence=False)
if value.startswith('absence::'):
return queryset.filter(user_was_present=False, user_check_type_slug=value.split('::')[1])
return queryset.filter(user_checks__presence=False, user_checks__type_slug=value.split('::')[1])
if value.startswith('presence::'):
return queryset.filter(user_was_present=True, user_check_type_slug=value.split('::')[1])
return queryset.filter(user_checks__presence=True, user_checks__type_slug=value.split('::')[1])
return queryset
def do_nothing(self, queryset, name, value):
@ -579,7 +584,7 @@ class BookingCheckPresenceForm(forms.Form):
class PartialBookingCheckForm(forms.ModelForm):
user_was_present = forms.NullBooleanField(
presence = forms.NullBooleanField(
label=_('Status'),
widget=forms.RadioSelect(
choices=(
@ -594,44 +599,70 @@ class PartialBookingCheckForm(forms.ModelForm):
absence_check_type = forms.ChoiceField(label=_('Type'), required=False)
class Meta:
model = Booking
fields = ['user_check_start_time', 'user_check_end_time', 'user_was_present']
model = BookingCheck
fields = ['start_time', 'end_time', 'presence', 'type_label', 'type_slug']
widgets = {
'user_check_start_time': widgets.TimeWidget(step=60),
'user_check_end_time': widgets.TimeWidget(step=60),
'start_time': widgets.TimeWidgetWithButton(
step=60, button_label=_('Fill with booking start time')
),
'end_time': widgets.TimeWidgetWithButton(step=60, button_label=_('Fill with booking end time')),
'type_label': forms.HiddenInput(),
'type_slug': forms.HiddenInput(),
}
def __init__(self, *args, **kwargs):
agenda = kwargs.pop('agenda')
super().__init__(*args, **kwargs)
self.check_types = get_agenda_check_types(self.instance.event.agenda)
self.check_types = get_agenda_check_types(agenda)
absence_check_types = [(ct.slug, ct.label) for ct in self.check_types if ct.kind == 'absence']
presence_check_types = [(ct.slug, ct.label) for ct in self.check_types if ct.kind == 'presence']
if presence_check_types:
self.fields['presence_check_type'].choices = [(None, '---------')] + presence_check_types
self.fields['presence_check_type'].initial = self.instance.user_check_type_slug
self.fields['presence_check_type'].initial = self.instance.type_slug
else:
del self.fields['presence_check_type']
if absence_check_types:
self.fields['absence_check_type'].choices = [(None, '---------')] + absence_check_types
self.fields['absence_check_type'].initial = self.instance.user_check_type_slug
self.fields['absence_check_type'].initial = self.instance.type_slug
else:
del self.fields['absence_check_type']
if not self.instance.booking.start_time:
self.fields['start_time'].widget = widgets.TimeWidget(step=60)
self.fields['end_time'].widget = widgets.TimeWidget(step=60)
self.fields['presence'].widget.choices = ((None, _('Not checked')), (True, _('Present')))
self.fields.pop('absence_check_type', None)
def clean(self):
if self.cleaned_data['user_was_present'] is not None:
kind = 'presence' if self.cleaned_data['user_was_present'] else 'absence'
if (
self.cleaned_data.get('start_time')
and self.cleaned_data.get('end_time')
and self.cleaned_data['end_time'] <= self.cleaned_data['start_time']
):
raise ValidationError(_('Arrival must be before departure.'))
if self.instance.overlaps_existing_check(
self.cleaned_data['start_time'], self.cleaned_data['end_time']
):
raise ValidationError(_('Booking check hours overlap existing check.'))
if self.cleaned_data['presence'] is not None:
kind = 'presence' if self.cleaned_data['presence'] else 'absence'
if f'{kind}_check_type' in self.cleaned_data:
self.check_type_slug = self.cleaned_data[f'{kind}_check_type']
self.check_type_label = dict(self.fields[f'{kind}_check_type'].choices).get(
self.check_type_slug
self.cleaned_data['type_slug'] = self.cleaned_data[f'{kind}_check_type']
self.cleaned_data['type_label'] = dict(self.fields[f'{kind}_check_type'].choices).get(
self.cleaned_data['type_slug']
)
def save(self):
if hasattr(self, 'check_type_slug'):
self.instance.user_check_type_slug = self.check_type_slug
self.instance.user_check_type_label = self.check_type_label
if self.cleaned_data['presence'] is None:
self.instance.delete()
return self.instance
self.instance.refresh_computed_times()
return super().save()
@ -1206,14 +1237,7 @@ class ImportEventsForm(forms.Form):
super().__init__(**kwargs)
def clean_events_csv_file(self):
class ValidationErrorWithOrdinal(ValidationError):
line_offset = 1
def __init__(self, message, event_no):
super().__init__(message)
self.message = format_html(message, event_no=mark_safe(ordinal(event_no + self.line_offset)))
exclude_from_validation = ['desk', 'meeting_type', 'primary_event']
self.exclude_from_validation = ['desk', 'meeting_type', 'primary_event']
content = self.cleaned_data['events_csv_file'].read()
if b'\0' in content:
@ -1234,49 +1258,118 @@ class ImportEventsForm(forms.Form):
except csv.Error:
dialect = None
events = []
warnings = {}
events_by_slug = {x.slug: x for x in Event.objects.filter(agenda=self.agenda.pk)}
event_ids_with_bookings = set(
errors = []
self.events = []
self.warnings = {}
self.events_by_slug = {x.slug: x for x in Event.objects.filter(agenda=self.agenda.pk)}
self.event_ids_with_bookings = set(
Booking.objects.filter(
event__agenda=self.agenda.pk, cancellation_datetime__isnull=True
).values_list('event_id', flat=True)
)
seen_slugs = set(events_by_slug.keys())
self.seen_slugs = set(self.events_by_slug.keys())
line_offset = 1
for i, csvline in enumerate(csv.reader(StringIO(content), dialect=dialect)):
if not csvline:
continue
if len(csvline) < 3:
raise ValidationErrorWithOrdinal(_('Invalid file format. ({event_no} event)'), i)
if i == 0 and csvline[0].strip('#') in ('date', 'Date', _('date'), _('Date')):
ValidationErrorWithOrdinal.line_offset = 0
line_offset = 0
continue
# label needed to generate a slug
label = None
if len(csvline) >= 5:
label = force_str(csvline[4])
try:
event = self.parse_csvline(csvline)
except ValidationError as e:
for error in getattr(e, 'error_list', [e]):
errors.append(
format_html(
'{message} ({event_no} event)',
message=error.message,
event_no=mark_safe(ordinal(i + line_offset)),
)
)
else:
self.events.append(event)
# get or create event
event = None
slug = None
if len(csvline) >= 6:
slug = force_str(csvline[5]) if csvline[5] else None
# get existing event if relevant
if slug and slug in seen_slugs:
event = events_by_slug[slug]
# update label
event.label = label
if event is None:
# new event
event = Event(agenda_id=self.agenda.pk, label=label)
# generate a slug if not provided
event.slug = slug or generate_slug(event, seen_slugs=seen_slugs, agenda=self.agenda.pk)
# maintain caches
seen_slugs.add(event.slug)
events_by_slug[event.slug] = event
if errors:
errors = [_('Invalid file format:')] + errors
raise ValidationError(errors)
def parse_csvline(self, csvline):
if len(csvline) < 3:
raise ValidationError(_('Not enough columns.'))
# label needed to generate a slug
label = None
if len(csvline) >= 5:
label = force_str(csvline[4])
# get or create event
event = None
slug = None
if len(csvline) >= 6:
slug = force_str(csvline[5]) if csvline[5] else None
# get existing event if relevant
if slug and slug in self.seen_slugs:
event = self.events_by_slug[slug]
# update label
event.label = label
if event is None:
# new event
event = Event(agenda_id=self.agenda.pk, label=label)
# generate a slug if not provided
event.slug = slug or generate_slug(event, seen_slugs=self.seen_slugs, agenda=self.agenda.pk)
# maintain caches
self.seen_slugs.add(event.slug)
self.events_by_slug[event.slug] = event
for datetime_fmt in (
'%Y-%m-%d %H:%M',
'%d/%m/%Y %H:%M',
'%d/%m/%Y %Hh%M',
'%Y-%m-%d %H:%M:%S',
'%d/%m/%Y %H:%M:%S',
):
try:
event_datetime = make_aware(
datetime.datetime.strptime('%s %s' % tuple(csvline[:2]), datetime_fmt)
)
except ValueError:
continue
if (
event.pk is not None
and event.start_datetime != event_datetime
and event.start_datetime > now()
and event.pk in self.event_ids_with_bookings
and event.pk not in self.warnings
):
# event start datetime has changed, event is not past and has not cancelled bookings
# => warn the user
self.warnings[event.pk] = event
event.start_datetime = event_datetime
break
else:
raise ValidationError(_('Wrong start date/time format.'))
try:
event.places = int(csvline[2])
except ValueError:
raise ValidationError(_('Number of places must be an integer.'))
if len(csvline) >= 4:
try:
event.waiting_list_places = int(csvline[3])
except ValueError:
raise ValidationError(_('Number of places in waiting list must be an integer.'))
column_index = 7
for more_attr in ('description', 'pricing', 'url'):
if len(csvline) >= column_index:
setattr(event, more_attr, csvline[column_index - 1])
column_index += 1
if len(csvline) >= 10 and csvline[9]: # publication date is optional
for datetime_fmt in (
'%Y-%m-%d',
'%d/%m/%Y',
'%Y-%m-%d %H:%M',
'%d/%m/%Y %H:%M',
'%d/%m/%Y %Hh%M',
@ -1284,93 +1377,39 @@ class ImportEventsForm(forms.Form):
'%d/%m/%Y %H:%M:%S',
):
try:
event_datetime = make_aware(
datetime.datetime.strptime('%s %s' % tuple(csvline[:2]), datetime_fmt)
event.publication_datetime = make_aware(
datetime.datetime.strptime(csvline[9], datetime_fmt)
)
break
except ValueError:
continue
if (
event.pk is not None
and event.start_datetime != event_datetime
and event.start_datetime > now()
and event.pk in event_ids_with_bookings
and event.pk not in warnings
):
# event start datetime has changed, event is not past and has not cancelled bookings
# => warn the user
warnings[event.pk] = event
event.start_datetime = event_datetime
break
else:
raise ValidationErrorWithOrdinal(
_('Invalid file format. (date/time format, {event_no} event)'), i
)
try:
event.places = int(csvline[2])
except ValueError:
raise ValidationError(_('Invalid file format. (number of places, {event_no} event)'), i)
if len(csvline) >= 4:
raise ValidationError(_('Wrong publication date/time format.'))
if self.agenda.partial_bookings:
if len(csvline) < 11 or not csvline[10]:
raise ValidationError(_('Missing end_time.'))
event.end_time = csvline[10]
else:
self.exclude_from_validation.append('end_time')
if len(csvline) >= 11 and csvline[10]: # duration is optional
try:
event.waiting_list_places = int(csvline[3])
event.duration = int(csvline[10])
except ValueError:
raise ValidationError(
_('Invalid file format. (number of places in waiting list, {event_no} event)'), i
)
raise ValidationError(_('Duration must be an integer.'))
column_index = 7
for more_attr in ('description', 'pricing', 'url'):
if len(csvline) >= column_index:
setattr(event, more_attr, csvline[column_index - 1])
column_index += 1
try:
event.full_clean(exclude=self.exclude_from_validation)
except ValidationError as e:
errors = []
for label, field_errors in e.message_dict.items():
label_name = self.get_verbose_name(label)
msg = _('%s: ') % label_name if label_name else ''
msg += ', '.join(field_errors)
errors.append(msg)
raise ValidationError(errors)
if len(csvline) >= 10 and csvline[9]: # publication date is optional
for datetime_fmt in (
'%Y-%m-%d',
'%d/%m/%Y',
'%Y-%m-%d %H:%M',
'%d/%m/%Y %H:%M',
'%d/%m/%Y %Hh%M',
'%Y-%m-%d %H:%M:%S',
'%d/%m/%Y %H:%M:%S',
):
try:
event.publication_datetime = make_aware(
datetime.datetime.strptime(csvline[9], datetime_fmt)
)
break
except ValueError:
continue
else:
raise ValidationError(_('Invalid file format. (date/time format, {event_no} event)'), i)
if self.agenda.partial_bookings:
if len(csvline) < 11 or not csvline[10]:
raise ValidationError(_('Invalid file format. (missing end_time, {event_no} event)'), i)
event.end_time = csvline[10]
else:
exclude_from_validation.append('end_time')
if len(csvline) >= 11 and csvline[10]: # duration is optional
try:
event.duration = int(csvline[10])
except ValueError:
raise ValidationError(_('Invalid file format. (duration, {event_no} event)'), i)
try:
event.full_clean(exclude=exclude_from_validation)
except ValidationError as e:
errors = [_('Invalid file format:\n')]
for label, field_errors in e.message_dict.items():
label_name = self.get_verbose_name(label)
msg = _('%s: ') % label_name if label_name else ''
msg += _('%(errors)s (line %(line)d)') % {
'errors': ', '.join(field_errors),
'line': i + 1,
}
errors.append(msg)
raise ValidationError(errors)
events.append(event)
self.events = events
self.warnings = warnings
return event
@staticmethod
def get_verbose_name(field_name):
@ -1504,9 +1543,12 @@ class AgendaDisplaySettingsForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if kwargs['instance'].kind == 'events':
self.fields['booking_user_block_template'].help_text = (
_('Displayed for each booking in event page and check page'),
)
if self.instance.partial_bookings:
del self.fields['booking_user_block_template']
else:
self.fields['booking_user_block_template'].help_text = (
_('Displayed for each booking in event page and check page'),
)
else:
self.fields['booking_user_block_template'].help_text = (
_('Displayed for each booking in agenda view pages'),
@ -1526,6 +1568,26 @@ class AgendaBookingCheckSettingsForm(forms.ModelForm):
]
widgets = {'booking_extra_user_block_template': forms.Textarea(attrs={'rows': 3})}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.instance.partial_bookings:
del self.fields['enable_check_for_future_events']
del self.fields['booking_extra_user_block_template']
class AgendaInvoicingSettingsForm(forms.ModelForm):
class Meta:
model = Agenda
fields = [
'invoicing_unit',
'invoicing_tolerance',
]
def save(self):
super().save()
self.instance.async_refresh_booking_computed_times()
return self.instance
class AgendaNotificationsForm(forms.ModelForm):
class Meta:

View File

@ -420,7 +420,8 @@ div.event-title-meta span.tag {
color: white;
}
div.ui-dialog form p span.datetime input {
div.ui-dialog form p span.datetime input,
div.ui-dialog form input[type=time] {
width: auto;
}
@ -599,6 +600,10 @@ div#appbar a.active {
}
// Partial booking view
div#main-content.partial-booking-dayview {
// change default overflow to allow sticky hours list
overflow: visible;
}
.partial-booking {
--registrant-name-width: 15rem;
--zebra-color: hsla(0,0%,0%,0.05);
@ -657,6 +662,7 @@ div#appbar a.active {
&--datas {
box-sizing: border-box;
flex: 1 0 100%;
padding: .33rem 0;
@media (min-width: 761px) {
flex-basis: auto;
}
@ -673,24 +679,35 @@ div#appbar a.active {
}
&--bar-container {
position: relative;
padding: .33rem 0;
margin: 0.33rem 0;
}
&--bar {
--color: white;
box-sizing: border-box;
margin: 0.33rem 0;
margin: 0;
position: relative;
padding: 0.33em 0.66em;
background-color: var(--background);
color: var(--color);
&:not(:first-child) {
position: absolute;
top: 0;
}
.end-time {
float: right;
margin-left: .66em;
}
&.booking {
--background: #1066bc;
}
&.check.present {
--background: hsl(120, 57%, 35%);
&.check.present, &.computed.present {
--background: var(--green);
}
&.check.absent {
--background: hsl(355, 80%, 45%);
&.check.absent, &.computed.absent {
--background: var(--red);
}
&.computed {
opacity: 0.6;
}
}
}

View File

@ -22,5 +22,6 @@
<button>{% trans 'Set Date' %}</button>
</div>
{% endwith %}
{% block extra_date_title %}{% endblock %}
</h2>
{% endblock %}

View File

@ -21,7 +21,9 @@
<ul class="extra-actions-menu">
<li><a rel="popup" href="{% url 'chrono-manager-agenda-edit' pk=object.id %}">{% trans 'Options' %}</a></li>
{% block agenda-extra-menu-actions %}{% endblock %}
<li><a rel="popup" class="action-duplicate" href="{% url 'chrono-manager-agenda-duplicate' pk=object.pk %}">{% trans 'Duplicate' %}</a></li>
{% if user.is_staff %}
<li><a rel="popup" class="action-duplicate" href="{% url 'chrono-manager-agenda-duplicate' pk=object.pk %}">{% trans 'Duplicate' %}</a></li>
{% endif %}
<li><a download href="{% url 'chrono-manager-agenda-export' pk=object.id %}">{% trans 'Export Configuration (JSON)' %}</a></li>
{% if object.kind == 'events' %}
<li><a download href="{% url 'chrono-manager-agenda-export-events' pk=object.pk %}">{% trans 'Export Events (CSV)' %}</a></li>
@ -39,7 +41,7 @@
<div class="pk-tabs">
<div class="pk-tabs--tab-list" role="tablist">
{% block agenda-settings-extra-tab-buttons %}{% endblock %}
{% if object.kind != 'virtual' %}
{% if object.kind != 'virtual' and not object.partial_bookings %}
<button aria-controls="panel-reminders" aria-selected="false" id="tab-reminders" role="tab" tabindex="-1">{% trans "Booking reminders" %}</button>
{% endif %}
<button aria-controls="panel-delays" aria-selected="false" id="tab-delays" role="tab" tabindex="-1">{% trans "Booking Delays" %}</button>
@ -49,7 +51,7 @@
{% block agenda-settings-extra-tab-list %}{% endblock %}
{% if object.kind != 'virtual' %}
{% if object.kind != 'virtual' and not object.partial_bookings %}
<div aria-labelledby="tab-reminders" id="panel-reminders" role="tabpanel" tabindex="0" hidden="">
{% for info in agenda.reminder_settings.display_info %}
<p>{{ info }}</p>

View File

@ -27,7 +27,7 @@
</form>
<table class="main check-bookings">
<tbody>
{% if results and not event.checked %}
{% if results and not event.checked and not event.check_locked %}
<tr class="booking">
<td class="booking-actions">
<form method="post" action="{% url 'chrono-manager-event-checked' pk=agenda.pk event_pk=object.pk %}">

View File

@ -4,19 +4,19 @@
{% if agenda.booking_extra_user_block_template %}<span class="togglable"></span>{% endif %}
{{ booking.get_user_block }}{% if booking.places_count > 1 %} ({{ booking.places_count }} {% trans "places" %}){% endif %}
</td>
<td class="booking-status {% if booking.kind != "subscription" and booking.cancellation_datetime is None and booking.user_was_present is None %}without-status{% endif %}" data-{{ booking.kind }}-id="{{ booking.id }}">
<td class="booking-status {% if booking.kind != "subscription" and booking.cancellation_datetime is None and booking.user_check %}without-status{% endif %}" data-{{ booking.kind }}-id="{{ booking.id }}">
{% if booking.kind == "subscription" %}
({% trans "Not booked" %})
{% elif booking.cancellation_datetime is None %}
{{ booking.user_was_present|yesno:_('Present,Absent,-') }}
{% if booking.user_was_present is not None and booking.user_check_type_label %}
({{ booking.user_check_type_label }})
{% if booking.user_check %}{{ booking.user_check.presence|yesno:_('Present,Absent') }}{% else %}-{% endif %}
{% if booking.user_check.type_label %}
({{ booking.user_check.type_label }})
{% endif %}
{% else %}
({% trans "Cancelled" %})
{% endif %}
{% if not event.checked or not agenda.disable_check_update %}
{% if booking.user_was_present is not None and not event.check_locked %}
{% if booking.user_check and not event.check_locked %}
<form method="post" action="{% url 'chrono-manager-booking-reset' pk=agenda.pk booking_pk=booking.pk %}" class="with-ajax reset">
{% csrf_token %}
<a href="#">{% trans "Reset" context "check" %}</a>
@ -43,7 +43,7 @@
{% endif %}
{% csrf_token %}
<button class="submit-button"
{% if booking.user_was_present is True %}disabled{% endif %}
{% if booking.user_check.presence %}disabled{% endif %}
>{% trans "Presence" %}</button>
{% if booking.presence_form.check_type.field.choices.1 %}{{ booking.presence_form.check_type }}{% endif %}
<script>
@ -62,7 +62,7 @@
{% endif %}
{% csrf_token %}
<button class="submit-button"
{% if booking.user_was_present is False %}disabled{% endif %}
{% if booking.user_check.presence is False %}disabled{% endif %}
>{% trans "Absence" %}</button>
{% if booking.absence_form.check_type.field.choices.1 %}{{ booking.absence_form.check_type }}{% endif %}
<script>

View File

@ -19,7 +19,11 @@
{% endif %}
<button aria-controls="panel-display-options" aria-selected="false" id="tab-display-options" role="tab" tabindex="-1">{% trans "Display options" %}</button>
<button aria-controls="panel-booking-check-options" aria-selected="false" id="tab-booking-check-options" role="tab" tabindex="-1">{% trans "Booking check options" %}</button>
<button aria-controls="panel-notifications" aria-selected="false" id="tab-notifications" role="tab" tabindex="-1">{% trans "Management notifications" %}</button>
{% if agenda.partial_bookings %}
<button aria-controls="panel-invoicing-options" aria-selected="false" id="tab-invoicing-options" role="tab" tabindex="-1">{% trans "Invoicing options" %}</button>
{% else %}
<button aria-controls="panel-notifications" aria-selected="false" id="tab-notifications" role="tab" tabindex="-1">{% trans "Management notifications" %}</button>
{% endif %}
{% endblock %}
{% block agenda-settings-extra-tab-list %}
@ -86,10 +90,12 @@
{% trans "No event display template configured for this agenda." %}
{% endif %}
</li>
<li>
{% trans "Booking display template:" %}
{% if not agenda.partial_bookings %}
<li>
{% trans "Booking display template:" %}
<pre>{{ agenda.get_booking_user_block_template }}</pre>
</li>
</li>
{% endif %}
</ul>
<div class="panel--buttons">
<a rel="popup" class="button" href="{% url 'chrono-manager-agenda-display-settings' pk=object.pk %}">{% trans 'Configure' %}</a>
@ -113,32 +119,48 @@
{% endwith %}
<li>{% trans "Automatically mark event as checked when all bookings have been checked:" %} {{ agenda.mark_event_checked_auto|yesno }}</li>
<li>{% trans "Prevent the check of bookings when event was marked as checked:" %} {{ agenda.disable_check_update|yesno }}</li>
<li>{% trans "Enable the check of bookings when event has not passed:" %} {{ agenda.enable_check_for_future_events|yesno }}</li>
<li>
{% trans "Extra user block template:" %}
{% if not agenda.partial_bookings %}
<li>{% trans "Enable the check of bookings when event has not passed:" %} {{ agenda.enable_check_for_future_events|yesno }}</li>
<li>
{% trans "Extra user block template:" %}
<pre>{{ agenda.booking_extra_user_block_template }}</pre>
</li>
</li>
{% endif %}
</ul>
<div class="panel--buttons">
<a rel="popup" class="button" href="{% url 'chrono-manager-agenda-booking-check-settings' pk=object.pk %}">{% trans 'Configure' %}</a>
</div>
</div>
<div aria-labelledby="tab-notifications" hidden="" id="panel-notifications" role="tabpanel" tabindex="0">
{% for notification_type in object.notifications_settings.get_notification_types %}
{% if forloop.first %}<ul>{% endif %}
<li>
{% blocktrans trimmed with display_value=notification_type.display_value label=notification_type.label %}
{{ label }}: {{ display_value }} will be notified.
{% endblocktrans %}
</li>
{% if forloop.last %}</ul>{% endif %}
{% empty %}
<p>{% trans "Notifications are disabled for this agenda." %}</p>
{% endfor %}
<div class="panel--buttons">
<a rel="popup" class="button" href="{% url 'chrono-manager-agenda-notifications-settings' pk=object.id %}">{% trans 'Configure' %}</a>
{% if agenda.partial_bookings %}
<div aria-labelledby="tab-invoicing-options" hidden="" id="panel-invoicing-options" role="tabpanel" tabindex="0">
<ul>
<li>{% trans "Invoicing:" %} {{ agenda.get_invoicing_unit_display }}</li>
<li>{% trans "Tolerance:" %} {% blocktrans count num=agenda.invoicing_tolerance %}{{ num }} minute{% plural %}{{ num }} minutes{% endblocktrans %}</li>
</ul>
<div class="panel--buttons">
<a rel="popup" class="button" href="{% url 'chrono-manager-agenda-invoicing-settings' pk=object.pk %}">{% trans 'Configure' %}</a>
</div>
</div>
</div>
{% endif %}
{% if not agenda.partial_bookings %}
<div aria-labelledby="tab-notifications" hidden="" id="panel-notifications" role="tabpanel" tabindex="0">
{% for notification_type in object.notifications_settings.get_notification_types %}
{% if forloop.first %}<ul>{% endif %}
<li>
{% blocktrans trimmed with display_value=notification_type.display_value label=notification_type.label %}
{{ label }}: {{ display_value }} will be notified.
{% endblocktrans %}
</li>
{% if forloop.last %}</ul>{% endif %}
{% empty %}
<p>{% trans "Notifications are disabled for this agenda." %}</p>
{% endfor %}
<div class="panel--buttons">
<a rel="popup" class="button" href="{% url 'chrono-manager-agenda-notifications-settings' pk=object.id %}">{% trans 'Configure' %}</a>
</div>
</div>
{% endif %}
{% endblock %}

View File

@ -11,7 +11,22 @@
{% endblock %}
{% block content %}
<form method="post" enctype="multipart/form-data">
<form
method="post"
enctype="multipart/form-data"
data-fill-start_time="{{ object.booking.start_time|time:"H:i" }}"
data-fill-end_time="{{ object.booking.end_time|time:"H:i" }}"
>
{% if allow_adding_check %}
<p>
<a
rel="popup"
href="{% url 'chrono-manager-partial-booking-check' pk=agenda.pk booking_pk=object.booking.pk %}"
>
{% trans "Add second booking check" %}
</a>
</p>
{% endif %}
{% csrf_token %}
{{ form|with_template }}
<div class="buttons">
@ -23,7 +38,7 @@
$(function () {
presence_check_type_select = $('.widget[id=id_presence_check_type_p]');
absence_check_type_select = $('.widget[id=id_absence_check_type_p]');
$('input[type=radio][name=user_was_present]').change(function() {
$('input[type=radio][name=presence]').change(function() {
if (!this.checked)
return;
if (this.value == 'True') {
@ -37,6 +52,12 @@
absence_check_type_select.hide();
}
}).change();
$('.time-widget-button').on('click', function() {
var widget_name = $(this).data('related-widget');
var value = $(this).parents('form').data('fill-' + widget_name);
$('[name="' + widget_name + '"]').val(value);
});
});
</script>
</form>

View File

@ -1,6 +1,17 @@
{% extends "chrono/manager_agenda_day_view.html" %}
{% load i18n %}
{% block main-content-attributes %}class="partial-booking-dayview"{% endblock %}
{% block extra_date_title %}
{% if event.invoiced %}
<span class="invoiced tag">{% trans "Invoiced" %}</span>
{% elif event.check_locked %}
<span class="check-locked tag">{% trans "Check locked" %}</span>
{% endif %}
{% if event.checked %}<span class="checked tag">{% trans "Checked" %}</span>{% endif %}
{% endblock %}
{% block content %}
{% if not hours %}
@ -20,6 +31,13 @@
</script>
</form>
{% if results and not event.checked and not event.check_locked %}
<form method="post" action="{% url 'chrono-manager-event-checked' pk=agenda.pk event_pk=event.pk %}">
{% csrf_token %}
<button class="submit-button">{% trans "Mark the event as checked" %}</button>
</form>
{% endif %}
<div class="partial-booking" style="--nb-hours: {{ hours|length }}">
<div class="partial-booking--hours-list" aria-hidden="true">
{% for hour in hours %}
@ -28,48 +46,77 @@
</div>
<div class="partial-booking--registrant-items">
{% for booking in results %}
{% for user in users %}
<section class="partial-booking--registrant">
{% spaceless %}
<h3 class="registrant--name">
{% if booking.kind == "booking" and allow_check %}
{% if allow_check and user.check_url %}
<a
rel="popup"
href="{% url 'chrono-manager-partial-booking-check' pk=agenda.pk booking_pk=booking.pk %}"
>{{ booking.get_user_block }}</a>
href="{{ user.check_url }}"
>{{ user.name }}</a>
{% else %}
<span>{{ booking.get_user_block }}</span>
<span>{{ user.name }}</span>
{% endif %}
</h3>
{% endspaceless %}
<div class="registrant--datas">
{% if booking.kind == "booking" %}
<div class="registrant--bar-container">
{% for booking in user.bookings %}
{% if booking.start_time %}
<a
class="registrant--bar clearfix booking"
title="{% trans "Booked period" %}"
style="left: {{ booking.css_left }}%; width: {{ booking.css_width }}%;"
{% if allow_check and not booking.user_check %}
rel="popup"
href="{% url 'chrono-manager-partial-booking-check' pk=agenda.pk booking_pk=booking.pk %}"
{% endif %}
>
<strong class="sr-only">{% trans "Booked period:" %}</strong>
<time class="start-time" datetime="{{ booking.start_time|time:"H:i" }}">{{ booking.start_time|time:"H:i" }}</time>
<time class="end-time" datetime="{{ booking.end_time|time:"H:i" }}">{{ booking.end_time|time:"H:i" }}</time>
</a>
{% endif %}
{% endfor %}
</div>
{% if user.bookings %}
<div class="registrant--bar-container">
<p
class="registrant--bar booking"
title="{% trans "Booked period" %}"
style="left: {{ booking.css_left }}%; width: {{ booking.css_width }}%;"
>
<strong class="sr-only">{% trans "Booked period:" %}</strong>
<time datetime="{{ booking.start_time|time:"H:i" }}">{{ booking.start_time|time:"H:i" }}</time>
<time datetime="{{ booking.end_time|time:"H:i" }}">{{ booking.end_time|time:"H:i" }}</time>
</p>
{% for check in user.booking_checks %}
<a
class="registrant--bar clearfix check {{ check.css_class }}"
title="{% trans "Checked period:" %}"
style="left: {{ check.css_left }}%; width: {{ check.css_width }}%;"
{% if allow_check %}
rel="popup"
href="{% url 'chrono-manager-partial-booking-update-check' pk=agenda.pk check_pk=check.pk %}"
{% endif %}
>
<strong class="sr-only">{% trans "Checked period:" %}</strong>
{% if check.start_time %}
<time class="start-time" datetime="{{ check.start_time|time:"H:i" }}">{{ check.start_time|time:"H:i" }}</time>
{% endif %}
{% if check.end_time %}
<time class="end-time" datetime="{{ check.end_time|time:"H:i" }}">{{ check.end_time|time:"H:i" }}</time>
{% endif %}
{% if check.type_label %}<span>{{ check.type_label }}</span>{% endif %}
</a>
{% endfor %}
</div>
{% endif %}
{% if booking.user_was_present is not None %}
<div class="registrant--bar-container">
<p
class="registrant--bar check {{ booking.check_css_class }}"
title="{% trans "Checked period:" %}"
style="left: {{ booking.check_css_left }}%; width: {{ booking.check_css_width }}%;"
>
<strong class="sr-only">{% trans "Checked period:" %}</strong>
<time datetime="{{ booking.user_check_start_time|time:"H:i" }}">{{ booking.user_check_start_time|time:"H:i" }}</time>
{% if booking.user_check_start_time and booking.user_check_end_time %}{% endif %}
<time datetime="{{ booking.user_check_end_time|time:"H:i" }}">{{ booking.user_check_end_time|time:"H:i" }}</time>
{% if booking.user_check_type_label %}<span>{{ booking.user_check_type_label }}</span>{% endif %}
</p>
{% for check in user.booking_checks %}
{% if check.computed_start_time and check.computed_end_time %}
<p
class="registrant--bar clearfix computed {{ check.css_class }}"
title="{% trans "Computed period" %}"
style="left: {{ check.computed_css_left }}%; width: {{ check.computed_css_width }}%;"
>
<strong class="sr-only">{% trans "Computed period:" %}</strong>
<time class="start-time" datetime="{{ check.computed_start_time|time:"H:i" }}">{{ check.computed_start_time|time:"H:i" }}</time>
<time class="end-time" datetime="{{ check.computed_end_time|time:"H:i" }}">{{ check.computed_end_time|time:"H:i" }}</time>
</p>
{% endif %}
{% endfor %}
</div>
{% endif %}
</div>

View File

@ -0,0 +1 @@
{% load i18n %}{% include "django/forms/widgets/input.html" %} <button type="button" class="time-widget-button" data-related-widget="{{ widget.name }}">{{ widget.button_label }}</button>

View File

@ -170,6 +170,11 @@ urlpatterns = [
views.agenda_booking_check_settings,
name='chrono-manager-agenda-booking-check-settings',
),
path(
'agendas/<int:pk>/invoicing-options',
views.agenda_invoicing_settings,
name='chrono-manager-agenda-invoicing-settings',
),
path('agendas/<int:pk>/delete', views.agenda_delete, name='chrono-manager-agenda-delete'),
path('agendas/<int:pk>/export', views.agenda_export, name='chrono-manager-agenda-export'),
path('agendas/<int:pk>/add-event', views.agenda_add_event, name='chrono-manager-agenda-add-event'),
@ -434,6 +439,16 @@ urlpatterns = [
views.partial_booking_check_view,
name='chrono-manager-partial-booking-check',
),
path(
'agendas/<int:pk>/booking-checks/<int:check_pk>',
views.partial_booking_update_check_view,
name='chrono-manager-partial-booking-update-check',
),
path(
'agendas/<int:pk>/subscriptions/<int:subscription_pk>/check/<int:event_pk>',
views.partial_booking_subscription_check_view,
name='chrono-manager-partial-booking-subscription-check',
),
path(
'agendas/<int:pk>/subscriptions/<int:subscription_pk>/extra-user-block',
views.subscription_extra_user_block,

View File

@ -67,6 +67,7 @@ from chrono.agendas.models import (
AgendaNotificationsSettings,
AgendaReminderSettings,
Booking,
BookingCheck,
BookingColor,
Category,
Desk,
@ -98,6 +99,7 @@ from .forms import (
AgendaDisplaySettingsForm,
AgendaDuplicateForm,
AgendaEditForm,
AgendaInvoicingSettingsForm,
AgendaNotificationsForm,
AgendaReminderForm,
AgendaReminderTestForm,
@ -1163,6 +1165,18 @@ class AgendaBookingCheckSettingsView(AgendaEditView):
agenda_booking_check_settings = AgendaBookingCheckSettingsView.as_view()
class AgendaInvoicingSettingsView(AgendaEditView):
form_class = AgendaInvoicingSettingsForm
title = _('Configure invoicing options')
tab_anchor = 'invoicing-options'
def set_agenda(self, **kwargs):
self.agenda = get_object_or_404(Agenda, pk=kwargs.get('pk'), kind='events', partial_bookings=True)
agenda_invoicing_settings = AgendaInvoicingSettingsView.as_view()
class AgendaDeleteView(DeleteView):
template_name = 'chrono/manager_confirm_delete.html'
model = Agenda
@ -1324,7 +1338,7 @@ class AgendaDateView(DateMixin, ViewableAgendaMixin):
except ValueError: # no meeting types defined
context['hour_span'] = 1
context['booking_colors'] = BookingColor.objects.filter(
bookings__event__in=self.object_list
bookings__event__in=self.object_list, bookings__cancellation_datetime__isnull=True
).distinct()
context['user_can_manage'] = self.agenda.can_be_managed(self.request.user)
return context
@ -1380,12 +1394,12 @@ class EventChecksMixin:
filters = {k: sorted(list(v)) for k, v in filters}
return filters
def add_filters_context(self, context, event):
def add_filters_context(self, context, event, add_check_forms=True):
# booking base queryset
booking_qs_kwargs = {}
if not self.agenda.subscriptions.exists():
booking_qs_kwargs = {'cancellation_datetime__isnull': True}
booking_qs = event.booking_set
booking_qs = event.booking_set.prefetch_related('user_checks').order_by('start_time')
booked_qs = booking_qs.filter(
in_waiting_list=False, primary_booking__isnull=True, **booking_qs_kwargs
)
@ -1427,27 +1441,35 @@ class EventChecksMixin:
results = []
booked_without_status = False
for booking in booked_filterset.qs:
if booking.cancellation_datetime is None and booking.user_was_present is None:
booking.kind = 'booking'
results.append(booking)
if not add_check_forms:
continue
if booking.cancellation_datetime is None and not booking.user_check:
booked_without_status = True
booking.absence_form = BookingCheckAbsenceForm(
agenda=self.agenda,
initial={'check_type': booking.user_check_type_slug},
initial={'check_type': booking.user_check.type_slug if booking.user_check else None},
)
booking.presence_form = BookingCheckPresenceForm(
agenda=self.agenda,
initial={'check_type': booking.user_check_type_slug},
initial={'check_type': booking.user_check.type_slug if booking.user_check else None},
)
booking.kind = 'booking'
results.append(booking)
for subscription in subscription_filterset.qs:
subscription.kind = 'subscription'
results.append(subscription)
if not add_check_forms:
continue
subscription.absence_form = BookingCheckAbsenceForm(
agenda=self.agenda,
)
subscription.presence_form = BookingCheckPresenceForm(
agenda=self.agenda,
)
subscription.kind = 'subscription'
results.append(subscription)
# sort results
if (
booked_filterset.form.is_valid()
@ -1604,15 +1626,17 @@ class AgendaDayView(EventChecksMixin, AgendaDateView, DayArchiveView):
def fill_partial_bookings_context(self, context):
try:
event = self.agenda.event_set.get(start_datetime__date=self.date.date())
event = self.agenda.event_set.get(
start_datetime__date=self.date.date(), recurrence_days__isnull=True
)
except Event.DoesNotExist:
return
context['allow_check'] = bool(
self.agenda.enable_check_for_future_events
or localtime(event.start_datetime).date() <= localtime().date()
)
self.add_filters_context(context, event)
context['event'] = event
context['allow_check'] = (
not event.checked or not self.agenda.disable_check_update
) and not event.check_locked
self.add_filters_context(context, event, add_check_forms=False)
min_time = localtime(event.start_datetime).time()
max_time = event.end_time
@ -1632,22 +1656,52 @@ class AgendaDayView(EventChecksMixin, AgendaDateView, DayArchiveView):
bookings = [x for x in context['results'] if x.kind == 'booking']
for booking in bookings:
booking.css_left = get_time_ratio(booking.start_time, start_time)
booking.css_width = get_time_ratio(booking.end_time, booking.start_time)
if booking.start_time:
booking.css_left = get_time_ratio(booking.start_time, start_time)
booking.css_width = get_time_ratio(booking.end_time, booking.start_time)
if booking.user_was_present is not None:
booking.check_css_class = 'present' if booking.user_was_present else 'absent'
if booking.user_check_start_time and booking.user_check_end_time:
booking.check_css_left = get_time_ratio(booking.user_check_start_time, start_time)
booking.check_css_width = get_time_ratio(
booking.user_check_end_time, booking.user_check_start_time
)
elif booking.user_check_start_time:
booking.check_css_left = get_time_ratio(booking.user_check_start_time, start_time)
booking.check_css_width = 1
booking.user_check_list = list(booking.user_checks.all()) # queryset is prefetched
for check in booking.user_check_list:
check.css_class = 'present' if check.presence else 'absent'
if not check.start_time:
check.css_class += ' end-only'
check.css_left = booking.css_left
check.css_width = booking.css_width
elif not check.end_time:
check.css_class += ' start-only'
check.css_left = get_time_ratio(check.start_time, start_time)
check.css_width = 4
else:
booking.check_css_left = get_time_ratio(booking.user_check_end_time, start_time) - 1
booking.check_css_width = 1
check.css_left = get_time_ratio(check.start_time, start_time)
check.css_width = get_time_ratio(check.end_time, check.start_time)
if check.computed_start_time and check.computed_end_time:
check.computed_css_left = get_time_ratio(check.computed_start_time, start_time)
check.computed_css_width = get_time_ratio(
check.computed_end_time, check.computed_start_time
)
users_info = {}
for result in context['results']:
user_info = users_info.setdefault(
result.user_external_id,
{
'name': result.user_name,
'bookings': [],
'booking_checks': [],
},
)
if result.kind == 'subscription':
user_info['check_url'] = reverse(
'chrono-manager-partial-booking-subscription-check',
kwargs={'pk': self.agenda.pk, 'event_pk': event.pk, 'subscription_pk': result.pk},
)
continue
user_info['bookings'].append(result)
user_info['booking_checks'].extend(result.user_check_list)
context['users'] = users_info.values()
agenda_day_view = AgendaDayView.as_view()
@ -1882,7 +1936,7 @@ class AgendaWeekMonthMixin:
]
booking_info_by_user = {}
bookings = Booking.objects.filter(event__in=self.events)
bookings = Booking.objects.filter(event__in=self.events).prefetch_related('user_checks')
for booking in bookings:
booking_info = booking_info_by_user.setdefault(
booking.user_external_id,
@ -1895,8 +1949,8 @@ class AgendaWeekMonthMixin:
)
user_bookings = booking_info['bookings']
if booking.user_was_present is not None:
booking.check_css_class = 'present' if booking.user_was_present else 'absent'
if booking.user_check:
booking.check_css_class = 'present' if booking.user_check.presence else 'absent'
user_bookings[localtime(booking.event.start_datetime).day - 1] = booking
@ -2238,10 +2292,16 @@ class AgendaExport(ManagedAgendaMixin, DetailView):
agenda_export = AgendaExport.as_view()
class AgendaDuplicate(ManagedAgendaMixin, FormView):
class AgendaDuplicate(FormView):
form_class = AgendaDuplicateForm
template_name = 'chrono/manager_agenda_duplicate_form.html'
def dispatch(self, request, *args, **kwargs):
self.agenda = get_object_or_404(Agenda, pk=kwargs.get('pk'))
if not request.user.is_staff:
raise PermissionDenied()
return super().dispatch(request, *args, **kwargs)
def get_success_url(self):
return reverse('chrono-manager-agenda-settings', kwargs={'pk': self.new_agenda.pk})
@ -2252,6 +2312,11 @@ class AgendaDuplicate(ManagedAgendaMixin, FormView):
self.new_agenda = self.agenda.duplicate(label=form.cleaned_data['label'])
return super().form_valid(form)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['agenda'] = self.agenda
return context
agenda_duplicate = AgendaDuplicate.as_view()
@ -2682,6 +2747,8 @@ class EventDetailView(ViewableAgendaMixin, DetailView):
def dispatch(self, request, *args, **kwargs):
if self.get_object().recurrence_days:
raise Http404('this view makes no sense for recurring events')
if self.get_object().agenda.partial_bookings:
raise Http404('this view makes no sense for partial bookings')
return super().dispatch(request, *args, **kwargs)
def get_template_names(self):
@ -2693,15 +2760,17 @@ class EventDetailView(ViewableAgendaMixin, DetailView):
context = super().get_context_data(**kwargs)
context['user_can_manage'] = self.agenda.can_be_managed(self.request.user)
event = self.object
context['booked'] = event.booking_set.filter(
cancellation_datetime__isnull=True, in_waiting_list=False
).order_by('creation_datetime')
context['booked'] = (
event.booking_set.filter(cancellation_datetime__isnull=True, in_waiting_list=False)
.prefetch_related('user_checks')
.order_by('creation_datetime')
)
context['waiting'] = event.booking_set.filter(
cancellation_datetime__isnull=True, in_waiting_list=True
).order_by('creation_datetime')
event.present_count = len([b for b in context['booked'] if b.user_was_present is True])
event.absent_count = len([b for b in context['booked'] if b.user_was_present is False])
event.notchecked_count = len([b for b in context['booked'] if b.user_was_present is None])
event.present_count = len([b for b in context['booked'] if b.user_check and b.user_check.presence])
event.absent_count = len([b for b in context['booked'] if b.user_check and not b.user_check.presence])
event.notchecked_count = len([b for b in context['booked'] if not b.user_check])
return context
@ -2710,8 +2779,19 @@ event_view = EventDetailView.as_view()
class EventDetailRedirectView(RedirectView):
def get_redirect_url(self, *args, **kwargs):
agenda = get_object_or_404(Agenda, slug=kwargs['slug'])
agenda = get_object_or_404(Agenda, slug=kwargs['slug'], kind='events')
event = get_object_or_404(Event, slug=kwargs['event_slug'], agenda=agenda)
if agenda.partial_bookings:
day = localtime(event.start_datetime)
return reverse(
'chrono-manager-agenda-day-view',
kwargs={
'pk': agenda.pk,
'year': day.year,
'month': day.strftime('%m'),
'day': day.strftime('%d'),
},
)
return reverse('chrono-manager-event-view', kwargs={'pk': agenda.pk, 'event_pk': event.pk})
@ -2729,6 +2809,7 @@ class EventEditView(ManagedAgendaMixin, UpdateView):
self.request.GET.get('next') == 'settings'
or self.request.POST.get('next') == 'settings'
or self.object.recurrence_days
or self.object.agenda.partial_bookings
):
return reverse('chrono-manager-agenda-settings', kwargs={'pk': self.agenda.id})
return reverse('chrono-manager-event-view', kwargs={'pk': self.agenda.id, 'event_pk': self.object.id})
@ -2781,6 +2862,7 @@ class EventChecksView(ViewableAgendaMixin, EventChecksMixin, DetailView):
Agenda,
pk=kwargs.get('pk'),
kind='events',
partial_bookings=False,
)
def get_queryset(self):
@ -2827,7 +2909,7 @@ class EventCheckMixin:
event__cancelled=False,
cancellation_datetime__isnull=True,
in_waiting_list=False,
user_was_present__isnull=True,
user_checks__isnull=True,
)
def get_check_type(self, kind):
@ -2839,6 +2921,19 @@ class EventCheckMixin:
return ct
def response(self, request):
if self.agenda.partial_bookings:
day = localtime(self.event.start_datetime)
return HttpResponseRedirect(
reverse(
'chrono-manager-agenda-day-view',
kwargs={
'pk': self.agenda.pk,
'year': day.year,
'month': day.strftime('%m'),
'day': day.strftime('%d'),
},
)
)
return HttpResponseRedirect(
reverse(
'chrono-manager-event-check',
@ -2858,10 +2953,19 @@ class EventPresenceView(EventCheckMixin, ViewableAgendaMixin, FormView):
def post(self, request, *args, **kwargs):
qs_kwargs = {}
check_type = self.get_check_type(kind='presence')
qs_kwargs['user_check_type_slug'] = check_type.slug if check_type else None
qs_kwargs['user_check_type_label'] = check_type.label if check_type else None
qs_kwargs['type_slug'] = check_type.slug if check_type else None
qs_kwargs['type_label'] = check_type.label if check_type else None
bookings = self.get_bookings()
bookings.update(user_was_present=True, **qs_kwargs)
booking_checks_to_create = []
for booking in bookings:
if booking.user_check:
continue
booking_check = BookingCheck(booking=booking, presence=True, **qs_kwargs)
booking_checks_to_create.append(booking_check)
BookingCheck.objects.filter(booking__in=bookings).update(presence=True, **qs_kwargs)
BookingCheck.objects.bulk_create(booking_checks_to_create)
self.event.set_is_checked()
return self.response(request)
@ -2880,10 +2984,19 @@ class EventAbsenceView(EventCheckMixin, ViewableAgendaMixin, FormView):
def post(self, request, *args, **kwargs):
qs_kwargs = {}
check_type = self.get_check_type(kind='absence')
qs_kwargs['user_check_type_slug'] = check_type.slug if check_type else None
qs_kwargs['user_check_type_label'] = check_type.label if check_type else None
qs_kwargs['type_slug'] = check_type.slug if check_type else None
qs_kwargs['type_label'] = check_type.label if check_type else None
bookings = self.get_bookings()
bookings.update(user_was_present=False, **qs_kwargs)
booking_checks_to_create = []
for booking in bookings:
if booking.user_check:
continue
booking_check = BookingCheck(booking=booking, presence=False, **qs_kwargs)
booking_checks_to_create.append(booking_check)
BookingCheck.objects.filter(booking__in=bookings).update(presence=False, **qs_kwargs)
BookingCheck.objects.bulk_create(booking_checks_to_create)
self.event.set_is_checked()
return self.response(request)
@ -3663,10 +3776,12 @@ class BookingCheckMixin:
def response(self, request, booking):
if is_ajax(request):
booking.absence_form = BookingCheckAbsenceForm(
agenda=self.agenda, initial={'check_type': booking.user_check_type_slug}
agenda=self.agenda,
initial={'check_type': booking.user_check.type_slug if booking.user_check else None},
)
booking.presence_form = BookingCheckPresenceForm(
agenda=self.agenda, initial={'check_type': booking.user_check_type_slug}
agenda=self.agenda,
initial={'check_type': booking.user_check.type_slug if booking.user_check else None},
)
booking.kind = 'booking'
return render(
@ -4416,30 +4531,61 @@ class SharedCustodySettingsView(UpdateView):
shared_custody_settings = SharedCustodySettingsView.as_view()
class PartialBookingCheckView(ViewableAgendaMixin, UpdateView):
class PartialBookingCheckMixin(ViewableAgendaMixin):
template_name = 'chrono/manager_partial_booking_form.html'
model = Booking
pk_url_kwarg = 'booking_pk'
form_class = PartialBookingCheckForm
def get_queryset(self, **kwargs):
qs = super().get_queryset()
return qs.filter(
Q(event__start_datetime__date__lte=localtime().date())
| Q(event__agenda__enable_check_for_future_events=True),
)
def get_object(self):
booking = self.get_booking(**self.kwargs)
return BookingCheck(booking=booking)
def get_success_url(self):
date = self.object.event.start_datetime
date = self.object.booking.event.start_datetime
return reverse(
'chrono-manager-agenda-day-view',
kwargs={'pk': self.agenda.pk, 'year': date.year, 'month': date.month, 'day': date.day},
)
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs['agenda'] = self.agenda
return kwargs
class PartialBookingCheckView(PartialBookingCheckMixin, BookingCheckMixin, UpdateView):
pass
partial_booking_check_view = PartialBookingCheckView.as_view()
class PartialBookingSubscriptionCheckView(PartialBookingCheckMixin, SubscriptionCheckMixin, UpdateView):
def get_object(self):
if self.request.method == 'POST':
return super().get_object()
else:
return BookingCheck(booking=Booking())
partial_booking_subscription_check_view = PartialBookingSubscriptionCheckView.as_view()
class PartialBookingUpdateCheckView(PartialBookingCheckMixin, UpdateView):
model = BookingCheck
pk_url_kwarg = 'check_pk'
def get_object(self):
return super(PartialBookingCheckMixin, self).get_object()
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['allow_adding_check'] = bool(self.object.booking.user_checks.count() == 1)
return context
partial_booking_update_check_view = PartialBookingUpdateCheckView.as_view()
def menu_json(request):
if not request.user.is_staff:
homepage_view = HomepageView(request=request)

View File

@ -64,6 +64,19 @@ class TimeWidget(TimeInput):
self.attrs['pattern'] = '[0-9]{2}:[0-9]{2}'
class TimeWidgetWithButton(TimeWidget):
template_name = 'chrono/widgets/time_with_button.html'
def __init__(self, **kwargs):
self.button_label = kwargs.pop('button_label')
super().__init__(**kwargs)
def get_context(self, *args, **kwargs):
ctx = super().get_context(*args, **kwargs)
ctx['widget']['button_label'] = self.button_label
return ctx
class WeekdaysWidget(CheckboxSelectMultiple):
template_name = 'chrono/widgets/weekdays.html'

View File

@ -199,7 +199,6 @@ SMS_SENDER = ''
REST_FRAMEWORK = {'EXCEPTION_HANDLER': 'chrono.api.utils.exception_handler'}
SHARED_CUSTODY_ENABLED = False
LEGACY_FILLSLOTS_ENABLED = False
PARTIAL_BOOKINGS_ENABLED = False
CHRONO_ANTS_HUB_URL = None

View File

@ -17,7 +17,7 @@
from django.db import connection
from uwsgidecorators import spool # pylint: disable=import-error
from chrono.agendas.models import Event, ICSError, TimePeriodExceptionSource
from chrono.agendas.models import Agenda, Event, ICSError, TimePeriodExceptionSource
def set_connection(domain):
@ -83,3 +83,31 @@ def ants_hub_city_push(args):
City.push()
except Exception: # noqa pylint: disable=broad-except
pass
@spool
def refresh_booking_computed_times_from_agenda(args):
if args.get('domain'):
# multitenant installation
set_connection(args['domain'])
try:
agenda = Agenda.objects.get(pk=args['agenda_id'])
except Agenda.DoesNotExist:
return
agenda.refresh_booking_computed_times()
@spool
def refresh_booking_computed_times_from_event(args):
if args.get('domain'):
# multitenant installation
set_connection(args['domain'])
try:
event = Event.objects.get(pk=args['event_id'])
except Event.DoesNotExist:
return
event.refresh_booking_computed_times()

View File

@ -161,7 +161,7 @@ setup(
install_requires=[
'django>=3.2, <3.3',
'gadjo',
'djangorestframework>=3.4,<3.14',
'djangorestframework>=3.4,<3.15',
'django-filter',
'vobject',
'python-dateutil',

View File

@ -126,7 +126,7 @@ def ants_setup(db, freezer):
mairie_agenda,
paris('2023-04-11 11:00'),
meeting_type='mt-30',
extra_data={'ants_identifiant_predemande': 'ABCDEFGH'},
extra_data={'ants_identifiant_predemande': 'ABCDEFGH , IJKLMNOP'},
)
add_meeting(
@ -185,6 +185,7 @@ def test_export_to_push(ants_setup):
'rdvs': [
{'annule': True, 'date': '2023-04-10T07:00:00+00:00', 'id': '12345678'},
{'date': '2023-04-11T09:00:00+00:00', 'id': 'ABCDEFGH'},
{'date': '2023-04-11T09:00:00+00:00', 'id': 'IJKLMNOP'},
],
'plages': [
{

View File

@ -1494,38 +1494,33 @@ def test_datetimes_multiple_agendas_with_status(app):
places=5,
agenda=agenda,
)
Booking.objects.create(event=event_absence, user_external_id='xxx', user_was_present=False)
booking = Booking.objects.create(event=event_absence, user_external_id='xxx')
booking.mark_user_absence()
event_absence_with_reason = Event.objects.create(
slug='event-absence_with_reason',
start_datetime=now() - datetime.timedelta(days=11),
places=5,
agenda=agenda,
)
Booking.objects.create(
event=event_absence_with_reason,
user_external_id='xxx',
user_was_present=False,
user_check_type_slug='foo-reason',
)
booking = Booking.objects.create(event=event_absence_with_reason, user_external_id='xxx')
booking.mark_user_absence(check_type_slug='foo-reason')
event_presence = Event.objects.create(
slug='event-presence',
start_datetime=now() - datetime.timedelta(days=10),
places=5,
agenda=agenda,
)
Booking.objects.create(event=event_presence, user_external_id='xxx', user_was_present=True)
booking = Booking.objects.create(event=event_presence, user_external_id='xxx')
booking.mark_user_presence()
event_presence_with_reason = Event.objects.create(
slug='event-presence_with_reason',
start_datetime=now() - datetime.timedelta(days=9),
places=5,
agenda=agenda,
)
Booking.objects.create(
event=event_presence_with_reason,
user_external_id='xxx',
user_was_present=True,
user_check_type_slug='foo-reason',
)
booking = Booking.objects.create(event=event_presence_with_reason, user_external_id='xxx')
booking.mark_user_presence(check_type_slug='foo-reason')
event_booked_future = Event.objects.create(
slug='event-booked-future',
start_datetime=now() + datetime.timedelta(days=1),

View File

@ -434,7 +434,6 @@ def test_datetimes_api_meetings_agenda_short_time_periods(app, meetings_agenda,
resp = app.get('/api/agenda/meetings/%s/datetimes/' % meeting_type.id)
assert len(resp.json['data']) == 2
fillslot_url = resp.json['data'][0]['api']['fillslot_url']
two_slots = [resp.json['data'][0]['id'], resp.json['data'][1]['id']]
time_period.end_time = datetime.time(10, 15)
time_period.save()
@ -448,13 +447,6 @@ def test_datetimes_api_meetings_agenda_short_time_periods(app, meetings_agenda,
assert resp.json['reason'] == 'no more desk available' # legacy
assert resp.json['err_class'] == 'no more desk available'
assert resp.json['err_desc'] == 'no more desk available'
# booking the two slots fails too
fillslots_url = '/api/agenda/%s/fillslots/' % meeting_type.agenda.slug
resp = app.post(fillslots_url, params={'slots': two_slots})
assert resp.json['err'] == 1
assert resp.json['reason'] == 'no more desk available' # legacy
assert resp.json['err_class'] == 'no more desk available'
assert resp.json['err_desc'] == 'no more desk available'
@pytest.mark.freeze_time('2021-02-25')

View File

@ -123,6 +123,40 @@ def test_recurring_events_api_list(app, freezer):
assert not any('example_event' in x['id'] for x in resp.json['data'])
@pytest.mark.freeze_time('2021-12-13 14:00')
def test_recurring_events_api_list_display_template(app):
agenda = Agenda.objects.create(label='Foo bar', kind='events')
Desk.objects.create(agenda=agenda, slug='_exceptions_holder')
Event.objects.create(label='Normal event', start_datetime=now(), places=5, agenda=agenda)
event = Event.objects.create(
label='Example Event',
start_datetime=now(),
recurrence_days=[1, 4, 5], # Monday, Thursday, Friday
recurrence_end_date=now() + datetime.timedelta(days=30),
places=2,
agenda=agenda,
)
event.create_all_recurrences()
resp = app.get('/api/agendas/recurring-events/?agendas=%s' % agenda.slug)
assert [x['text'] for x in resp.json['data']] == [
'Monday: Example Event',
'Thursday: Example Event',
'Friday: Example Event',
]
agenda.event_display_template = (
'{% if event.recurrence_days %}{{ event.weekday }}{% else %}{{ event }}{% endif %}'
)
agenda.save()
resp = app.get('/api/agendas/recurring-events/?agendas=%s' % agenda.slug)
assert [x['text'] for x in resp.json['data']] == ['Monday', 'Thursday', 'Friday']
resp = app.get('/api/agenda/%s/datetimes/' % agenda.slug)
assert [x['text'] for x in resp.json['data']][:3] == ['Example Event', 'Example Event', 'Example Event']
@pytest.mark.freeze_time('2021-12-13 14:00') # Monday of 50th week
def test_recurring_events_api_list_shared_custody(app):
agenda = Agenda.objects.create(

View File

@ -454,205 +454,6 @@ def test_booking_api_meetings_agenda_exclude_slots(app, user):
assert resp.json['err'] == 0
def test_booking_api_fillslots(app, user):
agenda = Agenda.objects.create(label='Foo bar', kind='events')
events = []
for i in range(3):
events.append(
Event.objects.create(
label='Event', start_datetime=now() + datetime.timedelta(days=5 + i), places=20, agenda=agenda
)
)
events_ids = [x.id for x in events]
events_slugs = [x.slug for x in events]
event = events[0]
# unauthenticated
resp = app.post('/api/agenda/%s/fillslots/' % agenda.slug, status=401)
for agenda_key in (agenda.slug, agenda.id): # acces datetimes via agenda slug or id (legacy)
resp_datetimes = app.get('/api/agenda/%s/datetimes/' % agenda_key)
api_event_slugs = [x['id'] for x in resp_datetimes.json['data']]
assert api_event_slugs == events_slugs
assert Booking.objects.count() == 0
app.authorization = ('Basic', ('john.doe', 'password'))
resp = app.post('/api/agenda/%s/fillslots/' % agenda.slug, params={'slots': events_ids})
primary_booking_id = resp.json['booking_id']
Booking.objects.get(id=primary_booking_id)
assert resp.json['datetime'] == localtime(event.start_datetime).strftime('%Y-%m-%d %H:%M:%S')
assert 'booking_url' in resp.json['api']
assert 'accept_url' in resp.json['api']
assert 'suspend_url' in resp.json['api']
assert 'cancel_url' in resp.json['api']
assert urlparse.urlparse(resp.json['api']['booking_url']).netloc
assert urlparse.urlparse(resp.json['api']['accept_url']).netloc
assert urlparse.urlparse(resp.json['api']['suspend_url']).netloc
assert urlparse.urlparse(resp.json['api']['cancel_url']).netloc
assert Booking.objects.count() == 3
# these 3 bookings are related, the first is the primary one
bookings = Booking.objects.all().order_by('pk')
assert bookings[0].primary_booking is None
assert bookings[1].primary_booking.id == bookings[0].id == primary_booking_id
assert bookings[2].primary_booking.id == bookings[0].id == primary_booking_id
# access by slug
resp = app.post('/api/agenda/%s/fillslots/' % agenda.slug, params={'slots': events_slugs})
primary_booking_id_2 = resp.json['booking_id']
assert Booking.objects.count() == 6
assert Booking.objects.filter(event__agenda=agenda).count() == 6
# 6 = 2 primary + 2*2 secondary
assert Booking.objects.filter(event__agenda=agenda, primary_booking__isnull=True).count() == 2
assert Booking.objects.filter(event__agenda=agenda, primary_booking=primary_booking_id).count() == 2
assert Booking.objects.filter(event__agenda=agenda, primary_booking=primary_booking_id_2).count() == 2
# test with additional data
resp = app.post_json(
'/api/agenda/%s/fillslots/' % agenda.id,
params={
'slots': events_ids,
'label': 'foo',
'user_external_id': 'some_external_id',
'user_last_name': 'bar',
'user_display_label': 'foo',
'backoffice_url': 'http://example.net/',
},
)
booking_id = resp.json['booking_id']
booking = Booking.objects.get(pk=booking_id)
assert booking.label == 'foo'
assert booking.user_external_id == 'some_external_id'
assert booking.user_last_name == 'bar'
assert booking.user_display_label == 'foo'
assert booking.backoffice_url == 'http://example.net/'
assert Booking.objects.filter(primary_booking=booking_id, label='foo').count() == 2
# cancel
cancel_url = resp.json['api']['cancel_url']
assert Booking.objects.filter(cancellation_datetime__isnull=False).count() == 0
assert Booking.objects.get(id=booking_id).cancellation_datetime is None
resp_cancel = app.post(cancel_url)
assert resp_cancel.json['err'] == 0
assert Booking.objects.filter(cancellation_datetime__isnull=False).count() == 3
assert Booking.objects.get(id=booking_id).cancellation_datetime is not None
# extra data stored in extra_data field
resp = app.post_json(
'/api/agenda/%s/fillslots/' % agenda.id,
params={'slots': events_ids, 'label': 'l', 'user_last_name': 'u', 'backoffice_url': '', 'foo': 'bar'},
)
assert Booking.objects.get(id=resp.json['booking_id']).label == 'l'
assert Booking.objects.get(id=resp.json['booking_id']).user_last_name == 'u'
assert Booking.objects.get(id=resp.json['booking_id']).backoffice_url == ''
assert Booking.objects.get(id=resp.json['booking_id']).extra_data == {'foo': 'bar'}
for booking in Booking.objects.filter(primary_booking=resp.json['booking_id']):
assert booking.extra_data == {'foo': 'bar'}
# test invalid data are refused
resp = app.post_json(
'/api/agenda/%s/fillslots/' % agenda.id,
params={'slots': events_ids, 'user_last_name': {'foo': 'bar'}},
status=400,
)
assert resp.json['err'] == 1
assert resp.json['reason'] == 'invalid payload' # legacy
assert resp.json['err_class'] == 'invalid payload'
assert resp.json['err_desc'] == 'invalid payload'
assert len(resp.json['errors']) == 1
assert 'user_last_name' in resp.json['errors']
# extra_data list/dict values are refused
resp = app.post_json(
'/api/agenda/%s/fillslots/' % agenda.id,
params={'slots': events_ids, 'foo': ['bar', 'baz']},
status=400,
)
assert resp.json['err'] == 1
assert resp.json['err_class'] == 'wrong type for extra_data foo value'
resp = app.post_json(
'/api/agenda/%s/fillslots/' % agenda.id,
params={'slots': events_ids, 'foo': {'bar': 'baz'}},
status=400,
)
assert resp.json['err'] == 1
assert resp.json['err_class'] == 'wrong type for extra_data foo value'
# empty or missing slots
resp = app.post_json('/api/agenda/%s/fillslots/' % agenda.id, params={'slots': []}, status=400)
assert resp.json['err'] == 1
assert resp.json['reason'] == 'invalid payload' # legacy
assert resp.json['err_desc'] == 'invalid payload'
assert resp.json['errors']['slots'] == ['This field is required.']
resp = app.post_json('/api/agenda/%s/fillslots/' % agenda.id, status=400)
assert resp.json['err'] == 1
assert resp.json['reason'] == 'invalid payload' # legacy
assert resp.json['err_desc'] == 'invalid payload'
assert resp.json['errors']['slots'] == ['This field is required.']
resp = app.post_json('/api/agenda/%s/fillslots/' % agenda.id, params={'slots': ''}, status=400)
assert resp.json['err'] == 1
assert resp.json['err_desc'] == 'invalid payload'
assert resp.json['errors']['slots'] == ['This field is required.']
# invalid slots format
resp = app.post_json('/api/agenda/%s/fillslots/' % agenda.id, params={'slots': 'foobar'}, status=400)
assert resp.json['err'] == 1
assert resp.json['reason'] == 'invalid slugs: foobar' # legacy
assert resp.json['err_class'] == 'invalid slugs: foobar'
assert resp.json['err_desc'] == 'invalid slugs: foobar'
# unknown agendas
resp = app.post('/api/agenda/foobar/fillslots/', status=404)
resp = app.post('/api/agenda/0/fillslots/', status=404)
# check bookable period
with mock.patch('chrono.agendas.models.Event.in_bookable_period') as in_bookable_period:
in_bookable_period.return_value = True
resp = app.post_json(
'/api/agenda/%s/fillslots/' % agenda.id,
params={'slots': events_ids},
)
assert resp.json['err'] == 0
in_bookable_period.return_value = False
resp = app.post_json(
'/api/agenda/%s/fillslots/' % agenda.id,
params={'slots': events_ids},
)
assert resp.json['err'] == 1
assert resp.json['reason'] == 'event not bookable' # legacy
assert resp.json['err_class'] == 'event not bookable'
assert resp.json['err_desc'] == 'event event is not bookable'
def test_booking_api_fillslots_slots_string_param(app, user):
agenda = Agenda.objects.create(label='Foo bar', kind='events')
Event.objects.create(
label='Event', start_datetime=now() + datetime.timedelta(days=5), places=10, agenda=agenda
)
Event.objects.create(
label='Event', start_datetime=now() + datetime.timedelta(days=5), places=10, agenda=agenda
)
events_ids = [x.id for x in Event.objects.filter(agenda=agenda)]
app.authorization = ('Basic', ('john.doe', 'password'))
# empty string
resp = app.post_json('/api/agenda/%s/fillslots/' % agenda.slug, params={'slots': ''}, status=400)
assert resp.json['err'] == 1
assert resp.json['err_class'] == 'invalid payload'
assert resp.json['err_desc'] == 'invalid payload'
slots_string_param = ','.join([str(e) for e in events_ids])
resp = app.post_json('/api/agenda/%s/fillslots/' % agenda.slug, params={'slots': slots_string_param})
assert Booking.objects.count() == 2
start = now() + datetime.timedelta(days=2)
Event.objects.create(label='Long Slug', slug='a' * 100, start_datetime=start, places=2, agenda=agenda)
Event.objects.create(label='Long Slug', slug='b' * 100, start_datetime=start, places=2, agenda=agenda)
events_ids = [x.id for x in Event.objects.filter(label='Long Slug')]
slots_string_param = ','.join([str(e) for e in events_ids])
resp = app.post_json('/api/agenda/%s/fillslots/' % agenda.slug, params={'slots': slots_string_param})
assert Booking.objects.count() == 4
def test_booking_api_meeting(app, meetings_agenda, user):
agenda_id = meetings_agenda.slug
meeting_type = MeetingType.objects.get(agenda=meetings_agenda)
@ -867,90 +668,6 @@ def test_booking_api_meeting_with_resources(app, user):
assert list(booking.event.resources.all()) == [resource1, resource2]
def test_booking_api_meeting_fillslots(app, meetings_agenda, user):
agenda_id = meetings_agenda.slug
meeting_type = MeetingType.objects.get(agenda=meetings_agenda)
resp = app.get('/api/agenda/meetings/%s/datetimes/' % meeting_type.id)
slots = [resp.json['data'][0]['id'], resp.json['data'][1]['id']]
app.authorization = ('Basic', ('john.doe', 'password'))
resp_booking = app.post('/api/agenda/%s/fillslots/' % agenda_id, params={'slots': slots})
assert Booking.objects.count() == 2
primary_booking = Booking.objects.filter(primary_booking__isnull=True).first()
secondary_booking = Booking.objects.filter(primary_booking=primary_booking.id).first()
assert resp_booking.json['datetime'] == localtime(primary_booking.event.start_datetime).strftime(
'%Y-%m-%d %H:%M:%S'
)
assert resp_booking.json['end_datetime'] == localtime(secondary_booking.event.end_datetime).strftime(
'%Y-%m-%d %H:%M:%S'
)
resp2 = app.get('/api/agenda/meetings/%s/datetimes/' % meeting_type.id)
assert len(resp.json['data']) == len([x for x in resp2.json['data'] if not x.get('disabled')]) + 2
# try booking the same timeslots
resp2 = app.post('/api/agenda/%s/fillslots/' % agenda_id, params={'slots': slots})
assert resp2.json['err'] == 1
assert resp2.json['reason'] == 'no more desk available' # legacy
assert resp2.json['err_class'] == 'no more desk available'
assert resp2.json['err_desc'] == 'no more desk available'
# try booking partially free timeslots (one free, one busy)
nonfree_slots = [resp.json['data'][0]['id'], resp.json['data'][2]['id']]
resp2 = app.post('/api/agenda/%s/fillslots/' % agenda_id, params={'slots': nonfree_slots})
assert resp2.json['err'] == 1
assert resp2.json['reason'] == 'no more desk available' # legacy
assert resp2.json['err_class'] == 'no more desk available'
assert resp2.json['err_desc'] == 'no more desk available'
# booking other free timeslots
free_slots = [resp.json['data'][3]['id'], resp.json['data'][2]['id']]
resp2 = app.post('/api/agenda/%s/fillslots/' % agenda_id, params={'slots': free_slots})
assert resp2.json['err'] == 0
cancel_url = resp2.json['api']['cancel_url']
assert Booking.objects.count() == 4
# 4 = 2 primary + 2 secondary
assert Booking.objects.filter(primary_booking__isnull=True).count() == 2
assert Booking.objects.filter(primary_booking__isnull=False).count() == 2
# cancel
assert Booking.objects.filter(cancellation_datetime__isnull=False).count() == 0
resp_cancel = app.post(cancel_url)
assert resp_cancel.json['err'] == 0
assert Booking.objects.filter(cancellation_datetime__isnull=False).count() == 2
def test_booking_api_meeting_fillslots_wrong_slot(app, user):
agenda = Agenda.objects.create(label='Foo', kind='meetings')
app.authorization = ('Basic', ('john.doe', 'password'))
impossible_slots = ['1:2017-05-22-1130', '2:2017-05-22-1100']
resp = app.post('/api/agenda/%s/fillslots/' % agenda.slug, params={'slots': impossible_slots}, status=400)
assert resp.json['err'] == 1
assert resp.json['reason'] == 'all slots must have the same meeting type id (1)' # legacy
assert resp.json['err_class'] == 'all slots must have the same meeting type id (1)'
assert resp.json['err_desc'] == 'all slots must have the same meeting type id (1)'
unknown_slots = ['0:2017-05-22-1130']
resp = app.post('/api/agenda/%s/fillslots/' % agenda.slug, params={'slots': unknown_slots}, status=400)
assert resp.json['err'] == 1
assert resp.json['reason'] == 'invalid meeting type id: 0' # legacy
assert resp.json['err_class'] == 'invalid meeting type id: 0'
assert resp.json['err_desc'] == 'invalid meeting type id: 0'
unknown_slots = ['foobar:2017-05-22-1130']
resp = app.post('/api/agenda/%s/fillslots/' % agenda.slug, params={'slots': unknown_slots}, status=400)
assert resp.json['err'] == 1
assert resp.json['reason'] == 'invalid meeting type id: foobar' # legacy
assert resp.json['err_class'] == 'invalid meeting type id: foobar'
assert resp.json['err_desc'] == 'invalid meeting type id: foobar'
badformat_slots = ['foo:2020-10-28-14h00']
resp = app.post('/api/agenda/%s/fillslots/' % agenda.slug, params={'slots': badformat_slots}, status=400)
assert resp.json['err'] == 1
assert resp.json['reason'] == 'bad datetime format: 2020-10-28-14h00' # legacy
assert resp.json['err_class'] == 'bad datetime format: 2020-10-28-14h00'
assert resp.json['err_desc'] == 'bad datetime format: 2020-10-28-14h00'
def test_booking_api_meeting_across_daylight_saving_time(app, meetings_agenda, user):
meetings_agenda.maximal_booking_delay = 365
meetings_agenda.save()
@ -1002,12 +719,6 @@ def test_booking_api_meeting_weekday_indexes(app, user):
assert Booking.objects.count() == 1
assert resp.json['duration'] == 30
# multiple slots
slots = [datetimes_resp.json['data'][1]['id'], datetimes_resp.json['data'][2]['id']]
assert slots == ['plop:2022-02-03-1130', 'plop:2022-02-17-1100']
resp = app.post('/api/agenda/%s/fillslots/' % agenda.slug, params={'slots': slots})
assert Booking.objects.count() == 3
# try to book slot on a skipped week
slot = datetimes_resp.json['data'][3]['id']
time_period.weekday_indexes = [1]
@ -1057,11 +768,12 @@ def test_booking_api_meeting_date_time_period(app, user):
assert Booking.objects.count() == 1
assert resp.json['duration'] == 30
# multiple slots
# book another two slots
slots = [datetimes_resp.json['data'][1]['id'], datetimes_resp.json['data'][2]['id']]
assert slots == ['plop:2022-10-24-1230', 'plop:2022-10-24-1300']
resp = app.post('/api/agenda/%s/fillslots/' % agenda.slug, params={'slots': slots})
assert Booking.objects.count() == 3
for i, slot in enumerate(slots, 2):
resp = app.post('/api/agenda/%s/fillslot/%s/' % (agenda.slug, slot))
assert Booking.objects.count() == i
assert resp.json['duration'] == 30
resp = app.post('/api/agenda/%s/fillslot/%s/' % (agenda.slug, slot))
assert resp.json['err'] == 1
@ -1148,16 +860,6 @@ def test_booking_api_available(app, user):
assert resp.json['err'] == 0
assert 'places' not in resp.json
# not for multiple booking
events = [
x for x in Event.objects.filter(agenda=agenda).order_by('start_datetime') if x.in_bookable_period()
][:2]
slots = [x.pk for x in events]
resp = app.post('/api/agenda/%s/fillslots/' % agenda.slug, params={'slots': slots, 'count': '3'})
assert resp.json['err'] == 0
assert 'places' not in resp.json
def test_booking_api_force_waiting_list(app, user):
agenda = Agenda.objects.create(label='Foo bar', kind='events')
@ -1487,164 +1189,6 @@ def test_multiple_booking_api(app, user):
assert Event.objects.get(id=event.id).booked_waiting_list_places == 2
def test_multiple_booking_api_fillslots(app, user):
agenda = Agenda.objects.create(label='Foo bar', kind='events')
events = []
for i in range(2):
events.append(
Event.objects.create(
label='Event', start_datetime=now() + datetime.timedelta(days=5 + i), places=20, agenda=agenda
)
)
events_slugs = [x.slug for x in events]
resp_datetimes = app.get('/api/agenda/%s/datetimes/' % agenda.id)
slots = [x['id'] for x in resp_datetimes.json['data'] if x['id'] in events_slugs]
app.authorization = ('Basic', ('john.doe', 'password'))
resp = app.post('/api/agenda/%s/fillslots/?count=NaN' % agenda.slug, params={'slots': slots}, status=400)
assert resp.json['err'] == 1
assert resp.json['reason'] == 'invalid value for count (NaN)' # legacy
assert resp.json['err_class'] == 'invalid value for count (NaN)'
assert resp.json['err_desc'] == 'invalid value for count (NaN)'
resp = app.post(
'/api/agenda/%s/fillslots/' % agenda.slug, params={'slots': slots, 'count': 'NaN'}, status=400
)
assert resp.json['err'] == 1
assert resp.json['reason'] == 'invalid payload' # legacy
assert resp.json['err_class'] == 'invalid payload'
assert resp.json['err_desc'] == 'invalid payload'
assert 'count' in resp.json['errors']
# get 3 places on 2 slots
resp = app.post('/api/agenda/%s/fillslots/' % agenda.slug, params={'slots': slots, 'count': '3'})
# one booking with 5 children
booking = Booking.objects.get(id=resp.json['booking_id'])
cancel_url = resp.json['api']['cancel_url']
assert Booking.objects.filter(primary_booking=booking).count() == 5
assert resp.json['datetime'] == localtime(events[0].start_datetime).strftime('%Y-%m-%d %H:%M:%S')
assert 'accept_url' in resp.json['api']
assert 'cancel_url' in resp.json['api']
assert 'ics_url' in resp.json['api']
resp_events = resp.json['events']
assert len(resp_events) == len(events)
for e, resp_e in zip(events, resp_events):
assert e.slug == resp_e['slug']
assert e.description == resp_e['description']
assert str(e) == resp_e['text']
assert localtime(e.start_datetime).strftime('%Y-%m-%d %H:%M:%S') == resp_e['datetime']
for event in events:
assert Event.objects.get(id=event.id).booked_places == 3
resp = app.post('/api/agenda/%s/fillslots/' % agenda.slug, params={'slots': slots, 'count': 2})
for event in events:
assert Event.objects.get(id=event.id).booked_places == 5
resp = app.post(cancel_url)
for event in events:
assert Event.objects.get(id=event.id).booked_places == 2
# check available places overflow
# NB: limit only the first event !
events[0].places = 3
events[0].waiting_list_places = 8
events[0].save()
resp = app.post('/api/agenda/%s/fillslots/' % agenda.slug, params={'slots': slots, 'count': 5})
for event in events:
assert Event.objects.get(id=event.id).booked_places == 2
assert Event.objects.get(id=event.id).booked_waiting_list_places == 5
accept_url = resp.json['api']['accept_url']
resp = app.post('/api/agenda/%s/fillslots/' % agenda.slug, params={'slots': slots, 'count': 5})
assert resp.json['err'] == 1
assert resp.json['reason'] == 'sold out' # legacy
assert resp.json['err_class'] == 'sold out'
assert resp.json['err_desc'] == 'sold out'
for event in events:
assert Event.objects.get(id=event.id).booked_places == 2
assert Event.objects.get(id=event.id).booked_waiting_list_places == 5
# accept the waiting list
resp = app.post(accept_url)
for event in events:
assert Event.objects.get(id=event.id).booked_places == 7
assert Event.objects.get(id=event.id).booked_waiting_list_places == 0
# check with a short waiting list
Booking.objects.all().delete()
# NB: limit only the first event !
events[0].places = 4
events[0].waiting_list_places = 2
events[0].save()
resp = app.post('/api/agenda/%s/fillslots/' % agenda.slug, params={'slots': slots, 'count': 5})
assert resp.json['err'] == 1
assert resp.json['reason'] == 'sold out' # legacy
assert resp.json['err_class'] == 'sold out'
assert resp.json['err_desc'] == 'sold out'
resp = app.post('/api/agenda/%s/fillslots/' % agenda.slug, params={'slots': slots, 'count': 3})
assert resp.json['err'] == 0
for event in events:
assert Event.objects.get(id=event.id).booked_places == 3
assert Event.objects.get(id=event.id).booked_waiting_list_places == 0
resp = app.post('/api/agenda/%s/fillslots/' % agenda.slug, params={'slots': slots, 'count': 3})
assert resp.json['err'] == 1
assert resp.json['reason'] == 'sold out' # legacy
assert resp.json['err_class'] == 'sold out'
assert resp.json['err_desc'] == 'sold out'
resp = app.post('/api/agenda/%s/fillslots/' % agenda.slug, params={'slots': slots, 'count': '2'})
assert resp.json['err'] == 0
for event in events:
assert Event.objects.get(id=event.id).booked_places == 3
assert Event.objects.get(id=event.id).booked_waiting_list_places == 2
def test_multiple_booking_move_booking(app, user):
agenda = Agenda(label='Foo bar')
agenda.save()
first_date = localtime(now()).replace(hour=17, minute=0, second=0, microsecond=0)
first_date += datetime.timedelta(days=1)
events = []
for i in range(10):
event = Event(start_datetime=first_date + datetime.timedelta(days=i), places=20, agenda=agenda)
event.save()
events.append(event)
first_two_events = events[:2]
events_slugs = [x.slug for x in first_two_events]
resp_datetimes = app.get('/api/agenda/%s/datetimes/' % agenda.id)
slots = [x['id'] for x in resp_datetimes.json['data'] if x['id'] in events_slugs]
app.authorization = ('Basic', ('john.doe', 'password'))
# get 1 place on 2 slots
resp = app.post('/api/agenda/%s/fillslots/' % agenda.slug, params={'slots': slots})
booking = Booking.objects.get(id=resp.json['booking_id'])
assert Booking.objects.filter(primary_booking=booking).count() == 1
for event in first_two_events:
assert Event.objects.get(id=event.id).booked_places == 1
# change, 1 place on 2 other slots
last_two_events = events[-2:]
events_slugs = [x.slug for x in last_two_events]
resp_datetimes = app.get('/api/agenda/%s/datetimes/' % agenda.id)
slots = [x['id'] for x in resp_datetimes.json['data'] if x['id'] in events_slugs]
resp = app.post(
'/api/agenda/%s/fillslots/' % agenda.slug, params={'slots': slots, 'cancel_booking_id': booking.pk}
)
booking = Booking.objects.get(id=resp.json['booking_id'])
assert Booking.objects.filter(primary_booking=booking).count() == 1
for event in first_two_events:
assert Event.objects.get(id=event.id).booked_places == 0
for event in last_two_events:
assert Event.objects.get(id=event.id).booked_places == 1
def test_agenda_meeting_api_multiple_desk(app, user):
agenda = Agenda.objects.create(
label='foo', kind='meetings', minimal_booking_delay=1, maximal_booking_delay=56
@ -1743,92 +1287,6 @@ def test_agenda_meeting_api_multiple_desk(app, user):
assert len(ctx.captured_queries) == 9
def test_agenda_meeting_api_fillslots_multiple_desks(app, user):
agenda = Agenda.objects.create(
label='foo', kind='meetings', minimal_booking_delay=1, maximal_booking_delay=56
)
meeting_type = MeetingType.objects.create(agenda=agenda, label='Blah', duration=30)
default_desk = Desk.objects.create(agenda=agenda, label='Desk 1')
time_period = TimePeriod.objects.create(
weekday=0, start_time=datetime.time(10, 0), end_time=datetime.time(12, 0), desk=default_desk
)
app.authorization = ('Basic', ('john.doe', 'password'))
# add a second desk, same timeperiods
time_period = agenda.desk_set.first().timeperiod_set.first()
desk2 = Desk.objects.create(label='Desk 2', agenda=agenda)
TimePeriod.objects.create(
start_time=time_period.start_time,
end_time=time_period.end_time,
weekday=time_period.weekday,
desk=desk2,
)
resp = app.get('/api/agenda/meetings/%s/datetimes/' % meeting_type.id)
slots = [x['id'] for x in resp.json['data'][:3]]
def get_free_places():
resp = app.get('/api/agenda/meetings/%s/datetimes/' % meeting_type.id)
return len([x for x in resp.json['data'] if not x['disabled']])
start_free_places = get_free_places()
# booking 3 slots on desk 1
fillslots_url = '/api/agenda/%s/fillslots/' % agenda.pk
resp = app.post(fillslots_url, params={'slots': slots})
assert resp.json['err'] == 0
desk1 = resp.json['desk']['slug']
cancel_url = resp.json['api']['cancel_url']
assert get_free_places() == start_free_places
# booking same slots again, will be on desk 2
resp = app.post(fillslots_url, params={'slots': slots})
assert resp.json['err'] == 0
assert resp.json['desk']['slug'] != desk2
# 3 places are disabled in datetimes list
assert get_free_places() == start_free_places - len(slots)
# try booking again: no desk available
resp = app.post(fillslots_url, params={'slots': slots})
assert resp.json['err'] == 1
assert resp.json['reason'] == 'no more desk available' # legacy
assert resp.json['err_class'] == 'no more desk available'
assert resp.json['err_desc'] == 'no more desk available'
assert get_free_places() == start_free_places - len(slots)
# cancel desk 1 booking
resp = app.post(cancel_url)
assert resp.json['err'] == 0
# all places are free again
assert get_free_places() == start_free_places
# booking a single slot (must be on desk 1)
resp = app.post('/api/agenda/%s/fillslot/%s/' % (agenda.pk, slots[1]))
assert resp.json['err'] == 0
assert resp.json['desk']['slug'] == desk1
cancel_url = resp.json['api']['cancel_url']
assert get_free_places() == start_free_places - 1
# try booking the 3 slots again: no desk available, one slot is not fully available
resp = app.post(fillslots_url, params={'slots': slots})
assert resp.json['err'] == 1
assert resp.json['reason'] == 'no more desk available' # legacy
assert resp.json['err_class'] == 'no more desk available'
assert resp.json['err_desc'] == 'no more desk available'
# cancel last signel slot booking, desk1 will be free
resp = app.post(cancel_url)
assert resp.json['err'] == 0
assert get_free_places() == start_free_places
# booking again is ok, on desk 1
resp = app.post(fillslots_url, params={'slots': slots})
assert resp.json['err'] == 0
assert resp.json['desk']['slug'] == desk1
assert get_free_places() == start_free_places - len(slots)
def test_agenda_meeting_same_day(app, mock_now, user):
app.authorization = ('Basic', ('john.doe', 'password'))
agenda = Agenda(label='Foo', kind='meetings')
@ -2237,59 +1695,6 @@ def test_duration_on_booking_api_fillslot_response(app, user):
assert 'DTEND:20170521T235700Z' in ics
@pytest.mark.freeze_time('2017-04-01')
def test_duration_on_booking_api_fillslots_response(app, user):
agenda = Agenda(label='Foo bar')
agenda.save()
first_date = datetime.datetime(2017, 5, 20, 1, 12)
durations = [None, 0, 45]
evt = []
for i in range(3):
evt.append(
Event(
start_datetime=first_date + datetime.timedelta(days=i),
duration=durations[i],
places=20,
agenda=agenda,
)
)
evt[i].save()
assert evt[0].end_datetime is None
assert evt[1].end_datetime == evt[1].start_datetime
assert evt[2].end_datetime == evt[2].start_datetime + datetime.timedelta(minutes=45)
app.authorization = ('Basic', ('john.doe', 'password'))
# first event having null duration
string_param = ','.join([str(e.id) for e in evt[::-1]]) # unordered parameters
resp = app.post_json('/api/agenda/%s/fillslots/' % agenda.slug, params={'slots': string_param})
r_evt = resp.json['events']
assert r_evt[0]['datetime'] == '2017-05-20 01:12:00'
assert r_evt[0]['end_datetime'] is None
assert r_evt[1]['datetime'] == '2017-05-21 01:12:00'
assert r_evt[1]['end_datetime'] == r_evt[1]['datetime']
assert r_evt[2]['datetime'] == '2017-05-22 01:12:00'
assert r_evt[2]['end_datetime'] == '2017-05-22 01:57:00'
assert 'ics_url' in resp.json['api']
ics = app.get(resp.json['api']['ics_url']).text
assert 'DTSTART:20170519T231200Z' in ics
assert 'DTEND:' not in ics
# first event having duration
evt[0].duration = 90
evt[0].save()
resp = app.post_json('/api/agenda/%s/fillslots/' % agenda.slug, params={'slots': string_param})
r_evt = resp.json['events']
assert r_evt[0]['datetime'] == '2017-05-20 01:12:00'
assert r_evt[0]['end_datetime'] == '2017-05-20 02:42:00'
assert 'ics_url' in resp.json['api']
ics = app.get(resp.json['api']['ics_url']).text
assert 'DTSTART:20170519T231200Z' in ics
assert 'DTEND:20170520T004200Z' in ics
def test_fillslot_past_event(app, user):
app.authorization = ('Basic', ('john.doe', 'password'))
agenda = Agenda.objects.create(
@ -2623,19 +2028,60 @@ def test_user_external_id(app, user):
meeting_event.delete()
def test_booking_api_fillslots_deprecated(app, user, settings):
settings.LEGACY_FILLSLOTS_ENABLED = False
agenda = Agenda.objects.create(label='Foo bar', kind='events')
@pytest.mark.freeze_time('2021-02-23 14:00')
def test_booking_api_partial_booking(app, user):
agenda = Agenda.objects.create(label='Foo bar', kind='events', partial_bookings=True)
event = Event.objects.create(
label='Event', start_datetime=now() + datetime.timedelta(days=5), places=10, agenda=agenda
label='Event',
start_datetime=now() + datetime.timedelta(days=5),
duration=120,
places=1,
agenda=agenda,
)
app.authorization = ('Basic', ('john.doe', 'password'))
resp = app.post_json('/api/agenda/%s/fillslots/' % agenda.slug, params={'slots': [event.id]}, status=400)
assert 'deprecated' in resp.json['err_desc']
assert Booking.objects.count() == 0
resp = app.post(
'/api/agenda/%s/fillslot/%s/' % (agenda.slug, event.id),
params={'start_time': '10:00', 'end_time': '15:00'},
)
booking = Booking.objects.get()
assert booking.start_time == datetime.time(10, 00)
assert booking.end_time == datetime.time(15, 00)
resp = app.post_json('/api/agenda/%s/fillslot/%s/' % (agenda.slug, event.id))
assert Booking.objects.count() == 1
# missing start_time
resp = app.post(
'/api/agenda/%s/fillslot/%s/' % (agenda.slug, event.id), params={'end_time': '10:00'}, status=400
)
assert (
resp.json['errors']['non_field_errors'][0]
== 'must include start_time and end_time for partial bookings agenda'
)
# missing end_time
resp = app.post(
'/api/agenda/%s/fillslot/%s/' % (agenda.slug, event.id), params={'start_time': '10:00'}, status=400
)
assert (
resp.json['errors']['non_field_errors'][0]
== 'must include start_time and end_time for partial bookings agenda'
)
# null end_time
resp = app.post_json(
'/api/agenda/%s/fillslot/%s/' % (agenda.slug, event.id),
params={'start_time': '10:00', 'end_time': None},
status=400,
)
assert (
resp.json['errors']['non_field_errors'][0]
== 'must include start_time and end_time for partial bookings agenda'
)
# end before start
resp = app.post(
'/api/agenda/%s/fillslot/%s/' % (agenda.slug, event.id),
params={'start_time': '10:00', 'end_time': '09:00'},
status=400,
)
assert resp.json['errors']['non_field_errors'][0] == 'start_time must be before end_time'

View File

@ -48,6 +48,10 @@ def test_api_events_fillslots(app, user):
assert len(resp.json['waiting_list_events']) == 0
assert resp.json['cancelled_booking_count'] == 0
assert len(resp.json['cancelled_events']) == 0
assert (
resp.json['bookings_ics_url']
== 'http://testserver/api/bookings/ics/?user_external_id=user_id&agenda=foo-bar'
)
events = Event.objects.all()
assert events.filter(booked_places=1).count() == 2
@ -556,3 +560,31 @@ def test_api_events_fillslots_exclude_user_forbidden(app, user):
resp = app.post_json(fillslots_url, params=params, status=400)
assert resp.json['err'] == 1
assert resp.json['errors']['exclude_user'][0] == 'This parameter is not supported.'
@pytest.mark.freeze_time('2021-02-23 14:00')
def test_api_events_fillslots_partial_bookings(app, user):
agenda = Agenda.objects.create(label='Foo bar', kind='events', partial_bookings=True)
Event.objects.create(
label='Event',
start_datetime=now() + datetime.timedelta(days=5),
duration=120,
places=1,
agenda=agenda,
)
app.authorization = ('Basic', ('john.doe', 'password'))
fillslots_url = '/api/agenda/foo-bar/events/fillslots/'
params = {'user_external_id': 'user_id', 'start_time': '10:00', 'end_time': '15:00', 'slots': 'event'}
resp = app.post_json(fillslots_url, params=params)
booking = Booking.objects.get()
assert booking.start_time == datetime.time(10, 00)
assert booking.end_time == datetime.time(15, 00)
del params['start_time']
resp = app.post_json(fillslots_url, params=params, status=400)
assert (
resp.json['errors']['non_field_errors'][0]
== 'must include start_time and end_time for partial bookings agenda'
)

View File

@ -178,6 +178,7 @@ def test_api_events_fillslots_multiple_agendas(app, user):
)
assert first_event.booking_set.filter(cancellation_datetime__isnull=True).count() == 1
assert second_event.booking_set.filter(cancellation_datetime__isnull=True).count() == 1
assert resp.json['bookings_ics_url'] == 'http://testserver/api/bookings/ics/?user_external_id=user_id'
# booking modification
params = {'user_external_id': 'user_id', 'slots': 'first-agenda@event'}
@ -795,3 +796,36 @@ def test_api_events_fillslots_multiple_agendas_overlapping_events(app, user, fre
params={'user_external_id': 'user_id', 'check_overlaps': True, 'slots': 'foo-bar-2@event-2'},
)
assert resp.json['booking_count'] == 1
@pytest.mark.freeze_time('2021-02-23 14:00')
def test_api_events_fillslots_multiple_agendas_partial_bookings(app, user):
agenda = Agenda.objects.create(label='Foo bar', kind='events', partial_bookings=True)
Event.objects.create(
label='Event',
start_datetime=now() + datetime.timedelta(days=5),
duration=120,
places=1,
agenda=agenda,
)
app.authorization = ('Basic', ('john.doe', 'password'))
fillslots_url = '/api/agendas/events/fillslots/?agendas=foo-bar'
params = {
'user_external_id': 'user_id',
'start_time': '10:00',
'end_time': '15:00',
'slots': 'foo-bar@event',
}
resp = app.post_json(fillslots_url, params=params)
booking = Booking.objects.get()
assert booking.start_time == datetime.time(10, 00)
assert booking.end_time == datetime.time(15, 00)
del params['start_time']
resp = app.post_json(fillslots_url, params=params, status=400)
assert (
resp.json['errors']['non_field_errors'][0]
== 'must include start_time and end_time for partial bookings agenda'
)

View File

@ -1283,6 +1283,58 @@ def test_recurring_events_api_fillslots_multiple_agendas(app, user):
]
@pytest.mark.freeze_time('2021-09-06 12:00')
def test_recurring_events_api_fillslots_update_from_date(app, user):
agenda = Agenda.objects.create(
label='Foo bar', kind='events', minimal_booking_delay=0, maximal_booking_delay=0
)
Desk.objects.create(agenda=agenda, slug='_exceptions_holder')
event = Event.objects.create(
label='Event',
start_datetime=now(),
recurrence_days=[1, 2, 4, 5], # Monday, Tuesday, Thursday, Friday
places=1,
waiting_list_places=1,
agenda=agenda,
recurrence_end_date=now() + datetime.timedelta(days=28), # 4 weeks
)
event.create_all_recurrences()
app.authorization = ('Basic', ('john.doe', 'password'))
fillslots_url = '/api/agendas/recurring-events/fillslots/?agendas=foo-bar&'
params = {'user_external_id': 'user_id'}
# Book Monday and Thursday
params['slots'] = 'foo-bar@event:1,foo-bar@event:4'
resp = app.post_json(fillslots_url + 'action=book', params=params)
assert resp.json['booking_count'] == 8
assert resp.json['cancelled_booking_count'] == 0
bookings = Booking.objects.filter(cancellation_datetime__isnull=True).order_by('event__start_datetime')
assert [x.strftime('%A %d/%m') for x in bookings.values_list('event__start_datetime', flat=True)] == [
'Monday 06/09',
'Thursday 09/09',
'Monday 13/09',
'Thursday 16/09',
'Monday 20/09',
'Thursday 23/09',
'Monday 27/09',
'Thursday 30/09',
]
# Book only Friday from 20/09
params['slots'] = 'foo-bar@event:5'
resp = app.post_json(fillslots_url + 'action=update-from-date&date_start=2021-09-20', params=params)
assert resp.json['booking_count'] == 2
assert resp.json['cancelled_booking_count'] == 4
assert [x.strftime('%A %d/%m') for x in bookings.values_list('event__start_datetime', flat=True)] == [
'Monday 06/09',
'Thursday 09/09',
'Monday 13/09',
'Thursday 16/09',
'Friday 24/09',
'Friday 01/10',
]
@pytest.mark.freeze_time('2021-09-06 12:00')
def test_recurring_events_api_fillslots_multiple_agendas_queries(app, user):
events_type = EventsType.objects.create(label='Foo')
@ -1807,3 +1859,120 @@ def test_recurring_events_api_fillslots_partial_bookings_update(app, user):
).count()
== 5
)
@pytest.mark.freeze_time('2023-05-01 10:00')
def test_recurring_events_api_fillslots_by_days_partial_bookings(app, user):
agenda = Agenda.objects.create(label='Foo bar', kind='events', partial_bookings=True)
Desk.objects.create(agenda=agenda, slug='_exceptions_holder')
start_datetime = make_aware(datetime.datetime(2023, 5, 2, 8, 0))
event = Event.objects.create(
label='Event Mon-Wed',
start_datetime=start_datetime,
end_time=datetime.time(18, 00),
places=2,
recurrence_end_date=start_datetime + datetime.timedelta(days=30),
recurrence_days=[1, 2, 3],
agenda=agenda,
)
event.create_all_recurrences()
event = Event.objects.create(
label='Event Thu-Sat',
start_datetime=start_datetime,
end_time=datetime.time(18, 00),
places=2,
recurrence_end_date=start_datetime + datetime.timedelta(days=30),
recurrence_days=[4, 5, 6],
agenda=agenda,
)
event.create_all_recurrences()
app.authorization = ('Basic', ('john.doe', 'password'))
params = {
'user_external_id': 'user_id',
'tuesday': '09:00,12:00',
'friday': '08:00,18:00',
}
fillslots_url = '/api/agendas/recurring-events/fillslots-by-day/?action=update&agendas=%s' % agenda.slug
resp = app.post_json(fillslots_url, params=params)
assert resp.json['booking_count'] == 9
assert Booking.objects.count() == 9
assert (
Booking.objects.filter(
event__slug__startswith='event-mon-wed--2023-05-',
start_time=datetime.time(9, 00),
end_time=datetime.time(12, 00),
event__start_datetime__iso_week_day=2,
).count()
== 5
)
assert (
Booking.objects.filter(
event__slug__startswith='event-thu-sat--2023-05-',
start_time=datetime.time(8, 00),
end_time=datetime.time(18, 00),
event__start_datetime__iso_week_day=5,
).count()
== 4
)
# change bookings
params = {
'user_external_id': 'user_id',
'wednesday': '10:00,14:00',
'thursday': None, # null values are allowed and ignored
'sunday': '12:00,16:00', # unbookable day will be ignored
'slots': 'xxx', # parameter of normal API, ignored
'start_time': None, # parameter of normal API, ignored
}
resp = app.post_json(fillslots_url, params=params)
assert resp.json['booking_count'] == 5
assert resp.json['cancelled_booking_count'] == 9
assert Booking.objects.count() == 5
assert (
Booking.objects.filter(
event__slug__startswith='event-mon-wed--2023-05-',
start_time=datetime.time(10, 00),
end_time=datetime.time(14, 00),
event__start_datetime__iso_week_day=3,
).count()
== 5
)
agenda2 = Agenda.objects.create(label='Foo Bar 2', kind='events', partial_bookings=True)
resp = app.post_json(
'/api/agendas/recurring-events/fillslots-by-day/?action=update&agendas=%s,%s'
% (agenda.slug, agenda2.slug),
params=params,
status=400,
)
assert resp.json['err'] == 1
assert resp.json['errors']['non_field_errors'][0] == 'Multiple agendas are not supported.'
agenda_events = Agenda.objects.create(label='Not partial bookings', kind='events')
resp = app.post_json(
'/api/agendas/recurring-events/fillslots-by-day/?action=update&agendas=%s' % agenda_events.slug,
params=params,
status=400,
)
assert resp.json['err'] == 1
assert resp.json['errors']['non_field_errors'][0] == 'Agenda kind must be partial bookings.'
params = {'user_external_id': 'user_id', 'wednesday': '11:00,10:00'}
resp = app.post_json(fillslots_url, params=params, status=400)
assert resp.json['err'] == 1
assert resp.json['errors']['wednesday'][0] == 'Start hour must be before end hour.'
params = {'user_external_id': 'user_id', 'wednesday': '11:00,xxx'}
resp = app.post_json(fillslots_url, params=params, status=400)
assert resp.json['err'] == 1
assert resp.json['errors']['wednesday']['1'][0].startswith('Time has wrong format')
params = {'user_external_id': 'user_id', 'wednesday': '11:00'}
resp = app.post_json(fillslots_url, params=params, status=400)
assert resp.json['errors']['wednesday'][0] == 'Ensure this field has at least 2 elements.'
params = {'user_external_id': 'user_id', 'wednesday': '11:00,13:00,15:00'}
resp = app.post_json(fillslots_url, params=params, status=400)
assert resp.json['errors']['wednesday'][0] == 'Ensure this field has no more than 2 elements.'

View File

@ -20,7 +20,7 @@ from chrono.utils.timezone import localtime, now
pytestmark = pytest.mark.django_db
def test_agendas_api(app):
def test_agendas_api(settings, app):
edit_group = Group.objects.create(name='Edit')
view_group = Group.objects.create(name='View')
category_a = Category.objects.create(label='Category A')
@ -36,7 +36,7 @@ def test_agendas_api(app):
Desk.objects.create(agenda=event_agenda, slug='_exceptions_holder')
event_agenda2 = Agenda.objects.create(label='Foo bar 2', category=category_a, events_type=events_type2)
Desk.objects.create(agenda=event_agenda2, slug='_exceptions_holder')
event_agenda3 = Agenda.objects.create(label='Foo bar 3')
event_agenda3 = Agenda.objects.create(label='Foo bar 3', partial_bookings=True)
Desk.objects.create(agenda=event_agenda3, slug='_exceptions_holder')
meetings_agenda1 = Agenda.objects.create(
label='Foo bar Meeting', kind='meetings', category=category_b, view_role=view_group
@ -75,7 +75,6 @@ def test_agendas_api(app):
'booking_form_url': None,
'api': {
'datetimes_url': 'http://testserver/api/agenda/foo-bar/datetimes/',
'fillslots_url': 'http://testserver/api/agenda/foo-bar/fillslots/',
'backoffice_url': 'http://testserver/manage/agendas/%s/' % event_agenda.pk,
},
},
@ -96,7 +95,6 @@ def test_agendas_api(app):
'booking_form_url': None,
'api': {
'datetimes_url': 'http://testserver/api/agenda/foo-bar-2/datetimes/',
'fillslots_url': 'http://testserver/api/agenda/foo-bar-2/fillslots/',
'backoffice_url': 'http://testserver/manage/agendas/%s/' % event_agenda2.pk,
},
},
@ -117,7 +115,6 @@ def test_agendas_api(app):
'booking_form_url': None,
'api': {
'datetimes_url': 'http://testserver/api/agenda/foo-bar-3/datetimes/',
'fillslots_url': 'http://testserver/api/agenda/foo-bar-3/fillslots/',
'backoffice_url': 'http://testserver/manage/agendas/%s/' % event_agenda3.pk,
},
},
@ -141,7 +138,6 @@ def test_agendas_api(app):
'meetings_url': 'http://testserver/api/agenda/foo-bar-meeting/meetings/',
'desks_url': 'http://testserver/api/agenda/foo-bar-meeting/desks/',
'resources_url': 'http://testserver/api/agenda/foo-bar-meeting/resources/',
'fillslots_url': 'http://testserver/api/agenda/foo-bar-meeting/fillslots/',
'backoffice_url': 'http://testserver/manage/agendas/%s/' % meetings_agenda1.pk,
},
},
@ -162,7 +158,6 @@ def test_agendas_api(app):
'meetings_url': 'http://testserver/api/agenda/foo-bar-meeting-2/meetings/',
'desks_url': 'http://testserver/api/agenda/foo-bar-meeting-2/desks/',
'resources_url': 'http://testserver/api/agenda/foo-bar-meeting-2/resources/',
'fillslots_url': 'http://testserver/api/agenda/foo-bar-meeting-2/fillslots/',
'backoffice_url': 'http://testserver/manage/agendas/%s/' % meetings_agenda2.pk,
},
},
@ -181,7 +176,6 @@ def test_agendas_api(app):
'api': {
'meetings_url': 'http://testserver/api/agenda/virtual-agenda/meetings/',
'desks_url': 'http://testserver/api/agenda/virtual-agenda/desks/',
'fillslots_url': 'http://testserver/api/agenda/virtual-agenda/fillslots/',
'backoffice_url': 'http://testserver/manage/agendas/%s/' % virtual_agenda.pk,
},
},
@ -297,6 +291,15 @@ def test_agendas_api(app):
resp = app.get('/api/agenda/', params={'with_open_events': '1'})
assert len(resp.json['data']) == 1
settings.PARTIAL_BOOKINGS_ENABLED = True
resp = app.get('/api/agenda/')
assert resp.json['data'][0]['kind'] == 'events'
assert resp.json['data'][0]['partial_bookings'] is False
assert resp.json['data'][1]['kind'] == 'events'
assert resp.json['data'][1]['partial_bookings'] is False
assert resp.json['data'][2]['kind'] == 'events'
assert resp.json['data'][2]['partial_bookings'] is True
for _ in range(10):
event_agenda = Agenda.objects.create(label='Foo bar', category=category_a)
Desk.objects.create(agenda=event_agenda, slug='_exceptions_holder')
@ -429,7 +432,6 @@ def test_virtual_agenda_detail(app, virtual_meetings_agenda):
'api': {
'meetings_url': 'http://testserver/api/agenda/%s/meetings/' % virtual_meetings_agenda.slug,
'desks_url': 'http://testserver/api/agenda/%s/desks/' % virtual_meetings_agenda.slug,
'fillslots_url': 'http://testserver/api/agenda/%s/fillslots/' % virtual_meetings_agenda.slug,
'backoffice_url': 'http://testserver/manage/agendas/%s/' % virtual_meetings_agenda.pk,
},
},

View File

@ -5,7 +5,16 @@ import pytest
from django.db import connection
from django.test.utils import CaptureQueriesContext
from chrono.agendas.models import Agenda, Booking, BookingColor, Category, Desk, Event, MeetingType
from chrono.agendas.models import (
Agenda,
Booking,
BookingCheck,
BookingColor,
Category,
Desk,
Event,
MeetingType,
)
from chrono.utils.lingo import CheckType
from chrono.utils.timezone import localtime, make_aware, now
@ -104,6 +113,40 @@ def test_booking_ics(app, some_data, meetings_agenda, user, settings, rf):
assert 'DTEND:%sZ\r\n' % end in booking_ics
@pytest.mark.freeze_time('2023-09-18 14:00')
def test_bookings_ics(app, user):
agenda = Agenda.objects.create(label='Foo bar', kind='events')
event = Event.objects.create(
label='Event A', agenda=agenda, start_datetime=now() + datetime.timedelta(days=3), places=10
)
Booking.objects.create(event=event, user_external_id='enfant-1234')
agenda = Agenda.objects.create(label='Foo bar 2', kind='events')
event = Event.objects.create(
label='Event B', agenda=agenda, start_datetime=now() + datetime.timedelta(days=4), places=10
)
Booking.objects.create(event=event, user_external_id='enfant-1234')
resp = app.get('/api/bookings/ics/')
assert resp.json['err'] == 1
assert resp.json['err_desc'] == 'missing param user_external_id'
resp = app.get('/api/bookings/ics/', params={'user_external_id': 'enfant-1234'})
assert 'BEGIN:VCALENDAR' in resp.text
assert resp.text.count('UID') == 2
assert 'DTSTART:20230921' in resp.text
assert 'DTSTART:20230922' in resp.text
resp = app.get('/api/bookings/ics/', params={'user_external_id': 'enfant-1234', 'agenda': 'foo-bar'})
assert resp.text.count('UID') == 1
assert 'DTSTART:20230921' in resp.text
assert 'DTSTART:20230922' not in resp.text
resp = app.get('/api/bookings/ics/', params={'user_external_id': 'enfant-1234', 'agenda': 'xxx'})
assert 'BEGIN:VCALENDAR' in resp.text
assert resp.text.count('UID') == 0
def test_bookings_api(app, user):
events_agenda = Agenda.objects.create(label='Foo bar', kind='events')
events_event = Event.objects.create(
@ -143,7 +186,7 @@ def test_bookings_api(app, user):
with CaptureQueriesContext(connection) as ctx:
resp = app.get('/api/bookings/', params={'user_external_id': 'enfant-1234'})
assert len(ctx.captured_queries) == 2
assert len(ctx.captured_queries) == 3
assert resp.json['err'] == 0
assert resp.json['data'] == [
@ -319,8 +362,10 @@ def test_bookings_api_filter_user_was_present(app, user):
agenda=agenda, start_datetime=make_aware(datetime.datetime(2017, 5, 22, 0, 0)), places=10
)
Booking.objects.create(event=event, user_external_id='42')
booking2 = Booking.objects.create(event=event, user_external_id='42', user_was_present=True)
booking3 = Booking.objects.create(event=event, user_external_id='42', user_was_present=False)
booking2 = Booking.objects.create(event=event, user_external_id='42')
booking2.mark_user_presence()
booking3 = Booking.objects.create(event=event, user_external_id='42')
booking3.mark_user_absence()
app.authorization = ('Basic', ('john.doe', 'password'))
resp = app.get('/api/bookings/', params={'user_external_id': '42', 'user_was_present': True})
@ -336,21 +381,12 @@ def test_bookings_api_filter_user_absence_reason(app, user):
event = Event.objects.create(
agenda=agenda, start_datetime=make_aware(datetime.datetime(2017, 5, 22, 0, 0)), places=10
)
Booking.objects.create(event=event, user_external_id='42', user_was_present=False)
booking2 = Booking.objects.create(
event=event,
user_external_id='42',
user_was_present=False,
user_check_type_slug='foo-bar',
user_check_type_label='Foo bar',
)
Booking.objects.create(
event=event,
user_external_id='42',
user_was_present=True,
user_check_type_slug='foo-bar-2',
user_check_type_label='Foo bar 2',
)
booking = Booking.objects.create(event=event, user_external_id='42')
booking.mark_user_absence()
booking2 = Booking.objects.create(event=event, user_external_id='42')
booking2.mark_user_absence(check_type_slug='foo-bar', check_type_label='Foo bar')
booking3 = Booking.objects.create(event=event, user_external_id='42')
booking3.mark_user_presence(check_type_slug='foo-bar-2', check_type_label='Foo bar 2')
app.authorization = ('Basic', ('john.doe', 'password'))
resp = app.get('/api/bookings/', params={'user_external_id': '42', 'user_absence_reason': 'foo'})
@ -365,7 +401,7 @@ def test_bookings_api_filter_user_absence_reason(app, user):
resp = app.get('/api/bookings/', params={'user_external_id': '42', 'user_absence_reason': 'foo-bar-2'})
assert resp.json['err'] == 0
assert [b['id'] for b in resp.json['data']] == []
Booking.objects.update(user_was_present=True)
BookingCheck.objects.update(presence=True)
resp = app.get('/api/bookings/', params={'user_external_id': '42', 'user_absence_reason': 'Foo bar 2'})
assert resp.json['err'] == 0
assert [b['id'] for b in resp.json['data']] == []
@ -376,21 +412,12 @@ def test_bookings_api_filter_user_presence_reason(app, user):
event = Event.objects.create(
agenda=agenda, start_datetime=make_aware(datetime.datetime(2017, 5, 22, 0, 0)), places=10
)
Booking.objects.create(event=event, user_external_id='42', user_was_present=True)
booking2 = Booking.objects.create(
event=event,
user_external_id='42',
user_was_present=True,
user_check_type_slug='foo-bar',
user_check_type_label='Foo bar',
)
Booking.objects.create(
event=event,
user_external_id='42',
user_was_present=False,
user_check_type_slug='foo-bar-2',
user_check_type_label='Foo bar 2',
)
booking = Booking.objects.create(event=event, user_external_id='42')
booking.mark_user_presence()
booking2 = Booking.objects.create(event=event, user_external_id='42')
booking2.mark_user_presence(check_type_slug='foo-bar', check_type_label='Foo bar')
booking3 = Booking.objects.create(event=event, user_external_id='42')
booking3.mark_user_absence(check_type_slug='foo-bar-2', check_type_label='Foo bar 2')
app.authorization = ('Basic', ('john.doe', 'password'))
resp = app.get('/api/bookings/', params={'user_external_id': '42', 'user_presence_reason': 'foo'})
@ -405,7 +432,7 @@ def test_bookings_api_filter_user_presence_reason(app, user):
resp = app.get('/api/bookings/', params={'user_external_id': '42', 'user_presence_reason': 'foo-bar-2'})
assert resp.json['err'] == 0
assert [b['id'] for b in resp.json['data']] == []
Booking.objects.update(user_was_present=False)
BookingCheck.objects.update(presence=False)
resp = app.get('/api/bookings/', params={'user_external_id': '42', 'user_presence_reason': 'Foo bar 2'})
assert resp.json['err'] == 0
assert [b['id'] for b in resp.json['data']] == []
@ -481,7 +508,11 @@ def test_bookings_api_filter_event(app, user):
def test_booking_api_present(app, user, flag):
agenda = Agenda.objects.create(label='Foo bar', kind='events')
event = Event.objects.create(agenda=agenda, start_datetime=now(), places=10)
booking = Booking.objects.create(event=event, user_was_present=flag, user_check_type_slug='foo-bar')
booking = Booking.objects.create(event=event)
if flag is True:
booking.mark_user_presence(check_type_slug='foo-bar')
elif flag is False:
booking.mark_user_absence(check_type_slug='foo-bar')
app.authorization = ('Basic', ('john.doe', 'password'))
resp = app.get('/api/booking/%s/' % booking.pk)
@ -580,8 +611,13 @@ def test_booking_patch_api_present(app, user, flag):
# set flag
app.patch_json('/api/booking/%s/' % booking.pk, params={'user_was_present': flag})
booking.refresh_from_db()
assert booking.user_was_present == flag
if flag is not None:
assert booking.user_check.presence == flag
else:
assert not booking.user_check
event.refresh_from_db()
assert event.checked is False
@ -589,14 +625,17 @@ def test_booking_patch_api_present(app, user, flag):
agenda.save()
app.patch_json('/api/booking/%s/' % booking.pk, params={'user_was_present': flag})
booking.refresh_from_db()
assert booking.user_was_present == flag
if flag is not None:
assert booking.user_check.presence == flag
else:
assert not booking.user_check
event.refresh_from_db()
assert event.checked == (flag is not None)
# reset
app.patch_json('/api/booking/%s/' % booking.pk, params={'user_was_present': None})
booking.refresh_from_db()
assert booking.user_was_present is None
assert not booking.user_check
# make secondary bookings
Booking.objects.create(event=event, primary_booking=booking)
@ -606,11 +645,14 @@ def test_booking_patch_api_present(app, user, flag):
app.patch_json('/api/booking/%s/' % booking.pk, params={'user_was_present': flag})
booking.refresh_from_db()
assert booking.user_was_present == flag
# all secondary bookings are updated
assert list(booking.secondary_booking_set.values_list('user_was_present', flat=True)) == [flag, flag]
if flag is not None:
assert booking.user_check.presence == flag
else:
assert not booking.user_check
# secondary bookings are left untouched
assert list(booking.secondary_booking_set.values_list('user_checks', flat=True)) == [None, None]
other_booking.refresh_from_db()
assert other_booking.user_was_present is None # not changed
assert not other_booking.user_check # not changed
# mark the event as checked
event.checked = True
@ -639,7 +681,8 @@ def test_booking_patch_api_absence_reason(check_types, app, user):
check_types.return_value = []
agenda = Agenda.objects.create(kind='events')
event = Event.objects.create(agenda=agenda, start_datetime=now(), places=10)
booking = Booking.objects.create(event=event, user_was_present=False)
booking = Booking.objects.create(event=event)
booking.mark_user_absence()
app.authorization = ('Basic', ('john.doe', 'password'))
@ -664,8 +707,8 @@ def test_booking_patch_api_absence_reason(check_types, app, user):
# it works with label
app.patch_json('/api/booking/%s/' % booking.pk, params={'user_absence_reason': 'Foo bar'})
booking.refresh_from_db()
assert booking.user_check_type_slug == 'foo-bar'
assert booking.user_check_type_label == 'Foo bar'
assert booking.user_check.type_slug == 'foo-bar'
assert booking.user_check.type_label == 'Foo bar'
# unknown
resp = app.patch_json(
@ -679,51 +722,47 @@ def test_booking_patch_api_absence_reason(check_types, app, user):
# reset
app.patch_json('/api/booking/%s/' % booking.pk, params={'user_absence_reason': ''})
booking.refresh_from_db()
assert booking.user_check_type_slug is None
assert booking.user_check_type_label is None
assert booking.user_check.type_slug is None
assert booking.user_check.type_label is None
app.patch_json('/api/booking/%s/' % booking.pk, params={'user_absence_reason': None})
booking.refresh_from_db()
assert booking.user_check_type_slug is None
assert booking.user_check_type_label is None
assert booking.user_check.type_slug is None
assert booking.user_check.type_label is None
# make secondary bookings
Booking.objects.create(event=event, primary_booking=booking, user_was_present=False)
Booking.objects.create(event=event, primary_booking=booking, user_was_present=False)
Booking.objects.create(event=event, primary_booking=booking)
Booking.objects.create(event=event, primary_booking=booking)
# and other booking
other_booking = Booking.objects.create(event=event)
# it works also with slug
app.patch_json('/api/booking/%s/' % booking.pk, params={'user_absence_reason': 'foo-bar'})
booking.refresh_from_db()
assert booking.user_check_type_slug == 'foo-bar'
assert booking.user_check_type_label == 'Foo bar'
# all secondary bookings are updated
assert list(booking.secondary_booking_set.values_list('user_check_type_slug', flat=True)) == [
'foo-bar',
'foo-bar',
]
assert booking.user_check.type_slug == 'foo-bar'
assert booking.user_check.type_label == 'Foo bar'
# secondary bookings are left unchanged
assert list(booking.secondary_booking_set.values_list('user_checks', flat=True)) == [None, None]
other_booking.refresh_from_db()
assert other_booking.user_check_type_slug is None # not changed
assert other_booking.user_check_type_label is None # not changed
assert not other_booking.user_check # not changed
# user_was_present is True, can not set user_absence_reason
Booking.objects.update(user_was_present=True)
# presence is True, can not set user_absence_reason
BookingCheck.objects.update(presence=True)
resp = app.patch_json(
'/api/booking/%s/' % booking.pk, params={'user_absence_reason': 'foo-bar'}, status=400
)
assert resp.json['err'] == 6
assert resp.json['err_desc'] == 'user is marked as present, can not set absence reason'
# but it's ok if user_was_present is set to False
# but it's ok if presence is set to False
resp = app.patch_json(
'/api/booking/%s/' % booking.pk,
params={'user_absence_reason': 'foo-bar', 'user_was_present': False},
)
assert resp.json['err'] == 0
booking.refresh_from_db()
assert booking.user_was_present is False
assert booking.user_check_type_slug == 'foo-bar'
assert booking.user_check_type_label == 'Foo bar'
assert booking.user_check.presence is False
assert booking.user_check.type_slug == 'foo-bar'
assert booking.user_check.type_label == 'Foo bar'
# mark the event as checked
event.checked = True
@ -756,7 +795,8 @@ def test_booking_patch_api_presence_reason(check_types, app, user):
check_types.return_value = []
agenda = Agenda.objects.create(kind='events')
event = Event.objects.create(agenda=agenda, start_datetime=now(), places=10)
booking = Booking.objects.create(event=event, user_was_present=True)
booking = Booking.objects.create(event=event)
booking.mark_user_presence()
app.authorization = ('Basic', ('john.doe', 'password'))
@ -781,8 +821,8 @@ def test_booking_patch_api_presence_reason(check_types, app, user):
# it works with label
app.patch_json('/api/booking/%s/' % booking.pk, params={'user_presence_reason': 'Foo bar'})
booking.refresh_from_db()
assert booking.user_check_type_slug == 'foo-bar'
assert booking.user_check_type_label == 'Foo bar'
assert booking.user_check.type_slug == 'foo-bar'
assert booking.user_check.type_label == 'Foo bar'
# unknown
resp = app.patch_json(
@ -796,51 +836,47 @@ def test_booking_patch_api_presence_reason(check_types, app, user):
# reset
app.patch_json('/api/booking/%s/' % booking.pk, params={'user_presence_reason': ''})
booking.refresh_from_db()
assert booking.user_check_type_slug is None
assert booking.user_check_type_label is None
assert booking.user_check.type_slug is None
assert booking.user_check.type_label is None
app.patch_json('/api/booking/%s/' % booking.pk, params={'user_presence_reason': None})
booking.refresh_from_db()
assert booking.user_check_type_slug is None
assert booking.user_check_type_label is None
assert booking.user_check.type_slug is None
assert booking.user_check.type_label is None
# make secondary bookings
Booking.objects.create(event=event, primary_booking=booking, user_was_present=True)
Booking.objects.create(event=event, primary_booking=booking, user_was_present=True)
Booking.objects.create(event=event, primary_booking=booking)
Booking.objects.create(event=event, primary_booking=booking)
# and other booking
other_booking = Booking.objects.create(event=event)
# it works also with slug
app.patch_json('/api/booking/%s/' % booking.pk, params={'user_presence_reason': 'foo-bar'})
booking.refresh_from_db()
assert booking.user_check_type_slug == 'foo-bar'
assert booking.user_check_type_label == 'Foo bar'
# all secondary bookings are updated
assert list(booking.secondary_booking_set.values_list('user_check_type_slug', flat=True)) == [
'foo-bar',
'foo-bar',
]
assert booking.user_check.type_slug == 'foo-bar'
assert booking.user_check.type_label == 'Foo bar'
# secondary bookings are left unchanged
assert list(booking.secondary_booking_set.values_list('user_checks', flat=True)) == [None, None]
other_booking.refresh_from_db()
assert other_booking.user_check_type_slug is None # not changed
assert other_booking.user_check_type_label is None # not changed
assert not other_booking.user_check # not changed
# user_was_present is False, can not set user_presence_reason
Booking.objects.update(user_was_present=False)
# presence is False, can not set user_presence_reason
BookingCheck.objects.update(presence=False)
resp = app.patch_json(
'/api/booking/%s/' % booking.pk, params={'user_presence_reason': 'foo-bar'}, status=400
)
assert resp.json['err'] == 6
assert resp.json['err_desc'] == 'user is marked as absent, can not set presence reason'
# but it's ok if user_was_present is set to True
# but it's ok if presence is set to True
resp = app.patch_json(
'/api/booking/%s/' % booking.pk,
params={'user_presence_reason': 'foo-bar', 'user_was_present': True},
)
assert resp.json['err'] == 0
booking.refresh_from_db()
assert booking.user_was_present is True
assert booking.user_check_type_slug == 'foo-bar'
assert booking.user_check_type_label == 'Foo bar'
assert booking.user_check.presence is True
assert booking.user_check.type_slug == 'foo-bar'
assert booking.user_check.type_label == 'Foo bar'
# mark the event as checked
event.checked = True
@ -971,54 +1007,51 @@ def test_booking_patch_api_user_fields(app, user):
def test_booking_patch_api_extra_data(app, user):
agenda = Agenda.objects.create(kind='events')
event = Event.objects.create(agenda=agenda, start_datetime=now(), places=10)
booking = Booking.objects.create(event=event, user_was_present=True)
booking = Booking.objects.create(event=event)
booking.mark_user_presence()
app.authorization = ('Basic', ('john.doe', 'password'))
app.patch_json('/api/booking/%s/' % booking.pk, params={'foo': 'bar'})
booking.refresh_from_db()
assert booking.user_was_present is True # not changed
assert booking.user_check.presence is True # not changed
assert booking.extra_data == {'foo': 'bar'}
app.patch_json('/api/booking/%s/' % booking.pk, params={'foo': 'baz'})
booking.refresh_from_db()
assert booking.user_was_present is True # not changed
assert booking.user_check.presence is True # not changed
assert booking.extra_data == {'foo': 'baz'}
app.patch_json('/api/booking/%s/' % booking.pk, params={'foooo': 'bar'})
booking.refresh_from_db()
assert booking.user_was_present is True # not changed
assert booking.user_check.presence is True # not changed
assert booking.extra_data == {'foo': 'baz', 'foooo': 'bar'}
app.patch_json('/api/booking/%s/' % booking.pk, params={'foooo': 'baz', 'foo': None})
booking.refresh_from_db()
assert booking.user_was_present is True # not changed
assert booking.user_check.presence is True # not changed
assert booking.extra_data == {'foo': None, 'foooo': 'baz'}
# make secondary bookings
Booking.objects.create(event=event, primary_booking=booking, user_was_present=False)
Booking.objects.create(event=event, primary_booking=booking, user_was_present=False)
Booking.objects.create(event=event, primary_booking=booking)
Booking.objects.create(event=event, primary_booking=booking)
# and other booking
other_booking = Booking.objects.create(event=event)
app.patch_json('/api/booking/%s/' % booking.pk, params={'foooo': 'baz', 'foo': None})
assert booking.user_was_present is True # not changed
assert booking.user_check.presence is True # not changed
assert booking.extra_data == {'foo': None, 'foooo': 'baz'}
# all secondary bookings are upadted
assert list(booking.secondary_booking_set.values_list('extra_data', flat=True)) == [
{'foo': None, 'foooo': 'baz'},
{'foo': None, 'foooo': 'baz'},
]
assert list(booking.secondary_booking_set.values_list('user_was_present', flat=True)) == [
False,
False,
] # not changed
other_booking.refresh_from_db()
assert other_booking.extra_data is None # not changed
app.patch_json('/api/booking/%s/' % booking.pk, params={})
booking.refresh_from_db()
assert booking.user_was_present is True # not changed
assert booking.user_check.presence is True # not changed
assert booking.extra_data == {'foo': None, 'foooo': 'baz'} # not changed
resp = app.patch_json('/api/booking/%s/' % booking.pk, params={'foo': ['bar', 'baz']}, status=400)

View File

@ -201,17 +201,15 @@ def test_event_notify_checked(app, user):
assert event.checked is False
for i in range(8):
user_was_present = None
if i < 3:
user_was_present = True
elif i < 7:
user_was_present = False
Booking.objects.create(
booking = Booking.objects.create(
event=event,
user_was_present=user_was_present,
presence_callback_url='https://example.invalid/presence/%s' % i,
absence_callback_url='https://example.invalid/absence/%s' % i,
)
if i < 3:
booking.mark_user_presence()
elif i < 7:
booking.mark_user_absence()
app.authorization = ('Basic', ('john.doe', 'password'))
@ -1265,8 +1263,7 @@ def test_events_check_status(app, user):
assert resp.json['data'][0]['booking']['cancellation_datetime'] is None
# absence
booking.user_was_present = False
booking.save()
booking.mark_user_absence()
resp = app.get(url, params=params)
assert resp.json['err'] == 0
assert len(resp.json['data']) == 1
@ -1279,8 +1276,8 @@ def test_events_check_status(app, user):
assert resp.json['data'][0]['booking']['user_presence_reason'] is None
# absence with check type
booking.user_check_type_slug = 'foo-reason'
booking.save()
booking.user_check.type_slug = 'foo-reason'
booking.user_check.save()
resp = app.get(url, params=params)
assert resp.json['err'] == 0
assert len(resp.json['data']) == 1
@ -1293,9 +1290,7 @@ def test_events_check_status(app, user):
assert resp.json['data'][0]['booking']['user_presence_reason'] is None
# presence
booking.user_check_type_slug = None
booking.user_was_present = True
booking.save()
booking.mark_user_presence()
resp = app.get(url, params=params)
assert resp.json['err'] == 0
assert len(resp.json['data']) == 1
@ -1308,8 +1303,8 @@ def test_events_check_status(app, user):
assert resp.json['data'][0]['booking']['user_presence_reason'] is None
# presence with check type
booking.user_check_type_slug = 'foo-reason'
booking.save()
booking.user_check.type_slug = 'foo-reason'
booking.user_check.save()
resp = app.get(url, params=params)
assert resp.json['err'] == 0
assert len(resp.json['data']) == 1
@ -1322,8 +1317,9 @@ def test_events_check_status(app, user):
assert resp.json['data'][0]['booking']['user_presence_reason'] == 'foo-reason'
@pytest.mark.parametrize('partial_bookings', [True, False])
@pytest.mark.freeze_time('2022-05-30 14:00')
def test_events_check_status_events(app, user):
def test_events_check_status_events(app, user, partial_bookings):
events_type = EventsType.objects.create(
label='Foo',
custom_fields=[
@ -1332,7 +1328,13 @@ def test_events_check_status_events(app, user):
{'varname': 'bool', 'label': 'Bool', 'field_type': 'bool'},
],
)
agenda = Agenda.objects.create(label='Foo', events_type=events_type)
agenda = Agenda.objects.create(
label='Foo',
events_type=events_type,
partial_bookings=partial_bookings,
invoicing_unit='half_hour',
invoicing_tolerance=10,
)
start_datetime = now()
# recurring event
recurring_event = Event.objects.create(
@ -1390,12 +1392,18 @@ def test_events_check_status_events(app, user):
booking1 = Booking.objects.create(
event=first_event,
user_external_id='child:42',
user_was_present=True,
user_check_type_slug='foo-reason',
start_time=datetime.time(8, 0),
end_time=datetime.time(17, 0),
)
booking2 = Booking.objects.create(
event=event, user_external_id='child:42', user_was_present=True, user_check_type_slug='foo-reason'
booking1.mark_user_presence(
check_type_slug='foo-reason', start_time=datetime.time(7, 55), end_time=datetime.time(17, 15)
)
booking1.user_check.refresh_computed_times()
booking1.user_check.save()
assert booking1.user_check.computed_start_time == datetime.time(8, 0)
assert booking1.user_check.computed_end_time == datetime.time(17, 30)
booking2 = Booking.objects.create(event=event, user_external_id='child:42')
booking2.mark_user_presence(check_type_slug='foo-reason')
app.authorization = ('Basic', ('john.doe', 'password'))
url = '/api/agendas/events/check-status/'
@ -1407,121 +1415,180 @@ def test_events_check_status_events(app, user):
}
with CaptureQueriesContext(connection) as ctx:
resp = app.get(url, params=params)
assert len(ctx.captured_queries) == 5
assert len(ctx.captured_queries) == 6
assert resp.json['err'] == 0
assert resp.json['data'] == [
{
'event': {
'description': None,
'duration': None,
'label': 'Not Checked Event Label',
'slug': 'notchecked-event-slug',
'places': 10,
'pricing': None,
'publication_datetime': None,
'recurrence_days': None,
'recurrence_end_date': None,
'recurrence_week_interval': 1,
'start_datetime': localtime(notchecked_event.start_datetime).isoformat(),
'url': None,
'waiting_list_places': 0,
'agenda': agenda.slug,
'primary_event': None,
'check_locked': False,
'checked': False,
'invoiced': False,
'custom_field_bool': None,
'custom_field_text': '',
'custom_field_textarea': '',
},
'check_status': {'error_reason': 'event-not-checked', 'status': 'error'},
'booking': {},
},
{
'event': {
'description': None,
'duration': None,
'label': 'Event Label',
'slug': 'event-slug',
'places': 10,
'pricing': None,
'publication_datetime': None,
'recurrence_days': None,
'recurrence_end_date': None,
'recurrence_week_interval': 1,
'start_datetime': localtime(event.start_datetime).isoformat(),
'url': None,
'waiting_list_places': 0,
'agenda': agenda.slug,
'primary_event': None,
'check_locked': True,
'checked': True,
'invoiced': True,
'custom_field_bool': None,
'custom_field_text': '',
'custom_field_textarea': '',
},
'check_status': {'check_type': 'foo-reason', 'status': 'presence'},
'booking': {
'cancellation_datetime': None,
'use_color_for': None,
'creation_datetime': localtime(now()).isoformat(),
'extra_data': None,
'id': booking2.pk,
'in_waiting_list': False,
'user_absence_reason': None,
'user_email': '',
'user_first_name': '',
'user_last_name': '',
'user_phone_number': '',
'user_presence_reason': 'foo-reason',
'user_was_present': True,
'label': '',
},
},
{
'event': {
'description': None,
'duration': None,
'label': 'Recurring Event Label',
'slug': 'recurring-event-slug--2022-05-30-1600',
'places': 10,
'pricing': None,
'publication_datetime': None,
'recurrence_days': None,
'recurrence_end_date': None,
'recurrence_week_interval': 1,
'start_datetime': localtime(first_event.start_datetime).isoformat(),
'url': None,
'waiting_list_places': 0,
'agenda': agenda.slug,
'primary_event': recurring_event.slug,
'check_locked': False,
'checked': True,
'invoiced': False,
'custom_field_text': 'foo',
'custom_field_textarea': 'foo bar',
'custom_field_bool': True,
},
'check_status': {'check_type': 'foo-reason', 'status': 'presence'},
'booking': {
'cancellation_datetime': None,
'use_color_for': None,
'creation_datetime': localtime(now()).isoformat(),
'extra_data': None,
'id': booking1.pk,
'in_waiting_list': False,
'user_absence_reason': None,
'user_email': '',
'user_first_name': '',
'user_last_name': '',
'user_phone_number': '',
'user_presence_reason': 'foo-reason',
'user_was_present': True,
'label': '',
},
},
]
assert len(resp.json['data']) == 3
assert list(resp.json['data'][0].keys()) == ['event', 'check_status', 'booking']
assert resp.json['data'][0]['event'] == {
'description': None,
'duration': None,
'label': 'Not Checked Event Label',
'slug': 'notchecked-event-slug',
'places': 10,
'pricing': None,
'publication_datetime': None,
'recurrence_days': None,
'recurrence_end_date': None,
'recurrence_week_interval': 1,
'start_datetime': localtime(notchecked_event.start_datetime).isoformat(),
'url': None,
'waiting_list_places': 0,
'agenda': agenda.slug,
'primary_event': None,
'check_locked': False,
'checked': False,
'invoiced': False,
'custom_field_bool': None,
'custom_field_text': '',
'custom_field_textarea': '',
}
assert resp.json['data'][0]['check_status'] == {
'error_reason': 'event-not-checked',
'status': 'error',
}
assert resp.json['data'][0]['booking'] == {}
assert list(resp.json['data'][1].keys()) == ['event', 'check_status', 'booking']
assert resp.json['data'][1]['event'] == {
'description': None,
'duration': None,
'label': 'Event Label',
'slug': 'event-slug',
'places': 10,
'pricing': None,
'publication_datetime': None,
'recurrence_days': None,
'recurrence_end_date': None,
'recurrence_week_interval': 1,
'start_datetime': localtime(event.start_datetime).isoformat(),
'url': None,
'waiting_list_places': 0,
'agenda': agenda.slug,
'primary_event': None,
'check_locked': True,
'checked': True,
'invoiced': True,
'custom_field_bool': None,
'custom_field_text': '',
'custom_field_textarea': '',
}
assert resp.json['data'][1]['check_status'] == {
'check_type': 'foo-reason',
'status': 'presence',
}
if partial_bookings:
assert resp.json['data'][1]['booking'] == {
'cancellation_datetime': None,
'use_color_for': None,
'creation_datetime': localtime(now()).isoformat(),
'extra_data': None,
'id': booking2.pk,
'in_waiting_list': False,
'user_absence_reason': None,
'user_email': '',
'user_first_name': '',
'user_last_name': '',
'user_phone_number': '',
'user_presence_reason': 'foo-reason',
'user_was_present': True,
'label': '',
'start_time': None,
'end_time': None,
'duration': None,
'user_check_start_time': None,
'user_check_end_time': None,
'user_check_duration': None,
'computed_start_time': None,
'computed_end_time': None,
'computed_duration': None,
}
else:
assert resp.json['data'][1]['booking'] == {
'cancellation_datetime': None,
'use_color_for': None,
'creation_datetime': localtime(now()).isoformat(),
'extra_data': None,
'id': booking2.pk,
'in_waiting_list': False,
'user_absence_reason': None,
'user_email': '',
'user_first_name': '',
'user_last_name': '',
'user_phone_number': '',
'user_presence_reason': 'foo-reason',
'user_was_present': True,
'label': '',
}
assert list(resp.json['data'][2].keys()) == ['event', 'check_status', 'booking']
assert resp.json['data'][2]['event'] == {
'description': None,
'duration': None,
'label': 'Recurring Event Label',
'slug': 'recurring-event-slug--2022-05-30-1600',
'places': 10,
'pricing': None,
'publication_datetime': None,
'recurrence_days': None,
'recurrence_end_date': None,
'recurrence_week_interval': 1,
'start_datetime': localtime(first_event.start_datetime).isoformat(),
'url': None,
'waiting_list_places': 0,
'agenda': agenda.slug,
'primary_event': recurring_event.slug,
'check_locked': False,
'checked': True,
'invoiced': False,
'custom_field_text': 'foo',
'custom_field_textarea': 'foo bar',
'custom_field_bool': True,
}
assert resp.json['data'][2]['check_status'] == {
'check_type': 'foo-reason',
'status': 'presence',
}
if partial_bookings:
assert resp.json['data'][2]['booking'] == {
'cancellation_datetime': None,
'use_color_for': None,
'creation_datetime': localtime(now()).isoformat(),
'extra_data': None,
'id': booking1.pk,
'in_waiting_list': False,
'user_absence_reason': None,
'user_email': '',
'user_first_name': '',
'user_last_name': '',
'user_phone_number': '',
'user_presence_reason': 'foo-reason',
'user_was_present': True,
'label': '',
'start_time': '08:00:00',
'end_time': '17:00:00',
'duration': 540,
'user_check_start_time': '07:55:00',
'user_check_end_time': '17:15:00',
'user_check_duration': 560,
'computed_start_time': '08:00:00',
'computed_end_time': '17:30:00',
'computed_duration': 570,
}
else:
assert resp.json['data'][2]['booking'] == {
'cancellation_datetime': None,
'use_color_for': None,
'creation_datetime': localtime(now()).isoformat(),
'extra_data': None,
'id': booking1.pk,
'in_waiting_list': False,
'user_absence_reason': None,
'user_email': '',
'user_first_name': '',
'user_last_name': '',
'user_phone_number': '',
'user_presence_reason': 'foo-reason',
'user_was_present': True,
'label': '',
}
@pytest.mark.freeze_time('2022-05-30 14:00')
@ -1770,9 +1837,12 @@ def test_events_check_lock_params(app, user):
assert 'wrong format' in resp.json['errors']['date_end'][0]
@pytest.mark.parametrize('partial_bookings', [True, False])
@pytest.mark.freeze_time('2022-05-30 14:00')
def test_events_check_lock(app, user):
agenda = Agenda.objects.create(label='Foo')
def test_events_check_lock(app, user, partial_bookings):
agenda = Agenda.objects.create(label='Foo', partial_bookings=partial_bookings)
assert agenda.invoicing_unit == 'hour'
assert agenda.invoicing_tolerance == 0
event = Event.objects.create(
slug='event-slug',
label='Event Label',
@ -1781,6 +1851,20 @@ def test_events_check_lock(app, user):
agenda=agenda,
checked=True,
)
booking = Booking.objects.create(
event=event,
user_external_id='child:42',
start_time=datetime.time(8, 0),
end_time=datetime.time(17, 0),
)
booking.mark_user_presence(
check_type_slug='foo-reason',
start_time=datetime.time(7, 55),
end_time=datetime.time(17, 15),
)
# computed times are None, because refresh_computed_times was not called in this test
assert booking.user_check.computed_start_time is None
assert booking.user_check.computed_end_time is None
app.authorization = ('Basic', ('john.doe', 'password'))
url = '/api/agendas/events/check-lock/'
@ -1794,12 +1878,24 @@ def test_events_check_lock(app, user):
assert resp.json['err'] == 0
event.refresh_from_db()
assert event.check_locked is True
booking.refresh_from_db()
# computed times are still None, refresh_computed_times is not called on lock
assert booking.user_check.computed_start_time is None
assert booking.user_check.computed_end_time is None
params['check_locked'] = False
resp = app.post_json(url, params=params)
assert resp.json['err'] == 0
event.refresh_from_db()
assert event.check_locked is False
booking.refresh_from_db()
if partial_bookings:
assert booking.user_check.computed_start_time == datetime.time(7, 0)
assert booking.user_check.computed_end_time == datetime.time(18, 0)
else:
# not refreshed, not a partial_bookings agenda
assert booking.user_check.computed_start_time is None
assert booking.user_check.computed_end_time is None
@pytest.mark.freeze_time('2022-05-30 14:00')

View File

@ -121,32 +121,42 @@ def test_statistics_bookings(app, user, freezer):
# absence/presence
for i in range(10):
Booking.objects.create(event=event3, user_was_present=bool(i % 2))
booking = Booking.objects.create(event=event3)
if i % 2:
booking.mark_user_presence()
else:
booking.mark_user_absence()
event4 = Event.objects.create(start_datetime=now().replace(month=11, day=1), places=5, agenda=agenda)
Booking.objects.create(event=event4, user_was_present=True)
booking = Booking.objects.create(event=event4)
booking.mark_user_presence()
Booking.objects.create(event=event4, primary_booking=booking)
resp = app.get(url + '?group_by=user_was_present')
assert resp.json['data']['x_labels'] == ['2020-10-10', '2020-10-15', '2020-10-25', '2020-11-01']
assert resp.json['data']['series'] == [
{'label': 'Absent', 'data': [None, None, 5, None]},
{'label': 'Booked', 'data': [10, 1, 1, None]},
{'label': 'Present', 'data': [None, None, 5, 1]},
{'label': 'Present', 'data': [None, None, 5, 2]},
]
for i in range(9):
Booking.objects.create(
event=event3 if i % 2 else event4, extra_data={'menu': 'vegetables'}, user_was_present=bool(i % 3)
)
booking = Booking.objects.create(event=event3 if i % 2 else event4, extra_data={'menu': 'vegetables'})
if i % 3:
booking.mark_user_presence()
else:
booking.mark_user_absence()
for i in range(5):
Booking.objects.create(
event=event3 if i % 2 else event4, extra_data={'menu': 'meet'}, user_was_present=bool(i % 3)
)
booking = Booking.objects.create(event=event3 if i % 2 else event4, extra_data={'menu': 'meet'})
if i % 3:
booking.mark_user_presence()
else:
booking.mark_user_absence()
resp = app.get(url + '?group_by=menu')
assert resp.json['data']['x_labels'] == ['2020-10-10', '2020-10-15', '2020-10-25', '2020-11-01']
assert resp.json['data']['series'] == [
{'label': 'None', 'data': [10, 1, 11, 1]},
{'label': 'None', 'data': [10, 1, 11, 2]},
{'label': 'meet', 'data': [None, None, 2, 3]},
{'label': 'vegetables', 'data': [None, None, 4, 5]},
]
@ -158,7 +168,7 @@ def test_statistics_bookings(app, user, freezer):
{'label': 'Absent / meet', 'data': [None, None, 1, 1]},
{'label': 'Absent / vegetables', 'data': [None, None, 1, 2]},
{'label': 'Booked / None', 'data': [10, 1, 1, None]},
{'label': 'Present / None', 'data': [None, None, 5, 1]},
{'label': 'Present / None', 'data': [None, None, 5, 2]},
{'label': 'Present / meet', 'data': [None, None, 1, 2]},
{'label': 'Present / vegetables', 'data': [None, None, 3, 3]},
]

View File

@ -3243,6 +3243,16 @@ def test_duplicate_agenda(app, admin_user):
assert 'hop' in resp.text
def test_duplicate_agenda_as_manager(app, manager_user):
agenda = Agenda(label='Foo bar')
agenda.edit_role = manager_user.groups.all()[0]
agenda.save()
app = login(app, username='manager', password='manager')
resp = app.get('/manage/agendas/%s/settings' % agenda.pk)
assert '/manage/agendas/%s/duplicate' % agenda.pk not in resp
app.get('/manage/agendas/%s/duplicate' % agenda.pk, status=403)
def test_booking_cancellation_meetings_agenda(app, admin_user, manager_user, managers_group, api_user):
agenda = Agenda.objects.create(label='Passeports', kind='meetings', view_role=managers_group)
desk = Desk.objects.create(agenda=agenda, label='Desk A')
@ -3794,6 +3804,13 @@ def test_agenda_booking_colors(app, admin_user, api_user, view):
assert 'Booking colors:' in resp.text
assert len(resp.pyquery.find('div.booking-colors span.booking-color-label')) == 2
new_booking.cancel()
resp = app.get(url)
assert len(resp.pyquery.find('div.booking')) == 3
assert len(resp.pyquery.find('div.booking.booking-color-%s' % new_booking.color.index)) == 0
assert resp.text.count('Swimming') == 0 # no booking and no legend
assert len(resp.pyquery.find('div.booking-colors span.booking-color-label')) == 1
@freezegun.freeze_time('2022-03-01 14:00')
def test_agenda_day_and_month_views_weekday_indexes(app, admin_user):

View File

@ -798,7 +798,8 @@ def test_import_events(app, admin_user):
resp = app.get('/manage/agendas/%s/import-events' % agenda.id, status=200)
resp.form['events_csv_file'] = Upload('t.csv', b'xx', 'text/csv')
resp = resp.form.submit(status=200)
assert 'Invalid file format.' in resp.text
assert 'Invalid file format:' in resp.text
assert 'Wrong start date/time format. (1st event)' in resp.text
resp.form['events_csv_file'] = Upload('t.csv', b'xxxx\0\0xxxx', 'text/csv')
resp = resp.form.submit(status=200)
@ -806,15 +807,16 @@ def test_import_events(app, admin_user):
resp.form['events_csv_file'] = Upload('t.csv', b'2016-14-16,18:00', 'text/csv')
resp = resp.form.submit(status=200)
assert 'Invalid file format.' in resp.text
assert 'Invalid file format:' in resp.text
assert 'Not enough columns. (1st event)' in resp.text
resp.form['events_csv_file'] = Upload('t.csv', b'date,time,etc.\n2016-14-16,18:00,10', 'text/csv')
resp = resp.form.submit(status=200)
assert 'Invalid file format. (date/time format, 1st event)' in resp.text
assert 'Wrong start date/time format. (1st event)' in resp.text
resp.form['events_csv_file'] = Upload('t.csv', b'2016-14-16,18:00,10', 'text/csv')
resp = resp.form.submit(status=200)
assert 'Invalid file format. (date/time format, 1st event)' in resp.text
assert 'Wrong start date/time format. (1st event)' in resp.text
with override_settings(LANGUAGE_CODE='fr-fr'):
resp.form['events_csv_file'] = Upload('t.csv', b'2016-14-16,18:00,10', 'text/csv')
@ -824,11 +826,11 @@ def test_import_events(app, admin_user):
resp.form['events_csv_file'] = Upload('t.csv', b'2016-09-16,18:00,blah', 'text/csv')
resp = resp.form.submit(status=200)
assert 'Invalid file format. (number of places,' in resp.text
assert 'Number of places must be an integer. (1st event)' in resp.text
resp.form['events_csv_file'] = Upload('t.csv', b'2016-09-16,18:00,10,blah', 'text/csv')
resp = resp.form.submit(status=200)
assert 'Invalid file format. (number of places in waiting list,' in resp.text
assert 'Number of places in waiting list must be an integer. (1st event)' in resp.text
resp.form['events_csv_file'] = Upload('t.csv', b'2016-09-16,18:00,10,5,' + b'x' * 151, 'text/csv')
resp = resp.form.submit(status=200)
@ -988,7 +990,7 @@ def test_import_events(app, admin_user):
't.csv', b'2016-09-16,18:00,10,5,label,slug,description,pricing,url,foobar', 'text/csv'
)
resp = resp.form.submit(status=200)
assert 'Invalid file format. (date/time format' in resp.text
assert 'Wrong publication date/time format. (1st event)' in resp.text
# duration bad format
resp = app.get('/manage/agendas/%s/import-events' % agenda.id, status=200)
@ -996,7 +998,7 @@ def test_import_events(app, admin_user):
't.csv', b'2016-09-16,18:00,10,5,label,slug,description,pricing,url,2016-09-16,foobar', 'text/csv'
)
resp = resp.form.submit(status=200)
assert 'Invalid file format. (duration' in resp.text
assert 'Duration must be an integer. (1st event)' in resp.text
# import events with empty slugs
Event.objects.all().delete()
@ -1027,8 +1029,7 @@ def test_import_events(app, admin_user):
resp = app.get('/manage/agendas/%s/import-events' % agenda.id, status=200)
resp.form['events_csv_file'] = Upload('t.csv', b'2016-09-16,18:00,10,5,label,1234', 'text/csv')
resp = resp.form.submit(status=200)
assert 'value cannot be a number' in resp.text
assert 'Identifier:' in resp.text # verbose_name is shown, not field name ('slug:')
assert 'Identifier: This value cannot be a number. (1st event)' in resp.text
def test_import_event_nested_quotes(app, admin_user):
@ -1192,7 +1193,7 @@ def test_import_events_partial_bookings(app, admin_user):
't.csv', b'2016-09-16,18:00,10,5,label,slug,description,pricing,url,2016-09-16', 'text/csv'
)
resp = resp.form.submit(status=200)
assert 'Invalid file format. (missing end_time' in resp.text
assert 'Missing end_time.' in resp.text
# invalid end time
resp = app.get('/manage/agendas/%s/import-events' % agenda.pk)
@ -1203,6 +1204,27 @@ def test_import_events_partial_bookings(app, admin_user):
assert '“xxx” value has an invalid format' in resp.text
def test_import_events_multiple_errors(app, admin_user):
agenda = Agenda.objects.create(label='Foo bar', kind='events')
Desk.objects.create(agenda=agenda, slug='_exceptions_holder')
app = login(app)
resp = app.get('/manage/agendas/%s/import-events' % agenda.pk)
resp.form['events_csv_file'] = Upload(
't.csv',
b'2016-09-17,18:00,10,5,label,slug\n' # valid event
b'2016-09-17,19:00,xxx,5,label2,slug2\n' # invalid places
b'2016-09-17,20:00,10,5,,1234\n', # invalid slug
'text/csv',
)
resp = resp.form.submit(status=200)
assert [x.text for x in resp.pyquery('.errorlist li')] == [
'Invalid file format:',
'Number of places must be an integer. (2nd event)',
'Identifier: This value cannot be a number. (3rd event)',
]
@pytest.mark.freeze_time('2022-05-24')
def test_event_detail(app, admin_user):
agenda = Agenda.objects.create(label='Events', kind='events')
@ -1241,11 +1263,19 @@ def test_event_detail_redirect(app, admin_user):
waiting_list_places=2,
agenda=agenda,
)
day = localtime(event.start_datetime)
login(app)
resp = app.get('/manage/agendas/%s/events/%s/' % (agenda.slug, event.slug), status=302)
assert resp.location.endswith('/manage/agendas/%s/events/%s/' % (agenda.pk, event.pk))
agenda.partial_bookings = True
agenda.save()
resp = app.get('/manage/agendas/%s/events/%s/' % (agenda.slug, event.slug), status=302)
assert resp.location.endswith(
'/manage/agendas/%s/day/%d/%02d/%02d/' % (agenda.pk, day.year, day.month, day.day)
)
def test_event_cancellation(app, admin_user):
agenda = Agenda.objects.create(label='Events', kind='events')
@ -1712,6 +1742,14 @@ def test_event_check(app, admin_user):
assert '/manage/agendas/%s/events/%s/check' % (agenda.pk, event.pk) not in resp
app.get('/manage/agendas/%s/events/%s/check' % (agenda.pk, event.pk), status=404)
# partial bookings
event.cancellation_datetime = None
event.save()
agenda.partial_bookings = True
agenda.save()
app.get('/manage/agendas/%s/events/%s/' % (agenda.pk, event.pk), status=404)
app.get('/manage/agendas/%s/events/%s/check' % (agenda.pk, event.pk), status=404)
def test_event_checked(app, admin_user):
agenda = Agenda.objects.create(label='Events', kind='events', booking_check_filters='foo,bar')
@ -1727,15 +1765,12 @@ def test_event_checked(app, admin_user):
resp = app.get('/manage/agendas/%s/events/%s/check' % (agenda.pk, event.pk))
assert 'Mark the event as checked' not in resp
for i in range(8):
user_was_present = None
booking = Booking.objects.create(event=event)
if i < 3:
user_was_present = True
booking.mark_user_presence()
elif i < 7:
user_was_present = False
Booking.objects.create(
event=event,
user_was_present=user_was_present,
)
booking.mark_user_absence()
resp = app.get('/manage/agendas/%s/events/%s/check' % (agenda.pk, event.pk))
assert 'Mark the event as checked' in resp
assert event.checked is False
@ -1772,7 +1807,8 @@ def test_event_checked(app, admin_user):
assert 'Mark the event as checked' not in resp
resp = app.get('/manage/agendas/%s/settings' % agenda.id)
assert 'checked tag' in resp
Booking.objects.filter(user_was_present__isnull=True).update(user_was_present=True)
for booking in Booking.objects.filter(user_checks__isnull=True):
booking.mark_user_presence()
for url in urls:
resp = app.get(url)
assert '<span class="checked tag">Checked</span>' in resp
@ -1847,17 +1883,16 @@ def test_event_notify_checked(app, admin_user):
login(app)
for i in range(8):
user_was_present = None
if i < 3:
user_was_present = True
elif i < 7:
user_was_present = False
Booking.objects.create(
booking = Booking.objects.create(
event=event,
user_was_present=user_was_present,
presence_callback_url='https://example.invalid/presence/%s' % i,
absence_callback_url='https://example.invalid/absence/%s' % i,
)
if i < 3:
booking.mark_user_presence()
elif i < 7:
booking.mark_user_absence()
resp = app.get('/manage/agendas/%s/events/%s/check' % (agenda.pk, event.pk))
with mock.patch('chrono.utils.requests_wrapper.RequestsSession.send') as mock_send:
@ -1910,22 +1945,22 @@ def test_event_check_filters(check_types, app, admin_user):
user_last_name='empty',
extra_data={},
)
Booking.objects.create(
booking = Booking.objects.create(
event=event,
user_external_id='user:1',
user_first_name='User',
user_last_name='foo-val1 bar-none presence',
extra_data={'foo': 'val1', 'bar': ['val1']}, # bar is ignored, wrong value
user_was_present=True,
)
Booking.objects.create(
booking.mark_user_presence()
booking = Booking.objects.create(
event=event,
user_external_id='user:2',
user_first_name='User',
user_last_name='foo-val2 bar-val1 absence',
extra_data={'foo': 'val2', 'bar': 'val1'},
user_was_present=False,
)
booking.mark_user_absence()
Booking.objects.create(
event=event,
user_external_id='user:3',
@ -1933,44 +1968,42 @@ def test_event_check_filters(check_types, app, admin_user):
user_last_name='foo-val1 bar-val2 not-checked',
extra_data={'foo': 'val1', 'bar': 'val2'},
)
Booking.objects.create(
booking = Booking.objects.create(
event=event,
user_external_id='user:4',
user_first_name='User',
user_last_name='foo-none bar-val2 reason-foo',
extra_data={'bar': 'val2', 'foo': None}, # foo is ignored, empty value
user_was_present=False,
user_check_type_slug='foo-reason',
)
Booking.objects.create(
booking.mark_user_absence(check_type_slug='foo-reason')
booking = Booking.objects.create(
event=event,
user_external_id='user:5',
user_first_name='User',
user_last_name='foo-none bar-val2 reason-bar',
extra_data={'bar': 'val2', 'foo': ''}, # foo is ignored, empty value
user_was_present=True,
user_check_type_slug='bar-reason',
)
Booking.objects.create(
booking.mark_user_presence(check_type_slug='bar-reason')
booking = Booking.objects.create(
event=event,
user_external_id='user:6',
user_first_name='User',
user_last_name='foo-none bar-val2 cancelled-absence',
extra_data={'bar': 'val2'},
user_was_present=False,
user_check_type_slug='foo-reason',
cancellation_datetime=now(),
)
Booking.objects.create(
booking.mark_user_absence(check_type_slug='foo-reason')
booking.cancellation_datetime = now()
booking.save()
booking = Booking.objects.create(
event=event,
user_external_id='user:7',
user_first_name='User',
user_last_name='foo-none bar-val2 cancelled-presence',
extra_data={'bar': 'val2'},
user_was_present=True,
user_check_type_slug='bar-reason',
cancellation_datetime=now(),
)
booking.mark_user_presence(check_type_slug='bar-reason')
booking.cancellation_datetime = now()
booking.save()
Subscription.objects.create(
agenda=agenda,
@ -2054,7 +2087,7 @@ def test_event_check_filters(check_types, app, admin_user):
resp = app.get(
'/manage/agendas/%s/events/%s/check' % (agenda.pk, event.pk), params={'extra-data-foo': 'val1'}
)
assert len(ctx.captured_queries) == 10
assert len(ctx.captured_queries) == 11
assert 'User none' not in resp
assert 'User empty' not in resp
assert 'User foo-val1 bar-none presence' in resp
@ -2330,13 +2363,9 @@ def test_event_check_booking(check_types, app, admin_user):
assert '/manage/agendas/%s/bookings/%s/absence' % (agenda.pk, booking.pk) in resp
assert '/manage/agendas/%s/bookings/%s/reset' % (agenda.pk, booking.pk) not in resp
booking.refresh_from_db()
assert booking.user_was_present is None
assert booking.user_check_type_slug is None
assert booking.user_check_type_label is None
assert not booking.user_check
secondary_booking.refresh_from_db()
assert secondary_booking.user_was_present is None
assert secondary_booking.user_check_type_slug is None
assert secondary_booking.user_check_type_label is None
assert not secondary_booking.user_check
event.refresh_from_db()
assert event.checked is False
@ -2361,13 +2390,11 @@ def test_event_check_booking(check_types, app, admin_user):
assert '/manage/agendas/%s/bookings/%s/absence' % (agenda.pk, booking.pk) in resp
assert '/manage/agendas/%s/bookings/%s/reset' % (agenda.pk, booking.pk) in resp
booking.refresh_from_db()
assert booking.user_was_present is True
assert booking.user_check_type_slug is None
assert booking.user_check_type_label is None
assert booking.user_check.presence is True
assert booking.user_check.type_slug is None
assert booking.user_check.type_label is None
secondary_booking.refresh_from_db()
assert secondary_booking.user_was_present is True
assert secondary_booking.user_check_type_slug is None
assert secondary_booking.user_check_type_label is None
assert not secondary_booking.user_check
event.refresh_from_db()
assert event.checked is False
@ -2386,13 +2413,11 @@ def test_event_check_booking(check_types, app, admin_user):
assert len(resp.pyquery.find('td.booking-actions button[disabled]')) == 1
assert resp.pyquery.find('td.booking-actions button[disabled]')[0].text == 'Absence'
booking.refresh_from_db()
assert booking.user_was_present is False
assert booking.user_check_type_slug is None
assert booking.user_check_type_label is None
assert booking.user_check.presence is False
assert booking.user_check.type_slug is None
assert booking.user_check.type_label is None
secondary_booking.refresh_from_db()
assert secondary_booking.user_was_present is False
assert secondary_booking.user_check_type_slug is None
assert secondary_booking.user_check_type_label is None
assert not secondary_booking.user_check
event.refresh_from_db()
assert event.checked is True
@ -2413,13 +2438,11 @@ def test_event_check_booking(check_types, app, admin_user):
).follow()
assert 'Foo reason' in resp
booking.refresh_from_db()
assert booking.user_was_present is False
assert booking.user_check_type_slug == 'foo-reason'
assert booking.user_check_type_label == 'Foo reason'
assert booking.user_check.presence is False
assert booking.user_check.type_slug == 'foo-reason'
assert booking.user_check.type_label == 'Foo reason'
secondary_booking.refresh_from_db()
assert secondary_booking.user_was_present is False
assert secondary_booking.user_check_type_slug == 'foo-reason'
assert secondary_booking.user_check_type_label == 'Foo reason'
assert not secondary_booking.user_check
event.refresh_from_db()
assert event.checked is True
@ -2435,13 +2458,11 @@ def test_event_check_booking(check_types, app, admin_user):
assert len(resp.pyquery.find('td.booking-actions button[disabled]')) == 1
assert resp.pyquery.find('td.booking-actions button[disabled]')[0].text == 'Presence'
booking.refresh_from_db()
assert booking.user_was_present is True
assert booking.user_check_type_slug is None
assert booking.user_check_type_label is None
assert booking.user_check.presence is True
assert booking.user_check.type_slug is None
assert booking.user_check.type_label is None
secondary_booking.refresh_from_db()
assert secondary_booking.user_was_present is True
assert secondary_booking.user_check_type_slug is None
assert secondary_booking.user_check_type_label is None
assert not secondary_booking.user_check
event.refresh_from_db()
assert event.checked is True
@ -2467,13 +2488,11 @@ def test_event_check_booking(check_types, app, admin_user):
).follow()
assert 'Bar reason' in resp
booking.refresh_from_db()
assert booking.user_was_present is True
assert booking.user_check_type_slug == 'bar-reason'
assert booking.user_check_type_label == 'Bar reason'
assert booking.user_check.presence is True
assert booking.user_check.type_slug == 'bar-reason'
assert booking.user_check.type_label == 'Bar reason'
secondary_booking.refresh_from_db()
assert secondary_booking.user_was_present is True
assert secondary_booking.user_check_type_slug == 'bar-reason'
assert secondary_booking.user_check_type_label == 'Bar reason'
assert not secondary_booking.user_check
event.refresh_from_db()
assert event.checked is True
@ -2649,10 +2668,10 @@ def test_event_check_cancelled_booking(check_types, app, admin_user):
assert resp.pyquery.find('td.booking-actions button[disabled]')[0].text == 'Presence'
booking.refresh_from_db()
assert booking.cancellation_datetime is None
assert booking.user_was_present is True
assert booking.user_check.presence is True
secondary_booking.refresh_from_db()
assert secondary_booking.cancellation_datetime is None
assert secondary_booking.user_was_present is True
assert not secondary_booking.user_check
booking.cancel()
resp = app.post(
@ -2664,10 +2683,10 @@ def test_event_check_cancelled_booking(check_types, app, admin_user):
assert resp.pyquery.find('td.booking-actions button[disabled]')[0].text == 'Absence'
booking.refresh_from_db()
assert booking.cancellation_datetime is None
assert booking.user_was_present is False
assert booking.user_check.presence is False
secondary_booking.refresh_from_db()
assert secondary_booking.cancellation_datetime is None
assert secondary_booking.user_was_present is False
assert not secondary_booking.user_check
@mock.patch('chrono.manager.forms.get_agenda_check_types')
@ -2809,9 +2828,9 @@ def test_event_check_subscription(check_types, app, admin_user):
params={'csrfmiddlewaretoken': token, 'check_type': 'bar-reason'},
)
booking = Booking.objects.latest('pk')
assert booking.user_was_present is True
assert booking.user_check_type_slug == 'bar-reason'
assert booking.user_check_type_label == 'Bar reason'
assert booking.user_check.presence is True
assert booking.user_check.type_slug == 'bar-reason'
assert booking.user_check.type_label == 'Bar reason'
assert booking.event == event
assert booking.user_external_id == subscription.user_external_id
assert booking.user_first_name == subscription.user_first_name
@ -2831,9 +2850,9 @@ def test_event_check_subscription(check_types, app, admin_user):
params={'csrfmiddlewaretoken': token, 'check_type': 'foo-reason'},
)
booking = Booking.objects.latest('pk')
assert booking.user_was_present is False
assert booking.user_check_type_slug == 'foo-reason'
assert booking.user_check_type_label == 'Foo reason'
assert booking.user_check.presence is False
assert booking.user_check.type_slug == 'foo-reason'
assert booking.user_check.type_label == 'Foo reason'
assert booking.event == event
assert booking.user_external_id == subscription.user_external_id
assert booking.user_first_name == subscription.user_first_name
@ -3106,9 +3125,9 @@ def test_event_check_all_bookings(check_types, app, admin_user):
'/manage/agendas/%s/events/%s/absence' % (agenda.pk, event.pk), params={'csrfmiddlewaretoken': token}
)
booking1.refresh_from_db()
assert booking1.user_was_present is False
assert booking1.user_check_type_slug is None
assert booking1.user_check_type_label is None
assert booking1.user_check.presence is False
assert booking1.user_check.type_slug is None
assert booking1.user_check.type_label is None
event.refresh_from_db()
assert event.checked is False
@ -3133,17 +3152,17 @@ def test_event_check_all_bookings(check_types, app, admin_user):
'/manage/agendas/%s/events/%s/presence' % (agenda.pk, event.pk), params={'csrfmiddlewaretoken': token}
)
booking1.refresh_from_db()
assert booking1.user_was_present is False
assert booking1.user_check_type_slug is None
assert booking1.user_check_type_label is None
assert booking1.user_check.presence is False
assert booking1.user_check.type_slug is None
assert booking1.user_check.type_label is None
booking2.refresh_from_db()
assert booking2.user_was_present is True
assert booking2.user_check_type_slug is None
assert booking2.user_check_type_label is None
assert booking2.user_check.presence is True
assert booking2.user_check.type_slug is None
assert booking2.user_check.type_label is None
secondary_booking.refresh_from_db()
assert secondary_booking.user_was_present is True
assert secondary_booking.user_check_type_slug is None
assert secondary_booking.user_check_type_label is None
assert secondary_booking.user_check.presence is True
assert secondary_booking.user_check.type_slug is None
assert secondary_booking.user_check.type_label is None
event.refresh_from_db()
assert event.checked is True
@ -3156,17 +3175,17 @@ def test_event_check_all_bookings(check_types, app, admin_user):
params={'csrfmiddlewaretoken': token, 'check_type': 'foo-reason'},
)
booking1.refresh_from_db()
assert booking1.user_was_present is False
assert booking1.user_check_type_slug is None
assert booking1.user_check_type_label is None
assert booking1.user_check.presence is False
assert booking1.user_check.type_slug is None
assert booking1.user_check.type_label is None
booking2.refresh_from_db()
assert booking2.user_was_present is True
assert booking2.user_check_type_slug is None
assert booking2.user_check_type_label is None
assert booking2.user_check.presence is True
assert booking2.user_check.type_slug is None
assert booking2.user_check.type_label is None
booking3.refresh_from_db()
assert booking3.user_was_present is False
assert booking3.user_check_type_slug == 'foo-reason'
assert booking3.user_check_type_label == 'Foo reason'
assert booking3.user_check.presence is False
assert booking3.user_check.type_slug == 'foo-reason'
assert booking3.user_check.type_label == 'Foo reason'
booking4 = Booking.objects.create(event=event, user_first_name='User', user_last_name='52')
resp = app.get('/manage/agendas/%s/events/%s/check' % (agenda.pk, event.pk))
@ -3176,21 +3195,21 @@ def test_event_check_all_bookings(check_types, app, admin_user):
params={'csrfmiddlewaretoken': token, 'check_type': 'bar-reason'},
)
booking1.refresh_from_db()
assert booking1.user_was_present is False
assert booking1.user_check_type_slug is None
assert booking1.user_check_type_label is None
assert booking1.user_check.presence is False
assert booking1.user_check.type_slug is None
assert booking1.user_check.type_label is None
booking2.refresh_from_db()
assert booking2.user_was_present is True
assert booking2.user_check_type_slug is None
assert booking2.user_check_type_label is None
assert booking2.user_check.presence is True
assert booking2.user_check.type_slug is None
assert booking2.user_check.type_label is None
booking3.refresh_from_db()
assert booking3.user_was_present is False
assert booking3.user_check_type_slug == 'foo-reason'
assert booking3.user_check_type_label == 'Foo reason'
assert booking3.user_check.presence is False
assert booking3.user_check.type_slug == 'foo-reason'
assert booking3.user_check.type_label == 'Foo reason'
booking4.refresh_from_db()
assert booking4.user_was_present is True
assert booking4.user_check_type_slug == 'bar-reason'
assert booking4.user_check_type_label == 'Bar reason'
assert booking4.user_check.presence is True
assert booking4.user_check.type_slug == 'bar-reason'
assert booking4.user_check.type_label == 'Bar reason'
# now disable check update
agenda.disable_check_update = True

View File

@ -5,7 +5,7 @@ import pytest
from chrono.agendas.models import Agenda, Booking, Event, Subscription
from chrono.utils.lingo import CheckType
from chrono.utils.timezone import make_aware
from chrono.utils.timezone import make_aware, now
from tests.utils import login
pytestmark = pytest.mark.django_db
@ -27,6 +27,7 @@ def test_manager_partial_bookings_add_agenda(app, admin_user, settings):
assert agenda.kind == 'events'
assert agenda.partial_bookings is True
assert agenda.default_view == 'day'
assert agenda.enable_check_for_future_events is True
resp = resp.click('Options')
assert resp.form['default_view'].options == [
@ -36,6 +37,82 @@ def test_manager_partial_bookings_add_agenda(app, admin_user, settings):
]
def test_options_partial_bookings_invoicing_settings(app, admin_user):
agenda = Agenda.objects.create(label='Foo bar', kind='events', partial_bookings=True)
assert agenda.invoicing_unit == 'hour'
assert agenda.invoicing_tolerance == 0
start_datetime = make_aware(datetime.datetime(2023, 5, 2, 8, 0))
event = Event.objects.create(
label='Event', start_datetime=start_datetime, end_time=datetime.time(18, 00), places=10, agenda=agenda
)
booking = Booking.objects.create(
user_external_id='xxx',
user_first_name='Jane',
user_last_name='Doe',
start_time=datetime.time(11, 00),
end_time=datetime.time(13, 30),
event=event,
)
booking.mark_user_presence(start_time=datetime.time(10, 55), end_time=datetime.time(14, 4))
agenda.refresh_booking_computed_times()
booking.refresh_from_db()
assert booking.user_check.computed_start_time == datetime.time(10, 0)
assert booking.user_check.computed_end_time == datetime.time(15, 0)
app = login(app)
resp = app.get('/manage/agendas/%s/settings' % agenda.pk)
resp = resp.click(href='/manage/agendas/%s/invoicing-options' % agenda.pk)
assert resp.form['invoicing_unit'].options == [
('hour', True, 'Per hour'),
('half_hour', False, 'Per half hour'),
('quarter', False, 'Per quarter-hour'),
('minute', False, 'Per minute'),
]
resp.form['invoicing_unit'] = 'half_hour'
resp.form['invoicing_tolerance'] = 10
resp = resp.form.submit().follow()
agenda.refresh_from_db()
assert agenda.invoicing_unit == 'half_hour'
assert agenda.invoicing_tolerance == 10
booking.refresh_from_db()
assert booking.user_check.computed_start_time == datetime.time(11, 0)
assert booking.user_check.computed_end_time == datetime.time(14, 0)
# check kind
agenda.partial_bookings = False
agenda.save()
resp = app.get('/manage/agendas/%s/settings' % agenda.pk)
assert '/manage/agendas/%s/invoicing-options' % agenda.pk not in resp
agenda.kind = 'meetings'
agenda.save()
resp = app.get('/manage/agendas/%s/settings' % agenda.pk)
assert '/manage/agendas/%s/invoicing-options' % agenda.pk not in resp
agenda.kind = 'virtual'
agenda.save()
resp = app.get('/manage/agendas/%s/settings' % agenda.pk)
assert '/manage/agendas/%s/invoicing-options' % agenda.pk not in resp
def test_options_partial_bookings_simpler_settings(app, admin_user):
agenda = Agenda.objects.create(label='Foo bar', kind='events', partial_bookings=True)
app = login(app)
resp = app.get('/manage/agendas/%s/settings' % agenda.pk)
assert 'Management notifications' not in resp.text
assert 'Booking reminders' not in resp.text
assert 'Booking display template' not in resp.text
assert 'Extra user block template' not in resp.text
assert 'Enable the check of bookings when event has not passed' not in resp.text
resp = app.get('/manage/agendas/%s/display-options' % agenda.pk)
assert 'booking_user_block_template' not in resp.form.fields
resp = app.get('/manage/agendas/%s/check-options' % agenda.pk)
assert 'enable_check_for_future_events' not in resp.form.fields
assert 'booking_extra_user_block_template' not in resp.form.fields
def test_manager_partial_bookings_add_event(app, admin_user):
agenda = Agenda.objects.create(label='Foo bar', kind='events', partial_bookings=True)
@ -133,6 +210,21 @@ def test_manager_partial_bookings_day_view(app, admin_user, freezer):
resp = resp.click('Next day')
assert 'No opening hours this day.' in resp.text
Event.objects.all().delete()
event = Event.objects.create(
label='Other Event',
start_datetime=start_datetime,
end_time=datetime.time(18, 00),
places=10,
agenda=agenda,
recurrence_days=[1, 2, 3, 4, 5, 6, 7],
recurrence_end_date=start_datetime + datetime.timedelta(days=7),
)
event.create_all_recurrences()
resp = app.get('/manage/agendas/%s/day/%d/%d/%d/' % (agenda.pk, today.year, today.month, today.day))
assert resp.pyquery('.partial-booking--hour')[0].text == '07\u202fh'
assert resp.pyquery('.partial-booking--hour')[-1].text == '19\u202fh'
def test_manager_partial_bookings_day_view_24_hours(app, admin_user, freezer):
agenda = Agenda.objects.create(label='Foo bar', kind='events', partial_bookings=True)
@ -151,6 +243,71 @@ def test_manager_partial_bookings_day_view_24_hours(app, admin_user, freezer):
)
def test_manager_partial_bookings_day_view_multiple_bookings(app, admin_user, freezer):
agenda = Agenda.objects.create(label='Foo bar', kind='events', partial_bookings=True)
start_datetime = make_aware(datetime.datetime(2023, 5, 2, 8, 0))
event = Event.objects.create(
label='Event', start_datetime=start_datetime, end_time=datetime.time(18, 00), places=10, agenda=agenda
)
Booking.objects.create(
user_external_id='xxx',
user_first_name='Jane',
user_last_name='Doe',
start_time=datetime.time(9, 00),
end_time=datetime.time(12, 00),
event=event,
)
Booking.objects.create(
user_external_id='xxx',
user_first_name='Jane',
user_last_name='Doe',
start_time=datetime.time(15, 00),
end_time=datetime.time(18, 00),
event=event,
)
app = login(app)
today = start_datetime.date()
resp = app.get('/manage/agendas/%s/day/%d/%d/%d/' % (agenda.pk, today.year, today.month, today.day))
assert len(resp.pyquery('.partial-booking--registrant')) == 1
assert len(resp.pyquery('.registrant--bar')) == 2
assert resp.pyquery('.registrant--bar')[0].findall('time')[0].text == '09:00'
assert resp.pyquery('.registrant--bar')[0].findall('time')[1].text == '12:00'
assert resp.pyquery('.registrant--bar')[1].findall('time')[0].text == '15:00'
assert resp.pyquery('.registrant--bar')[1].findall('time')[1].text == '18:00'
# check first booking
resp = resp.click('Booked period', index=0)
resp.form['start_time'] = '09:30'
resp.form['end_time'] = '12:00'
resp.form['presence'] = 'True'
resp = resp.form.submit().follow()
assert len(resp.pyquery('.partial-booking--registrant')) == 1
assert len(resp.pyquery('.registrant--bar')) == 4
assert len(resp.pyquery('.registrant--bar.check')) == 1
assert len(resp.pyquery('.registrant--bar.computed')) == 1
assert resp.pyquery('.registrant--bar.check')[0].findall('time')[0].text == '09:30'
assert resp.pyquery('.registrant--bar.check')[0].findall('time')[1].text == '12:00'
# check second booking
resp = resp.click('Booked period')
resp.form['start_time'] = '15:00'
resp.form['end_time'] = '17:00'
resp.form['presence'] = 'True'
resp = resp.form.submit().follow()
assert len(resp.pyquery('.partial-booking--registrant')) == 1
assert len(resp.pyquery('.registrant--bar')) == 6
assert len(resp.pyquery('.registrant--bar.check')) == 2
assert len(resp.pyquery('.registrant--bar.computed')) == 2
assert resp.pyquery('.registrant--bar.check')[0].findall('time')[0].text == '09:30'
assert resp.pyquery('.registrant--bar.check')[0].findall('time')[1].text == '12:00'
assert resp.pyquery('.registrant--bar.check')[1].findall('time')[0].text == '15:00'
assert resp.pyquery('.registrant--bar.check')[1].findall('time')[1].text == '17:00'
@mock.patch('chrono.manager.forms.get_agenda_check_types')
def test_manager_partial_bookings_check(check_types, app, admin_user):
check_types.return_value = []
@ -159,7 +316,7 @@ def test_manager_partial_bookings_check(check_types, app, admin_user):
event = Event.objects.create(
label='Event', start_datetime=start_datetime, end_time=datetime.time(18, 00), places=10, agenda=agenda
)
Booking.objects.create(
booking = Booking.objects.create(
user_external_id='xxx',
user_first_name='Jane',
user_last_name='Doe',
@ -177,29 +334,60 @@ def test_manager_partial_bookings_check(check_types, app, admin_user):
assert resp.pyquery('.registrant--bar time')[1].text == '13:30'
assert resp.pyquery('.registrant--bar')[0].attrib['style'] == 'left: 30.77%; width: 19.23%;'
resp = resp.click('Jane Doe')
resp = resp.click('Booked period')
assert 'presence_check_type' not in resp.form.fields
assert 'absence_check_type' not in resp.form.fields
resp.form['user_check_start_time'] = '11:01'
resp.form['user_check_end_time'] = '13:15'
resp.form['user_was_present'] = 'True'
resp = resp.form.submit().follow()
assert resp.pyquery('form').attr('data-fill-start_time') == '11:00'
assert resp.pyquery('form').attr('data-fill-end_time') == '13:30'
assert resp.pyquery('.time-widget-button')[0].text == 'Fill with booking start time'
assert resp.pyquery('.time-widget-button')[1].text == 'Fill with booking end time'
assert len(resp.pyquery('.registrant--bar')) == 2
resp.form['start_time'] = '11:01'
resp.form['end_time'] = '11:00'
resp.form['presence'] = 'True'
resp = resp.form.submit()
assert 'Arrival must be before departure.' in resp.text
resp.form['start_time'] = '11:01'
resp.form['end_time'] = '13:15'
resp.form['presence'] = 'True'
resp = resp.form.submit().follow()
booking.refresh_from_db()
assert booking.user_check.start_time == datetime.time(11, 1)
assert booking.user_check.end_time == datetime.time(13, 15)
assert booking.user_check.computed_start_time == datetime.time(11, 0)
assert booking.user_check.computed_end_time == datetime.time(14, 0)
assert len(resp.pyquery('.registrant--bar')) == 3
assert len(resp.pyquery('.registrant--bar.booking')) == 1
assert len(resp.pyquery('.registrant--bar.check.present')) == 1
assert resp.pyquery('.registrant--bar.check time')[0].text == '11:01'
assert resp.pyquery('.registrant--bar.check time')[1].text == '13:15'
assert resp.pyquery('.registrant--bar.check')[0].attrib['style'] == 'left: 30.9%; width: 17.18%;'
assert resp.pyquery('.registrant--bar span').text() == ''
assert len(resp.pyquery('.registrant--bar.computed.present')) == 1
assert resp.pyquery('.registrant--bar.computed time')[0].text == '11:00'
assert resp.pyquery('.registrant--bar.computed time')[1].text == '14:00'
assert resp.pyquery('.registrant--bar.computed')[0].attrib['style'] == 'left: 30.77%; width: 23.08%;'
agenda.invoicing_unit = 'half_hour'
agenda.invoicing_tolerance = 10
agenda.save()
agenda.refresh_booking_computed_times()
booking.refresh_from_db()
assert booking.user_check.start_time == datetime.time(11, 1)
assert booking.user_check.end_time == datetime.time(13, 15)
assert booking.user_check.computed_start_time == datetime.time(11, 0)
assert booking.user_check.computed_end_time == datetime.time(13, 30)
check_types.return_value = [
CheckType(slug='foo-reason', label='Foo reason', kind='absence'),
CheckType(slug='bar-reason', label='Bar reason', kind='presence'),
CheckType(slug='baz-reason', label='Baz reason', kind='presence'),
]
resp = resp.click('Jane Doe')
resp = resp.click('Checked period')
assert resp.form['presence_check_type'].options == [
('', True, '---------'),
('bar-reason', False, 'Bar reason'),
@ -212,67 +400,196 @@ def test_manager_partial_bookings_check(check_types, app, admin_user):
resp.form['presence_check_type'] = 'bar-reason'
resp = resp.form.submit().follow()
assert len(resp.pyquery('.registrant--bar')) == 2
assert len(resp.pyquery('.registrant--bar')) == 3
assert len(resp.pyquery('.registrant--bar.booking')) == 1
assert len(resp.pyquery('.registrant--bar.check.present')) == 1
assert resp.pyquery('.registrant--bar.check time')[0].text == '11:01'
assert resp.pyquery('.registrant--bar.check time')[1].text == '13:15'
assert resp.pyquery('.registrant--bar.check')[0].attrib['style'] == 'left: 30.9%; width: 17.18%;'
assert resp.pyquery('.registrant--bar span').text() == 'Bar reason'
assert len(resp.pyquery('.registrant--bar.computed.present')) == 1
assert resp.pyquery('.registrant--bar.computed time')[0].text == '11:00'
assert resp.pyquery('.registrant--bar.computed time')[1].text == '13:30'
assert resp.pyquery('.registrant--bar.computed')[0].attrib['style'] == 'left: 30.77%; width: 19.23%;'
resp = resp.click('Jane Doe')
resp = resp.click('Checked period')
assert resp.form['presence_check_type'].value == 'bar-reason'
resp.form['user_was_present'] = 'False'
resp.form['presence'] = 'False'
resp = resp.form.submit().follow()
assert len(resp.pyquery('.registrant--bar')) == 2
assert len(resp.pyquery('.registrant--bar')) == 3
assert len(resp.pyquery('.registrant--bar.booking')) == 1
assert len(resp.pyquery('.registrant--bar.check.absent')) == 1
assert len(resp.pyquery('.registrant--bar.computed.absent')) == 1
assert resp.pyquery('.registrant--bar span').text() == ''
resp = resp.click('Jane Doe')
resp.form['user_was_present'] = ''
resp = resp.click('Checked period')
resp.form['presence'] = ''
resp = resp.form.submit().follow()
assert len(resp.pyquery('.registrant--bar')) == 1
assert len(resp.pyquery('.registrant--bar.booking')) == 1
assert resp.pyquery('.registrant--bar span').text() == ''
# event is checked
event.checked = True
event.save()
assert agenda.disable_check_update is False
resp = app.get('/manage/agendas/%s/day/%d/%d/%d/' % (agenda.pk, today.year, today.month, today.day))
assert '/manage/agendas/%s/bookings/%s/check' % (agenda.pk, booking.pk) in resp
app.get('/manage/agendas/%s/bookings/%s/check' % (agenda.pk, booking.pk), status=200)
def test_manager_partial_bookings_check_future_events(app, admin_user, freezer):
# event check is locked
event.check_locked = True
event.save()
resp = app.get('/manage/agendas/%s/day/%d/%d/%d/' % (agenda.pk, today.year, today.month, today.day))
assert '/manage/agendas/%s/bookings/%s/check' % (agenda.pk, booking.pk) not in resp
app.get('/manage/agendas/%s/bookings/%s/check' % (agenda.pk, booking.pk), status=404)
# now disable check update
event.check_locked = False
event.save()
agenda.disable_check_update = True
agenda.save()
resp = app.get('/manage/agendas/%s/day/%d/%d/%d/' % (agenda.pk, today.year, today.month, today.day))
assert '/manage/agendas/%s/bookings/%s/check' % (agenda.pk, booking.pk) not in resp
app.get('/manage/agendas/%s/bookings/%s/check' % (agenda.pk, booking.pk), status=404)
def test_manager_partial_bookings_multiple_checks(app, admin_user):
agenda = Agenda.objects.create(label='Foo bar', kind='events', partial_bookings=True)
start_datetime = make_aware(datetime.datetime(2023, 5, 2, 8, 0))
event = Event.objects.create(
label='Event', start_datetime=start_datetime, end_time=datetime.time(18, 00), places=10, agenda=agenda
)
booking = Booking.objects.create(
Booking.objects.create(
user_external_id='xxx',
user_first_name='Jane',
user_last_name='Doe',
start_time=datetime.time(11, 00),
end_time=datetime.time(13, 30),
start_time=datetime.time(9, 00),
end_time=datetime.time(18, 00),
event=event,
)
app = login(app)
day = start_datetime.date()
resp = app.get('/manage/agendas/%s/day/%d/%d/%d/' % (agenda.pk, day.year, day.month, day.day))
today = start_datetime.date()
resp = app.get('/manage/agendas/%s/day/%d/%d/%d/' % (agenda.pk, today.year, today.month, today.day))
resp = resp.click('Booked period')
assert 'Add second booking check' not in resp.text
resp.form['start_time'] = '09:30'
resp.form['end_time'] = '12:00'
resp.form['presence'] = 'True'
resp = resp.form.submit().follow()
assert len(resp.pyquery('.registrant--bar')) == 3
assert len(resp.pyquery('.registrant--bar.check')) == 1
assert len(resp.pyquery('.registrant--bar.computed')) == 1
assert resp.pyquery('.registrant--bar.check')[0].findall('time')[0].text == '09:30'
assert resp.pyquery('.registrant--bar.check')[0].findall('time')[1].text == '12:00'
resp = resp.click('Checked period')
resp = resp.click('Add second booking check')
resp.form['start_time'] = '12:30'
resp.form['end_time'] = '17:30'
resp.form['presence'] = 'False'
resp = resp.form.submit().follow()
assert len(resp.pyquery('.registrant--bar')) == 5
assert len(resp.pyquery('.registrant--bar.check')) == 2
assert len(resp.pyquery('.registrant--bar.computed')) == 2
assert resp.pyquery('.registrant--bar.check')[1].findall('time')[0].text == '12:30'
assert resp.pyquery('.registrant--bar.check')[1].findall('time')[1].text == '17:30'
resp = resp.click('Checked period', index=1)
assert 'Add second booking check' not in resp.text
resp.form['start_time'] = '11:30'
resp = resp.form.submit()
assert 'Booking check hours overlap existing check.' in resp.text
resp.form['start_time'] = '12:30'
resp.form['presence'] = ''
resp = resp.form.submit().follow()
assert len(resp.pyquery('.registrant--bar')) == 3
assert len(resp.pyquery('.registrant--bar.check')) == 1
assert len(resp.pyquery('.registrant--bar.computed')) == 1
assert resp.pyquery('.registrant--bar.check')[0].findall('time')[0].text == '09:30'
assert resp.pyquery('.registrant--bar.check')[0].findall('time')[1].text == '12:00'
@mock.patch('chrono.manager.forms.get_agenda_check_types')
def test_manager_partial_bookings_check_subscription(check_types, app, admin_user):
check_types.return_value = []
agenda = Agenda.objects.create(label='Foo bar', kind='events', partial_bookings=True)
start_datetime = make_aware(datetime.datetime(2023, 5, 2, 8, 0))
event = Event.objects.create(
label='Event',
start_datetime=start_datetime,
end_time=datetime.time(18, 00),
places=10,
agenda=agenda,
recurrence_days=list(range(1, 8)),
recurrence_end_date=start_datetime + datetime.timedelta(days=30),
)
event.create_all_recurrences()
Subscription.objects.create(
agenda=agenda,
user_external_id='xxx',
user_first_name='Jane',
user_last_name='Doe',
date_start=event.start_datetime,
date_end=event.start_datetime + datetime.timedelta(days=2),
)
app = login(app)
today = start_datetime.date()
resp = app.get('/manage/agendas/%s/day/%d/%d/%d/' % (agenda.pk, today.year, today.month, today.day))
assert len(resp.pyquery('.registrant--bar')) == 0
assert len(resp.pyquery('.registrant--name a')) == 1
app.get('/manage/agendas/%s/bookings/%s/check' % (agenda.pk, booking.pk), status=200)
freezer.move_to(start_datetime - datetime.timedelta(days=1))
resp = resp.click('Jane Doe')
assert 'Fill with booking start time' not in resp.text
assert 'absence_check_type' not in resp.form.fields
assert resp.form['presence'].options == [
('', True, None),
('True', False, None),
] # no 'False' option
assert not Booking.objects.exists()
resp.form['start_time'] = '10:00'
resp.form['end_time'] = '16:00'
resp.form['presence'] = 'True'
resp = resp.form.submit().follow()
resp = app.get('/manage/agendas/%s/day/%d/%d/%d/' % (agenda.pk, day.year, day.month, day.day))
assert len(resp.pyquery('.registrant--name a')) == 0
app.get('/manage/agendas/%s/bookings/%s/check' % (agenda.pk, booking.pk), status=404)
assert len(resp.pyquery('.registrant--bar')) == 2
assert len(resp.pyquery('.registrant--bar.check.present')) == 1
assert len(resp.pyquery('.registrant--bar.computed.present')) == 1
assert resp.pyquery('.registrant--bar.check time')[0].text == '10:00'
assert resp.pyquery('.registrant--bar.check time')[1].text == '16:00'
agenda.enable_check_for_future_events = True
agenda.save()
booking = Booking.objects.get()
assert booking.user_external_id == 'xxx'
assert booking.user_first_name == 'Jane'
assert booking.user_last_name == 'Doe'
resp = app.get('/manage/agendas/%s/day/%d/%d/%d/' % (agenda.pk, day.year, day.month, day.day))
assert len(resp.pyquery('.registrant--name a')) == 1
app.get('/manage/agendas/%s/bookings/%s/check' % (agenda.pk, booking.pk), status=200)
resp = resp.click('Checked period')
assert 'Fill with booking start time' not in resp.text
assert 'absence_check_type' not in resp.form.fields
assert resp.form['presence'].options == [
('', False, None),
('True', True, None),
] # no 'False' option
resp.form['presence'] = ''
resp = resp.form.submit().follow()
assert len(resp.pyquery('.registrant--bar')) == 0
@mock.patch('chrono.manager.forms.get_agenda_check_types')
@ -299,30 +616,27 @@ def test_manager_partial_bookings_check_filters(check_types, app, admin_user):
end_time=datetime.time(13, 30),
event=event,
)
Booking.objects.create(
booking = Booking.objects.create(
user_external_id='user:2',
user_first_name='User',
user_last_name='Present Vegan',
start_time=datetime.time(8, 00),
end_time=datetime.time(10, 00),
user_check_start_time=datetime.time(8, 00),
user_check_end_time=datetime.time(10, 00),
event=event,
extra_data={'menu': 'vegan'},
user_was_present=True,
)
Booking.objects.create(
booking.mark_user_presence(start_time=datetime.time(8, 00), end_time=datetime.time(10, 00))
booking = Booking.objects.create(
user_external_id='user:3',
user_first_name='User',
user_last_name='Absent Meat Foo Reason',
start_time=datetime.time(12, 00),
end_time=datetime.time(14, 00),
user_check_start_time=datetime.time(12, 30),
user_check_end_time=datetime.time(14, 30),
event=event,
extra_data={'menu': 'meat'},
user_was_present=False,
user_check_type_slug='foo-reason',
)
booking.mark_user_absence(
check_type_slug='foo-reason', start_time=datetime.time(12, 30), end_time=datetime.time(14, 30)
)
Subscription.objects.create(
agenda=agenda,
@ -352,9 +666,8 @@ def test_manager_partial_bookings_check_filters(check_types, app, admin_user):
'User Present Vegan',
]
# one registrant has not booked, no bar is shown and no booking check link
# one registrant has not booked, no bar is shown
assert len(resp.pyquery('.registrant--bar.booking')) == 3
assert len(resp.pyquery('.registrant--name a')) == 3
resp = app.get(url, params={'booking-status': 'booked'})
assert [x.text_content() for x in resp.pyquery('.registrant--name')] == [
@ -376,6 +689,106 @@ def test_manager_partial_bookings_check_filters(check_types, app, admin_user):
assert [x.text_content() for x in resp.pyquery('.registrant--name')] == ['User Absent Meat Foo Reason']
def test_manager_partial_bookings_event_checked(app, admin_user):
agenda = Agenda.objects.create(
label='Foo bar',
kind='events',
partial_bookings=True,
)
start_datetime = make_aware(datetime.datetime(2023, 5, 2, 8, 0))
event = Event.objects.create(
label='Event', start_datetime=start_datetime, end_time=datetime.time(18, 00), places=10, agenda=agenda
)
login(app)
today = start_datetime.date()
url = '/manage/agendas/%s/day/%d/%d/%d/' % (agenda.pk, today.year, today.month, today.day)
resp = app.get(url)
assert 'Mark the event as checked' not in resp
for i in range(8):
booking = Booking.objects.create(
event=event,
start_time=datetime.time(8, 00),
end_time=datetime.time(10, 00),
)
if i < 3:
booking.mark_user_presence(start_time=datetime.time(8, 00), end_time=datetime.time(10, 00))
elif i < 7:
booking.mark_user_absence(start_time=datetime.time(8, 00), end_time=datetime.time(10, 00))
resp = app.get(url)
assert 'Mark the event as checked' in resp
assert event.checked is False
resp = app.get('/manage/agendas/%s/settings' % agenda.id)
assert 'checked tag' not in resp
resp = app.get('/manage/agendas/%s/day/%d/%d/%d/' % (agenda.pk, today.year, today.month, today.day))
assert '<span class="checked tag">Checked</span>' not in resp
assert 'check-locked' not in resp
assert 'invoiced' not in resp
token = resp.context['csrf_token']
resp = app.post(
'/manage/agendas/%s/events/%s/checked' % (agenda.pk, event.pk),
params={'csrfmiddlewaretoken': token},
)
event.refresh_from_db()
assert event.checked is True
resp = resp.follow()
assert 'Mark the event as checked' not in resp
resp = app.get('/manage/agendas/%s/settings' % agenda.id)
assert 'checked tag' in resp
for booking in Booking.objects.filter(user_checks__isnull=True):
booking.mark_user_presence(start_time=datetime.time(8, 00), end_time=datetime.time(10, 00))
resp = app.get('/manage/agendas/%s/day/%d/%d/%d/' % (agenda.pk, today.year, today.month, today.day))
assert '<span class="checked tag">Checked</span>' in resp
assert 'check-locked' not in resp
assert 'invoiced' not in resp
# event not in past
agenda.disable_check_update = False
agenda.save()
assert agenda.enable_check_for_future_events is False
today = now().date()
event.start_datetime = make_aware(
datetime.datetime(today.year, today.month, today.day, 8, 0)
) + datetime.timedelta(days=1)
event.save()
app.post(
'/manage/agendas/%s/events/%s/checked' % (agenda.pk, event.pk),
params={'csrfmiddlewaretoken': token},
status=404,
)
# not in past, but check for future events is enabled
agenda.enable_check_for_future_events = True
agenda.save()
app.post(
'/manage/agendas/%s/events/%s/checked' % (agenda.pk, event.pk),
params={'csrfmiddlewaretoken': token},
status=302,
)
# event check is locked
event.checked = False
event.check_locked = True
event.save()
today = event.start_datetime.date()
resp = app.get('/manage/agendas/%s/day/%d/%d/%d/' % (agenda.pk, today.year, today.month, today.day))
assert '<span class="check-locked tag">Check locked</span>' in resp
assert 'invoiced' not in resp
app.post(
'/manage/agendas/%s/events/%s/checked' % (agenda.pk, event.pk),
params={'csrfmiddlewaretoken': token},
status=404,
)
# event check is locked and envent is invoiced
event.invoiced = True
event.save()
resp = app.get('/manage/agendas/%s/day/%d/%d/%d/' % (agenda.pk, today.year, today.month, today.day))
assert 'check-locked' not in resp
assert '<span class="invoiced tag">Invoiced</span>' in resp
def test_manager_partial_bookings_month_view(app, admin_user, freezer):
agenda = Agenda.objects.create(label='Foo bar', kind='events', partial_bookings=True)
start_datetime = make_aware(datetime.datetime(2023, 5, 2, 8, 0))
@ -398,28 +811,24 @@ def test_manager_partial_bookings_month_view(app, admin_user, freezer):
end_time=datetime.time(13, 30),
event=e,
)
Booking.objects.create(
booking = Booking.objects.create(
user_external_id='user:2',
user_first_name='User',
user_last_name='Present',
start_time=datetime.time(8, 00),
end_time=datetime.time(10, 00),
user_check_start_time=datetime.time(8, 00),
user_check_end_time=datetime.time(10, 00),
event=event,
user_was_present=True,
)
Booking.objects.create(
booking.mark_user_presence(start_time=datetime.time(8, 00), end_time=datetime.time(10, 00))
booking = Booking.objects.create(
user_external_id='user:3',
user_first_name='User',
user_last_name='Absent',
start_time=datetime.time(12, 00),
end_time=datetime.time(14, 00),
user_check_start_time=datetime.time(12, 30),
user_check_end_time=datetime.time(14, 30),
event=event,
user_was_present=False,
)
booking.mark_user_absence(start_time=datetime.time(12, 30), end_time=datetime.time(14, 30))
Subscription.objects.create(
agenda=agenda,
user_external_id='user:1',

View File

@ -45,6 +45,5 @@ EXCEPTIONS_SOURCES = {}
SITE_BASE_URL = 'https://example.com'
SHARED_CUSTODY_ENABLED = True
LEGACY_FILLSLOTS_ENABLED = True
PASSWORD_HASHERS = ['django.contrib.auth.hashers.MD5PasswordHasher']

View File

@ -17,6 +17,7 @@ from chrono.agendas.models import (
AgendaNotificationsSettings,
AgendaReminderSettings,
Booking,
BookingCheck,
Category,
Desk,
Event,
@ -3855,6 +3856,7 @@ def test_agenda_event_overlaps_recurring():
assert agenda.event_overlaps(**event_kwargs) is False
event = Event.objects.create(agenda=agenda, places=1, **event_kwargs)
event.create_all_recurrences()
assert agenda.event_overlaps(**event_kwargs) is True
assert agenda.event_overlaps(instance=event, **event_kwargs) is False
@ -3916,3 +3918,324 @@ def test_agenda_event_overlaps_recurring():
# normal event, after not on a recurrence day
event_kwargs['start_datetime'] = make_aware(datetime.datetime(2023, 5, 10, 14, 00))
assert agenda.event_overlaps(**event_kwargs) is False
@pytest.mark.parametrize(
'start_time, user_check_start_time, tolerance, unit, expected',
[
# no check
(None, None, 0, 'hour', None),
(None, None, 0, 'half_hour', None),
(None, None, 0, 'quarter', None),
(None, None, 0, 'minutes', None),
# hour unit - no booking
(None, datetime.time(7, 50), 10, 'hour', datetime.time(8, 0)),
(None, datetime.time(7, 49), 10, 'hour', datetime.time(7, 0)),
(None, datetime.time(7, 50), 0, 'hour', datetime.time(7, 0)),
# hour unit - with booking
(datetime.time(8, 0), datetime.time(7, 50), 10, 'hour', datetime.time(8, 0)),
(datetime.time(8, 0), datetime.time(7, 49), 10, 'hour', datetime.time(7, 0)),
(datetime.time(8, 0), datetime.time(8, 30), 10, 'hour', datetime.time(8, 0)),
(datetime.time(8, 0), datetime.time(7, 50), 0, 'hour', datetime.time(7, 0)),
# half_hour unit - no booking
(None, datetime.time(7, 50), 10, 'half_hour', datetime.time(8, 0)),
(None, datetime.time(7, 49), 10, 'half_hour', datetime.time(7, 30)),
(None, datetime.time(7, 50), 0, 'half_hour', datetime.time(7, 30)),
# half_hour unit - with booking
(datetime.time(8, 0), datetime.time(7, 50), 10, 'half_hour', datetime.time(8, 0)),
(datetime.time(8, 0), datetime.time(7, 49), 10, 'half_hour', datetime.time(7, 30)),
(datetime.time(8, 0), datetime.time(8, 30), 10, 'half_hour', datetime.time(8, 0)),
(datetime.time(8, 0), datetime.time(7, 50), 0, 'half_hour', datetime.time(7, 30)),
# quarter unit - no booking
(None, datetime.time(7, 50), 10, 'quarter', datetime.time(8, 0)),
(None, datetime.time(7, 49), 10, 'quarter', datetime.time(7, 45)),
(None, datetime.time(7, 50), 0, 'quarter', datetime.time(7, 45)),
# quarter unit - with booking
(datetime.time(8, 0), datetime.time(7, 50), 10, 'quarter', datetime.time(8, 0)),
(datetime.time(8, 0), datetime.time(7, 49), 10, 'quarter', datetime.time(7, 45)),
(datetime.time(8, 0), datetime.time(8, 30), 10, 'quarter', datetime.time(8, 0)),
(datetime.time(8, 0), datetime.time(7, 50), 0, 'quarter', datetime.time(7, 45)),
# minute unit - no booking
(None, datetime.time(7, 50), 0, 'minute', datetime.time(7, 50)),
# minute unit - with booking
(datetime.time(8, 0), datetime.time(7, 50), 0, 'minute', datetime.time(7, 50)),
(datetime.time(8, 0), datetime.time(8, 5), 0, 'minute', datetime.time(8, 0)),
],
)
def test_booking_get_computed_start_time(start_time, user_check_start_time, tolerance, unit, expected):
agenda = Agenda.objects.create(
label='Agenda', kind='events', invoicing_unit=unit, invoicing_tolerance=tolerance
)
event = Event.objects.create(
agenda=agenda,
start_datetime=make_aware(datetime.datetime(2023, 5, 1, 14, 00)),
places=10,
)
booking = Booking.objects.create(event=event, start_time=start_time)
booking.mark_user_presence(start_time=user_check_start_time)
assert booking.user_check.get_computed_start_time() == expected
@pytest.mark.parametrize(
'end_time, user_check_end_time, tolerance, unit, expected',
[
# no check
(None, None, 0, 'hour', None),
(None, None, 0, 'half_hour', None),
(None, None, 0, 'quarter', None),
(None, None, 0, 'minutes', None),
# hour unit - no booking
(None, datetime.time(17, 10), 10, 'hour', datetime.time(17, 0)),
(None, datetime.time(17, 11), 10, 'hour', datetime.time(18, 0)),
(None, datetime.time(17, 10), 0, 'hour', datetime.time(18, 0)),
# hour unit - with booking
(datetime.time(17, 0), datetime.time(17, 10), 10, 'hour', datetime.time(17, 0)),
(datetime.time(17, 0), datetime.time(17, 11), 10, 'hour', datetime.time(18, 0)),
(datetime.time(17, 0), datetime.time(16, 30), 10, 'hour', datetime.time(17, 0)),
(datetime.time(17, 0), datetime.time(17, 10), 0, 'hour', datetime.time(18, 0)),
# half_hour unit - no booking
(None, datetime.time(17, 10), 10, 'half_hour', datetime.time(17, 0)),
(None, datetime.time(17, 11), 10, 'half_hour', datetime.time(17, 30)),
(None, datetime.time(17, 10), 0, 'half_hour', datetime.time(17, 30)),
# half_hour unit - with booking
(datetime.time(17, 0), datetime.time(17, 10), 10, 'half_hour', datetime.time(17, 0)),
(datetime.time(17, 0), datetime.time(17, 11), 10, 'half_hour', datetime.time(17, 30)),
(datetime.time(17, 0), datetime.time(16, 30), 10, 'half_hour', datetime.time(17, 0)),
(datetime.time(17, 0), datetime.time(17, 10), 0, 'half_hour', datetime.time(17, 30)),
# quarter unit - no booking
(None, datetime.time(17, 10), 10, 'quarter', datetime.time(17, 0)),
(None, datetime.time(17, 11), 10, 'quarter', datetime.time(17, 15)),
(None, datetime.time(17, 10), 0, 'quarter', datetime.time(17, 15)),
# quarter unit - with booking
(datetime.time(17, 0), datetime.time(17, 10), 10, 'quarter', datetime.time(17, 0)),
(datetime.time(17, 0), datetime.time(17, 11), 10, 'quarter', datetime.time(17, 15)),
(datetime.time(17, 0), datetime.time(16, 30), 10, 'quarter', datetime.time(17, 0)),
(datetime.time(17, 0), datetime.time(17, 10), 0, 'quarter', datetime.time(17, 15)),
# minute unit - no booking
(None, datetime.time(17, 10), 0, 'minute', datetime.time(17, 10)),
# minute unit - with booking
(datetime.time(17, 0), datetime.time(17, 10), 0, 'minute', datetime.time(17, 10)),
(datetime.time(17, 0), datetime.time(16, 50), 0, 'minute', datetime.time(17, 0)),
],
)
def test_booking_get_computed_end_time(end_time, user_check_end_time, tolerance, unit, expected):
agenda = Agenda.objects.create(
label='Agenda', kind='events', invoicing_unit=unit, invoicing_tolerance=tolerance
)
event = Event.objects.create(
agenda=agenda,
start_datetime=make_aware(datetime.datetime(2023, 5, 1, 14, 00)),
places=10,
)
booking = Booking.objects.create(event=event, end_time=end_time)
booking.mark_user_presence(end_time=user_check_end_time)
assert booking.user_check.get_computed_end_time() == expected
def test_agenda_refresh_booking_computed_times():
agenda = Agenda.objects.create(
label='Agenda',
kind='events',
partial_bookings=True,
)
agenda2 = Agenda.objects.create(label='other')
assert agenda.invoicing_unit == 'hour'
assert agenda.invoicing_tolerance == 0
event = Event.objects.create(
agenda=agenda,
start_datetime=make_aware(datetime.datetime(2023, 5, 1, 14, 00)),
places=10,
)
booking = Booking.objects.create(
event=event,
start_time=datetime.time(8, 0),
end_time=datetime.time(17, 0),
)
booking.mark_user_presence(start_time=datetime.time(7, 55), end_time=datetime.time(17, 15))
def reset_booking():
booking.user_check.computed_start_time = None
booking.user_check.computed_end_time = None
booking.user_check.save()
def test_booking(success):
agenda.refresh_booking_computed_times()
booking.refresh_from_db()
if success is True:
assert booking.user_check.computed_start_time == datetime.time(7, 0)
assert booking.user_check.computed_end_time == datetime.time(18, 0)
reset_booking()
else:
assert booking.user_check.computed_start_time is booking.user_check.computed_end_time is None
test_booking(True)
# wrong agenda kind
agenda.partial_bookings = False
agenda.save()
test_booking(False)
agenda.partial_bookings = True
agenda.kind = 'meetings'
agenda.save()
test_booking(False)
agenda.kind = 'virtual'
agenda.save()
test_booking(False)
# reset kind
agenda.kind = 'events'
agenda.save()
test_booking(True)
# wrong agenda
event.agenda = agenda2
event.save()
test_booking(False)
# event check locked
event.agenda = agenda
event.check_locked = True
event.save()
test_booking(False)
# event invoiced
event.check_locked = False
event.invoiced = True
event.save()
test_booking(False)
# event cancelled
event.invoiced = False
event.cancelled = True
event.save()
test_booking(False)
# booking cancelled
event.cancelled = False
event.save()
booking.cancellation_datetime = now()
booking.save()
test_booking(False)
# ok
booking.cancellation_datetime = None
booking.save()
test_booking(True)
def test_event_refresh_booking_computed_times():
agenda = Agenda.objects.create(
label='Agenda',
kind='events',
partial_bookings=True,
)
assert agenda.invoicing_unit == 'hour'
assert agenda.invoicing_tolerance == 0
event = Event.objects.create(
agenda=agenda,
start_datetime=make_aware(datetime.datetime(2023, 5, 1, 14, 00)),
places=10,
)
event2 = Event.objects.create(
agenda=agenda,
start_datetime=make_aware(datetime.datetime(2023, 5, 1, 14, 00)),
places=10,
)
booking = Booking.objects.create(
event=event,
start_time=datetime.time(8, 0),
end_time=datetime.time(17, 0),
)
booking.mark_user_presence(start_time=datetime.time(7, 55), end_time=datetime.time(17, 15))
def reset_booking():
booking.user_check.computed_start_time = None
booking.user_check.computed_end_time = None
booking.user_check.save()
def test_booking(success):
event.refresh_booking_computed_times()
booking.refresh_from_db()
if success is True:
assert booking.user_check.computed_start_time == datetime.time(7, 0)
assert booking.user_check.computed_end_time == datetime.time(18, 0)
reset_booking()
else:
assert booking.user_check.computed_start_time is booking.user_check.computed_end_time is None
test_booking(True)
# wrong agenda kind
agenda.partial_bookings = False
agenda.save()
test_booking(False)
agenda.partial_bookings = True
agenda.kind = 'meetings'
agenda.save()
test_booking(False)
agenda.kind = 'virtual'
agenda.save()
test_booking(False)
# reset kind
agenda.kind = 'events'
agenda.save()
test_booking(True)
# wrong event
booking.event = event2
booking.save()
test_booking(False)
# event check locked
booking.event = event
booking.save()
event.check_locked = True
event.save()
test_booking(False)
# event invoiced
event.check_locked = False
event.invoiced = True
event.save()
test_booking(False)
# event cancelled
event.invoiced = False
event.cancelled = True
event.save()
test_booking(False)
# booking cancelled
event.cancelled = False
event.save()
booking.cancellation_datetime = now()
booking.save()
test_booking(False)
# ok
booking.cancellation_datetime = None
booking.save()
test_booking(True)
def test_agenda_booking_user_check_property():
agenda = Agenda.objects.create(label='Agenda', kind='events')
event = Event.objects.create(agenda=agenda, start_datetime=now(), places=10)
booking = Booking.objects.create(event=event)
booking_check = BookingCheck.objects.create(booking=booking, presence=True)
assert booking.user_check == booking_check
BookingCheck.objects.create(booking=booking, presence=False)
booking.refresh_from_db()
with pytest.raises(AttributeError):
# pylint: disable=pointless-statement
booking.user_check

View File

@ -10,6 +10,7 @@ from unittest import mock
import pytest
from django.contrib.auth.models import Group
from django.core.files.base import ContentFile
from django.core.management import CommandError, call_command
from django.test import override_settings
from django.utils.encoding import force_bytes
@ -35,6 +36,8 @@ from chrono.agendas.models import (
from chrono.manager.utils import import_site
from chrono.utils.timezone import make_aware, now
from .test_agendas import ICS_SAMPLE, ICS_SAMPLE_WITH_NO_EVENTS
pytestmark = pytest.mark.django_db
@ -799,6 +802,99 @@ def test_import_export_time_period_exception_source_enabled():
assert source.settings_slug == 'holidays'
@mock.patch('chrono.agendas.models.requests.get')
def test_import_export_time_period_exception_source_remote_ics(mocked_get):
agenda = Agenda.objects.create(label='Foo bar', kind='meetings')
desk = Desk.objects.create(label='Desk', agenda=agenda)
source = TimePeriodExceptionSource.objects.create(desk=desk, ics_url='http://example.com/sample.ics')
mocked_response = mock.Mock()
mocked_response.text = ICS_SAMPLE
mocked_get.return_value = mocked_response
source.refresh_timeperiod_exceptions_from_ics()
assert TimePeriodException.objects.count() == 2
output = get_output_of_command('export_site')
payload = json.loads(output)
Agenda.objects.all().delete()
assert not TimePeriodExceptionSource.objects.exists()
assert not TimePeriodException.objects.exists()
import_site(payload)
source = TimePeriodExceptionSource.objects.get()
assert source.timeperiodexception_set.count() == 2
assert TimePeriodException.objects.count() == 2
# import again changes nothing
import_site(payload)
assert TimePeriodExceptionSource.objects.count() == 1
assert TimePeriodException.objects.count() == 2
# empty remote ics
mocked_response.text = ICS_SAMPLE_WITH_NO_EVENTS
Agenda.objects.all().delete()
import_site(payload)
assert TimePeriodExceptionSource.objects.count() == 1
assert TimePeriodException.objects.count() == 0
@mock.patch('chrono.agendas.models.requests.get')
def test_import_export_time_period_exception_source_ics_file(mocked_get):
agenda = Agenda.objects.create(label='Foo bar', kind='meetings')
desk = Desk.objects.create(label='Desk', agenda=agenda)
source = TimePeriodExceptionSource.objects.create(
desk=desk, ics_filename='sample.ics', ics_file=ContentFile(ICS_SAMPLE, name='sample.ics')
)
source.refresh_timeperiod_exceptions_from_ics()
assert TimePeriodException.objects.count() == 2
output = get_output_of_command('export_site')
payload = json.loads(output)
Agenda.objects.all().delete()
assert not TimePeriodExceptionSource.objects.exists()
assert not TimePeriodException.objects.exists()
import_site(payload)
source = TimePeriodExceptionSource.objects.get()
assert source.timeperiodexception_set.count() == 2
assert TimePeriodException.objects.count() == 2
# import again changes nothing
import_site(payload)
assert TimePeriodExceptionSource.objects.count() == 1
assert TimePeriodException.objects.count() == 2
@override_settings(
EXCEPTIONS_SOURCES={
'holidays': {'class': 'workalendar.europe.France', 'label': 'Holidays'},
}
)
def test_import_export_time_period_exception_legacy_file():
agenda = Agenda.objects.create(label='Foo bar', kind='events')
desk = Desk.objects.create(agenda=agenda, slug='_exceptions_holder')
desk.import_timeperiod_exceptions_from_settings()
output = get_output_of_command('export_site')
payload = json.loads(output)
source = payload['agendas'][0]['exceptions_desk']['exception_sources'][0]
del source['ics_file']
del source['ics_filename']
del source['ics_url']
agenda.delete()
assert not TimePeriodExceptionSource.objects.exists()
import_site(payload)
assert TimePeriodExceptionSource.objects.count() == 1
def test_import_export_do_not_duplicate_timeperiod_and_exceptions():
agenda = Agenda.objects.create(label='Foo bar', kind='meetings')
desk = Desk.objects.create(slug='test', agenda=agenda)

View File

@ -390,3 +390,54 @@ def test_migration_convert_week_days(transactional_db):
assert Event.objects.get(slug='all').recurrence_days == [1, 2, 3, 4, 5, 6, 7]
assert SharedCustodyRule.objects.get().days == [1, 5, 7]
def test_migration_booking_check_data(transactional_db):
app = 'agendas'
migrate_from = [(app, '0161_add_booking_check_model')]
migrate_to = [(app, '0162_migrate_booking_check_data')]
executor = MigrationExecutor(connection)
old_apps = executor.loader.project_state(migrate_from).apps
executor.migrate(migrate_from)
Agenda = old_apps.get_model(app, 'Agenda')
Event = old_apps.get_model(app, 'Event')
Booking = old_apps.get_model(app, 'Booking')
agenda = Agenda.objects.create(label='Foo', kind='events')
event = Event.objects.create(start_datetime=now(), places=10, agenda=agenda, slug='event')
not_checked = Booking.objects.create(event=event)
present = Booking.objects.create(event=event, user_was_present=True)
absent = Booking.objects.create(event=event, user_was_present=False)
with_check_type = Booking.objects.create(
event=event,
user_was_present=False,
user_check_type_slug='xxx',
user_check_type_label='XXX',
user_check_start_time=datetime.time(12, 0),
user_check_end_time=datetime.time(14, 0),
computed_start_time=datetime.time(12, 30),
computed_end_time=datetime.time(14, 30),
)
executor = MigrationExecutor(connection)
executor.migrate(migrate_to)
executor.loader.build_graph()
apps = executor.loader.project_state(migrate_to).apps
Booking = apps.get_model(app, 'Booking')
assert not hasattr(Booking.objects.get(pk=not_checked.pk), 'user_check')
assert Booking.objects.get(pk=present.pk).user_check.presence is True
assert Booking.objects.get(pk=absent.pk).user_check.presence is False
with_check_type = Booking.objects.get(pk=with_check_type.pk)
assert with_check_type.user_check.presence is False
assert with_check_type.user_check.type_slug == 'xxx'
assert with_check_type.user_check.type_label == 'XXX'
assert with_check_type.user_check.start_time == datetime.time(12, 0)
assert with_check_type.user_check.end_time == datetime.time(14, 0)
assert with_check_type.computed_start_time == datetime.time(12, 30)
assert with_check_type.computed_end_time == datetime.time(14, 30)

View File

@ -23,7 +23,8 @@ deps =
WebTest
mock<4
httmock
pylint
pylint<3
astroid<3
pylint-django
django-webtest
pytz
@ -34,6 +35,7 @@ deps =
weasyprint<52
django32: django>=3.2,<3.3
django32: psycopg2-binary>=2.9
djangorestframework>=3.12,<3.13 # matching debian bullseye
codestyle: pre-commit
git+https://git.entrouvert.org/publik-django-templatetags.git
responses
@ -53,7 +55,8 @@ deps =
WebTest
mock<4
httmock
pylint
pylint<3
astroid<3
pylint-django
django-webtest
pytz