Compare commits
126 Commits
b908f7c026
...
c38f8e9280
Author | SHA1 | Date |
---|---|---|
Lauréline Guérin | c38f8e9280 | |
Lauréline Guérin | a9d1287726 | |
Lauréline Guérin | 0ea056dcd5 | |
Lauréline Guérin | 3cef873ce4 | |
Lauréline Guérin | 966d93829f | |
Lauréline Guérin | 03f9172c98 | |
Lauréline Guérin | 176d23aa4b | |
Lauréline Guérin | 9331b06e04 | |
Lauréline Guérin | 3f8146c092 | |
Lauréline Guérin | f6a0b58167 | |
Lauréline Guérin | e6db17f145 | |
Lauréline Guérin | 84581ed02e | |
Lauréline Guérin | 4f13f936e2 | |
Frédéric Péters | 7df4de695d | |
Yann Weber | 095057839a | |
Frédéric Péters | 69f9877ba5 | |
Lauréline Guérin | 895758c70c | |
Lauréline Guérin | 3071fab8f8 | |
Lauréline Guérin | a4e5721dad | |
Lauréline Guérin | 068e5fe467 | |
Yann Weber | a36369ae1c | |
Yann Weber | 3bfa450f97 | |
Yann Weber | 917c918422 | |
Frédéric Péters | 9a1b37a5f7 | |
Frédéric Péters | 5204fcda47 | |
Yann Weber | 9945568a57 | |
Yann Weber | d428ef8385 | |
Frédéric Péters | 9c660e7a1e | |
Yann Weber | 47e7558298 | |
Yann Weber | f2285f7880 | |
Benjamin Dauvergne | 5a9379a7b8 | |
Benjamin Dauvergne | f749c5e9cb | |
Benjamin Dauvergne | f61d07f586 | |
Yann Weber | 154fe0ccea | |
Yann Weber | 14e7998895 | |
Lauréline Guérin | 8e35a25ad9 | |
Pierre Ducroquet | 5db20c9434 | |
Frédéric Péters | eeca5783dd | |
Lauréline Guérin | 3c052b467b | |
Frédéric Péters | 888c0638d0 | |
Lauréline Guérin | 05aa65e72a | |
Valentin Deniaud | e83bfee4c3 | |
Valentin Deniaud | 7bea1c912b | |
Valentin Deniaud | 526f255ee5 | |
Valentin Deniaud | 1740ebe572 | |
Lauréline Guérin | 698bbfc7a4 | |
Valentin Deniaud | d02210ab66 | |
Nicolas Roche | c4ecd1900a | |
Valentin Deniaud | 7096938cda | |
Lauréline Guérin | ee557adbcc | |
Valentin Deniaud | ce96e674c2 | |
Valentin Deniaud | 5501b88c34 | |
Valentin Deniaud | 440d02d505 | |
Valentin Deniaud | f8748710bc | |
Valentin Deniaud | 6804b08cc6 | |
Benjamin Dauvergne | aad10c71ee | |
Benjamin Dauvergne | 2831272e56 | |
Valentin Deniaud | 14b7de35cc | |
Valentin Deniaud | faccc579c5 | |
Valentin Deniaud | 7182871b9f | |
Valentin Deniaud | 46e6fbcf5b | |
Valentin Deniaud | d9a93ac2e3 | |
Valentin Deniaud | 21cd345c35 | |
Valentin Deniaud | 3161f47cd1 | |
Valentin Deniaud | b7c5d4f675 | |
Valentin Deniaud | 8a8bea24a6 | |
Thomas Jund | 9b27620a89 | |
Valentin Deniaud | c4540c245a | |
Valentin Deniaud | db57ef6cf7 | |
Valentin Deniaud | 0afa7b9244 | |
Thomas Jund | 3e478042f6 | |
Benjamin Dauvergne | aff03ffdea | |
Benjamin Dauvergne | 0543594e30 | |
Benjamin Dauvergne | 7fab4c0f41 | |
Benjamin Dauvergne | 5716d6b3dc | |
Benjamin Dauvergne | eafa816253 | |
Benjamin Dauvergne | d6a5861876 | |
Benjamin Dauvergne | 2d8912c0a3 | |
Lauréline Guérin | 3dac9ed0fb | |
Lauréline Guérin | 63a575f303 | |
Lauréline Guérin | 678ac6c1de | |
Thomas NOËL | 6a411b1859 | |
Lauréline Guérin | 4291cc73db | |
Lauréline Guérin | e4864ea95b | |
Lauréline Guérin | 9d1c33970c | |
Frédéric Péters | 031961ad80 | |
Lauréline Guérin | 5b8419efe5 | |
Lauréline Guérin | cff4ce0861 | |
Lauréline Guérin | 737ba6f0bb | |
Lauréline Guérin | 72be0166f3 | |
Lauréline Guérin | dae40958f4 | |
Lauréline Guérin | 05703dddb1 | |
Frédéric Péters | 78928bc760 | |
Thomas NOËL | a548753f2a | |
Emmanuel Cazenave | 8a7f83a02d | |
Emmanuel Cazenave | 36d1ea9ec0 | |
Lauréline Guérin | 368c239218 | |
Lauréline Guérin | 81aa0d95fc | |
Lauréline Guérin | 61a6bc35bb | |
Lauréline Guérin | b15e4a3c7c | |
Lauréline Guérin | f5e3f625d2 | |
Lauréline Guérin | a940ee3961 | |
Lauréline Guérin | 9defbefe1e | |
Emmanuel Cazenave | cba5520541 | |
Valentin Deniaud | a25a8e6ef1 | |
Valentin Deniaud | 2a56ba5432 | |
Valentin Deniaud | 2e22706c08 | |
Valentin Deniaud | 81e93dd4c5 | |
Valentin Deniaud | 3cb80d478a | |
Valentin Deniaud | ec497c66d9 | |
Valentin Deniaud | bcae843c0d | |
Valentin Deniaud | 6d31c85dd7 | |
Frédéric Péters | 17cddfbd4a | |
Frédéric Péters | 57a67073e3 | |
Frédéric Péters | ca32dc3a36 | |
Valentin Deniaud | fb7d928206 | |
Valentin Deniaud | 9b315f4be3 | |
Valentin Deniaud | 1fd95681fe | |
Frédéric Péters | fc86701ab2 | |
Valentin Deniaud | f34af55592 | |
Valentin Deniaud | 2cae3b7724 | |
Valentin Deniaud | 23a1b70dd7 | |
Valentin Deniaud | a13003cdec | |
Frédéric Péters | 6aa243817e | |
Lauréline Guérin | 15b2b26c08 | |
Lauréline Guérin | 918903fc8c |
|
@ -0,0 +1,26 @@
|
|||
# chrono - agendas system
|
||||
# Copyright (C) 2020 Entr'ouvert
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it
|
||||
# under the terms of the GNU Affero General Public License as published
|
||||
# by the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# 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/>.
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from chrono.agendas.models import Lease
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Clean expired leases and related bookings and events'
|
||||
|
||||
def handle(self, **options):
|
||||
Lease.clean()
|
|
@ -59,6 +59,7 @@ class Command(BaseCommand):
|
|||
event__start_datetime__lte=starts_before,
|
||||
event__start_datetime__gte=starts_after,
|
||||
in_waiting_list=False,
|
||||
primary_booking__isnull=True,
|
||||
**{f'{msg_type}_reminder_datetime__isnull': True},
|
||||
).select_related('event', 'event__agenda', 'event__agenda__reminder_settings')
|
||||
|
||||
|
|
|
@ -18,7 +18,7 @@ import copy
|
|||
from urllib.parse import urljoin
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.mail import send_mail
|
||||
from django.core.mail import EmailMultiAlternatives
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db.transaction import atomic
|
||||
from django.template.loader import render_to_string
|
||||
|
@ -72,4 +72,12 @@ class Command(BaseCommand):
|
|||
with atomic():
|
||||
setattr(event, status + '_notification_timestamp', timestamp)
|
||||
event.save()
|
||||
send_mail(subject, body, settings.DEFAULT_FROM_EMAIL, recipients, html_message=html_body)
|
||||
mail_msg = EmailMultiAlternatives(
|
||||
subject=subject,
|
||||
body=body,
|
||||
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||
to=[settings.DEFAULT_FROM_EMAIL],
|
||||
bcc=recipients,
|
||||
)
|
||||
mail_msg.attach_alternative(html_body, 'text/html')
|
||||
mail_msg.send()
|
||||
|
|
|
@ -11,7 +11,7 @@ class Migration(migrations.Migration):
|
|||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='meetingtype',
|
||||
options={'ordering': ['duration', 'label']},
|
||||
options={'ordering': ['duration', 'label'], 'verbose_name': 'Meeting type'},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='timeperiodexception',
|
||||
|
|
|
@ -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),
|
||||
),
|
||||
]
|
|
@ -0,0 +1,40 @@
|
|||
# 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',
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'ordering': ['start_time'],
|
||||
},
|
||||
),
|
||||
]
|
|
@ -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),
|
||||
]
|
|
@ -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',
|
||||
),
|
||||
]
|
|
@ -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'
|
||||
),
|
||||
),
|
||||
]
|
|
@ -0,0 +1,20 @@
|
|||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('agendas', '0164_alter_bookingcheck_booking'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='booking',
|
||||
name='previous_state',
|
||||
field=models.CharField(max_length=10, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='booking',
|
||||
name='request_uuid',
|
||||
field=models.UUIDField(editable=False, null=True),
|
||||
),
|
||||
]
|
|
@ -0,0 +1,41 @@
|
|||
# Generated by Django 3.2.18 on 2023-08-22 08:19
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
from chrono.agendas.models import get_lease_expiration
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('agendas', '0165_booking_revert'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Lease',
|
||||
fields=[
|
||||
(
|
||||
'id',
|
||||
models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
('lock_code', models.CharField(max_length=64, verbose_name='Lock code')),
|
||||
(
|
||||
'expiration_datetime',
|
||||
models.DateTimeField(verbose_name='Lease expiration time', default=get_lease_expiration),
|
||||
),
|
||||
(
|
||||
'booking',
|
||||
models.OneToOneField(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to='agendas.booking',
|
||||
verbose_name='Booking',
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Lease',
|
||||
'verbose_name_plural': 'Leases',
|
||||
},
|
||||
),
|
||||
]
|
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 3.2.21 on 2023-11-22 09:59
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('agendas', '0166_lease'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddConstraint(
|
||||
model_name='bookingcheck',
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=('booking', 'presence'), name='max_2_checks_on_booking'
|
||||
),
|
||||
),
|
||||
]
|
|
@ -0,0 +1,17 @@
|
|||
# Generated by Django 3.2.21 on 2023-12-06 09:32
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('agendas', '0167_bookingcheck_max_2_checks_on_booking'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='booking',
|
||||
name='from_recurring_fillslots',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
|
@ -0,0 +1,37 @@
|
|||
# Generated by Django 3.2.16 on 2023-12-22 08:46
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('agendas', '0168_booking_from_recurring_fillslots'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='booking',
|
||||
name='absence_callback_url',
|
||||
field=models.URLField(blank=True, max_length=500),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='booking',
|
||||
name='backoffice_url',
|
||||
field=models.URLField(blank=True, max_length=500),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='booking',
|
||||
name='cancel_callback_url',
|
||||
field=models.URLField(blank=True, max_length=500),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='booking',
|
||||
name='form_url',
|
||||
field=models.URLField(blank=True, max_length=500),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='booking',
|
||||
name='presence_callback_url',
|
||||
field=models.URLField(blank=True, max_length=500),
|
||||
),
|
||||
]
|
|
@ -0,0 +1,25 @@
|
|||
# Generated by Django 3.2.18 on 2024-01-22 10:55
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('agendas', '0169_urlfield_maxlength_increase'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='agenda',
|
||||
name='events_type',
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name='agendas',
|
||||
to='agendas.eventstype',
|
||||
verbose_name='Events type',
|
||||
),
|
||||
),
|
||||
]
|
|
@ -0,0 +1,118 @@
|
|||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('snapshot', '0002_snapshot_models'),
|
||||
('agendas', '0170_alter_agenda_events_type'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='agenda',
|
||||
name='created_at',
|
||||
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='agenda',
|
||||
name='snapshot',
|
||||
field=models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name='temporary_instance',
|
||||
to='snapshot.agendasnapshot',
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='agenda',
|
||||
name='updated_at',
|
||||
field=models.DateTimeField(auto_now=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='category',
|
||||
name='created_at',
|
||||
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='category',
|
||||
name='snapshot',
|
||||
field=models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name='temporary_instance',
|
||||
to='snapshot.categorysnapshot',
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='category',
|
||||
name='updated_at',
|
||||
field=models.DateTimeField(auto_now=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='eventstype',
|
||||
name='created_at',
|
||||
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='eventstype',
|
||||
name='snapshot',
|
||||
field=models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name='temporary_instance',
|
||||
to='snapshot.eventstypesnapshot',
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='eventstype',
|
||||
name='updated_at',
|
||||
field=models.DateTimeField(auto_now=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='resource',
|
||||
name='created_at',
|
||||
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='resource',
|
||||
name='snapshot',
|
||||
field=models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name='temporary_instance',
|
||||
to='snapshot.resourcesnapshot',
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='resource',
|
||||
name='updated_at',
|
||||
field=models.DateTimeField(auto_now=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='unavailabilitycalendar',
|
||||
name='created_at',
|
||||
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='unavailabilitycalendar',
|
||||
name='snapshot',
|
||||
field=models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name='temporary_instance',
|
||||
to='snapshot.unavailabilitycalendarsnapshot',
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='unavailabilitycalendar',
|
||||
name='updated_at',
|
||||
field=models.DateTimeField(auto_now=True),
|
||||
),
|
||||
]
|
|
@ -76,6 +76,16 @@ from django.utils.translation import gettext
|
|||
from django.utils.translation import gettext_lazy as _
|
||||
from django.utils.translation import ngettext, pgettext_lazy
|
||||
|
||||
from chrono.apps.export_import.models import WithApplicationMixin
|
||||
from chrono.apps.snapshot.models import (
|
||||
AgendaSnapshot,
|
||||
CategorySnapshot,
|
||||
EventsTypeSnapshot,
|
||||
ResourceSnapshot,
|
||||
UnavailabilityCalendarSnapshot,
|
||||
WithSnapshotManager,
|
||||
WithSnapshotMixin,
|
||||
)
|
||||
from chrono.utils.date import get_weekday_index
|
||||
from chrono.utils.db import ArraySubquery, SumCardinality
|
||||
from chrono.utils.interval import Interval, IntervalSet
|
||||
|
@ -173,7 +183,12 @@ TimeSlot = collections.namedtuple(
|
|||
)
|
||||
|
||||
|
||||
class Agenda(models.Model):
|
||||
class Agenda(WithSnapshotMixin, WithApplicationMixin, models.Model):
|
||||
# mark temporarily restored snapshots
|
||||
snapshot = models.ForeignKey(
|
||||
AgendaSnapshot, on_delete=models.CASCADE, null=True, related_name='temporary_instance'
|
||||
)
|
||||
|
||||
label = models.CharField(_('Label'), max_length=150)
|
||||
slug = models.SlugField(_('Identifier'), max_length=160, unique=True)
|
||||
kind = models.CharField(_('Kind'), max_length=20, choices=AGENDA_KINDS, default='events')
|
||||
|
@ -279,7 +294,7 @@ class Agenda(models.Model):
|
|||
events_type = models.ForeignKey(
|
||||
'agendas.EventsType',
|
||||
verbose_name=_('Events type'),
|
||||
on_delete=models.CASCADE,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='agendas',
|
||||
null=True,
|
||||
blank=True,
|
||||
|
@ -309,6 +324,16 @@ class Agenda(models.Model):
|
|||
validators=[MaxValueValidator(59)],
|
||||
)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
application_component_type = 'agendas'
|
||||
application_label_singular = _('Agenda')
|
||||
application_label_plural = _('Agendas')
|
||||
|
||||
objects = WithSnapshotManager()
|
||||
snapshots = WithSnapshotManager(snapshots=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['label']
|
||||
|
||||
|
@ -397,7 +422,7 @@ class Agenda(models.Model):
|
|||
.filter(total=real_agendas.count())
|
||||
)
|
||||
return [
|
||||
MeetingType(duration=mt['duration'], label=mt['label'], slug=mt['slug'])
|
||||
MeetingType(duration=mt['duration'], label=mt['label'], slug=mt['slug'], agenda=self)
|
||||
for mt in queryset.order_by('slug')
|
||||
]
|
||||
|
||||
|
@ -439,6 +464,20 @@ class Agenda(models.Model):
|
|||
raise ValueError()
|
||||
return gcd
|
||||
|
||||
def get_dependencies(self):
|
||||
yield self.view_role
|
||||
yield self.edit_role
|
||||
yield self.category
|
||||
if self.kind == 'virtual':
|
||||
yield from self.real_agendas.all()
|
||||
if self.kind == 'meetings':
|
||||
yield from self.resources.all()
|
||||
for desk in self.desk_set.all():
|
||||
yield from desk.get_dependencies()
|
||||
if self.kind == 'events':
|
||||
yield self.events_type
|
||||
yield from self.desk_set.get().get_dependencies()
|
||||
|
||||
def export_json(self):
|
||||
agenda = {
|
||||
'label': self.label,
|
||||
|
@ -447,6 +486,7 @@ class Agenda(models.Model):
|
|||
'category': self.category.slug if self.category else None,
|
||||
'minimal_booking_delay': self.minimal_booking_delay,
|
||||
'maximal_booking_delay': self.maximal_booking_delay,
|
||||
'anonymize_delay': self.anonymize_delay,
|
||||
'permissions': {
|
||||
'view': self.view_role.name if self.view_role else None,
|
||||
'edit': self.edit_role.name if self.edit_role else None,
|
||||
|
@ -467,7 +507,14 @@ class Agenda(models.Model):
|
|||
agenda['booking_check_filters'] = self.booking_check_filters
|
||||
agenda['event_display_template'] = self.event_display_template
|
||||
agenda['mark_event_checked_auto'] = self.mark_event_checked_auto
|
||||
agenda['disable_check_update'] = self.disable_check_update
|
||||
agenda['enable_check_for_future_events'] = self.enable_check_for_future_events
|
||||
agenda['booking_extra_user_block_template'] = self.booking_extra_user_block_template
|
||||
agenda['events_type'] = self.events_type.slug if self.events_type else None
|
||||
agenda['partial_bookings'] = self.partial_bookings
|
||||
if self.partial_bookings:
|
||||
agenda['invoicing_tolerance'] = self.invoicing_tolerance
|
||||
agenda['invoicing_unit'] = self.invoicing_unit
|
||||
elif self.kind == 'meetings':
|
||||
agenda['meetingtypes'] = [x.export_json() for x in self.meetingtype_set.filter(deleted=False)]
|
||||
agenda['desks'] = [desk.export_json() for desk in self.desk_set.all()]
|
||||
|
@ -478,7 +525,7 @@ class Agenda(models.Model):
|
|||
return agenda
|
||||
|
||||
@classmethod
|
||||
def import_json(cls, data, overwrite=False):
|
||||
def import_json(cls, data, overwrite=False, snapshot=None):
|
||||
data = copy.deepcopy(data)
|
||||
permissions = data.pop('permissions') or {}
|
||||
reminder_settings = data.pop('reminder_settings', None)
|
||||
|
@ -512,7 +559,13 @@ class Agenda(models.Model):
|
|||
data['events_type'] = EventsType.objects.get(slug=data['events_type'])
|
||||
except EventsType.DoesNotExist:
|
||||
raise AgendaImportError(_('Missing "%s" events type') % data['events_type'])
|
||||
agenda, created = cls.objects.update_or_create(slug=data['slug'], defaults=data)
|
||||
slug = data.pop('slug')
|
||||
qs_kwargs = {}
|
||||
if snapshot:
|
||||
qs_kwargs = {'snapshot': snapshot}
|
||||
else:
|
||||
qs_kwargs = {'slug': slug}
|
||||
agenda, created = cls.objects.update_or_create(defaults=data, **qs_kwargs)
|
||||
if overwrite:
|
||||
AgendaReminderSettings.objects.filter(agenda=agenda).delete()
|
||||
if reminder_settings:
|
||||
|
@ -1192,6 +1245,7 @@ class Agenda(models.Model):
|
|||
start_datetime=None,
|
||||
end_datetime=None,
|
||||
user_external_id=None,
|
||||
lock_code=None,
|
||||
):
|
||||
"""Get all occupation state of all possible slots for the given agenda (of
|
||||
its real agendas for a virtual agenda) and the given meeting_type.
|
||||
|
@ -1210,6 +1264,7 @@ class Agenda(models.Model):
|
|||
and bookings sets.
|
||||
If it is excluded, ignore it completely.
|
||||
It if is booked, report the slot as full.
|
||||
If it is booked but match the lock code, report the slot as open.
|
||||
"""
|
||||
resources = resources or []
|
||||
# virtual agendas have one constraint :
|
||||
|
@ -1313,6 +1368,8 @@ class Agenda(models.Model):
|
|||
.order_by('desk_id', 'start_datetime', 'meeting_type__duration')
|
||||
.values_list('desk_id', 'start_datetime', 'meeting_type__duration')
|
||||
)
|
||||
if lock_code:
|
||||
booked_events = booked_events.exclude(booking__lease__lock_code=lock_code)
|
||||
# compute exclusion set by desk from all bookings, using
|
||||
# itertools.groupby() to group them by desk_id
|
||||
bookings.update(
|
||||
|
@ -1346,6 +1403,8 @@ class Agenda(models.Model):
|
|||
.order_by('start_datetime', 'meeting_type__duration')
|
||||
.values_list('start_datetime', 'meeting_type__duration')
|
||||
)
|
||||
if lock_code:
|
||||
booked_events = booked_events.exclude(booking__lease__lock_code=lock_code)
|
||||
# compute exclusion set
|
||||
resources_bookings = IntervalSet.from_ordered(
|
||||
(event_start_datetime, event_start_datetime + datetime.timedelta(minutes=event_duration))
|
||||
|
@ -1371,6 +1430,8 @@ class Agenda(models.Model):
|
|||
.order_by('start_datetime', 'meeting_type__duration')
|
||||
.values_list('start_datetime', 'meeting_type__duration')
|
||||
)
|
||||
if lock_code:
|
||||
booked_events = booked_events.exclude(booking__lease__lock_code=lock_code)
|
||||
# compute exclusion set by desk from all bookings, using
|
||||
# itertools.groupby() to group them by desk_id
|
||||
user_bookings = IntervalSet.from_ordered(
|
||||
|
@ -1587,6 +1648,47 @@ 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,
|
||||
)
|
||||
.prefetch_related('user_checks')
|
||||
.select_related('event__agenda')
|
||||
)
|
||||
to_update = []
|
||||
for booking in bookings_queryset:
|
||||
to_update += booking.refresh_computed_times()
|
||||
if to_update:
|
||||
BookingCheck.objects.bulk_update(to_update, ['computed_start_time', 'computed_end_time'])
|
||||
|
||||
def get_datetimes_url(self):
|
||||
assert self.kind == 'events'
|
||||
return reverse('api-agenda-datetimes', kwargs={'agenda_identifier': self.slug})
|
||||
|
||||
|
||||
class VirtualMember(models.Model):
|
||||
"""Trough model to link virtual agendas to their real agendas.
|
||||
|
@ -1944,6 +2046,10 @@ class MeetingType(models.Model):
|
|||
class Meta:
|
||||
ordering = ['duration', 'label']
|
||||
unique_together = ['agenda', 'slug']
|
||||
verbose_name = _('Meeting type')
|
||||
|
||||
def __str__(self):
|
||||
return '%s - %s' % (self._meta.verbose_name, self.label)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
assert self.agenda.kind != 'virtual', "a meetingtype can't reference a virtual agenda"
|
||||
|
@ -1978,6 +2084,15 @@ class MeetingType(models.Model):
|
|||
|
||||
return new_meeting_type
|
||||
|
||||
def get_datetimes_url(self):
|
||||
return reverse(
|
||||
'api-agenda-meeting-datetimes',
|
||||
kwargs={
|
||||
'agenda_identifier': self.agenda.slug,
|
||||
'meeting_identifier': self.slug,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class Event(models.Model):
|
||||
id = models.BigAutoField(primary_key=True)
|
||||
|
@ -2039,6 +2154,12 @@ class Event(models.Model):
|
|||
full_notification_timestamp = models.DateTimeField(null=True, blank=True)
|
||||
cancelled_notification_timestamp = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
# Store alternate version of booked_places and booked_waiting_list_places,
|
||||
# when an Event queryset is annotated with
|
||||
# Event.annotate_queryset_for_lock_code()
|
||||
unlocked_booked_places = None
|
||||
unlocked_booked_waiting_list_places = None
|
||||
|
||||
class Meta:
|
||||
ordering = ['agenda', 'start_datetime', 'duration', 'label']
|
||||
unique_together = ('agenda', 'slug')
|
||||
|
@ -2104,7 +2225,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
|
||||
|
@ -2127,28 +2249,80 @@ 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:
|
||||
url = booking.presence_callback_url
|
||||
elif booking.user_was_present is False and booking.absence_callback_url:
|
||||
url = booking.absence_callback_url
|
||||
partial_bookings = self.agenda.partial_bookings
|
||||
for user_check in BookingCheck.objects.filter(booking__event=self).select_related('booking'):
|
||||
if user_check.presence is True and user_check.booking.presence_callback_url:
|
||||
url = user_check.booking.presence_callback_url
|
||||
elif user_check.presence is False and user_check.booking.absence_callback_url:
|
||||
url = user_check.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': user_check.presence,
|
||||
'user_check_type_slug': user_check.type_slug,
|
||||
'user_check_type_label': user_check.type_label,
|
||||
}
|
||||
if partial_bookings:
|
||||
payload.update(
|
||||
{
|
||||
'start_time': user_check.start_time.isoformat() if user_check.start_time else None,
|
||||
'end_time': user_check.end_time.isoformat() if user_check.end_time else None,
|
||||
'computed_start_time': user_check.computed_start_time.isoformat()
|
||||
if user_check.computed_start_time
|
||||
else None,
|
||||
'computed_end_time': user_check.computed_end_time.isoformat()
|
||||
if user_check.computed_end_time
|
||||
else None,
|
||||
}
|
||||
)
|
||||
try:
|
||||
response = requests_wrapper.post(url, json=payload, remote_service='auto', timeout=15)
|
||||
if response and not response.ok:
|
||||
logging.error(
|
||||
'error (HTTP %s) notifying checked booking (%s)', response.status_code, booking.id
|
||||
'error (HTTP %s) notifying checked booking (%s)',
|
||||
response.status_code,
|
||||
user_check.booking_id,
|
||||
)
|
||||
except requests.Timeout:
|
||||
logging.error('error (timeout) notifying checked booking (%s)', booking.id)
|
||||
logging.error('error (timeout) notifying checked booking (%s)', user_check.booking_id)
|
||||
except Exception as e: # noqa pylint: disable=broad-except
|
||||
logging.error('error (%s) notifying checked booking (%s)', e, booking.id)
|
||||
logging.error('error (%s) notifying checked booking (%s)', e, user_check.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,
|
||||
).prefetch_related('user_checks')
|
||||
to_update = []
|
||||
for booking in bookings_queryset:
|
||||
booking.event = self # to avoid lots of querysets
|
||||
to_update += booking.refresh_computed_times()
|
||||
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:
|
||||
|
@ -2201,7 +2375,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,
|
||||
),
|
||||
),
|
||||
|
@ -2215,6 +2389,26 @@ class Event(models.Model):
|
|||
)
|
||||
return qs
|
||||
|
||||
@staticmethod
|
||||
def annotate_queryset_for_lock_code(qs, lock_code):
|
||||
qs = qs.annotate(
|
||||
unlocked_booked_places=Count(
|
||||
'booking',
|
||||
filter=Q(
|
||||
booking__cancellation_datetime__isnull=True,
|
||||
)
|
||||
& ~Q(booking__lease__lock_code=lock_code),
|
||||
),
|
||||
unlocked_booked_waiting_list_places=Count(
|
||||
'booking',
|
||||
filter=Q(
|
||||
booking__cancellation_datetime__isnull=False,
|
||||
)
|
||||
& ~Q(booking__lease__lock_code=lock_code),
|
||||
),
|
||||
)
|
||||
return qs
|
||||
|
||||
@staticmethod
|
||||
def annotate_queryset_with_overlaps(qs, other_events=None):
|
||||
if not other_events:
|
||||
|
@ -2320,10 +2514,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)),
|
||||
|
@ -2331,13 +2529,36 @@ class Event(models.Model):
|
|||
notchecked_count=Coalesce(Subquery(notchecked_count, output_field=IntegerField()), Value(0)),
|
||||
)
|
||||
|
||||
def get_booked_places(self):
|
||||
if self.unlocked_booked_places is None:
|
||||
return self.booked_places
|
||||
else:
|
||||
return self.unlocked_booked_places
|
||||
|
||||
def get_booked_waiting_list_places(self):
|
||||
if self.unlocked_booked_waiting_list_places is None:
|
||||
return self.booked_waiting_list_places
|
||||
else:
|
||||
return self.unlocked_booked_waiting_list_places
|
||||
|
||||
def get_full(self):
|
||||
if self.agenda.partial_bookings:
|
||||
return False
|
||||
elif self.unlocked_booked_places is None:
|
||||
return self.full
|
||||
else:
|
||||
if self.waiting_list_places == 0:
|
||||
return self.get_booked_places() >= self.places
|
||||
else:
|
||||
return self.get_booked_waiting_list_places() >= self.waiting_list_places
|
||||
|
||||
@property
|
||||
def remaining_places(self):
|
||||
return max(0, self.places - self.booked_places)
|
||||
return max(0, self.places - self.get_booked_places())
|
||||
|
||||
@property
|
||||
def remaining_waiting_list_places(self):
|
||||
return max(0, self.waiting_list_places - self.booked_waiting_list_places)
|
||||
return max(0, self.waiting_list_places - self.get_booked_waiting_list_places())
|
||||
|
||||
@property
|
||||
def end_datetime(self):
|
||||
|
@ -2625,11 +2846,26 @@ class Event(models.Model):
|
|||
return custom_fields
|
||||
|
||||
|
||||
class EventsType(models.Model):
|
||||
class EventsType(WithSnapshotMixin, WithApplicationMixin, models.Model):
|
||||
# mark temporarily restored snapshots
|
||||
snapshot = models.ForeignKey(
|
||||
EventsTypeSnapshot, on_delete=models.CASCADE, null=True, related_name='temporary_instance'
|
||||
)
|
||||
|
||||
slug = models.SlugField(_('Identifier'), max_length=160, unique=True)
|
||||
label = models.CharField(_('Label'), max_length=150)
|
||||
custom_fields = models.JSONField(blank=True, default=list)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
application_component_type = 'events_types'
|
||||
application_label_singular = _('Events type')
|
||||
application_label_plural = _('Events types')
|
||||
|
||||
objects = WithSnapshotManager()
|
||||
snapshots = WithSnapshotManager(snapshots=True)
|
||||
|
||||
def __str__(self):
|
||||
return self.label
|
||||
|
||||
|
@ -2661,11 +2897,19 @@ class EventsType(models.Model):
|
|||
custom_fields.append(values)
|
||||
return custom_fields
|
||||
|
||||
def get_dependencies(self):
|
||||
return []
|
||||
|
||||
@classmethod
|
||||
def import_json(cls, data, overwrite=False):
|
||||
def import_json(cls, data, overwrite=False, snapshot=None):
|
||||
data = clean_import_data(cls, data)
|
||||
slug = data.pop('slug')
|
||||
events_type, created = cls.objects.update_or_create(slug=slug, defaults=data)
|
||||
qs_kwargs = {}
|
||||
if snapshot:
|
||||
qs_kwargs = {'snapshot': snapshot}
|
||||
else:
|
||||
qs_kwargs = {'slug': slug}
|
||||
events_type, created = cls.objects.update_or_create(defaults=data, **qs_kwargs)
|
||||
return created, events_type
|
||||
|
||||
def export_json(self):
|
||||
|
@ -2709,6 +2953,7 @@ class Booking(models.Model):
|
|||
primary_booking = models.ForeignKey(
|
||||
'self', null=True, on_delete=models.CASCADE, related_name='secondary_booking_set'
|
||||
)
|
||||
from_recurring_fillslots = models.BooleanField(default=False)
|
||||
|
||||
label = models.CharField(max_length=250, blank=True)
|
||||
user_display_label = models.CharField(
|
||||
|
@ -2719,23 +2964,21 @@ 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)
|
||||
user_check_end_time = models.TimeField(_('Departure'), null=True)
|
||||
out_of_min_delay = models.BooleanField(default=False)
|
||||
|
||||
extra_emails = ArrayField(models.EmailField(), default=list)
|
||||
extra_phone_numbers = ArrayField(models.CharField(max_length=16), default=list)
|
||||
|
||||
form_url = models.URLField(blank=True)
|
||||
backoffice_url = models.URLField(blank=True)
|
||||
cancel_callback_url = models.URLField(blank=True)
|
||||
presence_callback_url = models.URLField(blank=True)
|
||||
absence_callback_url = models.URLField(blank=True)
|
||||
form_url = models.URLField(blank=True, max_length=500)
|
||||
backoffice_url = models.URLField(blank=True, max_length=500)
|
||||
cancel_callback_url = models.URLField(blank=True, max_length=500)
|
||||
presence_callback_url = models.URLField(blank=True, max_length=500)
|
||||
absence_callback_url = models.URLField(blank=True, max_length=500)
|
||||
color = models.ForeignKey(BookingColor, null=True, on_delete=models.SET_NULL, related_name='bookings')
|
||||
|
||||
request_uuid = models.UUIDField(editable=False, null=True)
|
||||
previous_state = models.CharField(max_length=10, null=True)
|
||||
|
||||
start_time = models.TimeField(null=True)
|
||||
end_time = models.TimeField(null=True)
|
||||
|
||||
|
@ -2743,6 +2986,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)
|
||||
|
@ -2757,6 +3009,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():
|
||||
|
@ -2780,43 +3037,79 @@ 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()
|
||||
|
||||
def refresh_computed_times(self, commit=False):
|
||||
to_update = []
|
||||
user_checks = self.user_checks.all()
|
||||
|
||||
if len(user_checks) == 1:
|
||||
user_check = user_checks[0]
|
||||
changed = user_check._refresh_computed_times()
|
||||
if changed:
|
||||
to_update.append(user_check)
|
||||
|
||||
elif len(user_checks) == 2:
|
||||
user_check1, user_check2 = user_checks
|
||||
|
||||
if user_check1.presence is True:
|
||||
# first check is presence, compute it first
|
||||
changed = user_check1._refresh_computed_times(adjust_end_to_booking=False)
|
||||
if changed:
|
||||
to_update.append(user_check1)
|
||||
changed = user_check2._refresh_computed_times(other_user_check=user_check1)
|
||||
if changed:
|
||||
to_update.append(user_check2)
|
||||
else:
|
||||
# second check is presence, compute it first
|
||||
changed = user_check2._refresh_computed_times(adjust_start_to_booking=False)
|
||||
if changed:
|
||||
to_update.append(user_check2)
|
||||
changed = user_check1._refresh_computed_times(other_user_check=user_check2)
|
||||
if changed:
|
||||
to_update.append(user_check1)
|
||||
|
||||
if commit and to_update:
|
||||
BookingCheck.objects.bulk_update(to_update, ['computed_start_time', 'computed_end_time'])
|
||||
return to_update
|
||||
|
||||
def get_user_block(self):
|
||||
template_vars = Context(settings.TEMPLATE_VARS, autoescape=False)
|
||||
template_vars.update(
|
||||
|
@ -2908,12 +3201,32 @@ class Booking(models.Model):
|
|||
def get_backoffice_url(self):
|
||||
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)
|
||||
|
||||
class Meta:
|
||||
ordering = ['start_time']
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=['booking', 'presence'], name='max_2_checks_on_booking')
|
||||
]
|
||||
|
||||
def _get_previous_and_next_slots(self, _time):
|
||||
minutes = {
|
||||
'hour': 60,
|
||||
'half_hour': 30,
|
||||
'quarter': 15,
|
||||
}[self.event.agenda.invoicing_unit]
|
||||
}[self.booking.event.agenda.invoicing_unit]
|
||||
|
||||
time_minutes = _time.hour * 60 + _time.minute
|
||||
previous_slot_minutes = math.trunc(time_minutes / minutes) * minutes
|
||||
|
@ -2921,17 +3234,23 @@ class Booking(models.Model):
|
|||
next_slot = datetime.time(*divmod(previous_slot_minutes + minutes, 60))
|
||||
return previous_slot, next_slot
|
||||
|
||||
def get_computed_start_time(self):
|
||||
if self.user_check_start_time is None:
|
||||
def get_computed_start_time(self, other_user_check=None, adjust_to_booking=True):
|
||||
if self.start_time is None:
|
||||
return None
|
||||
|
||||
start_time = self.user_check_start_time
|
||||
if self.start_time:
|
||||
start_time = min(start_time, self.start_time)
|
||||
if self.event.agenda.invoicing_unit == 'minute':
|
||||
start_time = self.start_time
|
||||
if self.booking.start_time and adjust_to_booking:
|
||||
# adjust start_time to the start of the booking if requested
|
||||
start_time = min(self.start_time, self.booking.start_time)
|
||||
if other_user_check and other_user_check.computed_start_time and other_user_check.computed_end_time:
|
||||
# if other user_check exists and is completely computed
|
||||
if other_user_check.start_time < self.start_time:
|
||||
# if other user_check is the first of the day, start_time is the end of other user_check
|
||||
start_time = other_user_check.computed_end_time
|
||||
if self.booking.event.agenda.invoicing_unit == 'minute':
|
||||
return start_time
|
||||
|
||||
tolerance = self.event.agenda.invoicing_tolerance
|
||||
tolerance = self.booking.event.agenda.invoicing_tolerance
|
||||
|
||||
# compute previous and next slot
|
||||
previous_slot, next_slot = self._get_previous_and_next_slots(start_time)
|
||||
|
@ -2943,17 +3262,23 @@ class Booking(models.Model):
|
|||
# else take previous_slot
|
||||
return previous_slot
|
||||
|
||||
def get_computed_end_time(self):
|
||||
if self.user_check_end_time is None:
|
||||
def get_computed_end_time(self, other_user_check=None, adjust_to_booking=True):
|
||||
if self.end_time is None:
|
||||
return None
|
||||
|
||||
end_time = self.user_check_end_time
|
||||
if self.end_time:
|
||||
end_time = max(end_time, self.end_time)
|
||||
if self.event.agenda.invoicing_unit == 'minute':
|
||||
end_time = self.end_time
|
||||
if self.booking.end_time and adjust_to_booking:
|
||||
# adjust end_time to the end of the booking if requested
|
||||
end_time = max(self.end_time, self.booking.end_time)
|
||||
if other_user_check and other_user_check.computed_start_time and other_user_check.computed_end_time:
|
||||
# if other user_check exists and is completely computed
|
||||
if other_user_check.start_time > self.start_time:
|
||||
# if other user_check is the second of the day, end_time is the start of other user_check
|
||||
end_time = other_user_check.computed_start_time
|
||||
if self.booking.event.agenda.invoicing_unit == 'minute':
|
||||
return end_time
|
||||
|
||||
tolerance = self.event.agenda.invoicing_tolerance
|
||||
tolerance = self.booking.event.agenda.invoicing_tolerance
|
||||
|
||||
# compute previous and next slot
|
||||
previous_slot, next_slot = self._get_previous_and_next_slots(end_time)
|
||||
|
@ -2965,11 +3290,24 @@ class Booking(models.Model):
|
|||
# else take next_slot
|
||||
return next_slot
|
||||
|
||||
def get_partial_bookings_check_url(self, agenda, event=None):
|
||||
return reverse(
|
||||
'chrono-manager-partial-booking-check',
|
||||
kwargs={'pk': agenda.pk, 'booking_pk': self.pk},
|
||||
def _refresh_computed_times(
|
||||
self, other_user_check=None, adjust_start_to_booking=True, adjust_end_to_booking=True
|
||||
):
|
||||
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(
|
||||
other_user_check=other_user_check, adjust_to_booking=adjust_start_to_booking
|
||||
)
|
||||
self.computed_end_time = self.get_computed_end_time(
|
||||
other_user_check=other_user_check, adjust_to_booking=adjust_end_to_booking
|
||||
)
|
||||
# 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
|
||||
|
||||
|
||||
OpeningHour = collections.namedtuple('OpeningHour', ['begin', 'end'])
|
||||
|
@ -2997,6 +3335,9 @@ class Desk(models.Model):
|
|||
def base_slug(self):
|
||||
return slugify(self.label)
|
||||
|
||||
def get_dependencies(self):
|
||||
yield from self.unavailability_calendars.all()
|
||||
|
||||
@classmethod
|
||||
def import_json(cls, data):
|
||||
timeperiods = data.pop('timeperiods', [])
|
||||
|
@ -3136,11 +3477,26 @@ class Desk(models.Model):
|
|||
).delete() # source was not in settings anymore
|
||||
|
||||
|
||||
class Resource(models.Model):
|
||||
class Resource(WithSnapshotMixin, WithApplicationMixin, models.Model):
|
||||
# mark temporarily restored snapshots
|
||||
snapshot = models.ForeignKey(
|
||||
ResourceSnapshot, on_delete=models.CASCADE, null=True, related_name='temporary_instance'
|
||||
)
|
||||
|
||||
slug = models.SlugField(_('Identifier'), max_length=160, unique=True)
|
||||
label = models.CharField(_('Label'), max_length=150)
|
||||
description = models.TextField(_('Description'), blank=True, help_text=_('Optional description.'))
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
application_component_type = 'resources'
|
||||
application_label_singular = _('Resource')
|
||||
application_label_plural = _('Resources')
|
||||
|
||||
objects = WithSnapshotManager()
|
||||
snapshots = WithSnapshotManager(snapshots=True)
|
||||
|
||||
def __str__(self):
|
||||
return self.label
|
||||
|
||||
|
@ -3162,11 +3518,19 @@ class Resource(models.Model):
|
|||
group_ids = [x.id for x in user.groups.all()]
|
||||
return self.agenda_set.filter(edit_role_id__in=group_ids).exists()
|
||||
|
||||
def get_dependencies(self):
|
||||
return []
|
||||
|
||||
@classmethod
|
||||
def import_json(cls, data, overwrite=False):
|
||||
def import_json(cls, data, overwrite=False, snapshot=None):
|
||||
data = clean_import_data(cls, data)
|
||||
slug = data.pop('slug')
|
||||
resource, created = cls.objects.update_or_create(slug=slug, defaults=data)
|
||||
qs_kwargs = {}
|
||||
if snapshot:
|
||||
qs_kwargs = {'snapshot': snapshot}
|
||||
else:
|
||||
qs_kwargs = {'slug': slug}
|
||||
resource, created = cls.objects.update_or_create(defaults=data, **qs_kwargs)
|
||||
return created, resource
|
||||
|
||||
def export_json(self):
|
||||
|
@ -3177,10 +3541,25 @@ class Resource(models.Model):
|
|||
}
|
||||
|
||||
|
||||
class Category(models.Model):
|
||||
class Category(WithSnapshotMixin, WithApplicationMixin, models.Model):
|
||||
# mark temporarily restored snapshots
|
||||
snapshot = models.ForeignKey(
|
||||
CategorySnapshot, on_delete=models.CASCADE, null=True, related_name='temporary_instance'
|
||||
)
|
||||
|
||||
slug = models.SlugField(_('Identifier'), max_length=160, unique=True)
|
||||
label = models.CharField(_('Label'), max_length=150)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
application_component_type = 'agendas_categories'
|
||||
application_label_singular = _('Category (agendas)')
|
||||
application_label_plural = _('Categories (agendas)')
|
||||
|
||||
objects = WithSnapshotManager()
|
||||
snapshots = WithSnapshotManager(snapshots=True)
|
||||
|
||||
def __str__(self):
|
||||
return self.label
|
||||
|
||||
|
@ -3196,11 +3575,19 @@ class Category(models.Model):
|
|||
def base_slug(self):
|
||||
return slugify(self.label)
|
||||
|
||||
def get_dependencies(self):
|
||||
return []
|
||||
|
||||
@classmethod
|
||||
def import_json(cls, data, overwrite=False):
|
||||
def import_json(cls, data, overwrite=False, snapshot=None):
|
||||
data = clean_import_data(cls, data)
|
||||
slug = data.pop('slug')
|
||||
category, created = cls.objects.update_or_create(slug=slug, defaults=data)
|
||||
qs_kwargs = {}
|
||||
if snapshot:
|
||||
qs_kwargs = {'snapshot': snapshot}
|
||||
else:
|
||||
qs_kwargs = {'slug': slug}
|
||||
category, created = cls.objects.update_or_create(defaults=data, **qs_kwargs)
|
||||
return created, category
|
||||
|
||||
def export_json(self):
|
||||
|
@ -3486,7 +3873,12 @@ class TimePeriodExceptionSource(models.Model):
|
|||
}
|
||||
|
||||
|
||||
class UnavailabilityCalendar(models.Model):
|
||||
class UnavailabilityCalendar(WithSnapshotMixin, WithApplicationMixin, models.Model):
|
||||
# mark temporarily restored snapshots
|
||||
snapshot = models.ForeignKey(
|
||||
UnavailabilityCalendarSnapshot, on_delete=models.CASCADE, null=True, related_name='temporary_instance'
|
||||
)
|
||||
|
||||
label = models.CharField(_('Label'), max_length=150)
|
||||
slug = models.SlugField(_('Identifier'), max_length=160, unique=True)
|
||||
desks = models.ManyToManyField(Desk, related_name='unavailability_calendars')
|
||||
|
@ -3509,6 +3901,16 @@ class UnavailabilityCalendar(models.Model):
|
|||
on_delete=models.SET_NULL,
|
||||
)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
application_component_type = 'unavailability_calendars'
|
||||
application_label_singular = _('Unavailability calendar')
|
||||
application_label_plural = _('Unavailability calendars')
|
||||
|
||||
objects = WithSnapshotManager()
|
||||
snapshots = WithSnapshotManager(snapshots=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['label']
|
||||
|
||||
|
@ -3539,6 +3941,10 @@ class UnavailabilityCalendar(models.Model):
|
|||
def get_absolute_url(self):
|
||||
return reverse('chrono-manager-unavailability-calendar-view', kwargs={'pk': self.id})
|
||||
|
||||
def get_dependencies(self):
|
||||
yield self.view_role
|
||||
yield self.edit_role
|
||||
|
||||
def export_json(self):
|
||||
unavailability_calendar = {
|
||||
'label': self.label,
|
||||
|
@ -3552,7 +3958,7 @@ class UnavailabilityCalendar(models.Model):
|
|||
return unavailability_calendar
|
||||
|
||||
@classmethod
|
||||
def import_json(cls, data, overwrite=False):
|
||||
def import_json(cls, data, overwrite=False, snapshot=None):
|
||||
data = data.copy()
|
||||
permissions = data.pop('permissions', {})
|
||||
exceptions = data.pop('exceptions', [])
|
||||
|
@ -3560,7 +3966,13 @@ class UnavailabilityCalendar(models.Model):
|
|||
if permissions.get(permission):
|
||||
data[permission + '_role'] = Group.objects.get(name=permissions[permission])
|
||||
data = clean_import_data(cls, data)
|
||||
unavailability_calendar, created = cls.objects.update_or_create(slug=data['slug'], defaults=data)
|
||||
slug = data.pop('slug')
|
||||
qs_kwargs = {}
|
||||
if snapshot:
|
||||
qs_kwargs = {'snapshot': snapshot}
|
||||
else:
|
||||
qs_kwargs = {'slug': slug}
|
||||
unavailability_calendar, created = cls.objects.update_or_create(defaults=data, **qs_kwargs)
|
||||
if overwrite:
|
||||
TimePeriodException.objects.filter(unavailability_calendar=unavailability_calendar).delete()
|
||||
for exception in exceptions:
|
||||
|
@ -4000,12 +4412,6 @@ class Subscription(models.Model):
|
|||
except (VariableDoesNotExist, TemplateSyntaxError):
|
||||
return
|
||||
|
||||
def get_partial_bookings_check_url(self, agenda, event):
|
||||
return reverse(
|
||||
'chrono-manager-partial-booking-subscription-check',
|
||||
kwargs={'pk': agenda.pk, 'event_pk': event.pk, 'subscription_pk': self.pk},
|
||||
)
|
||||
|
||||
|
||||
class Person(models.Model):
|
||||
user_external_id = models.CharField(max_length=250, unique=True)
|
||||
|
@ -4378,3 +4784,29 @@ class SharedCustodySettings(models.Model):
|
|||
return cls.objects.get()
|
||||
except cls.DoesNotExist:
|
||||
return cls()
|
||||
|
||||
|
||||
def get_lease_expiration():
|
||||
return now() + datetime.timedelta(seconds=settings.CHRONO_LOCK_DURATION)
|
||||
|
||||
|
||||
class Lease(models.Model):
|
||||
booking = models.OneToOneField(Booking, on_delete=models.CASCADE, verbose_name=_('Booking'))
|
||||
lock_code = models.CharField(verbose_name=_('Lock code'), max_length=64, blank=False)
|
||||
expiration_datetime = models.DateTimeField(
|
||||
verbose_name=_('Lease expiration time'), default=get_lease_expiration
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('Lease')
|
||||
verbose_name_plural = _('Leases')
|
||||
|
||||
@classmethod
|
||||
def clean(cls):
|
||||
'''Clean objects linked to leases.'''
|
||||
|
||||
# Delete expired meeting's events, bookings and leases.'''
|
||||
Event.objects.filter(agenda__kind='meetings', booking__lease__expiration_datetime__lt=now()).delete()
|
||||
|
||||
# Delete expired event's bookings and leases'''
|
||||
Booking.objects.filter(event__agenda__kind='events', lease__expiration_datetime__lt=now()).delete()
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import collections
|
||||
import datetime
|
||||
import re
|
||||
|
||||
from django.contrib.auth.models import Group
|
||||
from django.db import models, transaction
|
||||
|
@ -42,6 +43,15 @@ class StringOrListField(serializers.ListField):
|
|||
return super().to_internal_value(data)
|
||||
|
||||
|
||||
class PhoneNumbersStringOrListField(serializers.ListField):
|
||||
def to_internal_value(self, data):
|
||||
if isinstance(data, str):
|
||||
data = [s.strip() for s in data.split(',') if s.strip()]
|
||||
# strip white spaces and dots
|
||||
data = [re.sub(r'[\s\.]', '', x) for x in data]
|
||||
return super().to_internal_value(data)
|
||||
|
||||
|
||||
class CommaSeparatedStringField(serializers.ListField):
|
||||
def get_value(self, dictionary):
|
||||
return super(serializers.ListField, self).get_value(dictionary)
|
||||
|
@ -79,11 +89,11 @@ class FillSlotSerializer(serializers.Serializer):
|
|||
exclude_user = serializers.BooleanField(default=False)
|
||||
events = serializers.CharField(max_length=16, allow_blank=True)
|
||||
bypass_delays = serializers.BooleanField(default=False)
|
||||
form_url = serializers.CharField(max_length=250, allow_blank=True)
|
||||
backoffice_url = serializers.URLField(allow_blank=True)
|
||||
cancel_callback_url = serializers.URLField(allow_blank=True)
|
||||
presence_callback_url = serializers.URLField(allow_blank=True)
|
||||
absence_callback_url = serializers.URLField(allow_blank=True)
|
||||
form_url = serializers.CharField(max_length=500, allow_blank=True)
|
||||
backoffice_url = serializers.URLField(allow_blank=True, max_length=500)
|
||||
cancel_callback_url = serializers.URLField(allow_blank=True, max_length=500)
|
||||
presence_callback_url = serializers.URLField(allow_blank=True, max_length=500)
|
||||
absence_callback_url = serializers.URLField(allow_blank=True, max_length=500)
|
||||
count = serializers.IntegerField(min_value=1)
|
||||
cancel_booking_id = serializers.CharField(max_length=250, allow_blank=True, allow_null=True)
|
||||
force_waiting_list = serializers.BooleanField(default=False)
|
||||
|
@ -91,12 +101,14 @@ class FillSlotSerializer(serializers.Serializer):
|
|||
extra_emails = StringOrListField(
|
||||
required=False, child=serializers.EmailField(max_length=250, allow_blank=False)
|
||||
)
|
||||
extra_phone_numbers = StringOrListField(
|
||||
extra_phone_numbers = PhoneNumbersStringOrListField(
|
||||
required=False, child=serializers.CharField(max_length=16, allow_blank=False)
|
||||
)
|
||||
check_overlaps = serializers.BooleanField(default=False)
|
||||
start_time = serializers.TimeField(required=False)
|
||||
end_time = serializers.TimeField(required=False)
|
||||
start_time = serializers.TimeField(required=False, allow_null=True)
|
||||
end_time = serializers.TimeField(required=False, allow_null=True)
|
||||
lock_code = serializers.CharField(max_length=64, required=False, allow_blank=False, trim_whitespace=True)
|
||||
confirm_after_lock = serializers.BooleanField(default=False)
|
||||
|
||||
def validate(self, attrs):
|
||||
super().validate(attrs)
|
||||
|
@ -229,7 +241,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')
|
||||
|
@ -260,6 +325,10 @@ class BookingSerializer(serializers.ModelSerializer):
|
|||
'cancellation_datetime',
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.user_check = kwargs.pop('user_check', None)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def to_internal_value(self, data):
|
||||
if 'color' in data:
|
||||
# legacy
|
||||
|
@ -278,16 +347,34 @@ class BookingSerializer(serializers.ModelSerializer):
|
|||
ret.pop('user_absence_reason', None)
|
||||
ret.pop('user_presence_reason', None)
|
||||
else:
|
||||
ret['user_absence_reason'] = (
|
||||
self.instance.user_check_type_slug if self.instance.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
|
||||
)
|
||||
user_was_present = self.user_check.presence if self.user_check else None
|
||||
ret['user_was_present'] = user_was_present
|
||||
ret['user_absence_reason'] = self.user_check.type_slug if user_was_present is False else None
|
||||
ret['user_presence_reason'] = self.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.computed_start_time = self.instance.get_computed_start_time()
|
||||
self.instance.computed_end_time = self.instance.get_computed_end_time()
|
||||
for key in ['', 'user_check_', 'computed_']:
|
||||
self.instance.user_check_start_time = self.user_check.start_time if self.user_check else None
|
||||
self.instance.user_check_end_time = self.user_check.end_time if self.user_check else None
|
||||
self.instance.computed_start_time = (
|
||||
self.user_check.computed_start_time if self.user_check else None
|
||||
)
|
||||
self.instance.computed_end_time = self.user_check.computed_end_time if self.user_check else None
|
||||
# adjust start_time (in case of multi checks)
|
||||
self.instance.adjusted_start_time = self.instance.start_time
|
||||
if (
|
||||
self.instance.start_time
|
||||
and self.instance.computed_start_time
|
||||
and self.instance.start_time < self.instance.computed_start_time
|
||||
):
|
||||
self.instance.adjusted_start_time = self.instance.computed_start_time
|
||||
# and end_time
|
||||
self.instance.adjusted_end_time = self.instance.end_time
|
||||
if (
|
||||
self.instance.end_time
|
||||
and self.instance.computed_end_time
|
||||
and self.instance.end_time > self.instance.computed_end_time
|
||||
):
|
||||
self.instance.adjusted_end_time = self.instance.computed_end_time
|
||||
for key in ['', 'user_check_', 'computed_', 'adjusted_']:
|
||||
start_key, end_key, minutes_key = (
|
||||
'%sstart_time' % key,
|
||||
'%send_time' % key,
|
||||
|
@ -359,6 +446,11 @@ class ResizeSerializer(serializers.Serializer):
|
|||
count = serializers.IntegerField(min_value=1)
|
||||
|
||||
|
||||
class PartialBookingsCheckSerializer(serializers.Serializer):
|
||||
user_external_id = serializers.CharField(max_length=250, allow_blank=True)
|
||||
timestamp = serializers.DateTimeField(input_formats=['iso-8601', '%Y-%m-%d'])
|
||||
|
||||
|
||||
class StatisticsFiltersSerializer(serializers.Serializer):
|
||||
time_interval = serializers.ChoiceField(choices=('day', _('Day')), default='day')
|
||||
start = serializers.DateTimeField(required=False, input_formats=['iso-8601', '%Y-%m-%d'])
|
||||
|
@ -380,6 +472,7 @@ class DatetimesSerializer(DateRangeSerializer):
|
|||
events = serializers.CharField(required=False, max_length=32, allow_blank=True)
|
||||
hide_disabled = serializers.BooleanField(default=False)
|
||||
bypass_delays = serializers.BooleanField(default=False)
|
||||
lock_code = serializers.CharField(max_length=64, required=False, allow_blank=False, trim_whitespace=True)
|
||||
|
||||
def validate(self, attrs):
|
||||
super().validate(attrs)
|
||||
|
@ -518,11 +611,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']]
|
||||
|
@ -595,6 +689,7 @@ class AgendaSerializer(serializers.ModelSerializer):
|
|||
'slug',
|
||||
'label',
|
||||
'kind',
|
||||
'partial_bookings',
|
||||
'minimal_booking_delay',
|
||||
'minimal_booking_delay_in_working_days',
|
||||
'maximal_booking_delay',
|
||||
|
@ -645,6 +740,10 @@ class AgendaSerializer(serializers.ModelSerializer):
|
|||
)
|
||||
if attrs.get('events_type') and attrs.get('kind', 'events') != 'events':
|
||||
raise ValidationError({'events_type': _('Option not available on %s agenda') % attrs['kind']})
|
||||
if attrs.get('partial_bookings') and attrs.get('kind', 'events') != 'events':
|
||||
raise ValidationError(
|
||||
{'partial_bookings': _('Option not available on %s agenda') % attrs['kind']}
|
||||
)
|
||||
return attrs
|
||||
|
||||
|
||||
|
|
|
@ -14,7 +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/>.
|
||||
|
||||
from django.urls import path, re_path
|
||||
from django.urls import include, path, re_path
|
||||
|
||||
from . import views
|
||||
|
||||
|
@ -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,
|
||||
|
@ -33,6 +38,11 @@ urlpatterns = [
|
|||
views.agendas_events_fillslots,
|
||||
name='api-agendas-events-fillslots',
|
||||
),
|
||||
path(
|
||||
'agendas/events/fillslots/<uuid:request_uuid>/revert/',
|
||||
views.agendas_events_fillslots_revert,
|
||||
name='api-agendas-events-fillslots-revert',
|
||||
),
|
||||
path(
|
||||
'agendas/events/check-status/',
|
||||
views.agendas_events_check_status,
|
||||
|
@ -123,6 +133,11 @@ urlpatterns = [
|
|||
views.subscription,
|
||||
name='api-agenda-subscription',
|
||||
),
|
||||
path(
|
||||
'agenda/<slug:agenda_identifier>/partial-bookings-check/',
|
||||
views.partial_bookings_check,
|
||||
name='api-partial-bookings-check',
|
||||
),
|
||||
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'),
|
||||
|
@ -140,4 +155,5 @@ urlpatterns = [
|
|||
),
|
||||
path('statistics/', views.statistics_list, name='api-statistics-list'),
|
||||
path('statistics/bookings/', views.bookings_statistics, name='api-statistics-bookings'),
|
||||
path('ants/', include('chrono.apps.ants_hub.api_urls')),
|
||||
]
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,23 @@
|
|||
# chrono - agendas system
|
||||
# Copyright (C) 2023 Entr'ouvert
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it
|
||||
# under the terms of the GNU Affero General Public License as published
|
||||
# by the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# 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/>.
|
||||
|
||||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
path('check-duplicate/', views.CheckDuplicateAPI.as_view(), name='api-ants-check-duplicate'),
|
||||
]
|
|
@ -16,6 +16,7 @@
|
|||
|
||||
import requests
|
||||
from django.conf import settings
|
||||
from django.utils.translation import gettext as _
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3.util.retry import Retry
|
||||
|
||||
|
@ -71,3 +72,17 @@ def push_rendez_vous_disponibles(payload):
|
|||
return True
|
||||
except (TypeError, KeyError, requests.RequestException) as e:
|
||||
raise AntsHubException(str(e))
|
||||
|
||||
|
||||
def check_duplicate(identifiants_predemande: list):
|
||||
params = [
|
||||
('identifiant_predemande', identifiant_predemande)
|
||||
for identifiant_predemande in identifiants_predemande
|
||||
]
|
||||
session = make_http_session()
|
||||
try:
|
||||
response = session.get(make_url('rdv-status/'), params=params)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except (ValueError, requests.RequestException) as e:
|
||||
return {'err': 1, 'err_desc': f'ANTS hub is unavailable: {e!r}'}
|
||||
|
|
|
@ -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
|
||||
|
@ -223,6 +224,7 @@ class Place(models.Model):
|
|||
event__desk__agenda__in=agendas,
|
||||
event__start_datetime__gt=now(),
|
||||
extra_data__ants_identifiant_predemande__isnull=False,
|
||||
lease__isnull=True,
|
||||
)
|
||||
.exclude(extra_data__ants_identifiant_predemande='')
|
||||
.values_list(
|
||||
|
@ -247,8 +249,8 @@ class Place(models.Model):
|
|||
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'),
|
||||
]
|
||||
|
|
|
@ -14,14 +14,21 @@
|
|||
# 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 re
|
||||
import sys
|
||||
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.core.cache import cache
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.utils.translation import gettext_noop as N_
|
||||
from django.views.generic import CreateView, DeleteView, ListView, TemplateView, UpdateView
|
||||
from rest_framework import permissions
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from chrono.api.utils import APIErrorBadRequest, Response
|
||||
|
||||
from . import hub, models
|
||||
|
||||
|
@ -249,3 +256,33 @@ class Synchronize(TemplateView):
|
|||
ants_hub_city_push.spool(domain=getattr(tenant, 'domain_url', None))
|
||||
else:
|
||||
models.City.push()
|
||||
|
||||
|
||||
class CheckDuplicateAPI(APIView):
|
||||
permission_classes = (permissions.IsAuthenticated,)
|
||||
|
||||
identifiant_predemande_re = re.compile(r'^[A-Z0-9]{10}$')
|
||||
|
||||
def post(self, request):
|
||||
if not settings.CHRONO_ANTS_HUB_URL:
|
||||
raise APIErrorBadRequest(N_('CHRONO_ANTS_HUB_URL is not configured'))
|
||||
|
||||
data = request.data if isinstance(request.data, dict) else {}
|
||||
identifiant_predemande = data.get('identifiant_predemande', request.GET.get('identifiant_predemande'))
|
||||
identifiants_predemande = identifiant_predemande or []
|
||||
|
||||
if isinstance(identifiants_predemande, str):
|
||||
identifiants_predemande = identifiants_predemande.split(',')
|
||||
|
||||
if not isinstance(identifiants_predemande, list):
|
||||
raise APIErrorBadRequest(
|
||||
N_('identifiant_predemande must be a list of identifiants separated by commas: %s'),
|
||||
repr(identifiants_predemande),
|
||||
)
|
||||
|
||||
identifiants_predemande = list(filter(None, map(str.upper, map(str.strip, identifiants_predemande))))
|
||||
|
||||
if not identifiants_predemande:
|
||||
return Response({'err': 0, 'data': {'accept_rdv': True}})
|
||||
|
||||
return Response(hub.check_duplicate(identifiants_predemande))
|
||||
|
|
|
@ -0,0 +1,300 @@
|
|||
# chrono - content management system
|
||||
# Copyright (C) 2016-2023 Entr'ouvert
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it
|
||||
# under the terms of the GNU Affero General Public License as published
|
||||
# by the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# 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 io
|
||||
import json
|
||||
import tarfile
|
||||
|
||||
from django.contrib.auth.models import Group
|
||||
from django.http import Http404
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework import permissions
|
||||
from rest_framework.generics import GenericAPIView
|
||||
from rest_framework.response import Response
|
||||
|
||||
from chrono.agendas.models import Agenda, Category, EventsType, Resource, UnavailabilityCalendar
|
||||
from chrono.apps.export_import.models import Application, ApplicationElement
|
||||
from chrono.manager.utils import import_site
|
||||
|
||||
klasses = {
|
||||
klass.application_component_type: klass
|
||||
for klass in [Agenda, Category, EventsType, Resource, UnavailabilityCalendar]
|
||||
}
|
||||
klasses['roles'] = Group
|
||||
klasses_translation = {
|
||||
'agendas_categories': 'categories', # categories type is already used in wcs for FormDef Category
|
||||
}
|
||||
klasses_translation_reverse = {v: k for k, v in klasses_translation.items()}
|
||||
|
||||
|
||||
class Index(GenericAPIView):
|
||||
permission_classes = (permissions.IsAuthenticatedOrReadOnly,)
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
data = []
|
||||
for klass in klasses.values():
|
||||
if klass == Group:
|
||||
data.append(
|
||||
{
|
||||
'id': 'roles',
|
||||
'text': _('Roles'),
|
||||
'singular': _('Role'),
|
||||
'urls': {
|
||||
'list': request.build_absolute_uri(
|
||||
reverse(
|
||||
'api-export-import-components-list',
|
||||
kwargs={'component_type': 'roles'},
|
||||
)
|
||||
),
|
||||
},
|
||||
'minor': True,
|
||||
}
|
||||
)
|
||||
continue
|
||||
component_type = {
|
||||
'id': klass.application_component_type,
|
||||
'text': klass.application_label_plural,
|
||||
'singular': klass.application_label_singular,
|
||||
'urls': {
|
||||
'list': request.build_absolute_uri(
|
||||
reverse(
|
||||
'api-export-import-components-list',
|
||||
kwargs={'component_type': klass.application_component_type},
|
||||
)
|
||||
),
|
||||
},
|
||||
}
|
||||
if klass not in [Agenda]:
|
||||
component_type['minor'] = True
|
||||
data.append(component_type)
|
||||
|
||||
return Response({'data': data})
|
||||
|
||||
|
||||
index = Index.as_view()
|
||||
|
||||
|
||||
def get_component_bundle_entry(request, component):
|
||||
if isinstance(component, Group):
|
||||
return {
|
||||
'id': component.role.slug if hasattr(component, 'role') else component.id,
|
||||
'text': component.name,
|
||||
'type': 'roles',
|
||||
'urls': {},
|
||||
# include uuid in object reference, this is not used for applification API but is useful
|
||||
# for authentic creating its role summary page.
|
||||
'uuid': component.role.uuid if hasattr(component, 'role') else None,
|
||||
}
|
||||
return {
|
||||
'id': str(component.slug),
|
||||
'text': component.label,
|
||||
'type': component.application_component_type,
|
||||
'urls': {
|
||||
'export': request.build_absolute_uri(
|
||||
reverse(
|
||||
'api-export-import-component-export',
|
||||
kwargs={
|
||||
'component_type': component.application_component_type,
|
||||
'slug': str(component.slug),
|
||||
},
|
||||
)
|
||||
),
|
||||
'dependencies': request.build_absolute_uri(
|
||||
reverse(
|
||||
'api-export-import-component-dependencies',
|
||||
kwargs={
|
||||
'component_type': component.application_component_type,
|
||||
'slug': str(component.slug),
|
||||
},
|
||||
)
|
||||
),
|
||||
'redirect': request.build_absolute_uri(
|
||||
reverse(
|
||||
'api-export-import-component-redirect',
|
||||
kwargs={
|
||||
'component_type': component.application_component_type,
|
||||
'slug': str(component.slug),
|
||||
},
|
||||
)
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class ListComponents(GenericAPIView):
|
||||
permission_classes = (permissions.IsAuthenticatedOrReadOnly,)
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
klass = klasses[kwargs['component_type']]
|
||||
order_by = 'slug'
|
||||
if klass == Group:
|
||||
order_by = 'name'
|
||||
response = [get_component_bundle_entry(request, x) for x in klass.objects.order_by(order_by)]
|
||||
return Response({'data': response})
|
||||
|
||||
|
||||
list_components = ListComponents.as_view()
|
||||
|
||||
|
||||
class ExportComponent(GenericAPIView):
|
||||
permission_classes = (permissions.IsAuthenticatedOrReadOnly,)
|
||||
|
||||
def get(self, request, slug, *args, **kwargs):
|
||||
klass = klasses[kwargs['component_type']]
|
||||
serialisation = klass.objects.get(slug=slug).export_json()
|
||||
return Response({'data': serialisation})
|
||||
|
||||
|
||||
export_component = ExportComponent.as_view()
|
||||
|
||||
|
||||
class ComponentDependencies(GenericAPIView):
|
||||
permission_classes = (permissions.IsAuthenticatedOrReadOnly,)
|
||||
|
||||
def get(self, request, slug, *args, **kwargs):
|
||||
klass = klasses[kwargs['component_type']]
|
||||
component = klass.objects.get(slug=slug)
|
||||
|
||||
def dependency_dict(element):
|
||||
return get_component_bundle_entry(request, element)
|
||||
|
||||
dependencies = [dependency_dict(x) for x in component.get_dependencies() if x]
|
||||
return Response({'err': 0, 'data': dependencies})
|
||||
|
||||
|
||||
component_dependencies = ComponentDependencies.as_view()
|
||||
|
||||
|
||||
def component_redirect(request, component_type, slug):
|
||||
klass = klasses[component_type]
|
||||
component = get_object_or_404(klass, slug=slug)
|
||||
if klass == Agenda:
|
||||
return redirect(reverse('chrono-manager-agenda-view', kwargs={'pk': component.pk}))
|
||||
if klass == Category:
|
||||
return redirect(reverse('chrono-manager-category-list'))
|
||||
if klass == EventsType:
|
||||
return redirect(reverse('chrono-manager-events-type-list'))
|
||||
if klass == Resource:
|
||||
return redirect(reverse('chrono-manager-resource-view', kwargs={'pk': component.pk}))
|
||||
if klass == UnavailabilityCalendar:
|
||||
return redirect(reverse('chrono-manager-unavailability-calendar-view', kwargs={'pk': component.pk}))
|
||||
raise Http404
|
||||
|
||||
|
||||
class BundleCheck(GenericAPIView):
|
||||
permission_classes = (permissions.IsAuthenticatedOrReadOnly,)
|
||||
|
||||
def put(self, request, *args, **kwargs):
|
||||
return Response({'err': 0, 'data': {}})
|
||||
|
||||
|
||||
bundle_check = BundleCheck.as_view()
|
||||
|
||||
|
||||
class BundleImport(GenericAPIView):
|
||||
permission_classes = (permissions.IsAuthenticatedOrReadOnly,)
|
||||
install = True
|
||||
|
||||
def put(self, request, *args, **kwargs):
|
||||
tar_io = io.BytesIO(request.read())
|
||||
components = {}
|
||||
with tarfile.open(fileobj=tar_io) as tar:
|
||||
manifest = json.loads(tar.extractfile('manifest.json').read().decode())
|
||||
self.application = Application.update_or_create_from_manifest(
|
||||
manifest,
|
||||
tar,
|
||||
editable=not self.install,
|
||||
)
|
||||
|
||||
for element in manifest.get('elements'):
|
||||
component_type = element['type']
|
||||
if component_type not in klasses or element['type'] == 'roles':
|
||||
continue
|
||||
component_type = klasses_translation.get(component_type, component_type)
|
||||
if component_type not in components:
|
||||
components[component_type] = []
|
||||
component_content = (
|
||||
tar.extractfile('%s/%s' % (element['type'], element['slug'])).read().decode()
|
||||
)
|
||||
components[component_type].append(json.loads(component_content).get('data'))
|
||||
# init cache of application elements, from manifest
|
||||
self.application_elements = set()
|
||||
# import agendas
|
||||
self.do_something(components)
|
||||
# create application elements
|
||||
self.link_objects(components)
|
||||
# remove obsolete application elements
|
||||
self.unlink_obsolete_objects()
|
||||
return Response({'err': 0})
|
||||
|
||||
def do_something(self, components):
|
||||
if components:
|
||||
import_site(components)
|
||||
|
||||
def link_objects(self, components):
|
||||
for component_type, component_list in components.items():
|
||||
component_type = klasses_translation_reverse.get(component_type, component_type)
|
||||
klass = klasses[component_type]
|
||||
for component in component_list:
|
||||
try:
|
||||
existing_component = klass.objects.get(slug=component['slug'])
|
||||
except klass.DoesNotExist:
|
||||
pass
|
||||
else:
|
||||
element = ApplicationElement.update_or_create_for_object(
|
||||
self.application, existing_component
|
||||
)
|
||||
self.application_elements.add(element.content_object)
|
||||
|
||||
def unlink_obsolete_objects(self):
|
||||
known_elements = ApplicationElement.objects.filter(application=self.application)
|
||||
for element in known_elements:
|
||||
if element.content_object not in self.application_elements:
|
||||
element.delete()
|
||||
|
||||
|
||||
bundle_import = BundleImport.as_view()
|
||||
|
||||
|
||||
class BundleDeclare(BundleImport):
|
||||
install = False
|
||||
|
||||
def do_something(self, components):
|
||||
# no installation on declare
|
||||
pass
|
||||
|
||||
|
||||
bundle_declare = BundleDeclare.as_view()
|
||||
|
||||
|
||||
class BundleUnlink(GenericAPIView):
|
||||
permission_classes = (permissions.IsAuthenticatedOrReadOnly,)
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
if request.POST.get('application'):
|
||||
try:
|
||||
application = Application.objects.get(slug=request.POST['application'])
|
||||
except Application.DoesNotExist:
|
||||
pass
|
||||
else:
|
||||
application.delete()
|
||||
|
||||
return Response({'err': 0})
|
||||
|
||||
|
||||
bundle_unlink = BundleUnlink.as_view()
|
|
@ -0,0 +1,62 @@
|
|||
# Generated by Django 3.2.18 on 2023-10-13 09:19
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('contenttypes', '0002_remove_content_type_name'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Application',
|
||||
fields=[
|
||||
(
|
||||
'id',
|
||||
models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
('name', models.CharField(max_length=100)),
|
||||
('slug', models.SlugField(max_length=100, unique=True)),
|
||||
('icon', models.FileField(blank=True, null=True, upload_to='applications/icons/')),
|
||||
('description', models.TextField(blank=True)),
|
||||
('documentation_url', models.URLField(blank=True)),
|
||||
('version_number', models.CharField(max_length=100)),
|
||||
('version_notes', models.TextField(blank=True)),
|
||||
('editable', models.BooleanField(default=True)),
|
||||
('visible', models.BooleanField(default=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ApplicationElement',
|
||||
fields=[
|
||||
(
|
||||
'id',
|
||||
models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
('object_id', models.PositiveIntegerField()),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
'application',
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE, to='export_import.application'
|
||||
),
|
||||
),
|
||||
(
|
||||
'content_type',
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype'
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('application', 'content_type', 'object_id')},
|
||||
},
|
||||
),
|
||||
]
|
|
@ -0,0 +1,134 @@
|
|||
# chrono - content management system
|
||||
# Copyright (C) 2016-2023 Entr'ouvert
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it
|
||||
# under the terms of the GNU Affero General Public License as published
|
||||
# by the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# 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 collections
|
||||
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db import models
|
||||
|
||||
|
||||
class WithApplicationMixin:
|
||||
@property
|
||||
def applications(self):
|
||||
if getattr(self, '_applications', None) is None:
|
||||
Application.load_for_object(self)
|
||||
return self._applications
|
||||
|
||||
|
||||
class Application(models.Model):
|
||||
name = models.CharField(max_length=100)
|
||||
slug = models.SlugField(max_length=100, unique=True)
|
||||
icon = models.FileField(
|
||||
upload_to='applications/icons/',
|
||||
blank=True,
|
||||
null=True,
|
||||
)
|
||||
description = models.TextField(blank=True)
|
||||
documentation_url = models.URLField(blank=True)
|
||||
version_number = models.CharField(max_length=100)
|
||||
version_notes = models.TextField(blank=True)
|
||||
editable = models.BooleanField(default=True)
|
||||
visible = models.BooleanField(default=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
def __str__(self):
|
||||
return str(self.name)
|
||||
|
||||
@classmethod
|
||||
def update_or_create_from_manifest(cls, manifest, tar, editable=False):
|
||||
application, dummy = cls.objects.get_or_create(
|
||||
slug=manifest.get('slug'), defaults={'editable': editable}
|
||||
)
|
||||
application.name = manifest.get('application')
|
||||
application.description = manifest.get('description')
|
||||
application.documentation_url = manifest.get('documentation_url')
|
||||
application.version_number = manifest.get('version_number') or 'unknown'
|
||||
application.version_notes = manifest.get('version_notes')
|
||||
if not editable:
|
||||
application.editable = editable
|
||||
application.visible = manifest.get('visible', True)
|
||||
application.save()
|
||||
icon = manifest.get('icon')
|
||||
if icon:
|
||||
application.icon.save(icon, tar.extractfile(icon), save=True)
|
||||
else:
|
||||
application.icon.delete()
|
||||
return application
|
||||
|
||||
@classmethod
|
||||
def select_for_object_class(cls, object_class):
|
||||
content_type = ContentType.objects.get_for_model(object_class)
|
||||
elements = ApplicationElement.objects.filter(content_type=content_type)
|
||||
return cls.objects.filter(pk__in=elements.values('application'), visible=True).order_by('name')
|
||||
|
||||
@classmethod
|
||||
def populate_objects(cls, object_class, objects):
|
||||
content_type = ContentType.objects.get_for_model(object_class)
|
||||
elements = ApplicationElement.objects.filter(
|
||||
content_type=content_type, application__visible=True
|
||||
).prefetch_related('application')
|
||||
elements_by_objects = collections.defaultdict(list)
|
||||
for element in elements:
|
||||
elements_by_objects[element.object_id].append(element)
|
||||
for obj in objects:
|
||||
applications = [element.application for element in elements_by_objects.get(obj.pk) or []]
|
||||
obj._applications = sorted(applications, key=lambda a: a.name)
|
||||
|
||||
@classmethod
|
||||
def load_for_object(cls, obj):
|
||||
content_type = ContentType.objects.get_for_model(obj.__class__)
|
||||
elements = ApplicationElement.objects.filter(
|
||||
content_type=content_type, object_id=obj.pk, application__visible=True
|
||||
).prefetch_related('application')
|
||||
applications = [element.application for element in elements]
|
||||
obj._applications = sorted(applications, key=lambda a: a.name)
|
||||
|
||||
def get_objects_for_object_class(self, object_class):
|
||||
content_type = ContentType.objects.get_for_model(object_class)
|
||||
elements = ApplicationElement.objects.filter(content_type=content_type, application=self)
|
||||
return object_class.objects.filter(pk__in=elements.values('object_id'))
|
||||
|
||||
@classmethod
|
||||
def get_orphan_objects_for_object_class(cls, object_class):
|
||||
content_type = ContentType.objects.get_for_model(object_class)
|
||||
elements = ApplicationElement.objects.filter(content_type=content_type, application__visible=True)
|
||||
return object_class.objects.exclude(pk__in=elements.values('object_id'))
|
||||
|
||||
|
||||
class ApplicationElement(models.Model):
|
||||
application = models.ForeignKey(Application, on_delete=models.CASCADE)
|
||||
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
|
||||
object_id = models.PositiveIntegerField()
|
||||
content_object = GenericForeignKey('content_type', 'object_id')
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = ['application', 'content_type', 'object_id']
|
||||
|
||||
@classmethod
|
||||
def update_or_create_for_object(cls, application, obj):
|
||||
content_type = ContentType.objects.get_for_model(obj.__class__)
|
||||
element, created = cls.objects.get_or_create(
|
||||
application=application,
|
||||
content_type=content_type,
|
||||
object_id=obj.pk,
|
||||
)
|
||||
if not created:
|
||||
element.save()
|
||||
return element
|
|
@ -0,0 +1,47 @@
|
|||
# chrono - content management system
|
||||
# Copyright (C) 2016-2023 Entr'ouvert
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it
|
||||
# under the terms of the GNU Affero General Public License as published
|
||||
# by the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# 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/>.
|
||||
|
||||
from django.urls import path
|
||||
|
||||
from . import api_views
|
||||
|
||||
urlpatterns = [
|
||||
path('export-import/', api_views.index, name='api-export-import'),
|
||||
path('export-import/bundle-check/', api_views.bundle_check),
|
||||
path('export-import/bundle-declare/', api_views.bundle_declare),
|
||||
path('export-import/bundle-import/', api_views.bundle_import),
|
||||
path('export-import/unlink/', api_views.bundle_unlink),
|
||||
path(
|
||||
'export-import/<slug:component_type>/',
|
||||
api_views.list_components,
|
||||
name='api-export-import-components-list',
|
||||
),
|
||||
path(
|
||||
'export-import/<slug:component_type>/<slug:slug>/',
|
||||
api_views.export_component,
|
||||
name='api-export-import-component-export',
|
||||
),
|
||||
path(
|
||||
'export-import/<slug:component_type>/<slug:slug>/dependencies/',
|
||||
api_views.component_dependencies,
|
||||
name='api-export-import-component-dependencies',
|
||||
),
|
||||
path(
|
||||
'export-import/<slug:component_type>/<slug:slug>/redirect/',
|
||||
api_views.component_redirect,
|
||||
name='api-export-import-component-redirect',
|
||||
),
|
||||
]
|
|
@ -0,0 +1,30 @@
|
|||
# chrono - agendas system
|
||||
# Copyright (C) 2016-2024 Entr'ouvert
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it
|
||||
# under the terms of the GNU Affero General Public License as published
|
||||
# by the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# 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 datetime
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.utils.timezone import now
|
||||
|
||||
from chrono.agendas.models import Agenda, Category, EventsType, Resource, UnavailabilityCalendar
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Clear obsolete snapshot instances'
|
||||
|
||||
def handle(self, **options):
|
||||
for model in [Agenda, Category, EventsType, Resource, UnavailabilityCalendar]:
|
||||
model.snapshots.filter(updated_at__lte=now() - datetime.timedelta(days=1)).delete()
|
|
@ -0,0 +1,7 @@
|
|||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = []
|
||||
|
||||
operations = []
|
|
@ -0,0 +1,186 @@
|
|||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('agendas', '0170_alter_agenda_events_type'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('snapshot', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='UnavailabilityCalendarSnapshot',
|
||||
fields=[
|
||||
(
|
||||
'id',
|
||||
models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
('timestamp', models.DateTimeField(auto_now_add=True)),
|
||||
('comment', models.TextField(blank=True, null=True)),
|
||||
('serialization', models.JSONField(blank=True, default=dict)),
|
||||
('label', models.CharField(blank=True, max_length=150, verbose_name='Label')),
|
||||
('application_slug', models.CharField(max_length=100, null=True)),
|
||||
('application_version', models.CharField(max_length=100, null=True)),
|
||||
(
|
||||
'instance',
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
to='agendas.unavailabilitycalendar',
|
||||
related_name='instance_snapshots',
|
||||
),
|
||||
),
|
||||
(
|
||||
'user',
|
||||
models.ForeignKey(
|
||||
null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'ordering': ('-timestamp',),
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ResourceSnapshot',
|
||||
fields=[
|
||||
(
|
||||
'id',
|
||||
models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
('timestamp', models.DateTimeField(auto_now_add=True)),
|
||||
('comment', models.TextField(blank=True, null=True)),
|
||||
('serialization', models.JSONField(blank=True, default=dict)),
|
||||
('label', models.CharField(blank=True, max_length=150, verbose_name='Label')),
|
||||
('application_slug', models.CharField(max_length=100, null=True)),
|
||||
('application_version', models.CharField(max_length=100, null=True)),
|
||||
(
|
||||
'instance',
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
to='agendas.resource',
|
||||
related_name='instance_snapshots',
|
||||
),
|
||||
),
|
||||
(
|
||||
'user',
|
||||
models.ForeignKey(
|
||||
null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'ordering': ('-timestamp',),
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='EventsTypeSnapshot',
|
||||
fields=[
|
||||
(
|
||||
'id',
|
||||
models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
('timestamp', models.DateTimeField(auto_now_add=True)),
|
||||
('comment', models.TextField(blank=True, null=True)),
|
||||
('serialization', models.JSONField(blank=True, default=dict)),
|
||||
('label', models.CharField(blank=True, max_length=150, verbose_name='Label')),
|
||||
('application_slug', models.CharField(max_length=100, null=True)),
|
||||
('application_version', models.CharField(max_length=100, null=True)),
|
||||
(
|
||||
'instance',
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
to='agendas.eventstype',
|
||||
related_name='instance_snapshots',
|
||||
),
|
||||
),
|
||||
(
|
||||
'user',
|
||||
models.ForeignKey(
|
||||
null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'ordering': ('-timestamp',),
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='CategorySnapshot',
|
||||
fields=[
|
||||
(
|
||||
'id',
|
||||
models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
('timestamp', models.DateTimeField(auto_now_add=True)),
|
||||
('comment', models.TextField(blank=True, null=True)),
|
||||
('serialization', models.JSONField(blank=True, default=dict)),
|
||||
('label', models.CharField(blank=True, max_length=150, verbose_name='Label')),
|
||||
('application_slug', models.CharField(max_length=100, null=True)),
|
||||
('application_version', models.CharField(max_length=100, null=True)),
|
||||
(
|
||||
'instance',
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
to='agendas.category',
|
||||
related_name='instance_snapshots',
|
||||
),
|
||||
),
|
||||
(
|
||||
'user',
|
||||
models.ForeignKey(
|
||||
null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'ordering': ('-timestamp',),
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='AgendaSnapshot',
|
||||
fields=[
|
||||
(
|
||||
'id',
|
||||
models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
('timestamp', models.DateTimeField(auto_now_add=True)),
|
||||
('comment', models.TextField(blank=True, null=True)),
|
||||
('serialization', models.JSONField(blank=True, default=dict)),
|
||||
('label', models.CharField(blank=True, max_length=150, verbose_name='Label')),
|
||||
('application_slug', models.CharField(max_length=100, null=True)),
|
||||
('application_version', models.CharField(max_length=100, null=True)),
|
||||
(
|
||||
'instance',
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
to='agendas.agenda',
|
||||
related_name='instance_snapshots',
|
||||
),
|
||||
),
|
||||
(
|
||||
'user',
|
||||
models.ForeignKey(
|
||||
null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'ordering': ('-timestamp',),
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
]
|
|
@ -0,0 +1,131 @@
|
|||
# chrono - agendas system
|
||||
# Copyright (C) 2016-2024 Entr'ouvert
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it
|
||||
# under the terms of the GNU Affero General Public License as published
|
||||
# by the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# 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/>.
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
class WithSnapshotManager(models.Manager):
|
||||
snapshots = False
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.snapshots = kwargs.pop('snapshots', False)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = super().get_queryset()
|
||||
if self.snapshots:
|
||||
return queryset.filter(snapshot__isnull=False)
|
||||
else:
|
||||
return queryset.filter(snapshot__isnull=True)
|
||||
|
||||
|
||||
class WithSnapshotMixin:
|
||||
@classmethod
|
||||
def get_snapshot_model(cls):
|
||||
return cls._meta.get_field('snapshot').related_model
|
||||
|
||||
def take_snapshot(self, *args, **kwargs):
|
||||
self.get_snapshot_model().take(self, *args, **kwargs)
|
||||
|
||||
|
||||
class AbstractSnapshot(models.Model):
|
||||
timestamp = models.DateTimeField(auto_now_add=True)
|
||||
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, null=True)
|
||||
comment = models.TextField(blank=True, null=True)
|
||||
serialization = models.JSONField(blank=True, default=dict)
|
||||
label = models.CharField(_('Label'), max_length=150, blank=True)
|
||||
application_slug = models.CharField(max_length=100, null=True)
|
||||
application_version = models.CharField(max_length=100, null=True)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
ordering = ('-timestamp',)
|
||||
|
||||
@classmethod
|
||||
def get_instance_model(cls):
|
||||
return cls._meta.get_field('instance').related_model
|
||||
|
||||
@classmethod
|
||||
def take(cls, instance, request=None, comment=None, deletion=False, label=None, application=None):
|
||||
snapshot = cls(instance=instance, comment=comment, label=label or '')
|
||||
if request and not request.user.is_anonymous:
|
||||
snapshot.user = request.user
|
||||
if not deletion:
|
||||
snapshot.serialization = instance.export_json()
|
||||
else:
|
||||
snapshot.serialization = {}
|
||||
snapshot.comment = comment or _('deletion')
|
||||
if application:
|
||||
snapshot.application_slug = application.slug
|
||||
snapshot.application_version = application.version_number
|
||||
snapshot.save()
|
||||
|
||||
def get_instance(self):
|
||||
try:
|
||||
# try reusing existing instance
|
||||
return self.get_instance_model().snapshots.get(snapshot=self)
|
||||
except self.get_instance_model().DoesNotExist:
|
||||
return self.load_instance(self.serialization, snapshot=self)
|
||||
|
||||
def load_instance(self, json_instance, snapshot=None):
|
||||
return self.get_instance_model().import_json(json_instance, snapshot=snapshot)[1]
|
||||
|
||||
|
||||
class AgendaSnapshot(AbstractSnapshot):
|
||||
instance = models.ForeignKey(
|
||||
'agendas.Agenda',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
related_name='instance_snapshots',
|
||||
)
|
||||
|
||||
|
||||
class CategorySnapshot(AbstractSnapshot):
|
||||
instance = models.ForeignKey(
|
||||
'agendas.Category',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
related_name='instance_snapshots',
|
||||
)
|
||||
|
||||
|
||||
class EventsTypeSnapshot(AbstractSnapshot):
|
||||
instance = models.ForeignKey(
|
||||
'agendas.EventsType',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
related_name='instance_snapshots',
|
||||
)
|
||||
|
||||
|
||||
class ResourceSnapshot(AbstractSnapshot):
|
||||
instance = models.ForeignKey(
|
||||
'agendas.Resource',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
related_name='instance_snapshots',
|
||||
)
|
||||
|
||||
|
||||
class UnavailabilityCalendarSnapshot(AbstractSnapshot):
|
||||
instance = models.ForeignKey(
|
||||
'agendas.UnavailabilityCalendar',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
related_name='instance_snapshots',
|
||||
)
|
File diff suppressed because it is too large
Load Diff
|
@ -48,6 +48,7 @@ from chrono.agendas.models import (
|
|||
AgendaNotificationsSettings,
|
||||
AgendaReminderSettings,
|
||||
Booking,
|
||||
BookingCheck,
|
||||
Category,
|
||||
Desk,
|
||||
Event,
|
||||
|
@ -82,7 +83,7 @@ class AgendaAddForm(forms.ModelForm):
|
|||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if settings.PARTIAL_BOOKINGS_ENABLED:
|
||||
if 'kind' in self.fields and settings.PARTIAL_BOOKINGS_ENABLED:
|
||||
self.fields['kind'].choices += [('partial-bookings', _('Partial bookings'))]
|
||||
|
||||
class Meta:
|
||||
|
@ -95,6 +96,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
|
||||
|
@ -521,6 +523,20 @@ class BookingCheckFilterSet(django_filters.FilterSet):
|
|||
)
|
||||
self.filters['booking-status'].parent = self
|
||||
|
||||
if self.agenda.partial_bookings:
|
||||
self.filters['display'] = django_filters.MultipleChoiceFilter(
|
||||
label=_('Display'),
|
||||
choices=[
|
||||
('booked', _('Booked periods')),
|
||||
('checked', _('Checked periods')),
|
||||
('computed', _('Computed periods')),
|
||||
],
|
||||
widget=forms.CheckboxSelectMultiple,
|
||||
method='do_nothing',
|
||||
initial=['booked', 'checked', 'computed'],
|
||||
)
|
||||
self.filters['display'].parent = self
|
||||
|
||||
def filter_booking_status(self, queryset, name, value):
|
||||
if value == 'not-booked':
|
||||
return queryset.none()
|
||||
|
@ -530,15 +546,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):
|
||||
|
@ -583,7 +599,7 @@ class BookingCheckPresenceForm(forms.Form):
|
|||
|
||||
|
||||
class PartialBookingCheckForm(forms.ModelForm):
|
||||
user_was_present = forms.NullBooleanField(
|
||||
presence = forms.NullBooleanField(
|
||||
label=_('Status'),
|
||||
widget=forms.RadioSelect(
|
||||
choices=(
|
||||
|
@ -598,19 +614,21 @@ 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 = ['presence', 'start_time', 'end_time', 'type_label', 'type_slug']
|
||||
widgets = {
|
||||
'user_check_start_time': widgets.TimeWidgetWithButton(
|
||||
'start_time': widgets.TimeWidgetWithButton(
|
||||
step=60, button_label=_('Fill with booking start time')
|
||||
),
|
||||
'user_check_end_time': widgets.TimeWidgetWithButton(
|
||||
step=60, button_label=_('Fill with booking end 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):
|
||||
def __init__(self, *args, first_check_form=None, **kwargs):
|
||||
agenda = kwargs.pop('agenda')
|
||||
self.event = kwargs.pop('event')
|
||||
self.first_check_form = first_check_form
|
||||
super().__init__(*args, **kwargs)
|
||||
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']
|
||||
|
@ -618,39 +636,78 @@ class PartialBookingCheckForm(forms.ModelForm):
|
|||
|
||||
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.start_time:
|
||||
self.fields['user_check_start_time'].widget = widgets.TimeWidget(step=60)
|
||||
self.fields['user_check_end_time'].widget = widgets.TimeWidget(step=60)
|
||||
self.fields['user_was_present'].widget.choices = ((None, _('Not checked')), (True, _('Present')))
|
||||
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_check_end_time'] <= self.cleaned_data['user_check_start_time']:
|
||||
if self.cleaned_data.get('presence') is None:
|
||||
return
|
||||
|
||||
start_time = self.cleaned_data.get('start_time')
|
||||
end_time = self.cleaned_data.get('end_time')
|
||||
|
||||
if not start_time and not end_time:
|
||||
raise ValidationError(_('Both arrival and departure cannot not be empty.'))
|
||||
|
||||
if start_time and end_time and end_time <= start_time:
|
||||
raise ValidationError(_('Arrival must be before departure.'))
|
||||
|
||||
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['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 clean_presence(self):
|
||||
if (
|
||||
self.first_check_form
|
||||
and self.cleaned_data['presence'] is not None
|
||||
and self.cleaned_data['presence'] == self.first_check_form.cleaned_data['presence']
|
||||
):
|
||||
raise ValidationError(_('Both booking checks cannot have the same status.'))
|
||||
|
||||
return self.cleaned_data['presence']
|
||||
|
||||
def clean_start_time(self):
|
||||
start_time = self.cleaned_data['start_time']
|
||||
if start_time and start_time < localtime(self.event.start_datetime).time():
|
||||
raise ValidationError(_('Arrival must be after opening time.'))
|
||||
|
||||
return start_time
|
||||
|
||||
def clean_end_time(self):
|
||||
end_time = self.cleaned_data['end_time']
|
||||
if end_time and end_time > self.event.end_time:
|
||||
raise ValidationError(_('Departure must be before closing time.'))
|
||||
|
||||
return end_time
|
||||
|
||||
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
|
||||
return super().save()
|
||||
booking = self.instance.booking
|
||||
if self.cleaned_data['presence'] is None:
|
||||
if self.instance.pk:
|
||||
self.instance.delete()
|
||||
booking.refresh_computed_times(commit=True)
|
||||
return self.instance
|
||||
|
||||
super().save()
|
||||
booking.refresh_computed_times(commit=True)
|
||||
return self.instance
|
||||
|
||||
|
||||
class EventsTimesheetForm(forms.Form):
|
||||
|
@ -716,17 +773,29 @@ class EventsTimesheetForm(forms.Form):
|
|||
],
|
||||
initial='portrait',
|
||||
)
|
||||
booking_filter = forms.ChoiceField(
|
||||
label=_('Filter by status'),
|
||||
choices=[
|
||||
('all', _('All')),
|
||||
('with_booking', _('With booking')),
|
||||
('without_booking', _('Without booking')),
|
||||
],
|
||||
initial='all',
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.agenda = kwargs.pop('agenda')
|
||||
self.event = kwargs.pop('event', None)
|
||||
super().__init__(*args, **kwargs)
|
||||
self.with_subscriptions = self.agenda.subscriptions.exists()
|
||||
if self.event is not None:
|
||||
del self.fields['date_start']
|
||||
del self.fields['date_end']
|
||||
del self.fields['date_display']
|
||||
del self.fields['custom_nb_dates_per_page']
|
||||
del self.fields['activity_display']
|
||||
if not self.with_subscriptions:
|
||||
del self.fields['booking_filter']
|
||||
|
||||
def get_slots(self):
|
||||
extra_data = self.cleaned_data['extra_data'].split(',')
|
||||
|
@ -794,20 +863,21 @@ class EventsTimesheetForm(forms.Form):
|
|||
)
|
||||
|
||||
users = {}
|
||||
subscriptions = self.agenda.subscriptions.filter(date_start__lt=max_start, date_end__gt=min_start)
|
||||
for subscription in subscriptions:
|
||||
if subscription.user_external_id in users:
|
||||
continue
|
||||
users[subscription.user_external_id] = {
|
||||
'user_id': subscription.user_external_id,
|
||||
'user_first_name': subscription.user_first_name,
|
||||
'user_last_name': subscription.user_last_name,
|
||||
'extra_data': {k: (subscription.extra_data or {}).get(k) or '' for k in all_extra_data},
|
||||
'events': copy.deepcopy(event_slots),
|
||||
}
|
||||
if self.with_subscriptions:
|
||||
subscriptions = self.agenda.subscriptions.filter(date_start__lt=max_start, date_end__gt=min_start)
|
||||
for subscription in subscriptions:
|
||||
if subscription.user_external_id in users:
|
||||
continue
|
||||
users[subscription.user_external_id] = {
|
||||
'user_id': subscription.user_external_id,
|
||||
'user_first_name': subscription.user_first_name,
|
||||
'user_last_name': subscription.user_last_name,
|
||||
'extra_data': {k: (subscription.extra_data or {}).get(k) or '' for k in all_extra_data},
|
||||
'events': copy.deepcopy(event_slots),
|
||||
}
|
||||
|
||||
booking_qs_kwargs = {}
|
||||
if not self.agenda.subscriptions.exists():
|
||||
if not self.with_subscriptions:
|
||||
booking_qs_kwargs = {'cancellation_datetime__isnull': True}
|
||||
booked_qs = (
|
||||
Booking.objects.filter(
|
||||
|
@ -843,6 +913,19 @@ class EventsTimesheetForm(forms.Form):
|
|||
participants += 1
|
||||
break
|
||||
|
||||
if self.cleaned_data.get('booking_filter') == 'with_booking':
|
||||
# remove subscribed users without booking
|
||||
users = {
|
||||
k: user for k, user in users.items() if any(any(e['dates'].values()) for e in user['events'])
|
||||
}
|
||||
elif self.cleaned_data.get('booking_filter') == 'without_booking':
|
||||
# remove subscribed users with booking
|
||||
users = {
|
||||
k: user
|
||||
for k, user in users.items()
|
||||
if not any(any(e['dates'].values()) for e in user['events'])
|
||||
}
|
||||
|
||||
if self.cleaned_data['sort'] == 'lastname,firstname':
|
||||
sort_fields = ['user_last_name', 'user_first_name']
|
||||
else:
|
||||
|
@ -1598,9 +1681,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'),
|
||||
|
@ -1620,6 +1706,12 @@ 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:
|
||||
|
@ -1629,6 +1721,11 @@ class AgendaInvoicingSettingsForm(forms.ModelForm):
|
|||
'invoicing_tolerance',
|
||||
]
|
||||
|
||||
def save(self):
|
||||
super().save()
|
||||
self.instance.async_refresh_booking_computed_times()
|
||||
return self.instance
|
||||
|
||||
|
||||
class AgendaNotificationsForm(forms.ModelForm):
|
||||
class Meta:
|
||||
|
|
|
@ -201,6 +201,18 @@ table.agenda-table {
|
|||
text-align: center;
|
||||
}
|
||||
&.booking {
|
||||
&.lease {
|
||||
background:
|
||||
repeating-linear-gradient(
|
||||
135deg,
|
||||
hsla(10, 10%, 75%, 0.7) 0,
|
||||
hsla(10, 10%, 80%, 0.55) 10px,
|
||||
transparent 11px,
|
||||
transparent 20px);
|
||||
// color: currentColor;
|
||||
color: hsla(0, 0%, 0%, 0.7);
|
||||
}
|
||||
|
||||
left: 0;
|
||||
color: hsl(210, 84%, 40%);
|
||||
padding: 1ex;
|
||||
|
@ -562,6 +574,18 @@ div.agenda-settings .pk-tabs--container {
|
|||
|
||||
#event_details {
|
||||
margin: 1em 0;
|
||||
.objects-list .lease {
|
||||
background:
|
||||
repeating-linear-gradient(
|
||||
135deg,
|
||||
hsla(10, 10%, 75%, 0.7) 0,
|
||||
hsla(10, 10%, 80%, 0.55) 10px,
|
||||
transparent 11px,
|
||||
transparent 20px);
|
||||
}
|
||||
.objects-list .lease span {
|
||||
padding: 0 0.5ex 0 2ex;
|
||||
}
|
||||
}
|
||||
|
||||
@media print {
|
||||
|
@ -609,10 +633,11 @@ div#main-content.partial-booking-dayview {
|
|||
--zebra-color: hsla(0,0%,0%,0.05);
|
||||
--separator-color: white;
|
||||
--separator-size: 2px;
|
||||
--padding: 0.5rem;
|
||||
|
||||
position: relative;
|
||||
background: white;
|
||||
padding: 0.5rem;
|
||||
padding: var(--padding);
|
||||
|
||||
&--hours-list {
|
||||
background: white;
|
||||
|
@ -634,8 +659,88 @@ div#main-content.partial-booking-dayview {
|
|||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
&--occupation-rate-list {
|
||||
position: static;
|
||||
display: grid;
|
||||
grid-template-rows: 40px auto;
|
||||
align-items: end;
|
||||
margin-top: 0.33rem;
|
||||
margin-bottom: 1rem;
|
||||
border-top: 3px solid var(--zebra-color);
|
||||
grid-template-columns: repeat(var(--nb-hours), 1fr);
|
||||
@media (min-width: 761px) {
|
||||
grid-template-columns: var(--registrant-name-width) repeat(var(--nb-hours), 1fr);
|
||||
}
|
||||
}
|
||||
.occupation-rate-list--title {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: normal;
|
||||
justify-self: end;
|
||||
align-self: end;
|
||||
padding: .66rem;
|
||||
padding-bottom: 0;
|
||||
@media (max-width: 760px) {
|
||||
grid-column: 1/-1;
|
||||
grid-row: 2/3;
|
||||
}
|
||||
}
|
||||
.occupation-rate {
|
||||
@function linear-progress($from, $to) {
|
||||
$ratio: #{($to - $from) / 100};
|
||||
@return "calc(#{$ratio} * var(--rate-percent) + #{$from})";
|
||||
}
|
||||
--hue: #{linear-progress(40, 10)};
|
||||
--saturation: #{linear-progress(50%, 75%)};
|
||||
--luminosity: #{linear-progress(65%, 50%)};
|
||||
background-color: hsl(var(--hue), var(--saturation), var(--luminosity));
|
||||
height: calc(1% * var(--rate-percent));
|
||||
margin: 0;
|
||||
opacity: 80%;
|
||||
position: relative;
|
||||
&--info {
|
||||
display: block;
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
padding: .33em .66em;
|
||||
text-align: center;
|
||||
background-color: var(--font-color);
|
||||
color: white;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
margin-top: .5em;
|
||||
font-weight: bold;
|
||||
filter: drop-shadow(0 0 3px white);
|
||||
&::before {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border: .5em solid transparent;
|
||||
border-bottom-color: var(--font-color);
|
||||
}
|
||||
}
|
||||
&:not(:hover) .occupation-rate--info {
|
||||
display: none;
|
||||
}
|
||||
&:hover {
|
||||
opacity: 100%;
|
||||
z-index: 4;
|
||||
}
|
||||
&.overbooked {
|
||||
--hue: 0;
|
||||
--saturation: 95%;
|
||||
--luminosity: 40%;
|
||||
}
|
||||
}
|
||||
|
||||
&--registrant-items {
|
||||
margin-top: 0.5rem;
|
||||
position: relative;
|
||||
}
|
||||
&--registrant {
|
||||
display: flex;
|
||||
|
@ -682,36 +787,89 @@ div#main-content.partial-booking-dayview {
|
|||
margin: 0.33rem 0;
|
||||
}
|
||||
&--bar {
|
||||
--color: white;
|
||||
box-sizing: border-box;
|
||||
display: inline-block;
|
||||
margin: 0;
|
||||
position: relative;
|
||||
padding: 0.33em 0.66em;
|
||||
background-color: var(--background);
|
||||
color: var(--color);
|
||||
background-color: var(--bar-color);
|
||||
color: white;
|
||||
border: none;
|
||||
&:not(:first-child) {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
}
|
||||
.start-time, .end-time {
|
||||
display: inline-block;
|
||||
padding: 0.33em 0.66em;
|
||||
}
|
||||
.end-time {
|
||||
float: right;
|
||||
margin-left: .66em;
|
||||
}
|
||||
&.booking {
|
||||
--background: #1066bc;
|
||||
--bar-color: #1066bc;
|
||||
.occasional {
|
||||
font-style: italic;
|
||||
font-size: 90%;
|
||||
}
|
||||
}
|
||||
&.check.present, &.computed.present {
|
||||
--background: var(--green);
|
||||
--bar-color: var(--green);
|
||||
}
|
||||
&.check.absent, &.computed.absent {
|
||||
--background: var(--red);
|
||||
--bar-color: var(--red);
|
||||
}
|
||||
&.computed {
|
||||
opacity: 0.6;
|
||||
}
|
||||
&.end-only, &.start-only {
|
||||
background-color: transparent;
|
||||
.end-time, .start-time {
|
||||
background-color: var(--bar-color);
|
||||
position: relative;
|
||||
&::before {
|
||||
content:"?";
|
||||
color: var(--bar-color);
|
||||
font-weight: 800;
|
||||
line-height: 0;
|
||||
position: absolute;
|
||||
border: 0.75em solid transparent;
|
||||
width: 0;
|
||||
height: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
margin: auto;
|
||||
}
|
||||
}
|
||||
.start-time::before {
|
||||
left: 100%;
|
||||
border-left-color: var(--bar-color);
|
||||
text-indent: 0.25em;
|
||||
}
|
||||
.end-time::before {
|
||||
right: 100%;
|
||||
border-right-color: var(--bar-color);
|
||||
text-indent: -0.75em;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
&--hour-indicator-wrapper {
|
||||
position: absolute;
|
||||
inset: 0 var(--padding) 0 var(--padding);
|
||||
@media (min-width: 761px) {
|
||||
margin-left: var(--registrant-name-width);
|
||||
}
|
||||
}
|
||||
&--hour-indicator {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 1px;
|
||||
height: 100%;
|
||||
background-color: var(--red);
|
||||
z-index: 3;
|
||||
}
|
||||
}
|
||||
|
||||
.agenda-table.partial-bookings .booking {
|
||||
|
@ -729,6 +887,15 @@ div#main-content.partial-booking-dayview {
|
|||
}
|
||||
}
|
||||
|
||||
.partial-booking--check-icon {
|
||||
border: 0;
|
||||
&::before {
|
||||
content: "\f017"; /* clock */
|
||||
font-family: FontAwesome;
|
||||
padding-left: 1ex;
|
||||
}
|
||||
}
|
||||
|
||||
/* ants-hub */
|
||||
ul.objects-list.single-links li.ants-setting-not-configured a.edit {
|
||||
color: red;
|
||||
|
@ -774,3 +941,18 @@ ul.objects-list.single-links li.ants-setting-not-configured a.edit {
|
|||
|
||||
/* used for the city-edit link */
|
||||
.icon-edit::before { content: "\f044"; }
|
||||
|
||||
a.button.button-paragraph {
|
||||
text-align: left;
|
||||
box-sizing: border-box;
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
margin-bottom: 1rem;
|
||||
line-height: 150%;
|
||||
padding-top: 0.8em;
|
||||
padding-bottom: 0.8em;
|
||||
}
|
||||
|
||||
.application-logo, .application-icon {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
{% load thumbnail %}
|
||||
{% if application %}
|
||||
<h2>
|
||||
{% thumbnail application.icon '64x64' format='PNG' as im %}
|
||||
<img src="{{ im.url }}" alt="" class="application-logo" />
|
||||
{% endthumbnail %}
|
||||
{{ application }}
|
||||
</h2>
|
||||
{% elif no_application %}
|
||||
<h2>{{ title_no_application }}</h2>
|
||||
{% else %}
|
||||
<h2>{{ title_object_list }}</h2>
|
||||
{% endif %}
|
|
@ -0,0 +1,5 @@
|
|||
{% if application %}
|
||||
<a href="{{ object_list_url }}?application={{ application.slug }}">{{ application }}</a>
|
||||
{% elif no_application %}
|
||||
<a href="{{ object_list_url }}?no-application">{{ title_no_application }}</a>
|
||||
{% endif %}
|
|
@ -0,0 +1,12 @@
|
|||
{% load i18n thumbnail %}
|
||||
{% if object.applications %}
|
||||
<h3>{% trans "Applications" %}</h3>
|
||||
{% for application in object.applications %}
|
||||
<a class="button button-paragraph" href="{{ object_list_url }}?application={{ application.slug }}">
|
||||
{% thumbnail application.icon '16x16' format='PNG' as im %}
|
||||
<img src="{{ im.url }}" alt="" class="application-icon" width="16" />
|
||||
{% endthumbnail %}
|
||||
{{ application }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% endif %}
|
|
@ -0,0 +1,8 @@
|
|||
{% load thumbnail %}
|
||||
{% if not application and not no_application %}
|
||||
{% for application in object.applications %}
|
||||
{% thumbnail application.icon '16x16' format='PNG' as im %}
|
||||
<img src="{{ im.url }}" alt="" class="application-icon" width="16" />
|
||||
{% endthumbnail %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
|
@ -0,0 +1,15 @@
|
|||
{% load i18n thumbnail %}
|
||||
{% if applications %}
|
||||
<h3>{% trans "Applications" %}</h3>
|
||||
{% for application in applications %}
|
||||
<a class="button button-paragraph" href="?application={{ application.slug }}">
|
||||
{% thumbnail application.icon '16x16' format='PNG' as im %}
|
||||
<img src="{{ im.url }}" alt="" class="application-icon" width="16" />
|
||||
{% endthumbnail %}
|
||||
{{ application }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
<a class="button button-paragraph" href="?no-application">
|
||||
{{ title_no_application }}
|
||||
</a>
|
||||
{% endif %}
|
|
@ -14,7 +14,7 @@
|
|||
<a class="date-next pk-button" href="{{ view.get_next_day_url }}"><span class="sr-only">{% trans "Next day" %}</span></a>
|
||||
</span>
|
||||
<h2 class="date-nav">
|
||||
<span class="date-title">{{ view.date|date:"l j F Y" }}</span>
|
||||
<time datetime="{{ view.date|date:'Y-m-d' }}" class="date-title">{{ view.date|date:"l j F Y" }}</time>
|
||||
<button class="date-picker-opener"><span class="sr-only">{% trans "Pick a date" %}</span></button>
|
||||
{% with selected_day=view.date|date:"j" selected_month=view.date|date:"n" selected_year=view.date|date:"Y" %}
|
||||
<div class="date-picker" style="display: none">
|
||||
|
|
|
@ -16,14 +16,8 @@
|
|||
</h2>
|
||||
<span class="actions">
|
||||
<a class="extra-actions-menu-opener"></a>
|
||||
{% block agenda-extra-management-actions %}
|
||||
{% endblock %}
|
||||
<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 %}
|
||||
{% 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>
|
||||
|
@ -41,7 +35,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>
|
||||
|
@ -51,7 +45,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>
|
||||
|
@ -115,3 +109,20 @@
|
|||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block sidebar %}
|
||||
<aside id="sidebar">
|
||||
|
||||
<h3>{% trans "Actions" %}</h3>
|
||||
{% block agenda-extra-management-actions %}{% endblock %}
|
||||
<a class="button button-paragraph" rel="popup" href="{% url 'chrono-manager-agenda-edit' pk=object.id %}">{% trans 'Options' %}</a>
|
||||
{% if user.is_staff %}
|
||||
<a class="button button-paragraph" rel="popup" class="action-duplicate" href="{% url 'chrono-manager-agenda-duplicate' pk=object.pk %}">{% trans 'Duplicate' %}</a>
|
||||
{% endif %}
|
||||
|
||||
{% block agenda-extra-navigation-actions %}{% endblock %}
|
||||
|
||||
{% url 'chrono-manager-homepage' as object_list_url %}
|
||||
{% include 'chrono/includes/application_detail_fragment.html' %}
|
||||
</aside>
|
||||
{% endblock %}
|
||||
|
|
|
@ -11,7 +11,6 @@
|
|||
{% trans 'Agendas' as default_site_title %}
|
||||
{% firstof site_title default_site_title %}
|
||||
{% endblock %}
|
||||
{% block footer %}Chrono — Copyright © Entr'ouvert{% endblock %}
|
||||
|
||||
{% block homepage-url %}{{portal_agent_url}}{% endblock %}
|
||||
{% block homepage-title %}{{portal_agent_title}}{% endblock %}
|
||||
|
|
|
@ -1,6 +1,19 @@
|
|||
{% extends "chrono/manager_home.html" %}
|
||||
{% extends "chrono/manager_category_list.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block page-title-extra-label %}
|
||||
{% if object.pk %}{{ object.label }}{% else %}{{ block.super }}{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block breadcrumb %}
|
||||
{{ block.super }}
|
||||
{% if object.pk %}
|
||||
<a href="{% url 'chrono-manager-category-edit' pk=object.pk %}">{{ object.label }}</a>
|
||||
{% else %}
|
||||
<a href="{% url 'chrono-manager-category-add' %}">{% trans "New Category" %}</a>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block appbar %}
|
||||
{% if object.id %}
|
||||
<h2>{% trans "Edit Category" %}</h2>
|
||||
|
@ -20,3 +33,6 @@
|
|||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
{% block sidebar %}
|
||||
{% endblock %}
|
||||
|
|
|
@ -1,32 +1,37 @@
|
|||
{% extends "chrono/manager_base.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block page-title-extra-label %}
|
||||
{% trans "Categories" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block breadcrumb %}
|
||||
{{ block.super }}
|
||||
<a href="{% url 'chrono-manager-category-list' %}">{% trans "Categories" %}</a>
|
||||
{% url 'chrono-manager-category-list' as object_list_url %}
|
||||
<a href="{{ object_list_url }}">{% trans "Categories" %}</a>
|
||||
{% include 'chrono/includes/application_breadcrumb_fragment.html' with title_no_application=_('Categories outside applications') %}
|
||||
{% endblock %}
|
||||
|
||||
{% block appbar %}
|
||||
<h2>{% trans 'Categories' %}</h2>
|
||||
<span class="actions">
|
||||
<a rel="popup" href="{% url 'chrono-manager-category-add' %}">{% trans 'New' %}</a>
|
||||
</span>
|
||||
{% include 'chrono/includes/application_appbar_fragment.html' with title_no_application=_('Categories outside applications') title_object_list=_('Categories') %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
{% if object_list %}
|
||||
<div>
|
||||
<ul class="objects-list single-links">
|
||||
{% for object in object_list %}
|
||||
<li>
|
||||
<a href="{% url 'chrono-manager-category-edit' pk=object.pk %}">{{ object.label }} ({{ object.slug }})</a>
|
||||
<a href="{% url 'chrono-manager-category-edit' pk=object.pk %}">
|
||||
{% include 'chrono/includes/application_icon_fragment.html' %}
|
||||
{{ object.label }} ({{ object.slug }})
|
||||
</a>
|
||||
<a rel="popup" class="delete" href="{% url 'chrono-manager-category-delete' pk=object.id %}">{% trans "remove" %}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% else %}
|
||||
{% elif not no_application %}
|
||||
<div class="big-msg-info">
|
||||
{% blocktrans trimmed %}
|
||||
This site doesn't have any category yet. Click on the "New" button in the top
|
||||
|
@ -35,3 +40,14 @@
|
|||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block sidebar %}
|
||||
{% if not application and not no_application %}
|
||||
<aside id="sidebar">
|
||||
<h3>{% trans "Actions" %}</h3>
|
||||
<a class="button button-paragraph" rel="popup" href="{% url 'chrono-manager-category-add' %}">{% trans 'New category' %}</a>
|
||||
|
||||
{% include 'chrono/includes/application_list_fragment.html' with title_no_application=_('Categories outside applications') %}
|
||||
</aside>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
|
|
@ -29,3 +29,6 @@
|
|||
{% include "gadjo/pagination.html" %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block sidebar %}
|
||||
{% endblock %}
|
||||
|
|
|
@ -15,71 +15,71 @@
|
|||
</h3>
|
||||
<div>
|
||||
<form class="check-bookings-filters">
|
||||
{{ filterset.form.as_p }}
|
||||
<script>
|
||||
$(function() {
|
||||
$('form.check-bookings-filters input').on('change',
|
||||
function() {
|
||||
$(this).parents('form').submit();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
<fieldset class="gadjo-foldable gadjo-folded" id="filters">
|
||||
<legend class="gadjo-foldable-widget">{% trans "Filtering options" %}</legend>
|
||||
<div class="gadjo-folding">
|
||||
{{ filterset.form.as_p }}
|
||||
<button class="submit-button">{% trans "Apply" context 'form filtering action' %}</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
<table class="main check-bookings">
|
||||
<tbody>
|
||||
{% if results and not event.checked and not event.check_locked %}
|
||||
<tr class="booking">
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<table class="main check-bookings">
|
||||
<tbody>
|
||||
{% if results and not event.checked and not event.check_locked %}
|
||||
<tr class="booking">
|
||||
<td class="booking-actions" colspan="3">
|
||||
<form method="post" action="{% url 'chrono-manager-event-checked' pk=agenda.pk event_pk=object.pk %}">
|
||||
{% csrf_token %}
|
||||
<button class="submit-button">{% trans "Mark the event as checked" %}</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if booked_without_status %}
|
||||
{% if not event.checked or not agenda.disable_check_update %}
|
||||
<tr class="booking all-bookings">
|
||||
<td colspan="2"><b>{% trans "Mark all bookings without status:" %}</b></td>
|
||||
<td class="booking-actions">
|
||||
<form method="post" action="{% url 'chrono-manager-event-checked' pk=agenda.pk event_pk=object.pk %}">
|
||||
<form method="post" action="{% url 'chrono-manager-event-presence' pk=agenda.pk event_pk=object.pk %}" id="all-bookings-presence">
|
||||
{% csrf_token %}
|
||||
<button class="submit-button">{% trans "Mark the event as checked" %}</button>
|
||||
<button class="submit-button">{% trans "Presence" %}</button>
|
||||
{% if presence_form.check_type.field.choices.1 %}{{ presence_form.check_type }}{% endif %}
|
||||
<script>
|
||||
$(function() {
|
||||
$('#all-bookings-presence select').on('change',
|
||||
function() {
|
||||
$('#all-bookings-presence').submit();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</form>
|
||||
<form method="post" action="{% url 'chrono-manager-event-absence' pk=agenda.pk event_pk=object.pk %}" id="all-bookings-absence">
|
||||
{% csrf_token %}
|
||||
<button class="submit-button">{% trans "Absence" %}</button>
|
||||
{% if absence_form.check_type.field.choices.1 %}{{ absence_form.check_type }}{% endif %}
|
||||
<script>
|
||||
$(function() {
|
||||
$('#all-bookings-absence select').on('change',
|
||||
function() {
|
||||
$('#all-bookings-absence').submit();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if booked_without_status %}
|
||||
{% if not event.checked or not agenda.disable_check_update %}
|
||||
<tr class="booking all-bookings">
|
||||
<td colspan="2"><b>{% trans "Mark all bookings without status:" %}</b></td>
|
||||
<td class="booking-actions">
|
||||
<form method="post" action="{% url 'chrono-manager-event-presence' pk=agenda.pk event_pk=object.pk %}" id="all-bookings-presence">
|
||||
{% csrf_token %}
|
||||
<button class="submit-button">{% trans "Presence" %}</button>
|
||||
{% if presence_form.check_type.field.choices.1 %}{{ presence_form.check_type }}{% endif %}
|
||||
<script>
|
||||
$(function() {
|
||||
$('#all-bookings-presence select').on('change',
|
||||
function() {
|
||||
$('#all-bookings-presence').submit();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</form>
|
||||
<form method="post" action="{% url 'chrono-manager-event-absence' pk=agenda.pk event_pk=object.pk %}" id="all-bookings-absence">
|
||||
{% csrf_token %}
|
||||
<button class="submit-button">{% trans "Absence" %}</button>
|
||||
{% if absence_form.check_type.field.choices.1 %}{{ absence_form.check_type }}{% endif %}
|
||||
<script>
|
||||
$(function() {
|
||||
$('#all-bookings-absence select').on('change',
|
||||
function() {
|
||||
$('#all-bookings-absence').submit();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% for result in results %}
|
||||
<tr class="booking {% if agenda.booking_extra_user_block_template %}untoggled{% endif %}">
|
||||
{% include "chrono/manager_event_check_booking_fragment.html" with booking=result %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% for result in results %}
|
||||
<tr class="booking {% if agenda.booking_extra_user_block_template %}untoggled{% endif %}">
|
||||
{% include "chrono/manager_event_check_booking_fragment.html" with booking=result %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% if object.waiting_list_places %}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
{% endblock %}
|
||||
|
||||
{% block page-title-extra-label %}
|
||||
- {% firstof agenda.label event.label %}
|
||||
{% firstof agenda.label event.label %}
|
||||
{% endblock %}
|
||||
|
||||
{% block breadcrumb %}
|
||||
|
|
|
@ -28,12 +28,16 @@
|
|||
|
||||
<ul class="objects-list single-links">
|
||||
{% for booking in booked %}
|
||||
<li>
|
||||
<a {% if booking.get_backoffice_url %}href="{{ booking.get_backoffice_url }}"{% endif %}>{{ booking.get_user_block }}, {{ booking.creation_datetime|date:"DATETIME_FORMAT" }}</a>
|
||||
{% if not booking.primary_booking %}
|
||||
<a rel="popup" class="delete" href="{% url 'chrono-manager-booking-cancel' pk=agenda.id booking_pk=booking.id %}?next={{ request.path }}">{% trans "Cancel" %}</a>
|
||||
<li{% if booking.lease %} class="lease"{% endif %}>
|
||||
{% if booking.lease %}
|
||||
<span>{% trans "Currently being booked..." %}</span>
|
||||
{% else %}
|
||||
<a class="delete disabled" title="{% trans "Can not cancel a secondary booking" %}" href="#">{% trans "Cancel" %}</a>
|
||||
<a {% if booking.get_backoffice_url %}href="{{ booking.get_backoffice_url }}"{% endif %}>{{ booking.get_user_block }}, {{ booking.creation_datetime|date:"DATETIME_FORMAT" }}</a>
|
||||
{% if not booking.primary_booking %}
|
||||
<a rel="popup" class="delete" href="{% url 'chrono-manager-booking-cancel' pk=agenda.id booking_pk=booking.id %}?next={{ request.path }}">{% trans "Cancel" %}</a>
|
||||
{% else %}
|
||||
<a class="delete disabled" title="{% trans "Can not cancel a secondary booking" %}" href="#">{% trans "Cancel" %}</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
|
@ -53,7 +57,7 @@
|
|||
<div>
|
||||
<ul class="objects-list single-links">
|
||||
{% for booking in waiting %}
|
||||
<li><a {% if booking.get_backoffice_url %}href="{{ booking.get_backoffice_url }}"{% endif %}>{{ booking.get_user_block }}, {{ booking.creation_datetime|date:"DATETIME_FORMAT" }}</a></li>
|
||||
<li{% if booking.lease %} class="lease"{% endif %}><a {% if booking.get_backoffice_url %}href="{{ booking.get_backoffice_url }}"{% endif %}>{% if booking.lease %}{% trans "Currently being booked..." %}{% else %}{{ booking.get_user_block }}, {{ booking.creation_datetime|date:"DATETIME_FORMAT" }}{% endif %}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
|
|
|
@ -2,13 +2,14 @@
|
|||
{% load i18n %}
|
||||
|
||||
{% block agenda-extra-management-actions %}
|
||||
<a rel="popup" href="{% url 'chrono-manager-agenda-import-events' pk=object.id %}">{% trans 'Import Events' %}</a>
|
||||
<a rel="popup" href="{% url 'chrono-manager-agenda-add-event' pk=object.id %}">{% trans 'New Event' %}</a>
|
||||
<a class="button button-paragraph" rel="popup" href="{% url 'chrono-manager-agenda-import-events' pk=object.id %}">{% trans 'Import Events' %}</a>
|
||||
<a class="button button-paragraph" rel="popup" href="{% url 'chrono-manager-agenda-add-event' pk=object.id %}">{% trans 'New Event' %}</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block agenda-extra-menu-actions %}
|
||||
{% block agenda-extra-navigation-actions %}
|
||||
{% with lingo_url=object.get_lingo_url %}{% if lingo_url %}
|
||||
<li><a href="{{ lingo_url }}">{% trans 'Pricing' context 'pricing' %}</a></li>
|
||||
<h3>{% trans "Navigation" %}</h3>
|
||||
<a class="button button-paragraph" href="{{ lingo_url }}">{% trans 'Pricing' context 'pricing' %}</a>
|
||||
{% endif %}{% endwith %}
|
||||
{% endblock %}
|
||||
|
||||
|
@ -21,8 +22,9 @@
|
|||
<button aria-controls="panel-booking-check-options" aria-selected="false" id="tab-booking-check-options" role="tab" tabindex="-1">{% trans "Booking check options" %}</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 %}
|
||||
<button aria-controls="panel-notifications" aria-selected="false" id="tab-notifications" role="tab" tabindex="-1">{% trans "Management notifications" %}</button>
|
||||
{% endblock %}
|
||||
|
||||
{% block agenda-settings-extra-tab-list %}
|
||||
|
@ -89,10 +91,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>
|
||||
|
@ -116,11 +120,13 @@
|
|||
{% 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>
|
||||
|
@ -139,21 +145,23 @@
|
|||
</div>
|
||||
{% endif %}
|
||||
|
||||
<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 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>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
|
|
@ -1,6 +1,10 @@
|
|||
{% extends "chrono/manager_events_type_list.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block page-title-extra-label %}
|
||||
{% if object.pk %}{{ object.label }}{% else %}{{ block.super }}{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block breadcrumb %}
|
||||
{{ block.super }}
|
||||
{% if object.pk %}
|
||||
|
@ -55,3 +59,6 @@
|
|||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
{% block sidebar %}
|
||||
{% endblock %}
|
||||
|
|
|
@ -1,16 +1,19 @@
|
|||
{% extends "chrono/manager_base.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block page-title-extra-label %}
|
||||
{% trans "Events types" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block breadcrumb %}
|
||||
{{ block.super }}
|
||||
<a href="{% url 'chrono-manager-events-type-list' %}">{% trans "Events types" %}</a>
|
||||
{% url 'chrono-manager-events-type-list' as object_list_url %}
|
||||
<a href="{{ object_list_url }}">{% trans "Events types" %}</a>
|
||||
{% include 'chrono/includes/application_breadcrumb_fragment.html' with title_no_application=_('Events types outside applications') %}
|
||||
{% endblock %}
|
||||
|
||||
{% block appbar %}
|
||||
<h2>{% trans 'Events types' %}</h2>
|
||||
<span class="actions">
|
||||
<a rel="popup" href="{% url 'chrono-manager-events-type-add' %}">{% trans 'New' %}</a>
|
||||
</span>
|
||||
{% include 'chrono/includes/application_appbar_fragment.html' with title_no_application=_('Events types outside applications') title_object_list=_('Events types') %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
@ -22,13 +25,16 @@
|
|||
<ul class="objects-list single-links">
|
||||
{% for object in object_list %}
|
||||
<li>
|
||||
<a href="{% url 'chrono-manager-events-type-edit' pk=object.pk %}">{{ object.label }} ({{ object.slug }})</a>
|
||||
<a href="{% url 'chrono-manager-events-type-edit' pk=object.pk %}">
|
||||
{% include 'chrono/includes/application_icon_fragment.html' %}
|
||||
{{ object.label }} ({{ object.slug }})
|
||||
</a>
|
||||
<a rel="popup" class="delete" href="{% url 'chrono-manager-events-type-delete' pk=object.pk %}">{% trans "remove" %}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% else %}
|
||||
{% elif not no_application %}
|
||||
<div class="big-msg-info">
|
||||
{% blocktrans trimmed %}
|
||||
This site doesn't have any events type yet. Click on the "New" button in the top
|
||||
|
@ -37,3 +43,14 @@
|
|||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block sidebar %}
|
||||
{% if not application and not no_application %}
|
||||
<aside id="sidebar">
|
||||
<h3>{% trans "Actions" %}</h3>
|
||||
<a class="button button-paragraph" rel="popup" href="{% url 'chrono-manager-events-type-add' %}">{% trans 'New events type' %}</a>
|
||||
|
||||
{% include 'chrono/includes/application_list_fragment.html' with title_no_application=_('Events types outside applications') %}
|
||||
</aside>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
|
|
@ -1,39 +1,14 @@
|
|||
{% extends "chrono/manager_base.html" %}
|
||||
{% load i18n %}
|
||||
{% load i18n thumbnail %}
|
||||
|
||||
{% block appbar %}
|
||||
<h2>{% trans 'Agendas' %}</h2>
|
||||
<span class="actions">
|
||||
{% if user.is_staff or has_access_to_unavailability_calendars %}
|
||||
<a class="extra-actions-menu-opener"></a>
|
||||
<ul class="extra-actions-menu">
|
||||
{% if user.is_staff %}
|
||||
<li><a rel="popup" href="{% url 'chrono-manager-agendas-import' %}">{% trans 'Import' %}</a></li>
|
||||
<li><a rel="popup" href="{% url 'chrono-manager-agendas-export' %}" data-autoclose-dialog="true">{% trans 'Export' %}</a></li>
|
||||
<li><a href="{% url 'chrono-manager-events-type-list' %}">{% trans 'Events types' %}</a></li>
|
||||
{% if shared_custody_enabled %}
|
||||
<li><a rel="popup" href="{% url 'chrono-manager-shared-custody-settings' %}">{% trans 'Shared custody' %}</a></li>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if has_access_to_unavailability_calendars %}
|
||||
<li><a href="{% url 'chrono-manager-unavailability-calendar-list' %}">{% trans 'Unavailability calendars' %}</a></li>
|
||||
{% endif %}
|
||||
{% if user.is_staff %}
|
||||
<li><a href="{% url 'chrono-manager-resource-list' %}">{% trans 'Resources' %}</a></li>
|
||||
{% endif %}
|
||||
{% if ants_hub_enabled and user.is_staff %}
|
||||
<li><a href="{% url 'chrono-manager-ants-hub' %}">{% trans 'ANTS Hub' %}</a></li>
|
||||
{% endif %}
|
||||
{% if lingo_enabled %}
|
||||
<li><a href="{% url 'chrono-manager-events-report' %}">{% trans "Events report" %}</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% if user.is_staff %}
|
||||
<a href="{% url 'chrono-manager-category-list' %}">{% trans 'Categories' %}</a>
|
||||
<a rel="popup" href="{% url 'chrono-manager-agenda-add' %}">{% trans 'New' %}</a>
|
||||
{% endif %}
|
||||
</span>
|
||||
{% include 'chrono/includes/application_appbar_fragment.html' with title_no_application=_('Agendas outside applications') title_object_list=_('Agendas') %}
|
||||
{% endblock %}
|
||||
|
||||
{% block breadcrumb %}
|
||||
{{ block.super }}
|
||||
{% url 'chrono-manager-homepage' as object_list_url %}
|
||||
{% include 'chrono/includes/application_breadcrumb_fragment.html' with title_no_application=_('Agendas outside applications') %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
@ -45,12 +20,18 @@
|
|||
{% if group.grouper %}<h3>{{ group.grouper }}</h3>{% elif not forloop.first %}<h3>{% trans "Misc" %}</h3>{% endif %}
|
||||
<ul class="objects-list single-links">
|
||||
{% for object in group.list %}
|
||||
<li><a href="{% url 'chrono-manager-agenda-view' pk=object.id %}"><span class="badge">{{ object.get_real_kind_display }}</span> {{ object.label }}{% if user.is_staff %} <span class="identifier">[{% trans "identifier:" %} {{ object.slug }}]{% endif %}</span></a></li>
|
||||
<li>
|
||||
<a href="{% url 'chrono-manager-agenda-view' pk=object.id %}">
|
||||
<span class="badge">{{ object.get_real_kind_display }}</span>
|
||||
{% include 'chrono/includes/application_icon_fragment.html' %}
|
||||
{{ object.label }}{% if user.is_staff %} <span class="identifier">[{% trans "identifier:" %} {{ object.slug }}]{% endif %}</span>
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
{% elif not no_application %}
|
||||
<div class="big-msg-info">
|
||||
{% blocktrans trimmed %}
|
||||
This site doesn't have any agenda yet. Click on the "New" button in the top
|
||||
|
@ -60,3 +41,42 @@
|
|||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block sidebar %}
|
||||
{% if with_sidebar and not application and not no_application %}
|
||||
<aside id="sidebar">
|
||||
|
||||
{% if user.is_staff %}
|
||||
<h3>{% trans "Actions" %}</h3>
|
||||
<a class="button button-paragraph" rel="popup" href="{% url 'chrono-manager-agenda-add' %}">{% trans 'New agenda' %}</a>
|
||||
<a class="button button-paragraph" rel="popup" href="{% url 'chrono-manager-agendas-export' %}" data-autoclose-dialog="true">{% trans 'Export site' %}</a>
|
||||
<a class="button button-paragraph" rel="popup" href="{% url 'chrono-manager-agendas-import' %}">{% trans 'Import site' %}</a>
|
||||
{% endif %}
|
||||
|
||||
{% if user.is_staff or has_access_to_unavailability_calendars %}
|
||||
<h3>{% trans "Navigation" %}</h3>
|
||||
{% if user.is_staff %}
|
||||
<a class="button button-paragraph" href="{% url 'chrono-manager-category-list' %}">{% trans 'Categories' %}</a>
|
||||
<a class="button button-paragraph" href="{% url 'chrono-manager-events-type-list' %}">{% trans 'Events types' %}</a>
|
||||
{% if shared_custody_enabled %}
|
||||
<a class="button button-paragraph" rel="popup" href="{% url 'chrono-manager-shared-custody-settings' %}">{% trans 'Shared custody' %}</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if has_access_to_unavailability_calendars %}
|
||||
<a class="button button-paragraph" href="{% url 'chrono-manager-unavailability-calendar-list' %}">{% trans 'Unavailability calendars' %}</a>
|
||||
{% endif %}
|
||||
{% if user.is_staff %}
|
||||
<a class="button button-paragraph" href="{% url 'chrono-manager-resource-list' %}">{% trans 'Resources' %}</a>
|
||||
{% if ants_hub_enabled %}
|
||||
<a class="button button-paragraph" href="{% url 'chrono-manager-ants-hub' %}">{% trans 'ANTS Hub' %}</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if lingo_enabled %}
|
||||
<a class="button button-paragraph" href="{% url 'chrono-manager-events-report' %}">{% trans "Events report" %}</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% include 'chrono/includes/application_list_fragment.html' with title_no_application=_('Agendas outside applications') %}
|
||||
</aside>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
|
|
@ -55,3 +55,6 @@
|
|||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
{% block sidebar %}
|
||||
{% endblock %}
|
||||
|
|
|
@ -38,12 +38,16 @@
|
|||
{% endif %}
|
||||
|
||||
{% for booking in desk_info.bookings %}
|
||||
<div class="booking{% if booking.color %} booking-color-{{ booking.color.index }}{% endif %}"
|
||||
<div class="booking{% if booking.color %} booking-color-{{ booking.color.index }}{% endif %}{% if booking.lease %} lease{% endif %}"
|
||||
style="height: {{ booking.css_height }}%; min-height: {{ booking.css_height }}%; top: {{ booking.css_top }}%;"
|
||||
><span class="start-time">{{booking.event.start_datetime|date:"TIME_FORMAT"}}</span>
|
||||
<a {% if booking.get_backoffice_url %}href="{{booking.get_backoffice_url}}"{% endif %}>{{ booking.get_user_block }}</a>
|
||||
<a rel="popup" class="cancel" href="{% url 'chrono-manager-booking-cancel' pk=booking.event.agenda_id booking_pk=booking.pk %}?next={{ request.path }}">{% trans "Cancel" %}</a>
|
||||
{% if booking.color %}<span class="booking-color-label booking-bg-color-{{ booking.color.index }}">{{ booking.color }}</span>{% endif %}
|
||||
{% if booking.lease %}
|
||||
{% trans "Currently being booked..." %}
|
||||
{% else %}
|
||||
<a {% if booking.get_backoffice_url %}href="{{booking.get_backoffice_url}}"{% endif %}>{{ booking.get_user_block }}</a>
|
||||
<a rel="popup" class="cancel" href="{% url 'chrono-manager-booking-cancel' pk=booking.event.agenda_id booking_pk=booking.pk %}?next={{ request.path }}">{% trans "Cancel" %}</a>
|
||||
{% if booking.color %}<span class="booking-color-label booking-bg-color-{{ booking.color.index }}">{{ booking.color }}</span>{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</td>
|
||||
|
|
|
@ -31,12 +31,16 @@
|
|||
{% endfor %}
|
||||
|
||||
{% for slot in day.infos.booked_slots %}
|
||||
<div class="booking{% if slot.booking.color %} booking-color-{{ slot.booking.color.index }}{% endif %}" style="left:{{ slot.css_left|stringformat:".1f" }}%;height:{{ slot.css_height|stringformat:".1f" }}%;min-height:{{ slot.css_height|stringformat:".1f" }}%;top:{{ slot.css_top|stringformat:".1f" }}%;width:{{ slot.css_width|stringformat:".1f" }}%;">
|
||||
<div class="booking{% if slot.booking.color %} booking-color-{{ slot.booking.color.index }}{% endif %}{% if slot.booking.lease %} lease{% endif %}" style="left:{{ slot.css_left|stringformat:".1f" }}%;height:{{ slot.css_height|stringformat:".1f" }}%;min-height:{{ slot.css_height|stringformat:".1f" }}%;top:{{ slot.css_top|stringformat:".1f" }}%;width:{{ slot.css_width|stringformat:".1f" }}%;">
|
||||
<span class="start-time">{{slot.booking.event.start_datetime|date:"TIME_FORMAT"}}</span>
|
||||
<a {% if slot.booking.get_backoffice_url %}href="{{slot.booking.get_backoffice_url}}"{% endif %}>{{ slot.booking.get_user_block }}</a>
|
||||
<a rel="popup" class="cancel" href="{% url 'chrono-manager-booking-cancel' pk=slot.booking.event.agenda_id booking_pk=slot.booking.id %}?next={{ request.path }}">{% trans "Cancel" %}</a>
|
||||
{% if not single_desk %}<span class="desk">{{ slot.desk }}</span>{% endif %}
|
||||
{% if slot.booking.color %}<span class="booking-color-label booking-bg-color-{{ slot.booking.color.index }}">{{ slot.booking.color }}</span>{% endif %}
|
||||
{% if slot.booking.lease %}
|
||||
{% trans "Currently being booked..." %}
|
||||
{% else %}
|
||||
<a {% if slot.booking.get_backoffice_url %}href="{{slot.booking.get_backoffice_url}}"{% endif %}>{{ slot.booking.get_user_block }}</a>
|
||||
<a rel="popup" class="cancel" href="{% url 'chrono-manager-booking-cancel' pk=slot.booking.event.agenda_id booking_pk=slot.booking.id %}?next={{ request.path }}">{% trans "Cancel" %}</a>
|
||||
{% if not single_desk %}<span class="desk">{{ slot.desk }}</span>{% endif %}
|
||||
{% if slot.booking.color %}<span class="booking-color-label booking-bg-color-{{ slot.booking.color.index }}">{{ slot.booking.color }}</span>{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
|
|
@ -7,18 +7,69 @@
|
|||
{% endblock %}
|
||||
|
||||
{% block appbar %}
|
||||
<h2>{% trans "Check booking" %}</h2>
|
||||
<h2>
|
||||
{% blocktrans trimmed with user=view.bookings.0.user_name %}
|
||||
Check booking for {{ user }}
|
||||
{% endblocktrans %}
|
||||
</h2>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% if multiple_bookings %}
|
||||
<div class="pk-tabs">
|
||||
<div class="pk-tabs--tab-list" role="tablist" aria-label="{% trans "Booking tabs" %}">
|
||||
{% for booking in view.bookings %}
|
||||
<button role="tab"
|
||||
aria-selected="{{ forloop.first|yesno:"true,false" }}"
|
||||
aria-controls="panel-{{ booking.pk }}"
|
||||
id="tab-{{ booking.pk }}"
|
||||
tabindex="{{ forloop.first|yesno:"0,-1" }}"
|
||||
>
|
||||
{{ booking.start_time|time:"H:i" }} - {{ booking.end_time|time:"H:i" }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form
|
||||
method="post"
|
||||
enctype="multipart/form-data"
|
||||
data-fill-user_check_start_time="{{ booking.start_time|time:"H:i" }}"
|
||||
data-fill-user_check_end_time="{{ booking.end_time|time:"H:i" }}"
|
||||
{% if multiple_bookings %}class="pk-tabs--container"{% endif %}
|
||||
>
|
||||
{% csrf_token %}
|
||||
{{ form|with_template }}
|
||||
|
||||
{% for booking in view.bookings %}
|
||||
<div
|
||||
class="booking-check-forms"
|
||||
data-fill-start_time="{{ booking.start_time|time:"H:i" }}"
|
||||
data-fill-end_time="{{ booking.end_time|time:"H:i" }}"
|
||||
{% if multiple_bookings %}
|
||||
id="panel-{{ booking.pk }}"
|
||||
role="tabpanel" tabindex="0" {% if not forloop.first %}hidden{% endif %}
|
||||
data-tab-slug="{{ booking.pk }}"
|
||||
aria-labelledby="tab-{{ booking.pk }}"
|
||||
{% endif %}
|
||||
>
|
||||
|
||||
<div class="booking-check-form">
|
||||
{{ booking.check_forms.0|with_template }}
|
||||
</div>
|
||||
|
||||
{% if forms|length > 1 %}
|
||||
<fieldset
|
||||
class="gadjo-foldable {% if not forms.1.instance.pk and not forms.1.errors %}gadjo-folded{% endif %}"
|
||||
>
|
||||
<legend class="gadjo-foldable-widget">{% trans "Second booking check" %}</legend>
|
||||
|
||||
<div class="booking-check-form gadjo-folding">
|
||||
{{ booking.check_forms.1|with_template }}
|
||||
</div>
|
||||
</fieldset>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<div class="buttons">
|
||||
<button class="submit-button">{% trans "Save" %}</button>
|
||||
<a class="cancel" href="{{ agenda.get_absolute_url }}">{% trans 'Cancel' %}</a>
|
||||
|
@ -26,29 +77,37 @@
|
|||
|
||||
<script>
|
||||
$(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() {
|
||||
if (!this.checked)
|
||||
return;
|
||||
if (this.value == 'True') {
|
||||
presence_check_type_select.show();
|
||||
absence_check_type_select.hide();
|
||||
} else if (this.value == 'False') {
|
||||
absence_check_type_select.show();
|
||||
presence_check_type_select.hide();
|
||||
} else {
|
||||
presence_check_type_select.hide();
|
||||
absence_check_type_select.hide();
|
||||
}
|
||||
}).change();
|
||||
// Tabs are not loaded if form is in popup, remove this block when fixed in gadjo
|
||||
$(document.querySelectorAll('.pk-tabs')).each(function(i, el) {
|
||||
el.tabs = new gadjo_js.Tabs(el);
|
||||
});
|
||||
|
||||
$('.booking-check-form').each(function () {
|
||||
let presence_check_type_select = $(this).children('.widget[id*=presence_check_type]');
|
||||
let absence_check_type_select = $(this).children('.widget[id*=absence_check_type]');
|
||||
$(this).find('input[type=radio][name*=presence]').change(function() {
|
||||
if (!this.checked)
|
||||
return;
|
||||
if (this.value == 'True') {
|
||||
$(this).parents('.widget').siblings('.widget').show();
|
||||
absence_check_type_select.hide();
|
||||
} else if (this.value == 'False') {
|
||||
$(this).parents('.widget').siblings('.widget').show();
|
||||
presence_check_type_select.hide();
|
||||
} else {
|
||||
$(this).parents('.widget').siblings('.widget').hide();
|
||||
}
|
||||
}).change();
|
||||
});
|
||||
|
||||
$('.time-widget-button').on('click', function() {
|
||||
var widget_name = $(this).data('related-widget');
|
||||
var value = $(this).parents('form').data('fill-' + widget_name);
|
||||
var widget_id = widget_name.split('-').at(-1);
|
||||
var value = $(this).parents('.booking-check-forms').data('fill-' + widget_id);
|
||||
$('[name="' + widget_name + '"]').val(value);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</form>
|
||||
{% if multiple_bookings %}</div>{% endif %}
|
||||
{% endblock %}
|
||||
|
|
|
@ -19,95 +19,157 @@
|
|||
<p>{% trans "No opening hours this day." %}</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<form class="check-bookings-filters">
|
||||
{{ filterset.form.as_p }}
|
||||
<script>
|
||||
$(function() {
|
||||
$('form.check-bookings-filters input').on('change',
|
||||
function() {
|
||||
$(this).parents('form').submit();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</form>
|
||||
<div class="section">
|
||||
<div>
|
||||
<form class="check-bookings-filters">
|
||||
<fieldset class="gadjo-foldable gadjo-folded" id="filters">
|
||||
<legend class="gadjo-foldable-widget">{% trans "Filtering options" %}</legend>
|
||||
<div class="gadjo-folding">
|
||||
{{ filterset.form.as_p }}
|
||||
<button class="submit-button">{% trans "Apply" context 'form filtering action' %}</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% 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>
|
||||
<div class="section">
|
||||
<div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="partial-booking" style="--nb-hours: {{ hours|length }}">
|
||||
<div class="partial-booking--hours-list" aria-hidden="true">
|
||||
<div
|
||||
class="partial-booking"
|
||||
style="--nb-hours: {{ hours|length }}"
|
||||
data-start-datetime="{{ start_datetime.isoformat }}"
|
||||
data-end-datetime="{{ end_datetime.isoformat }}"
|
||||
>
|
||||
{% if view.date.date == today %}
|
||||
<div class="partial-booking--hour-indicator-wrapper" aria-hidden="true">
|
||||
<div class="partial-booking--hour-indicator" hidden></div>
|
||||
<script>
|
||||
const hour_indicator = (function() {
|
||||
const indicator = document.querySelector('.partial-booking--hour-indicator')
|
||||
|
||||
const div_container = document.querySelector('.partial-booking')
|
||||
const start = new Date(div_container.dataset.startDatetime).getTime()
|
||||
const end = new Date(div_container.dataset.endDatetime).getTime() + 3600000 - start
|
||||
|
||||
const indicator_position = function() {
|
||||
const now = Date.now() - start
|
||||
indicator.style.left = now * 100 / end + "%"
|
||||
}
|
||||
indicator_position();
|
||||
setInterval(indicator_position, 60000)
|
||||
indicator.hidden = false;
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="partial-booking--hours-list">
|
||||
{% for hour in hours %}
|
||||
<div class="partial-booking--hour">{{ hour|time:"H" }} h</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="partial-booking--occupation-rate-list">
|
||||
<h3 class="occupation-rate-list--title">{% trans "Occupation rate" %}</h3>
|
||||
{% for rate in occupation_rates %}
|
||||
<p
|
||||
class="occupation-rate {% if rate.overbooked %}overbooked{% endif %}"
|
||||
style="--rate-percent: {{ rate.height_percent }};"
|
||||
aria-label="{% blocktrans trimmed with start=rate.start_time|time:"H:i" end=rate.end_time|time:"H:i" %}
|
||||
From {{ start }} to {{ end }}:
|
||||
{% endblocktrans %}
|
||||
{{ rate.percent }}% ({{ rate.booked_places }}/{{ event.places }})"
|
||||
>
|
||||
<span class="occupation-rate--info">
|
||||
{{ rate.percent }}% <br> ({{ rate.booked_places }}/{{ event.places }})
|
||||
</span>
|
||||
</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="partial-booking--registrant-items">
|
||||
{% for user in users %}
|
||||
<section class="partial-booking--registrant">
|
||||
{% spaceless %}
|
||||
<h3 class="registrant--name">
|
||||
<span class="registrant--name-label">{{ user.name }}</span>
|
||||
{% if allow_check %}
|
||||
<a
|
||||
class="partial-booking--check-icon"
|
||||
rel="popup"
|
||||
{% if user.bookings|length > 1 %}data-selector=".pk-tabs"{% endif %}
|
||||
href="{{ user.check_url }}"
|
||||
>{{ user.name }}</a>
|
||||
{% else %}
|
||||
<span>{{ user.name }}</span>
|
||||
><span class="sr-only">{% trans "Check" %}</span></a>
|
||||
{% endif %}
|
||||
</h3>
|
||||
{% endspaceless %}
|
||||
<div class="registrant--datas">
|
||||
<div class="registrant--bar-container">
|
||||
{% for booking in user.bookings %}
|
||||
{% if booking.start_time %}
|
||||
<p
|
||||
class="registrant--bar clearfix booking"
|
||||
title="{% trans "Booked period" %}"
|
||||
style="left: {{ booking.css_left }}%; width: {{ booking.css_width }}%;"
|
||||
>
|
||||
<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>
|
||||
</p>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if user.bookings %}
|
||||
{% if not filterset.form.cleaned_data or 'booked' in filterset.form.cleaned_data.display %}
|
||||
<div class="registrant--bar-container">
|
||||
{% for booking in user.bookings %}
|
||||
{% if booking.user_was_present is not None %}
|
||||
{% if booking.start_time %}
|
||||
<p
|
||||
class="registrant--bar clearfix check {{ booking.check_css_class }}"
|
||||
title="{% trans "Checked period:" %}"
|
||||
style="left: {{ booking.check_css_left }}%; width: {{ booking.check_css_width }}%;"
|
||||
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 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>
|
||||
{% if not booking.from_recurring_fillslots %}
|
||||
<span class="occasional">{% trans "occasional" %}</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if user.bookings %}
|
||||
{% if not filterset.form.cleaned_data or 'checked' in filterset.form.cleaned_data.display %}
|
||||
<div class="registrant--bar-container">
|
||||
{% for check in user.booking_checks %}
|
||||
<p
|
||||
class="registrant--bar check {{ check.css_class }}"
|
||||
title="{% trans "Checked period" %}"
|
||||
style="left: {{ check.css_left }}%;{% if check.css_width %} width: {{ check.css_width }}%;{% endif %}"
|
||||
>
|
||||
<strong class="sr-only">{% trans "Checked period:" %}</strong>
|
||||
<time class="start-time" datetime="{{ booking.user_check_start_time|time:"H:i" }}">{{ booking.user_check_start_time|time:"H:i" }}</time>
|
||||
<time class="end-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 %}
|
||||
{% 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 %}
|
||||
</p>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="registrant--bar-container">
|
||||
{% for booking in user.bookings %}
|
||||
{% if booking.user_was_present is not None and booking.computed_start_time != None and booking.computed_end_time != None %}
|
||||
<p
|
||||
class="registrant--bar clearfix computed {{ booking.check_css_class }}"
|
||||
title="{% trans "Computed period" %}"
|
||||
style="left: {{ booking.computed_css_left }}%; width: {{ booking.computed_css_width }}%;"
|
||||
>
|
||||
<strong class="sr-only">{% trans "Computed period:" %}</strong>
|
||||
<time class="start-time" datetime="{{ booking.computed_start_time|time:"H:i" }}">{{ booking.computed_start_time|time:"H:i" }}</time>
|
||||
<time class="end-time" datetime="{{ booking.computed_end_time|time:"H:i" }}">{{ booking.computed_end_time|time:"H:i" }}</time>
|
||||
</p>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if not filterset.form.cleaned_data or 'computed' in filterset.form.cleaned_data.display %}
|
||||
<div class="registrant--bar-container">
|
||||
{% for check in user.booking_checks %}
|
||||
{% if check.computed_start_time and check.computed_end_time %}
|
||||
<p
|
||||
class="registrant--bar 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 %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
|
|
|
@ -59,3 +59,6 @@
|
|||
{% endfor %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block sidebar %}
|
||||
{% endblock %}
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
{% load i18n %}
|
||||
|
||||
{% block page-title-extra-label %}
|
||||
- {{ resource.label }}
|
||||
{{ resource.label }}
|
||||
{% endblock %}
|
||||
|
||||
{% block breadcrumb %}
|
||||
|
@ -17,8 +17,10 @@
|
|||
<span class="actions">
|
||||
{% block appbar-extras %}
|
||||
{% if request.user.is_staff %}
|
||||
<a rel="popup" href="{% url 'chrono-manager-resource-edit' pk=resource.pk %}">{% trans 'Edit' %}</a>
|
||||
<a rel="popup" href="{% url 'chrono-manager-resource-delete' pk=resource.pk %}">{% trans 'Delete' %}</a>
|
||||
<a class="extra-actions-menu-opener"></a>
|
||||
<ul class="extra-actions-menu">
|
||||
<li><a rel="popup" href="{% url 'chrono-manager-resource-delete' pk=resource.pk %}">{% trans 'Delete' %}</a></li>
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% include "chrono/manager_resource_view_buttons_fragment.html" with no_today=True no_opened=True %}
|
||||
{% endblock %}
|
||||
|
@ -53,3 +55,15 @@
|
|||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block sidebar %}
|
||||
<aside id="sidebar">
|
||||
{% if request.user.is_staff %}
|
||||
<h3>{% trans "Actions" %}</h3>
|
||||
<a class="button button-paragraph" rel="popup" href="{% url 'chrono-manager-resource-edit' pk=resource.pk %}">{% trans 'Edit' %}</a>
|
||||
{% endif %}
|
||||
|
||||
{% url 'chrono-manager-resource-list' as object_list_url %}
|
||||
{% include 'chrono/includes/application_detail_fragment.html' %}
|
||||
</aside>
|
||||
{% endblock %}
|
||||
|
|
|
@ -1,6 +1,15 @@
|
|||
{% extends "chrono/manager_home.html" %}
|
||||
{% extends "chrono/manager_resource_list.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block breadcrumb %}
|
||||
{{ block.super }}
|
||||
{% if object.pk %}
|
||||
<a href="{% url 'chrono-manager-resource-edit' pk=object.pk %}">{{ object.label }}</a>
|
||||
{% else %}
|
||||
<a href="{% url 'chrono-manager-resource-add' %}">{% trans "New Resource" %}</a>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block appbar %}
|
||||
{% if object.id %}
|
||||
<h2>{% trans "Edit Resource" %}</h2>
|
||||
|
@ -20,3 +29,6 @@
|
|||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
{% block sidebar %}
|
||||
{% endblock %}
|
||||
|
|
|
@ -1,16 +1,19 @@
|
|||
{% extends "chrono/manager_base.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block page-title-extra-label %}
|
||||
{% trans "Resources" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block breadcrumb %}
|
||||
{{ block.super }}
|
||||
<a href="{% url 'chrono-manager-resource-list' %}">{% trans "Resources" %}</a>
|
||||
{% url 'chrono-manager-resource-list' as object_list_url %}
|
||||
<a href="{{ object_list_url }}">{% trans "Resources" %}</a>
|
||||
{% include 'chrono/includes/application_breadcrumb_fragment.html' with title_no_application=_('Resources outside applications') %}
|
||||
{% endblock %}
|
||||
|
||||
{% block appbar %}
|
||||
<h2>{% trans 'Resources' %}</h2>
|
||||
<span class="actions">
|
||||
<a rel="popup" href="{% url 'chrono-manager-resource-add' %}">{% trans 'New' %}</a>
|
||||
</span>
|
||||
{% include 'chrono/includes/application_appbar_fragment.html' with title_no_application=_('Resources outside applications') title_object_list=_('Resources') %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
|
@ -20,12 +23,15 @@
|
|||
<ul class="objects-list single-links">
|
||||
{% for object in object_list %}
|
||||
<li>
|
||||
<a href="{% url 'chrono-manager-resource-view' pk=object.pk %}">{{ object.label }} ({{ object.slug }})</a>
|
||||
<a href="{% url 'chrono-manager-resource-view' pk=object.pk %}">
|
||||
{% include 'chrono/includes/application_icon_fragment.html' %}
|
||||
{{ object.label }} ({{ object.slug }})
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% else %}
|
||||
{% elif not no_application %}
|
||||
<div class="big-msg-info">
|
||||
{% blocktrans trimmed %}
|
||||
This site doesn't have any resource yet. Click on the "New" button in the top
|
||||
|
@ -34,3 +40,14 @@
|
|||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block sidebar %}
|
||||
{% if not application and not no_application %}
|
||||
<aside id="sidebar">
|
||||
<h3>{% trans "Actions" %}</h3>
|
||||
<a class="button button-paragraph" rel="popup" href="{% url 'chrono-manager-resource-add' %}">{% trans 'New resource' %}</a>
|
||||
|
||||
{% include 'chrono/includes/application_list_fragment.html' with title_no_application=_('Resources outside applications') %}
|
||||
</aside>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
|
|
@ -32,3 +32,6 @@
|
|||
{% block content %}
|
||||
{% include "chrono/manager_resource_week_timetable_fragment.html" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block sidebar %}
|
||||
{% endblock %}
|
||||
|
|
|
@ -59,3 +59,6 @@
|
|||
{% block content %}
|
||||
{% include "chrono/manager_resource_week_timetable_fragment.html" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block sidebar %}
|
||||
{% endblock %}
|
||||
|
|
|
@ -7,7 +7,16 @@
|
|||
{% endblock %}
|
||||
|
||||
{% block appbar %}
|
||||
<h2>{% trans "Settings" %}</h2>
|
||||
<h2>
|
||||
{% trans "Settings" %}
|
||||
<span class="identifier">
|
||||
[
|
||||
{% trans "guardians identifiers:" %} {{ agenda.first_guardian.user_external_id }}, {{ agenda.second_guardian.user_external_id }}
|
||||
/
|
||||
{% trans "child identifier:" %} {{ agenda.child.user_external_id }}
|
||||
]
|
||||
</span>
|
||||
</h2>
|
||||
<span class="actions">
|
||||
{% if user.is_staff %}
|
||||
<a class="extra-actions-menu-opener"></a>
|
||||
|
|
|
@ -68,3 +68,6 @@
|
|||
</script>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
{% block sidebar %}
|
||||
{% endblock %}
|
||||
|
|
|
@ -33,3 +33,6 @@
|
|||
{% include "gadjo/pagination.html" %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block sidebar %}
|
||||
{% endblock %}
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
{% load i18n %}
|
||||
|
||||
{% block page-title-extra-label %}
|
||||
- {{ unavailability_calendar.label }}
|
||||
{{ unavailability_calendar.label }}
|
||||
{% endblock %}
|
||||
|
||||
{% block breadcrumb %}
|
||||
|
@ -49,3 +49,6 @@
|
|||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block sidebar %}
|
||||
{% endblock %}
|
||||
|
|
|
@ -31,3 +31,6 @@
|
|||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
{% block sidebar %}
|
||||
{% endblock %}
|
||||
|
|
|
@ -1,18 +1,19 @@
|
|||
{% extends "chrono/manager_base.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block page-title-extra-label %}
|
||||
{% trans "Unavailability Calendars" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block breadcrumb %}
|
||||
{{ block.super }}
|
||||
<a href="{% url 'chrono-manager-unavailability-calendar-list' %}">{% trans "Unavailability Calendars" %}</a>
|
||||
{% url 'chrono-manager-unavailability-calendar-list' as object_list_url %}
|
||||
<a href="{{ object_list_url }}">{% trans "Unavailability Calendars" %}</a>
|
||||
{% include 'chrono/includes/application_breadcrumb_fragment.html' with title_no_application=_('Unavailability Calendars outside applications') %}
|
||||
{% endblock %}
|
||||
|
||||
{% block appbar %}
|
||||
<h2>{% trans 'Unavailability Calendars' %}</h2>
|
||||
{% if user.is_staff %}
|
||||
<span class="actions">
|
||||
<a rel="popup" href="{% url 'chrono-manager-unavailability-calendar-add' %}">{% trans 'New' %}</a>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% include 'chrono/includes/application_appbar_fragment.html' with title_no_application=_('Unavailability Calendars outside applications') title_object_list=_('Unavailability Calendars') %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
|
@ -22,12 +23,15 @@
|
|||
<ul class="objects-list single-links">
|
||||
{% for object in object_list %}
|
||||
<li>
|
||||
<a href="{% url 'chrono-manager-unavailability-calendar-view' pk=object.pk %}">{{ object.label }} ({{ object.slug }})</a>
|
||||
<a href="{% url 'chrono-manager-unavailability-calendar-view' pk=object.pk %}">
|
||||
{% include 'chrono/includes/application_icon_fragment.html' %}
|
||||
{{ object.label }} ({{ object.slug }})
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% else %}
|
||||
{% elif not no_application %}
|
||||
<div class="big-msg-info">
|
||||
{% blocktrans trimmed %}
|
||||
This site doesn't have any unavailability calendar yet. Click on the "New" button in the top
|
||||
|
@ -36,3 +40,16 @@
|
|||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block sidebar %}
|
||||
{% if not application and not no_application %}
|
||||
<aside id="sidebar">
|
||||
{% if user.is_staff %}
|
||||
<h3>{% trans "Actions" %}</h3>
|
||||
<a class="button button-paragraph" rel="popup" href="{% url 'chrono-manager-unavailability-calendar-add' %}">{% trans 'New unavailability calendar' %}</a>
|
||||
{% endif %}
|
||||
|
||||
{% include 'chrono/includes/application_list_fragment.html' with title_no_application=_('Unavailability Calendars outside applications') %}
|
||||
</aside>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
|
|
@ -11,16 +11,8 @@
|
|||
</h2>
|
||||
<span class="actions">
|
||||
<a class="extra-actions-menu-opener"></a>
|
||||
{% block agenda-extra-management-actions %}
|
||||
<a rel="popup" href="{% url 'chrono-manager-unavailability-calendar-import-unavailabilities' pk=unavailability_calendar.id %}">{% trans 'Manage unavailabilities from ICS' %}</a>
|
||||
<a rel="popup" href="{% url 'chrono-manager-unavailability-calendar-add-unavailability' pk=unavailability_calendar.id %}">{% trans 'Add Unavailability' %}</a>
|
||||
{% endblock %}
|
||||
<ul class="extra-actions-menu">
|
||||
<li><a rel="popup" href="{% url 'chrono-manager-unavailability-calendar-edit' pk=unavailability_calendar.id %}">{% trans 'Options' %}</a></li>
|
||||
<li><a download href="{% url 'chrono-manager-unavailability-calendar-export' pk=unavailability_calendar.id %}">{% trans 'Export Configuration (JSON)' %}</a></li>
|
||||
{% if user.is_staff %}
|
||||
<li><a rel="popup" href="{% url 'chrono-manager-unavailability-calendar-delete' pk=unavailability_calendar.id %}">{% trans 'Delete' %}</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</span>
|
||||
{% endblock %}
|
||||
|
@ -50,5 +42,19 @@
|
|||
</div>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block sidebar %}
|
||||
<aside id="sidebar">
|
||||
<h3>{% trans "Actions" %}</h3>
|
||||
<a class="button button-paragraph" rel="popup" href="{% url 'chrono-manager-unavailability-calendar-import-unavailabilities' pk=unavailability_calendar.id %}">{% trans 'Manage unavailabilities from ICS' %}</a>
|
||||
<a class="button button-paragraph" rel="popup" href="{% url 'chrono-manager-unavailability-calendar-add-unavailability' pk=unavailability_calendar.id %}">{% trans 'Add Unavailability' %}</a>
|
||||
<a class="button button-paragraph" rel="popup" href="{% url 'chrono-manager-unavailability-calendar-edit' pk=unavailability_calendar.id %}">{% trans 'Options' %}</a>
|
||||
{% if user.is_staff %}
|
||||
<a class="button button-paragraph" rel="popup" href="{% url 'chrono-manager-unavailability-calendar-delete' pk=unavailability_calendar.id %}">{% trans 'Delete' %}</a>
|
||||
{% endif %}
|
||||
|
||||
{% url 'chrono-manager-unavailability-calendar-list' as object_list_url %}
|
||||
{% include 'chrono/includes/application_detail_fragment.html' %}
|
||||
</aside>
|
||||
{% endblock %}
|
||||
|
|
|
@ -2,8 +2,8 @@
|
|||
{% load i18n %}
|
||||
|
||||
{% block agenda-extra-management-actions %}
|
||||
<a rel="popup" href="{% url 'chrono-manager-virtual-agenda-add-time-period' pk=object.id %}">{% trans 'Add Excluded Period' %}</a>
|
||||
<a rel="popup" href="{% url 'chrono-manager-agenda-add-virtual-member' pk=object.id %}">{% trans 'Include Agenda' %}</a>
|
||||
<a class="button button-paragraph" rel="popup" href="{% url 'chrono-manager-virtual-agenda-add-time-period' pk=object.id %}">{% trans 'Add Excluded Period' %}</a>
|
||||
<a class="button button-paragraph" rel="popup" href="{% url 'chrono-manager-agenda-add-virtual-member' pk=object.id %}">{% trans 'Include Agenda' %}</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block agenda-settings-extra-tab-buttons %}
|
||||
|
|
|
@ -435,15 +435,10 @@ urlpatterns = [
|
|||
name='chrono-manager-booking-extra-user-block',
|
||||
),
|
||||
path(
|
||||
'agendas/<int:pk>/bookings/<int:booking_pk>/check',
|
||||
'agendas/<int:pk>/events/<int:event_pk>/check-bookings/<str:user_external_id>',
|
||||
views.partial_booking_check_view,
|
||||
name='chrono-manager-partial-booking-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,
|
||||
|
|
|
@ -106,6 +106,10 @@ def import_site(data, if_empty=False, clean=False, overwrite=False):
|
|||
existing_roles_names = set(existing_roles.values_list('name', flat=True))
|
||||
raise AgendaImportError('Missing roles: "%s"' % ', '.join(role_names - existing_roles_names))
|
||||
|
||||
# sort agendas to import virtual agendas first
|
||||
if data.get('agendas'):
|
||||
data['agendas'] = sorted(data['agendas'], key=lambda a: a['kind'] == 'virtual')
|
||||
|
||||
with transaction.atomic():
|
||||
for cls, key in (
|
||||
(Category, 'categories'),
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -54,12 +54,15 @@ INSTALLED_APPS = (
|
|||
'django.contrib.staticfiles',
|
||||
'django.contrib.humanize',
|
||||
'gadjo',
|
||||
'rest_framework',
|
||||
'django_filters',
|
||||
'sorl.thumbnail',
|
||||
'chrono.agendas',
|
||||
'chrono.api',
|
||||
'chrono.manager',
|
||||
'chrono.apps.ants_hub',
|
||||
'rest_framework',
|
||||
'django_filters',
|
||||
'chrono.apps.export_import',
|
||||
'chrono.apps.snapshot',
|
||||
)
|
||||
|
||||
MIDDLEWARE = (
|
||||
|
@ -177,6 +180,9 @@ MELLON_IDENTITY_PROVIDERS = []
|
|||
# (see http://docs.python-requests.org/en/master/user/advanced/#proxies)
|
||||
REQUESTS_PROXIES = None
|
||||
|
||||
# default in seconds
|
||||
CHRONO_LOCK_DURATION = 10 * 60
|
||||
|
||||
# timeout used in python-requests call, in seconds
|
||||
# we use 28s by default: timeout just before web server, which is usually 30s
|
||||
REQUESTS_TIMEOUT = 28
|
||||
|
@ -203,6 +209,10 @@ PARTIAL_BOOKINGS_ENABLED = False
|
|||
|
||||
CHRONO_ANTS_HUB_URL = None
|
||||
|
||||
# from solr.thumbnail -- https://sorl-thumbnail.readthedocs.io/en/latest/reference/settings.html
|
||||
THUMBNAIL_PRESERVE_FORMAT = True
|
||||
THUMBNAIL_FORCE_OVERWRITE = False
|
||||
|
||||
local_settings_file = os.environ.get(
|
||||
'CHRONO_SETTINGS_FILE', os.path.join(os.path.dirname(__file__), 'local_settings.py')
|
||||
)
|
||||
|
|
|
@ -20,6 +20,8 @@ from django.contrib.auth.decorators import login_required
|
|||
from django.contrib.staticfiles.urls import staticfiles_urlpatterns
|
||||
from django.urls import include, path, re_path
|
||||
|
||||
from chrono.apps.export_import import urls as export_import_urls
|
||||
|
||||
from .api.urls import urlpatterns as chrono_api_urls
|
||||
from .manager.urls import urlpatterns as chrono_manager_urls
|
||||
from .urls_utils import decorated_includes
|
||||
|
@ -29,6 +31,7 @@ urlpatterns = [
|
|||
path('', homepage, name='home'),
|
||||
re_path(r'^manage/', decorated_includes(login_required, include(chrono_manager_urls))),
|
||||
path('api/', include(chrono_api_urls)),
|
||||
path('api/', include(export_import_urls)),
|
||||
path('logout/', LogoutView.as_view(), name='auth_logout'),
|
||||
path('login/', LoginView.as_view(), name='auth_login'),
|
||||
]
|
||||
|
|
|
@ -203,8 +203,7 @@ class IntervalSet:
|
|||
break
|
||||
if not c2:
|
||||
yield c1
|
||||
for c1 in l1:
|
||||
yield c1
|
||||
yield from l1
|
||||
break
|
||||
if c1[1] <= c2[0]:
|
||||
yield c1
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -4,6 +4,7 @@ After=network.target postgresql.service
|
|||
Wants=postgresql.service
|
||||
|
||||
[Service]
|
||||
SyslogIdentifier=uwsgi/%p
|
||||
Environment=CHRONO_SETTINGS_FILE=/usr/lib/%p/debian_config.py
|
||||
Environment=LANG=C.UTF-8
|
||||
User=%p
|
||||
|
|
|
@ -34,6 +34,7 @@ Depends: libcairo-gobject2,
|
|||
python3-django-tenant-schemas,
|
||||
python3-hobo (>= 1.34),
|
||||
python3-psycopg2,
|
||||
python3-sorl-thumbnail,
|
||||
python3-vobject,
|
||||
uwsgi,
|
||||
uwsgi-plugin-python3,
|
||||
|
|
|
@ -21,6 +21,7 @@ spooler-max-tasks = 20
|
|||
cron2 = minute=-5,unique=1 /usr/bin/chrono-manage tenant_command cancel_events --all-tenants -v0
|
||||
cron2 = minute=-5,unique=1 /usr/bin/chrono-manage tenant_command send_email_notifications --all-tenants -v0
|
||||
cron2 = minute=-5,unique=1 /usr/bin/chrono-manage tenant_command update_event_recurrences --all-tenants -v0
|
||||
cron2 = minute=-5,unique=1 /usr/bin/chrono-manage tenant_command clean_leases --all-tenants -v0
|
||||
# every fifteen minutes
|
||||
cron2 = minute=-15,unique=1 /usr/bin/chrono-manage tenant_command sync-ants-hub --all-tenants
|
||||
# hourly
|
||||
|
@ -61,6 +62,7 @@ buffer-size = 32768
|
|||
|
||||
py-tracebacker = /run/chrono/py-tracebacker.sock.
|
||||
stats = /run/chrono/stats.sock
|
||||
memory-report = true
|
||||
|
||||
ignore-sigpipe = true
|
||||
|
||||
|
|
7
setup.py
7
setup.py
|
@ -48,7 +48,7 @@ def get_version():
|
|||
real_number, commit_count, commit_hash = result.split('-', 2)
|
||||
version = '%s.post%s+%s' % (real_number, commit_count, commit_hash)
|
||||
else:
|
||||
version = result
|
||||
version = result.replace('.dirty', '+dirty')
|
||||
return version
|
||||
else:
|
||||
return '0.0.post%s' % len(subprocess.check_output(['git', 'rev-list', 'HEAD']).splitlines())
|
||||
|
@ -161,13 +161,14 @@ setup(
|
|||
install_requires=[
|
||||
'django>=3.2, <3.3',
|
||||
'gadjo',
|
||||
'djangorestframework>=3.4,<3.14',
|
||||
'django-filter',
|
||||
'djangorestframework>=3.4,<3.15',
|
||||
'django-filter<23.2',
|
||||
'vobject',
|
||||
'python-dateutil',
|
||||
'requests',
|
||||
'workalendar',
|
||||
'weasyprint',
|
||||
'sorl-thumbnail',
|
||||
],
|
||||
zip_safe=False,
|
||||
cmdclass={
|
||||
|
|
|
@ -0,0 +1,85 @@
|
|||
# chrono - agendas system
|
||||
# Copyright (C) 2023 Entr'ouvert
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it
|
||||
# under the terms of the GNU Affero General Public License as published
|
||||
# by the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# 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/>.
|
||||
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
import responses
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
from chrono.apps.ants_hub.hub import AntsHubException, check_duplicate, ping, push_rendez_vous_disponibles
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
def test_authorization(app, user):
|
||||
app.post('/api/ants/check-duplicate/', status=401)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user(db):
|
||||
user = User(username='john.doe', first_name='John', last_name='Doe', email='john.doe@example.net')
|
||||
user.set_password('password')
|
||||
user.save()
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_app(user, app):
|
||||
app.authorization = ('Basic', ('john.doe', 'password'))
|
||||
return app
|
||||
|
||||
|
||||
class TestCheckDuplicateAPI:
|
||||
def test_not_configured(self, auth_app):
|
||||
resp = auth_app.post('/api/ants/check-duplicate/', status=400)
|
||||
assert resp.json == {
|
||||
'err': 1,
|
||||
'err_class': 'CHRONO_ANTS_HUB_URL is not configured',
|
||||
'err_desc': 'CHRONO_ANTS_HUB_URL is not configured',
|
||||
'reason': 'CHRONO_ANTS_HUB_URL is not configured',
|
||||
}
|
||||
|
||||
def test_input_empty(self, hub, auth_app):
|
||||
resp = auth_app.post('/api/ants/check-duplicate/')
|
||||
assert resp.json == {'data': {'accept_rdv': True}, 'err': 0}
|
||||
|
||||
@mock.patch('chrono.apps.ants_hub.hub.check_duplicate')
|
||||
def test_proxy(self, check_duplicate_mock, hub, auth_app):
|
||||
# do not care about output
|
||||
check_duplicate_mock.return_value = {'err': 0, 'data': {'xyz': '1234'}}
|
||||
|
||||
# GET param
|
||||
resp = auth_app.post('/api/ants/check-duplicate/?identifiant_predemande= ABCdE12345, ,1234567890 ')
|
||||
assert resp.json == {'err': 0, 'data': {'xyz': '1234'}}
|
||||
assert check_duplicate_mock.call_args[0][0] == ['ABCDE12345', '1234567890']
|
||||
|
||||
# JSON payload as string
|
||||
resp = auth_app.post_json(
|
||||
'/api/ants/check-duplicate/?identifiant_predemande=XYZ',
|
||||
params={'identifiant_predemande': ' XBCdE12345, ,1234567890 '},
|
||||
)
|
||||
assert resp.json == {'err': 0, 'data': {'xyz': '1234'}}
|
||||
assert check_duplicate_mock.call_args[0][0] == ['XBCDE12345', '1234567890']
|
||||
|
||||
# JSON payload as list
|
||||
resp = auth_app.post_json(
|
||||
'/api/ants/check-duplicate/?identifiant_predemande=XYZ',
|
||||
params={'identifiant_predemande': [' YBCdE12345', ' ', '1234567890 ']},
|
||||
)
|
||||
assert resp.json == {'err': 0, 'data': {'xyz': '1234'}}
|
||||
assert check_duplicate_mock.call_args[0][0] == ['YBCDE12345', '1234567890']
|
|
@ -18,7 +18,7 @@ import pytest
|
|||
import requests
|
||||
import responses
|
||||
|
||||
from chrono.apps.ants_hub.hub import AntsHubException, ping, push_rendez_vous_disponibles
|
||||
from chrono.apps.ants_hub.hub import AntsHubException, check_duplicate, ping, push_rendez_vous_disponibles
|
||||
|
||||
|
||||
def test_ping_timeout(hub):
|
||||
|
@ -67,3 +67,37 @@ def test_push_rendez_vous_disponibles_application_error(hub):
|
|||
)
|
||||
with pytest.raises(AntsHubException, match='overload'):
|
||||
push_rendez_vous_disponibles({})
|
||||
|
||||
|
||||
class TestCheckDuplicate:
|
||||
def test_status_500(self, hub):
|
||||
hub.add(responses.GET, 'https://toto:@ants-hub.example.com/api/chrono/rdv-status/', status=500)
|
||||
assert check_duplicate(['A' * 10, '1' * 10]) == {
|
||||
'err': 1,
|
||||
'err_desc': "ANTS hub is unavailable: HTTPError('500 Server Error: Internal Server Error for url: https://toto:@ants-hub.example.com/api/chrono/rdv-status/?identifiant_predemande=AAAAAAAAAA&identifiant_predemande=1111111111')",
|
||||
}
|
||||
|
||||
def test_timeout(self, hub):
|
||||
hub.add(
|
||||
responses.GET,
|
||||
'https://toto:@ants-hub.example.com/api/chrono/rdv-status/',
|
||||
body=requests.Timeout('boom!'),
|
||||
)
|
||||
assert check_duplicate(['A' * 10, '1' * 10]) == {
|
||||
'err': 1,
|
||||
'err_desc': "ANTS hub is unavailable: Timeout('boom!')",
|
||||
}
|
||||
|
||||
def test_ok(self, hub):
|
||||
hub.add(
|
||||
responses.GET,
|
||||
'https://toto:@ants-hub.example.com/api/chrono/rdv-status/?identifiant_predemande=AAAAAAAAAA&identifiant_predemande=1111111111',
|
||||
json={
|
||||
'err': 0,
|
||||
'data': {},
|
||||
},
|
||||
)
|
||||
assert check_duplicate(['A' * 10, '1' * 10]) == {
|
||||
'err': 0,
|
||||
'data': {},
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -871,14 +871,14 @@ def test_meetings_and_virtual_datetimes_date_filter(app):
|
|||
virtual_agenda.save()
|
||||
|
||||
# exclude weekday6 through date_end, 4 slots each day * 5 days
|
||||
params = {'date_end': localtime(now() + datetime.timedelta(days=6)).date().isoformat()}
|
||||
params = {'date_end': (localtime(now()).date() + datetime.timedelta(days=6)).isoformat()}
|
||||
resp = app.get(foo_api_url, params=params)
|
||||
assert len(resp.json['data']) == 20
|
||||
resp = app.get(virtual_api_url, params=params)
|
||||
assert len(resp.json['data']) == 20
|
||||
|
||||
params = {
|
||||
'date_end': localtime(now() + datetime.timedelta(days=6))
|
||||
'date_end': (localtime(now()) + datetime.timedelta(days=6))
|
||||
.replace(hour=11, minute=0, second=0, microsecond=0)
|
||||
.isoformat()
|
||||
}
|
||||
|
|
|
@ -18,7 +18,7 @@ from chrono.agendas.models import (
|
|||
TimePeriod,
|
||||
VirtualMember,
|
||||
)
|
||||
from chrono.utils.timezone import localtime, now
|
||||
from chrono.utils.timezone import localtime, make_aware, now
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
@ -52,12 +52,14 @@ def test_booking_api(app, user):
|
|||
assert 'cancel_url' in resp.json['api']
|
||||
assert 'ics_url' in resp.json['api']
|
||||
assert 'anonymize_url' in resp.json['api']
|
||||
assert 'resize_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 urlparse.urlparse(resp.json['api']['ics_url']).netloc
|
||||
assert urlparse.urlparse(resp.json['api']['anonymize_url']).netloc
|
||||
assert urlparse.urlparse(resp.json['api']['resize_url']).netloc
|
||||
assert Booking.objects.count() == 1
|
||||
|
||||
# access by slug
|
||||
|
@ -79,7 +81,7 @@ def test_booking_api(app, user):
|
|||
'user_phone_number': '+33 (0) 6 12 34 56 78', # long phone number
|
||||
'form_url': 'http://example.net/',
|
||||
'extra_emails': ['baz@baz.com', 'hop@hop.com'],
|
||||
'extra_phone_numbers': ['+33123456789', '+33123456789'],
|
||||
'extra_phone_numbers': ['+33123456789', '+33 1 23 45 67 89'],
|
||||
'presence_callback_url': 'http://example.net/jump/trigger2/',
|
||||
'absence_callback_url': 'http://example.net/jump/trigger3/',
|
||||
},
|
||||
|
@ -95,6 +97,7 @@ def test_booking_api(app, user):
|
|||
assert booking.user_email == 'bar@bar.com'
|
||||
assert booking.user_phone_number == '+33 (0) 6 12 34 56 78'
|
||||
assert booking.form_url == 'http://example.net/'
|
||||
assert booking.from_recurring_fillslots is False
|
||||
|
||||
resp = app.post_json(
|
||||
'/api/agenda/%s/fillslot/%s/' % (agenda.id, event.id),
|
||||
|
@ -292,7 +295,7 @@ def test_booking_api_extra_emails(app, user):
|
|||
fillslot_url,
|
||||
params={
|
||||
'extra_emails': 'bar.com',
|
||||
'extra_phone_numbers': 'too loooooooooong',
|
||||
'extra_phone_numbers': 'too loooooooooooong',
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
|
@ -2033,9 +2036,10 @@ 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),
|
||||
start_datetime=make_aware(datetime.datetime(2021, 3, 1, 8, 0)),
|
||||
end_time=datetime.time(18, 00),
|
||||
duration=120,
|
||||
places=1,
|
||||
places=2,
|
||||
agenda=agenda,
|
||||
)
|
||||
|
||||
|
@ -2067,6 +2071,17 @@ def test_booking_api_partial_booking(app, user):
|
|||
== '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),
|
||||
|
@ -2074,3 +2089,17 @@ def test_booking_api_partial_booking(app, user):
|
|||
status=400,
|
||||
)
|
||||
assert resp.json['errors']['non_field_errors'][0] == 'start_time must be before end_time'
|
||||
|
||||
# start before opening time
|
||||
resp = app.post(
|
||||
'/api/agenda/%s/fillslot/%s/' % (agenda.slug, event.id),
|
||||
params={'start_time': '07:59', 'end_time': '18:00'},
|
||||
)
|
||||
assert resp.json['err_desc'] == 'booking start must be after opening time'
|
||||
|
||||
# end after closing time
|
||||
resp = app.post(
|
||||
'/api/agenda/%s/fillslot/%s/' % (agenda.slug, event.id),
|
||||
params={'start_time': '08:00', 'end_time': '18:01'},
|
||||
)
|
||||
assert resp.json['err_desc'] == 'booking end must be before closing time'
|
||||
|
|
|
@ -1,11 +1,13 @@
|
|||
import datetime
|
||||
|
||||
import pytest
|
||||
from django.core.management import call_command
|
||||
from django.db import connection
|
||||
from django.test.utils import CaptureQueriesContext
|
||||
|
||||
from chrono.agendas.models import Agenda, Booking, Event, EventsType
|
||||
from chrono.utils.timezone import localtime, now
|
||||
from chrono.agendas.models import Agenda, Booking, Event, EventsType, Lease
|
||||
from chrono.utils.timezone import localtime, make_aware, now
|
||||
from tests.utils import build_event_agenda
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
@ -52,6 +54,7 @@ def test_api_events_fillslots(app, user):
|
|||
resp.json['bookings_ics_url']
|
||||
== 'http://testserver/api/bookings/ics/?user_external_id=user_id&agenda=foo-bar'
|
||||
)
|
||||
assert 'revert_url' not in resp.json
|
||||
|
||||
events = Event.objects.all()
|
||||
assert events.filter(booked_places=1).count() == 2
|
||||
|
@ -567,7 +570,8 @@ 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),
|
||||
start_datetime=make_aware(datetime.datetime(2021, 3, 1, 8, 0)),
|
||||
end_time=datetime.time(18, 00),
|
||||
duration=120,
|
||||
places=1,
|
||||
agenda=agenda,
|
||||
|
@ -588,3 +592,270 @@ def test_api_events_fillslots_partial_bookings(app, user):
|
|||
resp.json['errors']['non_field_errors'][0]
|
||||
== 'must include start_time and end_time for partial bookings agenda'
|
||||
)
|
||||
|
||||
# start before opening time
|
||||
params['user_external_id'] = 'user_id_2'
|
||||
params['start_time'] = '07:59'
|
||||
params['end_time'] = '18:00'
|
||||
resp = app.post_json(fillslots_url, params=params)
|
||||
assert resp.json['err_desc'] == 'booking start must be after opening time'
|
||||
|
||||
# end after closing time
|
||||
params['start_time'] = '08:00'
|
||||
params['end_time'] = '18:01'
|
||||
resp = app.post_json(fillslots_url, params=params)
|
||||
assert resp.json['err_desc'] == 'booking end must be before closing time'
|
||||
|
||||
|
||||
def test_lock_code(app, user, freezer):
|
||||
agenda = build_event_agenda(
|
||||
events={
|
||||
'Event 1': {
|
||||
'start_datetime': now() + datetime.timedelta(days=1),
|
||||
'places': 1,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
# list events
|
||||
resp = app.get(agenda.get_datetimes_url())
|
||||
slot = resp.json['data'][0]
|
||||
assert slot['places']['available'] == 1
|
||||
assert slot['places']['full'] is False
|
||||
|
||||
# book first one
|
||||
fillslot_url = slot['api']['fillslot_url']
|
||||
app.authorization = ('Basic', ('john.doe', 'password'))
|
||||
resp = app.post_json(fillslot_url, params={'lock_code': 'MYLOCK'})
|
||||
assert resp.json['err'] == 0
|
||||
|
||||
# list events without lock code
|
||||
resp = app.get(agenda.get_datetimes_url())
|
||||
slot = resp.json['data'][0]
|
||||
assert slot['places']['available'] == 0
|
||||
assert slot['places']['full'] is True
|
||||
|
||||
# list events with lock code
|
||||
resp = app.get(agenda.get_datetimes_url(), params={'lock_code': 'MYLOCK', 'hide_disabled': 'true'})
|
||||
slot = resp.json['data'][0]
|
||||
assert slot['places']['available'] == 1
|
||||
assert slot['places']['full'] is False
|
||||
|
||||
# re-book first one without lock code
|
||||
app.authorization = ('Basic', ('john.doe', 'password'))
|
||||
resp = app.post_json(fillslot_url)
|
||||
assert resp.json['err'] == 1
|
||||
|
||||
# rebook first one with lock code
|
||||
app.authorization = ('Basic', ('john.doe', 'password'))
|
||||
resp = app.post_json(fillslot_url, params={'lock_code': 'MYLOCK'})
|
||||
assert resp.json['err'] == 0
|
||||
|
||||
# confirm booking
|
||||
app.authorization = ('Basic', ('john.doe', 'password'))
|
||||
resp = app.post_json(fillslot_url, params={'lock_code': 'MYLOCK', 'confirm_after_lock': True})
|
||||
assert resp.json['err'] == 0
|
||||
|
||||
# list events without lock code, after 30 minutes slot is still booked
|
||||
freezer.move_to(datetime.timedelta(minutes=30))
|
||||
resp = app.get(agenda.get_datetimes_url())
|
||||
slot = resp.json['data'][0]
|
||||
assert slot['places']['available'] == 0
|
||||
assert slot['places']['full'] is True
|
||||
|
||||
|
||||
def test_lock_code_expiration(app, user, freezer):
|
||||
agenda = build_event_agenda(
|
||||
events={
|
||||
'Event 1': {
|
||||
'start_datetime': now() + datetime.timedelta(days=1),
|
||||
'places': 1,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
# list events
|
||||
resp = app.get(agenda.get_datetimes_url())
|
||||
slot = resp.json['data'][0]
|
||||
|
||||
# book first one
|
||||
fillslot_url = slot['api']['fillslot_url']
|
||||
app.authorization = ('Basic', ('john.doe', 'password'))
|
||||
resp = app.post_json(fillslot_url, params={'lock_code': 'MYLOCK'})
|
||||
assert resp.json['err'] == 0
|
||||
|
||||
# list events without lock code
|
||||
resp = app.get(agenda.get_datetimes_url())
|
||||
slot = resp.json['data'][0]
|
||||
assert slot['places']['available'] == 0
|
||||
assert slot['places']['full'] is True
|
||||
|
||||
# list events without lock code, after 30 minutes slot is available
|
||||
freezer.move_to(datetime.timedelta(minutes=30))
|
||||
call_command('clean_leases')
|
||||
|
||||
resp = app.get(agenda.get_datetimes_url())
|
||||
slot = resp.json['data'][0]
|
||||
assert slot['places']['available'] == 1
|
||||
assert slot['places']['full'] is False
|
||||
|
||||
|
||||
def test_api_events_fillslots_with_lock_code(app, user, freezer):
|
||||
freezer.move_to('2021-09-06 12:00')
|
||||
app.authorization = ('Basic', ('john.doe', 'password'))
|
||||
agenda = build_event_agenda(
|
||||
events={
|
||||
'Event': {
|
||||
'start_datetime': now() + datetime.timedelta(days=1),
|
||||
'places': 2,
|
||||
'waiting_list_places': 1,
|
||||
},
|
||||
'Event 2': {
|
||||
'start_datetime': now() + datetime.timedelta(days=1),
|
||||
'places': 2,
|
||||
'waiting_list_places': 1,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
fillslots_url = '/api/agenda/%s/events/fillslots/' % agenda.slug
|
||||
params = {
|
||||
'user_external_id': 'user_id',
|
||||
'check_overlaps': True,
|
||||
'slots': 'event,event-2',
|
||||
'lock_code': 'MYLOCK',
|
||||
}
|
||||
with CaptureQueriesContext(connection) as ctx:
|
||||
resp = app.post_json(fillslots_url, params=params)
|
||||
assert len(ctx.captured_queries) == 14
|
||||
assert resp.json['booking_count'] == 2
|
||||
assert len(resp.json['booked_events']) == 2
|
||||
assert resp.json['booked_events'][0]['id'] == 'event'
|
||||
assert (
|
||||
resp.json['booked_events'][0]['booking']['id']
|
||||
== Booking.objects.filter(event=agenda._event).latest('pk').pk
|
||||
)
|
||||
assert resp.json['booked_events'][1]['id'] == 'event-2'
|
||||
assert (
|
||||
resp.json['booked_events'][1]['booking']['id']
|
||||
== Booking.objects.filter(event=agenda._event_2).latest('pk').pk
|
||||
)
|
||||
assert len(resp.json['waiting_list_events']) == 0
|
||||
assert resp.json['cancelled_booking_count'] == 0
|
||||
assert len(resp.json['cancelled_events']) == 0
|
||||
|
||||
events = Event.objects.all()
|
||||
assert events.filter(booked_places=1).count() == 2
|
||||
assert Booking.objects.count() == 2
|
||||
assert Lease.objects.count() == 2
|
||||
|
||||
response = app.get(agenda.get_datetimes_url())
|
||||
assert response.json['data'][0]['places']['available'] == 1
|
||||
assert response.json['data'][0]['places']['reserved'] == 1
|
||||
assert response.json['data'][1]['places']['available'] == 1
|
||||
assert response.json['data'][1]['places']['reserved'] == 1
|
||||
|
||||
response = app.get(agenda.get_datetimes_url() + '?lock_code=MYLOCK')
|
||||
assert response.json['data'][0]['places']['available'] == 2
|
||||
assert response.json['data'][0]['places']['reserved'] == 0
|
||||
assert response.json['data'][1]['places']['available'] == 2
|
||||
assert response.json['data'][1]['places']['reserved'] == 0
|
||||
|
||||
# rebooking, nothing change
|
||||
resp = app.post_json(fillslots_url, params=params)
|
||||
assert Booking.objects.count() == 2
|
||||
assert Lease.objects.count() == 2
|
||||
|
||||
response = app.get(agenda.get_datetimes_url())
|
||||
assert response.json['data'][0]['places']['available'] == 1
|
||||
assert response.json['data'][0]['places']['reserved'] == 1
|
||||
assert response.json['data'][1]['places']['available'] == 1
|
||||
assert response.json['data'][1]['places']['reserved'] == 1
|
||||
|
||||
response = app.get(agenda.get_datetimes_url() + '?lock_code=MYLOCK')
|
||||
assert response.json['data'][0]['places']['available'] == 2
|
||||
assert response.json['data'][0]['places']['reserved'] == 0
|
||||
assert response.json['data'][1]['places']['available'] == 2
|
||||
assert response.json['data'][1]['places']['reserved'] == 0
|
||||
|
||||
params['confirm_after_lock'] = 'true'
|
||||
resp = app.post_json(fillslots_url, params=params)
|
||||
assert Booking.objects.count() == 2
|
||||
assert Lease.objects.count() == 0
|
||||
|
||||
response = app.get(agenda.get_datetimes_url())
|
||||
assert response.json['data'][0]['places']['available'] == 1
|
||||
assert response.json['data'][0]['places']['reserved'] == 1
|
||||
assert response.json['data'][1]['places']['available'] == 1
|
||||
assert response.json['data'][1]['places']['reserved'] == 1
|
||||
|
||||
response = app.get(agenda.get_datetimes_url() + '?lock_code=MYLOCK')
|
||||
assert response.json['data'][0]['places']['available'] == 1
|
||||
assert response.json['data'][0]['places']['reserved'] == 1
|
||||
assert response.json['data'][1]['places']['available'] == 1
|
||||
assert response.json['data'][1]['places']['reserved'] == 1
|
||||
|
||||
|
||||
def test_api_events_fillslots_with_lock_code_expiration(app, user, freezer):
|
||||
freezer.move_to('2021-09-06 12:00')
|
||||
app.authorization = ('Basic', ('john.doe', 'password'))
|
||||
agenda = build_event_agenda(
|
||||
events={
|
||||
'Event': {
|
||||
'start_datetime': now() + datetime.timedelta(days=1),
|
||||
'places': 2,
|
||||
'waiting_list_places': 1,
|
||||
},
|
||||
'Event 2': {
|
||||
'start_datetime': now() + datetime.timedelta(days=1),
|
||||
'places': 2,
|
||||
'waiting_list_places': 1,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
fillslots_url = '/api/agenda/%s/events/fillslots/' % agenda.slug
|
||||
params = {
|
||||
'user_external_id': 'user_id',
|
||||
'check_overlaps': True,
|
||||
'slots': 'event,event-2',
|
||||
'lock_code': 'MYLOCK',
|
||||
}
|
||||
with CaptureQueriesContext(connection) as ctx:
|
||||
resp = app.post_json(fillslots_url, params=params)
|
||||
assert len(ctx.captured_queries) == 14
|
||||
assert resp.json['booking_count'] == 2
|
||||
assert len(resp.json['booked_events']) == 2
|
||||
assert resp.json['booked_events'][0]['id'] == 'event'
|
||||
assert (
|
||||
resp.json['booked_events'][0]['booking']['id']
|
||||
== Booking.objects.filter(event=agenda._event).latest('pk').pk
|
||||
)
|
||||
assert resp.json['booked_events'][1]['id'] == 'event-2'
|
||||
assert (
|
||||
resp.json['booked_events'][1]['booking']['id']
|
||||
== Booking.objects.filter(event=agenda._event_2).latest('pk').pk
|
||||
)
|
||||
assert len(resp.json['waiting_list_events']) == 0
|
||||
assert resp.json['cancelled_booking_count'] == 0
|
||||
assert len(resp.json['cancelled_events']) == 0
|
||||
|
||||
events = Event.objects.all()
|
||||
assert events.filter(booked_places=1).count() == 2
|
||||
assert Booking.objects.count() == 2
|
||||
assert Lease.objects.count() == 2
|
||||
|
||||
response = app.get(agenda.get_datetimes_url())
|
||||
assert response.json['data'][0]['places']['available'] == 1
|
||||
assert response.json['data'][0]['places']['reserved'] == 1
|
||||
assert response.json['data'][1]['places']['available'] == 1
|
||||
assert response.json['data'][1]['places']['reserved'] == 1
|
||||
|
||||
freezer.move_to('2021-09-06 13:00')
|
||||
call_command('clean_leases')
|
||||
|
||||
response = app.get(agenda.get_datetimes_url())
|
||||
assert response.json['data'][0]['places']['available'] == 2
|
||||
assert response.json['data'][0]['places']['reserved'] == 0
|
||||
assert response.json['data'][1]['places']['available'] == 2
|
||||
assert response.json['data'][1]['places']['reserved'] == 0
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import datetime
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from django.db import connection
|
||||
|
@ -163,7 +164,7 @@ def test_api_events_fillslots_multiple_agendas(app, user):
|
|||
params = {'user_external_id': 'user_id', 'check_overlaps': True, 'slots': event_slugs}
|
||||
with CaptureQueriesContext(connection) as ctx:
|
||||
resp = app.post_json('/api/agendas/events/fillslots/?agendas=%s' % agenda_slugs, params=params)
|
||||
assert len(ctx.captured_queries) == 17
|
||||
assert len(ctx.captured_queries) == 18
|
||||
assert resp.json['booking_count'] == 2
|
||||
assert len(resp.json['booked_events']) == 2
|
||||
assert resp.json['booked_events'][0]['id'] == 'first-agenda@event'
|
||||
|
@ -179,6 +180,14 @@ 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'
|
||||
request_uuid = first_event.booking_set.get().request_uuid
|
||||
assert request_uuid is not None
|
||||
assert second_event.booking_set.get().request_uuid == request_uuid
|
||||
assert first_event.booking_set.get().previous_state == 'unbooked'
|
||||
assert second_event.booking_set.get().previous_state == 'unbooked'
|
||||
assert (
|
||||
resp.json['revert_url'] == 'http://testserver/api/agendas/events/fillslots/%s/revert/' % request_uuid
|
||||
)
|
||||
|
||||
# booking modification
|
||||
params = {'user_external_id': 'user_id', 'slots': 'first-agenda@event'}
|
||||
|
@ -188,6 +197,12 @@ def test_api_events_fillslots_multiple_agendas(app, user):
|
|||
assert resp.json['cancelled_booking_count'] == 1
|
||||
assert first_event.booking_set.filter(cancellation_datetime__isnull=True).count() == 1
|
||||
assert second_event.booking_set.filter(cancellation_datetime__isnull=True).count() == 0
|
||||
request_uuid = second_event.booking_set.get().request_uuid
|
||||
assert request_uuid is not None
|
||||
assert second_event.booking_set.get().previous_state == 'booked'
|
||||
assert (
|
||||
resp.json['revert_url'] == 'http://testserver/api/agendas/events/fillslots/%s/revert/' % request_uuid
|
||||
)
|
||||
|
||||
params = {'user_external_id': 'user_id_2', 'slots': event_slugs}
|
||||
resp = app.post_json('/api/agendas/events/fillslots/?agendas=%s' % agenda_slugs, params=params)
|
||||
|
@ -331,6 +346,16 @@ def test_api_events_fillslots_multiple_agendas_with_cancelled(app, user):
|
|||
)
|
||||
assert Booking.objects.filter(user_external_id='user_id').count() == 3
|
||||
assert Booking.objects.filter(user_external_id='user_id', cancellation_datetime__isnull=True).count() == 3
|
||||
new_booking_1 = Booking.objects.get(event__agenda=agenda_1, event=event_1, user_external_id='user_id')
|
||||
request_uuid = new_booking_1.request_uuid
|
||||
assert request_uuid is not None
|
||||
booking_3 = Booking.objects.get(event__agenda=agenda_2, event=event_3, user_external_id='user_id')
|
||||
assert booking_3.request_uuid == request_uuid
|
||||
assert new_booking_1.previous_state == 'cancelled'
|
||||
assert booking_3.previous_state == 'unbooked'
|
||||
assert (
|
||||
resp.json['revert_url'] == 'http://testserver/api/agendas/events/fillslots/%s/revert/' % request_uuid
|
||||
)
|
||||
|
||||
assert Booking.objects.filter(pk=booking_1.pk).exists() is False # cancelled booking deleted
|
||||
booking_2.refresh_from_db()
|
||||
|
@ -803,7 +828,8 @@ 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),
|
||||
start_datetime=make_aware(datetime.datetime(2021, 3, 1, 8, 0)),
|
||||
end_time=datetime.time(18, 00),
|
||||
duration=120,
|
||||
places=1,
|
||||
agenda=agenda,
|
||||
|
@ -829,3 +855,324 @@ def test_api_events_fillslots_multiple_agendas_partial_bookings(app, user):
|
|||
resp.json['errors']['non_field_errors'][0]
|
||||
== 'must include start_time and end_time for partial bookings agenda'
|
||||
)
|
||||
|
||||
# start before opening time
|
||||
params['user_external_id'] = 'user_id_2'
|
||||
params['start_time'] = '07:59'
|
||||
params['end_time'] = '18:00'
|
||||
resp = app.post_json(fillslots_url, params=params)
|
||||
assert resp.json['err_desc'] == 'booking start must be after opening time'
|
||||
|
||||
# end after closing time
|
||||
params['start_time'] = '08:00'
|
||||
params['end_time'] = '18:01'
|
||||
resp = app.post_json(fillslots_url, params=params)
|
||||
assert resp.json['err_desc'] == 'booking end must be before closing time'
|
||||
|
||||
|
||||
@pytest.mark.freeze_time('2021-02-23 14:00')
|
||||
def test_api_events_fillslots_multiple_agendas_revert(app, user):
|
||||
agenda = Agenda.objects.create(label='Foo bar', kind='events')
|
||||
event = Event.objects.create(
|
||||
label='Event',
|
||||
start_datetime=now() + datetime.timedelta(days=5),
|
||||
duration=120,
|
||||
places=1,
|
||||
agenda=agenda,
|
||||
)
|
||||
|
||||
app.authorization = ('Basic', ('john.doe', 'password'))
|
||||
request_uuid = uuid.uuid4()
|
||||
|
||||
# no corresponding booking
|
||||
revert_url = '/api/agendas/events/fillslots/%s/revert/' % request_uuid
|
||||
resp = app.post(revert_url)
|
||||
assert resp.json == {
|
||||
'err': 0,
|
||||
'cancelled_booking_count': 0,
|
||||
'cancelled_events': [],
|
||||
'deleted_booking_count': 0,
|
||||
'deleted_events': [],
|
||||
'booked_booking_count': 0,
|
||||
'booked_events': [],
|
||||
}
|
||||
|
||||
booking1 = Booking.objects.create(event=event, request_uuid=uuid.uuid4())
|
||||
resp = app.post(revert_url)
|
||||
assert resp.json == {
|
||||
'err': 0,
|
||||
'cancelled_booking_count': 0,
|
||||
'cancelled_events': [],
|
||||
'deleted_booking_count': 0,
|
||||
'deleted_events': [],
|
||||
'booked_booking_count': 0,
|
||||
'booked_events': [],
|
||||
}
|
||||
booking1.refresh_from_db()
|
||||
assert booking1.cancellation_datetime is None
|
||||
|
||||
booking2 = Booking.objects.create(event=event, request_uuid=request_uuid)
|
||||
resp = app.post(revert_url)
|
||||
assert resp.json == {
|
||||
'err': 0,
|
||||
'cancelled_booking_count': 0,
|
||||
'cancelled_events': [],
|
||||
'deleted_booking_count': 0,
|
||||
'deleted_events': [],
|
||||
'booked_booking_count': 0,
|
||||
'booked_events': [],
|
||||
}
|
||||
booking1.refresh_from_db()
|
||||
assert booking1.cancellation_datetime is None
|
||||
booking2.refresh_from_db()
|
||||
assert booking2.cancellation_datetime is None
|
||||
|
||||
# booking was previously cancelled
|
||||
booking = Booking.objects.create(event=event, request_uuid=request_uuid, previous_state='cancelled')
|
||||
resp = app.post(revert_url)
|
||||
assert resp.json == {
|
||||
'err': 0,
|
||||
'cancelled_booking_count': 1,
|
||||
'cancelled_events': [
|
||||
{
|
||||
'agenda_label': 'Foo bar',
|
||||
'check_locked': False,
|
||||
'checked': False,
|
||||
'date': '2021-02-28',
|
||||
'datetime': '2021-02-28 15:00:00',
|
||||
'description': None,
|
||||
'duration': 120,
|
||||
'end_datetime': '2021-02-28 17:00:00',
|
||||
'id': 'foo-bar@event',
|
||||
'invoiced': False,
|
||||
'label': 'Event',
|
||||
'pricing': None,
|
||||
'slug': 'event',
|
||||
'text': 'Event',
|
||||
'url': None,
|
||||
}
|
||||
],
|
||||
'deleted_booking_count': 0,
|
||||
'deleted_events': [],
|
||||
'booked_booking_count': 0,
|
||||
'booked_events': [],
|
||||
}
|
||||
booking1.refresh_from_db()
|
||||
assert booking1.cancellation_datetime is None
|
||||
booking2.refresh_from_db()
|
||||
assert booking2.cancellation_datetime is None
|
||||
booking.refresh_from_db()
|
||||
assert booking.cancellation_datetime is not None
|
||||
|
||||
# again, but with a cancelled booking
|
||||
resp = app.post(revert_url)
|
||||
assert resp.json == {
|
||||
'err': 0,
|
||||
'cancelled_booking_count': 1,
|
||||
'cancelled_events': [
|
||||
{
|
||||
'agenda_label': 'Foo bar',
|
||||
'check_locked': False,
|
||||
'checked': False,
|
||||
'date': '2021-02-28',
|
||||
'datetime': '2021-02-28 15:00:00',
|
||||
'description': None,
|
||||
'duration': 120,
|
||||
'end_datetime': '2021-02-28 17:00:00',
|
||||
'id': 'foo-bar@event',
|
||||
'invoiced': False,
|
||||
'label': 'Event',
|
||||
'pricing': None,
|
||||
'slug': 'event',
|
||||
'text': 'Event',
|
||||
'url': None,
|
||||
}
|
||||
],
|
||||
'deleted_booking_count': 0,
|
||||
'deleted_events': [],
|
||||
'booked_booking_count': 0,
|
||||
'booked_events': [],
|
||||
}
|
||||
booking1.refresh_from_db()
|
||||
assert booking1.cancellation_datetime is None
|
||||
booking2.refresh_from_db()
|
||||
assert booking2.cancellation_datetime is None
|
||||
booking.refresh_from_db()
|
||||
assert booking.cancellation_datetime is not None
|
||||
|
||||
# booking was previously not cancelled
|
||||
booking.previous_state = 'booked'
|
||||
booking.save()
|
||||
resp = app.post(revert_url)
|
||||
assert resp.json == {
|
||||
'err': 0,
|
||||
'cancelled_booking_count': 0,
|
||||
'cancelled_events': [],
|
||||
'deleted_booking_count': 0,
|
||||
'deleted_events': [],
|
||||
'booked_booking_count': 1,
|
||||
'booked_events': [
|
||||
{
|
||||
'agenda_label': 'Foo bar',
|
||||
'check_locked': False,
|
||||
'checked': False,
|
||||
'date': '2021-02-28',
|
||||
'datetime': '2021-02-28 15:00:00',
|
||||
'description': None,
|
||||
'duration': 120,
|
||||
'end_datetime': '2021-02-28 17:00:00',
|
||||
'id': 'foo-bar@event',
|
||||
'invoiced': False,
|
||||
'label': 'Event',
|
||||
'pricing': None,
|
||||
'slug': 'event',
|
||||
'text': 'Event',
|
||||
'url': None,
|
||||
}
|
||||
],
|
||||
}
|
||||
booking1.refresh_from_db()
|
||||
assert booking1.cancellation_datetime is None
|
||||
booking2.refresh_from_db()
|
||||
assert booking2.cancellation_datetime is None
|
||||
booking.refresh_from_db()
|
||||
assert booking.cancellation_datetime is None
|
||||
|
||||
# again, but with a not cancelled booking
|
||||
resp = app.post(revert_url)
|
||||
assert resp.json == {
|
||||
'err': 0,
|
||||
'cancelled_booking_count': 0,
|
||||
'cancelled_events': [],
|
||||
'deleted_booking_count': 0,
|
||||
'deleted_events': [],
|
||||
'booked_booking_count': 1,
|
||||
'booked_events': [
|
||||
{
|
||||
'agenda_label': 'Foo bar',
|
||||
'check_locked': False,
|
||||
'checked': False,
|
||||
'date': '2021-02-28',
|
||||
'datetime': '2021-02-28 15:00:00',
|
||||
'description': None,
|
||||
'duration': 120,
|
||||
'end_datetime': '2021-02-28 17:00:00',
|
||||
'id': 'foo-bar@event',
|
||||
'invoiced': False,
|
||||
'label': 'Event',
|
||||
'pricing': None,
|
||||
'slug': 'event',
|
||||
'text': 'Event',
|
||||
'url': None,
|
||||
}
|
||||
],
|
||||
}
|
||||
booking1.refresh_from_db()
|
||||
assert booking1.cancellation_datetime is None
|
||||
booking2.refresh_from_db()
|
||||
assert booking2.cancellation_datetime is None
|
||||
booking.refresh_from_db()
|
||||
assert booking.cancellation_datetime is None
|
||||
|
||||
# booking was previously unbooked
|
||||
booking.previous_state = 'unbooked'
|
||||
booking.save()
|
||||
resp = app.post(revert_url)
|
||||
assert resp.json == {
|
||||
'err': 0,
|
||||
'cancelled_booking_count': 0,
|
||||
'cancelled_events': [],
|
||||
'deleted_booking_count': 1,
|
||||
'deleted_events': [
|
||||
{
|
||||
'agenda_label': 'Foo bar',
|
||||
'check_locked': False,
|
||||
'checked': False,
|
||||
'date': '2021-02-28',
|
||||
'datetime': '2021-02-28 15:00:00',
|
||||
'description': None,
|
||||
'duration': 120,
|
||||
'end_datetime': '2021-02-28 17:00:00',
|
||||
'id': 'foo-bar@event',
|
||||
'invoiced': False,
|
||||
'label': 'Event',
|
||||
'pricing': None,
|
||||
'slug': 'event',
|
||||
'text': 'Event',
|
||||
'url': None,
|
||||
}
|
||||
],
|
||||
'booked_booking_count': 0,
|
||||
'booked_events': [],
|
||||
}
|
||||
booking1.refresh_from_db()
|
||||
assert booking1.cancellation_datetime is None
|
||||
booking2.refresh_from_db()
|
||||
assert booking2.cancellation_datetime is None
|
||||
assert Booking.objects.filter(pk=booking.pk).exists() is False
|
||||
|
||||
# again, but with a cancelled booking
|
||||
booking = Booking.objects.create(
|
||||
event=event, request_uuid=request_uuid, previous_state='unbooked', cancellation_datetime=now()
|
||||
)
|
||||
resp = app.post(revert_url)
|
||||
assert resp.json == {
|
||||
'err': 0,
|
||||
'cancelled_booking_count': 0,
|
||||
'cancelled_events': [],
|
||||
'deleted_booking_count': 1,
|
||||
'deleted_events': [
|
||||
{
|
||||
'agenda_label': 'Foo bar',
|
||||
'check_locked': False,
|
||||
'checked': False,
|
||||
'date': '2021-02-28',
|
||||
'datetime': '2021-02-28 15:00:00',
|
||||
'description': None,
|
||||
'duration': 120,
|
||||
'end_datetime': '2021-02-28 17:00:00',
|
||||
'id': 'foo-bar@event',
|
||||
'invoiced': False,
|
||||
'label': 'Event',
|
||||
'pricing': None,
|
||||
'slug': 'event',
|
||||
'text': 'Event',
|
||||
'url': None,
|
||||
}
|
||||
],
|
||||
'booked_booking_count': 0,
|
||||
'booked_events': [],
|
||||
}
|
||||
booking1.refresh_from_db()
|
||||
assert booking1.cancellation_datetime is None
|
||||
booking2.refresh_from_db()
|
||||
assert booking2.cancellation_datetime is None
|
||||
assert Booking.objects.filter(pk=booking.pk).exists() is False
|
||||
|
||||
# check num queries
|
||||
Booking.objects.create(
|
||||
event=event, request_uuid=request_uuid, previous_state='cancelled', cancellation_datetime=now()
|
||||
)
|
||||
event = Event.objects.create(
|
||||
label='Event',
|
||||
start_datetime=now() + datetime.timedelta(days=5),
|
||||
duration=120,
|
||||
places=1,
|
||||
agenda=agenda,
|
||||
)
|
||||
Booking.objects.create(
|
||||
event=event, request_uuid=request_uuid, previous_state='booked', cancellation_datetime=now()
|
||||
)
|
||||
event = Event.objects.create(
|
||||
label='Event',
|
||||
start_datetime=now() + datetime.timedelta(days=5),
|
||||
duration=120,
|
||||
places=1,
|
||||
agenda=agenda,
|
||||
)
|
||||
Booking.objects.create(
|
||||
event=event, request_uuid=request_uuid, previous_state='unbooked', cancellation_datetime=now()
|
||||
)
|
||||
with CaptureQueriesContext(connection) as ctx:
|
||||
resp = app.post(revert_url)
|
||||
assert len(ctx.captured_queries) == 14
|
||||
|
|
|
@ -0,0 +1,274 @@
|
|||
# chrono - agendas system
|
||||
# Copyright (C) 2023 Entr'ouvert
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it
|
||||
# under the terms of the GNU Affero General Public License as published
|
||||
# by the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# 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 datetime
|
||||
|
||||
import pytest
|
||||
from django.core.management import call_command
|
||||
|
||||
from chrono.agendas.models import Booking, Lease
|
||||
from tests.utils import build_meetings_agenda, build_virtual_agenda
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_meetings_agenda(app, user):
|
||||
'''Test fillslot on meetings agenda with lock_code'''
|
||||
agenda = build_meetings_agenda(
|
||||
'Agenda',
|
||||
resources=['Re1'],
|
||||
meeting_types=(30,),
|
||||
desks={'desk-1': ['monday-friday 9:00-12:00 14:00-17:00']},
|
||||
)
|
||||
|
||||
datetimes_url = agenda._mt_30.get_datetimes_url()
|
||||
|
||||
# list free slots, with or without a lock
|
||||
resp = app.get(datetimes_url + '?lock_code=MYLOCK')
|
||||
free_slots = len(resp.json['data'])
|
||||
resp = app.get(datetimes_url + '?lock_code=OTHERLOCK')
|
||||
assert free_slots == len(resp.json['data'])
|
||||
resp = app.get(datetimes_url)
|
||||
assert free_slots == len(resp.json['data'])
|
||||
|
||||
# lock a slot
|
||||
fillslot_url = resp.json['data'][2]['api']['fillslot_url']
|
||||
app.authorization = ('Basic', ('john.doe', 'password'))
|
||||
app.post_json(fillslot_url, params={'lock_code': 'MYLOCK'})
|
||||
assert Booking.objects.count() == 1
|
||||
assert Lease.objects.get().lock_code == 'MYLOCK'
|
||||
|
||||
# list free slots: one is locked ...
|
||||
resp = app.get(datetimes_url)
|
||||
assert len([x for x in resp.json['data'] if x.get('disabled')]) == 1
|
||||
|
||||
resp = app.get(datetimes_url, params={'lock_code': 'OTHERLOCK'})
|
||||
assert len([x for x in resp.json['data'] if x.get('disabled')]) == 1
|
||||
|
||||
# ... unless it's MYLOCK
|
||||
resp = app.get(datetimes_url, params={'lock_code': 'MYLOCK'})
|
||||
assert len([x for x in resp.json['data'] if x.get('disabled')]) == 0
|
||||
|
||||
# can't lock the same timeslot ...
|
||||
resp_booking = app.post_json(fillslot_url, params={'lock_code': 'OTHERLOCK'})
|
||||
assert resp_booking.json['err'] == 1
|
||||
assert resp_booking.json['reason'] == 'no more desk available'
|
||||
|
||||
# ... unless with MYLOCK (aka "relock")
|
||||
resp_booking = app.post_json(fillslot_url, params={'lock_code': 'MYLOCK'})
|
||||
assert resp_booking.json['err'] == 0
|
||||
assert Booking.objects.count() == 1
|
||||
assert Lease.objects.get().lock_code == 'MYLOCK'
|
||||
|
||||
# can't book the slot ...
|
||||
resp_booking = app.post_json(fillslot_url)
|
||||
assert resp_booking.json['err'] == 1
|
||||
assert resp_booking.json['reason'] == 'no more desk available'
|
||||
|
||||
resp_booking = app.post_json(fillslot_url, params={'confirm_after_lock': True})
|
||||
assert resp_booking.json['err'] == 1
|
||||
assert resp_booking.json['reason'] == 'no more desk available'
|
||||
|
||||
resp_booking = app.post_json(fillslot_url, params={'lock_code': 'OTHERLOCK', 'confirm_after_lock': True})
|
||||
assert resp_booking.json['err'] == 1
|
||||
assert resp_booking.json['reason'] == 'no more desk available'
|
||||
|
||||
# ... unless with MYLOCK (aka "confirm")
|
||||
resp_booking = app.post_json(fillslot_url, params={'lock_code': 'MYLOCK', 'confirm_after_lock': True})
|
||||
assert resp_booking.json['err'] == 0
|
||||
assert Booking.objects.count() == 1
|
||||
assert Lease.objects.count() == 0
|
||||
|
||||
|
||||
def test_meetings_agenda_expiration(app, user, freezer):
|
||||
'''Test fillslot on meetings agenda with lock_code'''
|
||||
agenda = build_meetings_agenda(
|
||||
'Agenda',
|
||||
resources=['Re1'],
|
||||
meeting_types=(30,),
|
||||
desks={'desk-1': ['monday-friday 9:00-12:00 14:00-17:00']},
|
||||
)
|
||||
datetimes_url = agenda._mt_30.get_datetimes_url()
|
||||
|
||||
# list free slots
|
||||
resp = app.get(datetimes_url)
|
||||
|
||||
# lock a slot
|
||||
fillslot_url = resp.json['data'][2]['api']['fillslot_url']
|
||||
app.authorization = ('Basic', ('john.doe', 'password'))
|
||||
app.post_json(fillslot_url, params={'lock_code': 'MYLOCK'})
|
||||
assert Booking.objects.count() == 1
|
||||
assert Lease.objects.get().lock_code == 'MYLOCK'
|
||||
|
||||
# list free slots: one is locked ...
|
||||
resp = app.get(datetimes_url)
|
||||
assert len([x for x in resp.json['data'] if x.get('disabled')]) == 1
|
||||
|
||||
# after 30 minutes it is not locked anymore
|
||||
freezer.move_to(datetime.timedelta(minutes=30))
|
||||
call_command('clean_leases')
|
||||
|
||||
resp = app.get(datetimes_url)
|
||||
assert len([x for x in resp.json['data'] if x.get('disabled')]) == 0
|
||||
|
||||
|
||||
def test_meetings_agenda_with_resource_exclusion(app, user):
|
||||
'''Test fillslot on meetings agenda with lock_code and ressources'''
|
||||
agenda1 = build_meetings_agenda(
|
||||
'Agenda 1',
|
||||
resources=['Re1'],
|
||||
meeting_types=(30,),
|
||||
desks={'desk-1': ['monday-friday 9:00-12:00 14:00-17:00']},
|
||||
)
|
||||
agenda2 = build_meetings_agenda(
|
||||
'Agenda 2',
|
||||
resources=['Re1'],
|
||||
meeting_types=(30,),
|
||||
desks={'desk-1': ['monday-friday 9:00-12:00 14:00-17:00']},
|
||||
)
|
||||
resource = agenda2._re_re1
|
||||
agenda1_datetimes_url = agenda1._mt_30.get_datetimes_url()
|
||||
agenda2_datetimes_url = agenda2._mt_30.get_datetimes_url()
|
||||
|
||||
# list free slots, with or without a lock
|
||||
resp = app.get(agenda1_datetimes_url, params={'lock_code': 'OTHERLOCK', 'resources': resource.slug})
|
||||
free_slots = len(resp.json['data'])
|
||||
resp = app.get(agenda1_datetimes_url, params={'lock_code': 'MYLOCK', 'resources': resource.slug})
|
||||
assert free_slots == len(resp.json['data'])
|
||||
resp = app.get(agenda1_datetimes_url, params={'resources': resource.slug})
|
||||
assert free_slots == len(resp.json['data'])
|
||||
resp = app.get(agenda1_datetimes_url)
|
||||
assert free_slots == len(resp.json['data'])
|
||||
|
||||
# lock a slot
|
||||
fillslot_url = resp.json['data'][2]['api']['fillslot_url']
|
||||
app.authorization = ('Basic', ('john.doe', 'password'))
|
||||
app.post_json(fillslot_url + '?resources=re1', params={'lock_code': 'MYLOCK'})
|
||||
assert Booking.objects.count() == 1
|
||||
assert Lease.objects.get().lock_code == 'MYLOCK'
|
||||
|
||||
# list free slots: one is locked ...
|
||||
resp = app.get(agenda1_datetimes_url, params={'resources': resource.slug})
|
||||
assert free_slots == len(resp.json['data'])
|
||||
assert len([x for x in resp.json['data'] if x.get('disabled')]) == 1
|
||||
|
||||
resp = app.get(agenda1_datetimes_url, params={'lock_code': 'OTHERLOCK', 'resources': resource.slug})
|
||||
assert len([x for x in resp.json['data'] if x.get('disabled')]) == 1
|
||||
|
||||
# ... unless it's MYLOCK
|
||||
resp = app.get(agenda1_datetimes_url, params={'lock_code': 'MYLOCK', 'resources': resource.slug})
|
||||
assert free_slots == len(resp.json['data'])
|
||||
assert len([x for x in resp.json['data'] if x.get('disabled')]) == 0
|
||||
|
||||
# check slot is also disabled on another agenda with same resource
|
||||
resp = app.get(agenda2_datetimes_url)
|
||||
assert len([x for x in resp.json['data'] if x.get('disabled')]) == 0
|
||||
resp = app.get(agenda2_datetimes_url, params={'resources': resource.slug})
|
||||
assert len([x for x in resp.json['data'] if x.get('disabled')]) == 1
|
||||
|
||||
# can't lock the same timeslot ...
|
||||
resp_booking = app.post_json(fillslot_url + '?resources=re1', params={'lock_code': 'OTHERLOCK'})
|
||||
assert resp_booking.json['err'] == 1
|
||||
assert resp_booking.json['reason'] == 'no more desk available'
|
||||
|
||||
# ... unless with MYLOCK (aka "relock")
|
||||
resp_booking = app.post_json(fillslot_url + '?resources=re1', params={'lock_code': 'MYLOCK'})
|
||||
assert resp_booking.json['err'] == 0
|
||||
assert Booking.objects.count() == 1
|
||||
assert Lease.objects.get().lock_code == 'MYLOCK'
|
||||
|
||||
# can't book the slot ...
|
||||
resp_booking = app.post_json(fillslot_url + '?resources=re1')
|
||||
assert resp_booking.json['err'] == 1
|
||||
assert resp_booking.json['reason'] == 'no more desk available'
|
||||
|
||||
resp_booking = app.post_json(fillslot_url + '?resources=re1', params={'confirm_after_lock': True})
|
||||
assert resp_booking.json['err'] == 1
|
||||
assert resp_booking.json['reason'] == 'no more desk available'
|
||||
|
||||
resp_booking = app.post_json(
|
||||
fillslot_url + '?resources=re1', params={'lock_code': 'OTHERLOCK', 'confirm_after_lock': True}
|
||||
)
|
||||
assert resp_booking.json['err'] == 1
|
||||
assert resp_booking.json['reason'] == 'no more desk available'
|
||||
|
||||
# unless with MYLOCK (aka "confirm")
|
||||
resp_booking = app.post_json(
|
||||
fillslot_url + '?resources=re1', params={'lock_code': 'MYLOCK', 'confirm_after_lock': True}
|
||||
)
|
||||
assert resp_booking.json['err'] == 0
|
||||
assert Booking.objects.count() == 1
|
||||
assert Lease.objects.count() == 0
|
||||
|
||||
|
||||
def test_virtual_agenda_with_external_user_id_exclusion(app, user):
|
||||
'''Test lock_code use when excluding an external_user_id'''
|
||||
agenda = build_virtual_agenda(
|
||||
meeting_types=(30,),
|
||||
agendas={
|
||||
'Agenda 1': {
|
||||
'desks': {
|
||||
'desk': 'monday-friday 08:00-12:00 14:00-17:00',
|
||||
},
|
||||
},
|
||||
'Agenda 2': {
|
||||
'desks': {
|
||||
'desk': 'monday-friday 09:00-12:00',
|
||||
},
|
||||
},
|
||||
'Agenda 3': {
|
||||
'desks': {
|
||||
'desk': 'monday-friday 15:00-17:00',
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
datetimes_url = agenda._mt_30.get_datetimes_url()
|
||||
|
||||
resp = app.get(datetimes_url)
|
||||
slots = resp.json['data']
|
||||
# get first slot between 11 and 11:30
|
||||
slot = [slot for slot in slots if ' 11:00:00' in slot['datetime']][0]
|
||||
fillslot_url = slot['api']['fillslot_url']
|
||||
|
||||
app.authorization = ('Basic', ('john.doe', 'password'))
|
||||
resp = app.post_json(fillslot_url, params={'lock_code': 'MYLOCK', 'user_external_id': 'abcd'})
|
||||
assert resp.json['err'] == 0
|
||||
|
||||
# check the lease was created
|
||||
assert Booking.objects.filter(user_external_id='abcd', lease__lock_code='MYLOCK').count() == 1
|
||||
|
||||
# check 11:00 slot is still available
|
||||
resp = app.get(datetimes_url)
|
||||
slots = resp.json['data']
|
||||
assert any(
|
||||
s['datetime'] == slot['datetime'] for s in slots if not s['disabled']
|
||||
), f"slot {slot['datetime']} should be available"
|
||||
|
||||
# check 11:00 slot is unavailable when tested with user_external_id
|
||||
resp = app.get(datetimes_url, params={'user_external_id': 'abcd'})
|
||||
slots = resp.json['data']
|
||||
assert not any(
|
||||
s['datetime'] == slot['datetime'] for s in slots if not s['disabled']
|
||||
), f"slot {slot['datetime']} should not be available"
|
||||
|
||||
# check 11:00 slot is available if tested with user_external_id *AND* lock_code
|
||||
resp = app.get(datetimes_url, params={'lock_code': 'MYLOCK', 'user_external_id': 'abcd'})
|
||||
slots = resp.json['data']
|
||||
assert any(
|
||||
s['datetime'] == slot['datetime'] for s in slots if not s['disabled']
|
||||
), f"slot {slot['datetime']} should be available"
|
|
@ -67,6 +67,7 @@ def test_recurring_events_api_fillslots(app, user, freezer, action):
|
|||
assert 'booked_events' not in resp.json
|
||||
|
||||
assert Booking.objects.count() == 3
|
||||
assert Booking.objects.filter(from_recurring_fillslots=True).count() == 3
|
||||
assert Booking.objects.filter(event__primary_event=event).count() == 2
|
||||
assert Booking.objects.filter(event__primary_event=sunday_event).count() == 1
|
||||
|
||||
|
@ -1790,6 +1791,18 @@ def test_recurring_events_api_fillslots_partial_bookings(app, user):
|
|||
resp = app.post_json(fillslots_url, params=params, status=400)
|
||||
assert resp.json['errors']['non_field_errors'][0] == 'start_time must be before end_time'
|
||||
|
||||
# start before opening time
|
||||
params['start_time'] = '07:59'
|
||||
params['end_time'] = '18:00'
|
||||
resp = app.post_json(fillslots_url, params=params)
|
||||
assert resp.json['err_desc'] == 'booking start must be after opening time'
|
||||
|
||||
# end after closing time
|
||||
params['start_time'] = '08:00'
|
||||
params['end_time'] = '18:01'
|
||||
resp = app.post_json(fillslots_url, params=params)
|
||||
assert resp.json['err_desc'] == 'booking end must be before closing time'
|
||||
|
||||
|
||||
@pytest.mark.freeze_time('2023-05-01 10:00')
|
||||
def test_recurring_events_api_fillslots_partial_bookings_update(app, user):
|
||||
|
@ -1859,3 +1872,121 @@ 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(from_recurring_fillslots=True).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.'
|
||||
|
|
|
@ -15,6 +15,7 @@ from chrono.agendas.models import (
|
|||
Resource,
|
||||
TimePeriodException,
|
||||
)
|
||||
from chrono.apps.snapshot.models import AgendaSnapshot
|
||||
from chrono.utils.timezone import localtime, now
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
@ -449,6 +450,7 @@ def test_agenda_api_delete(app, user):
|
|||
resp = app.delete('/api/agenda/%s/' % agenda.slug)
|
||||
assert resp.json['err'] == 0
|
||||
assert not Agenda.objects.exists()
|
||||
assert AgendaSnapshot.objects.count() == 1
|
||||
|
||||
|
||||
def test_agenda_api_delete_busy(app, user):
|
||||
|
@ -561,6 +563,8 @@ def test_add_agenda(app, user, settings):
|
|||
assert len(resp.json['data']) == 1
|
||||
agenda = Agenda.objects.get(slug='my-agenda')
|
||||
assert agenda.kind == 'events'
|
||||
assert agenda.partial_bookings is False
|
||||
assert AgendaSnapshot.objects.count() == 1
|
||||
|
||||
settings.WORKING_DAY_CALENDAR = 'workalendar.europe.France'
|
||||
edit_group = Group.objects.create(name='Edit')
|
||||
|
@ -641,10 +645,37 @@ def test_add_agenda(app, user, settings):
|
|||
assert 'data' in resp.json
|
||||
|
||||
|
||||
def test_add_agenda_partial_bookings(app, user, settings):
|
||||
app.authorization = ('Basic', ('john.doe', 'password'))
|
||||
|
||||
params = {
|
||||
'label': 'My Agenda',
|
||||
'slug': 'my-agenda',
|
||||
'partial_bookings': True,
|
||||
}
|
||||
resp = app.post_json('/api/agenda/', params=params)
|
||||
|
||||
agenda = Agenda.objects.get(slug='my-agenda')
|
||||
assert agenda.kind == 'events'
|
||||
assert agenda.partial_bookings is True
|
||||
|
||||
# partial bookings meetings agenda is forbidden
|
||||
params = {
|
||||
'label': 'My Agenda 2',
|
||||
'slug': 'my-agenda-2',
|
||||
'partial_bookings': True,
|
||||
'kind': 'meetings',
|
||||
}
|
||||
resp = app.post_json('/api/agenda/', params=params, status=400)
|
||||
|
||||
assert resp.json['errors'] == {'partial_bookings': ['Option not available on meetings agenda']}
|
||||
|
||||
|
||||
@pytest.mark.freeze_time('2021-07-09T08:00:00.0+02:00')
|
||||
def test_patch_agenda(app, user):
|
||||
Category.objects.create(label='Category A')
|
||||
agenda = Agenda.objects.create(label='Foo bar', kind='events')
|
||||
Desk.objects.create(agenda=agenda, slug='_exceptions_holder')
|
||||
|
||||
app.authorization = ('Basic', ('john.doe', 'password'))
|
||||
|
||||
|
@ -653,6 +684,7 @@ def test_patch_agenda(app, user):
|
|||
assert resp.json['data']['text'] == 'Foo bar'
|
||||
assert resp.json['data']['kind'] == 'events'
|
||||
assert resp.json['data']['category'] is None
|
||||
assert AgendaSnapshot.objects.count() == 1
|
||||
|
||||
resp = app.patch_json('/api/agenda/%s/' % agenda.slug, params={'label': 'Test', 'kind': 'events'})
|
||||
assert resp.json['data']['id'] == 'foo-bar'
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue