Compare commits
172 Commits
Author | SHA1 | Date |
---|---|---|
Yann Weber | 6c2c412cfc | |
Yann Weber | 1aca9c2a66 | |
Frédéric Péters | 1ef0ba26af | |
Frédéric Péters | 4c14a32d82 | |
Frédéric Péters | 32d0a0c44b | |
Valentin Deniaud | 733cdfc9a9 | |
Lauréline Guérin | 77f3373820 | |
Valentin Deniaud | 169dc0a69a | |
Lauréline Guérin | 88d8feacd8 | |
Thomas NOËL | 6ee8fbf78d | |
Lauréline Guérin | 3403295d3d | |
Yann Weber | 0563e0642d | |
Yann Weber | 5a90c4851b | |
Yann Weber | d03e1e7940 | |
Yann Weber | b0f956c223 | |
Yann Weber | 570cf81c8e | |
Benjamin Dauvergne | 5fa96e62a8 | |
Lauréline Guérin | 7c91b91d89 | |
Lauréline Guérin | a167a91cde | |
Lauréline Guérin | 56b794468f | |
Lauréline Guérin | 184cf83dd7 | |
Yann Weber | 4e6f41c4de | |
Benjamin Dauvergne | 42f73e2626 | |
Valentin Deniaud | 3576928b2c | |
Frédéric Péters | ae55827939 | |
Lauréline Guérin | d733e91135 | |
Lauréline Guérin | 4b8c3412e4 | |
Valentin Deniaud | be975cfa29 | |
Lauréline Guérin | f7e224ba9b | |
Thomas Jund | 41cadbcfa9 | |
Lauréline Guérin | a34d55879e | |
Lauréline Guérin | 43c42c507c | |
Lauréline Guérin | 886afb206e | |
Lauréline Guérin | 2c30eec6ac | |
Lauréline Guérin | 1896c33f29 | |
Lauréline Guérin | 1f23f85b3d | |
Lauréline Guérin | 393a20b87b | |
Lauréline Guérin | df0e356e75 | |
Frédéric Péters | 07512150e8 | |
Lauréline Guérin | 2576350aae | |
Lauréline Guérin | 2187bf3dde | |
Lauréline Guérin | 4add868dd9 | |
Valentin Deniaud | 9c19321fb9 | |
Lauréline Guérin | a88db00e04 | |
Lauréline Guérin | eecbb80809 | |
Lauréline Guérin | e0f1d9541d | |
Lauréline Guérin | 701733da57 | |
Lauréline Guérin | d709fa9bc7 | |
Lauréline Guérin | 1d00c5fce8 | |
Lauréline Guérin | bd06f2b82f | |
Lauréline Guérin | ecf0ffd96e | |
Lauréline Guérin | 1b1bc13c82 | |
Lauréline Guérin | 06ab6f12b7 | |
Lauréline Guérin | 024b34b34f | |
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 |
|
@ -6,7 +6,7 @@ pipeline {
|
|||
stages {
|
||||
stage('Unit Tests') {
|
||||
steps {
|
||||
sh 'tox -rv -- --numprocesses 3'
|
||||
sh 'NUMPROCESSES=3 tox -rv'
|
||||
}
|
||||
post {
|
||||
always {
|
||||
|
|
|
@ -9,6 +9,7 @@ recursive-include chrono/api/templates *.html
|
|||
recursive-include chrono/agendas/templates *.html *.txt
|
||||
recursive-include chrono/manager/templates *.html *.txt
|
||||
recursive-include chrono/apps/ants_hub/templates *.html
|
||||
recursive-include chrono/apps/journal/templates *.html
|
||||
|
||||
# sql (migrations)
|
||||
recursive-include chrono/agendas/sql *.sql
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -17,6 +17,6 @@ class Migration(migrations.Migration):
|
|||
migrations.AddField(
|
||||
model_name='event',
|
||||
name='url',
|
||||
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='URL'),
|
||||
field=models.URLField(blank=True, null=True, verbose_name='URL'),
|
||||
),
|
||||
]
|
||||
|
|
|
@ -10,6 +10,6 @@ class Migration(migrations.Migration):
|
|||
migrations.AddField(
|
||||
model_name='agenda',
|
||||
name='desk_simple_management',
|
||||
field=models.BooleanField(default=False),
|
||||
field=models.BooleanField(default=False, verbose_name='Global desk management'),
|
||||
),
|
||||
]
|
||||
|
|
|
@ -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),
|
||||
),
|
||||
]
|
File diff suppressed because it is too large
Load Diff
|
@ -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, 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)
|
||||
|
@ -282,6 +294,7 @@ class RecurringFillslotsByDaySerializer(FillSlotSerializer):
|
|||
|
||||
|
||||
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')
|
||||
|
@ -312,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
|
||||
|
@ -330,14 +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:
|
||||
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,
|
||||
|
@ -409,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'])
|
||||
|
@ -425,11 +467,13 @@ class DateRangeSerializer(DateRangeMixin, serializers.Serializer):
|
|||
|
||||
class DatetimesSerializer(DateRangeSerializer):
|
||||
min_places = serializers.IntegerField(min_value=1, default=1)
|
||||
max_places = serializers.IntegerField(min_value=1, default=None)
|
||||
user_external_id = serializers.CharField(required=False, max_length=250, allow_blank=True)
|
||||
exclude_user_external_id = serializers.CharField(required=False, max_length=250, allow_blank=True)
|
||||
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)
|
||||
|
@ -568,11 +612,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']]
|
||||
|
@ -645,6 +690,7 @@ class AgendaSerializer(serializers.ModelSerializer):
|
|||
'slug',
|
||||
'label',
|
||||
'kind',
|
||||
'partial_bookings',
|
||||
'minimal_booking_delay',
|
||||
'minimal_booking_delay_in_working_days',
|
||||
'maximal_booking_delay',
|
||||
|
@ -695,6 +741,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
|
||||
|
||||
|
@ -38,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,
|
||||
|
@ -128,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'),
|
||||
|
@ -145,4 +155,6 @@ 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')),
|
||||
path('user-preferences/', include('chrono.apps.user_preferences.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
|
||||
|
@ -207,6 +208,7 @@ class Place(models.Model):
|
|||
'annulation_url': self.cancel_url,
|
||||
'plages': list(self.iter_open_dates()),
|
||||
'rdvs': list(self.iter_predemandes()),
|
||||
'logo_url': self.logo_url,
|
||||
}
|
||||
return payload
|
||||
|
||||
|
@ -223,6 +225,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 +250,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,464 @@
|
|||
# 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 json
|
||||
import tarfile
|
||||
|
||||
from django.contrib.auth.models import Group
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
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.api.utils import APIErrorBadRequest
|
||||
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()}
|
||||
|
||||
compare_urls = {
|
||||
'agendas': 'chrono-manager-agenda-history-compare',
|
||||
'categories': 'chrono-manager-category-history-compare',
|
||||
'events_types': 'chrono-manager-events-type-history-compare',
|
||||
'resources': 'chrono-manager-resource-history-compare',
|
||||
'unavailability_calendars': 'chrono-manager-unavailability-calendar-history-compare',
|
||||
}
|
||||
|
||||
|
||||
def get_klass_from_component_type(component_type):
|
||||
try:
|
||||
return klasses[component_type]
|
||||
except KeyError:
|
||||
raise Http404
|
||||
|
||||
|
||||
class Index(GenericAPIView):
|
||||
permission_classes = (permissions.IsAdminUser,)
|
||||
|
||||
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.IsAdminUser,)
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
klass = get_klass_from_component_type(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.IsAdminUser,)
|
||||
|
||||
def get(self, request, slug, *args, **kwargs):
|
||||
klass = get_klass_from_component_type(kwargs['component_type'])
|
||||
serialisation = get_object_or_404(klass, slug=slug).export_json()
|
||||
return Response({'data': serialisation})
|
||||
|
||||
|
||||
export_component = ExportComponent.as_view()
|
||||
|
||||
|
||||
class ComponentDependencies(GenericAPIView):
|
||||
permission_classes = (permissions.IsAdminUser,)
|
||||
|
||||
def get(self, request, slug, *args, **kwargs):
|
||||
klass = get_klass_from_component_type(kwargs['component_type'])
|
||||
component = get_object_or_404(klass, 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 = get_klass_from_component_type(component_type)
|
||||
component = get_object_or_404(klass, slug=slug)
|
||||
|
||||
if component_type not in klasses or component_type == 'roles':
|
||||
raise Http404
|
||||
|
||||
if (
|
||||
'compare' in request.GET
|
||||
and request.GET.get('application')
|
||||
and request.GET.get('version1')
|
||||
and request.GET.get('version2')
|
||||
):
|
||||
component_type = klasses_translation.get(component_type, component_type)
|
||||
return redirect(
|
||||
'%s?version1=%s&version2=%s&application=%s'
|
||||
% (
|
||||
reverse(compare_urls[component_type], args=[component.pk]),
|
||||
request.GET['version1'],
|
||||
request.GET['version2'],
|
||||
request.GET['application'],
|
||||
)
|
||||
)
|
||||
|
||||
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.IsAdminUser,)
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
bundle = request.FILES['bundle']
|
||||
try:
|
||||
with tarfile.open(fileobj=bundle) as tar:
|
||||
try:
|
||||
manifest = json.loads(tar.extractfile('manifest.json').read().decode())
|
||||
except KeyError:
|
||||
raise APIErrorBadRequest(_('Invalid tar file, missing manifest'))
|
||||
application_slug = manifest.get('slug')
|
||||
application_version = manifest.get('version_number')
|
||||
if not application_slug or not application_version:
|
||||
return Response({'data': {}})
|
||||
|
||||
differences = []
|
||||
unknown_elements = []
|
||||
no_history_elements = []
|
||||
legacy_elements = []
|
||||
content_types = ContentType.objects.get_for_models(
|
||||
*[v for k, v in klasses.items() if k != 'roles']
|
||||
)
|
||||
for element in manifest.get('elements'):
|
||||
component_type = element['type']
|
||||
if component_type not in klasses or component_type == 'roles':
|
||||
continue
|
||||
klass = klasses[component_type]
|
||||
component_type = klasses_translation.get(component_type, component_type)
|
||||
try:
|
||||
component = klass.objects.get(slug=element['slug'])
|
||||
except klass.DoesNotExist:
|
||||
unknown_elements.append(
|
||||
{
|
||||
'type': component_type,
|
||||
'slug': element['slug'],
|
||||
}
|
||||
)
|
||||
continue
|
||||
elements_qs = ApplicationElement.objects.filter(
|
||||
application__slug=application_slug,
|
||||
content_type=content_types[klass],
|
||||
object_id=component.pk,
|
||||
)
|
||||
if not elements_qs.exists():
|
||||
# object exists, but not linked to the application
|
||||
legacy_elements.append(
|
||||
{
|
||||
'type': component.application_component_type,
|
||||
'slug': str(component.slug),
|
||||
# information needed here, Relation objects may not exist yet in hobo
|
||||
'text': component.label,
|
||||
'url': reverse(
|
||||
'api-export-import-component-redirect',
|
||||
kwargs={
|
||||
'slug': str(component.slug),
|
||||
'component_type': component.application_component_type,
|
||||
},
|
||||
),
|
||||
}
|
||||
)
|
||||
continue
|
||||
snapshot_for_app = (
|
||||
klass.get_snapshot_model()
|
||||
.objects.filter(
|
||||
instance=component,
|
||||
application_slug=application_slug,
|
||||
application_version=application_version,
|
||||
)
|
||||
.order_by('timestamp')
|
||||
.last()
|
||||
)
|
||||
if not snapshot_for_app:
|
||||
# no snapshot for this bundle
|
||||
no_history_elements.append(
|
||||
{
|
||||
'type': element['type'],
|
||||
'slug': element['slug'],
|
||||
}
|
||||
)
|
||||
continue
|
||||
last_snapshot = (
|
||||
klass.get_snapshot_model().objects.filter(instance=component).latest('timestamp')
|
||||
)
|
||||
if snapshot_for_app.pk != last_snapshot.pk:
|
||||
differences.append(
|
||||
{
|
||||
'type': element['type'],
|
||||
'slug': element['slug'],
|
||||
'url': '%s?version1=%s&version2=%s'
|
||||
% (
|
||||
request.build_absolute_uri(
|
||||
reverse(compare_urls[component_type], args=[component.pk])
|
||||
),
|
||||
snapshot_for_app.pk,
|
||||
last_snapshot.pk,
|
||||
),
|
||||
}
|
||||
)
|
||||
except tarfile.TarError:
|
||||
raise APIErrorBadRequest(_('Invalid tar file'))
|
||||
|
||||
return Response(
|
||||
{
|
||||
'data': {
|
||||
'differences': differences,
|
||||
'unknown_elements': unknown_elements,
|
||||
'no_history_elements': no_history_elements,
|
||||
'legacy_elements': legacy_elements,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
bundle_check = BundleCheck.as_view()
|
||||
|
||||
|
||||
class BundleImport(GenericAPIView):
|
||||
permission_classes = (permissions.IsAdminUser,)
|
||||
install = True
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
bundle = request.FILES['bundle']
|
||||
components = {}
|
||||
try:
|
||||
with tarfile.open(fileobj=bundle) as tar:
|
||||
try:
|
||||
manifest = json.loads(tar.extractfile('manifest.json').read().decode())
|
||||
except KeyError:
|
||||
raise APIErrorBadRequest(_('Invalid tar file, missing manifest'))
|
||||
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] = []
|
||||
try:
|
||||
component_content = (
|
||||
tar.extractfile('%s/%s' % (element['type'], element['slug'])).read().decode()
|
||||
)
|
||||
except KeyError:
|
||||
raise APIErrorBadRequest(
|
||||
_(
|
||||
'Invalid tar file, missing component %s/%s'
|
||||
% (element['type'], element['slug'])
|
||||
)
|
||||
)
|
||||
components[component_type].append(json.loads(component_content).get('data'))
|
||||
except tarfile.TarError:
|
||||
raise APIErrorBadRequest(_('Invalid tar file'))
|
||||
|
||||
# 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)
|
||||
if self.install is True:
|
||||
existing_component.take_snapshot(
|
||||
comment=_('Application (%s)') % self.application,
|
||||
application=self.application,
|
||||
)
|
||||
|
||||
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.IsAdminUser,)
|
||||
|
||||
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') or ''
|
||||
application.documentation_url = manifest.get('documentation_url') or ''
|
||||
application.version_number = manifest.get('version_number') or 'unknown'
|
||||
application.version_notes = manifest.get('version_notes') or ''
|
||||
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,56 @@
|
|||
# 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
|
||||
|
||||
import django_filters
|
||||
from django.forms.widgets import DateInput
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from chrono.agendas.models import Agenda
|
||||
|
||||
from .models import AuditEntry
|
||||
|
||||
|
||||
class DateWidget(DateInput):
|
||||
input_type = 'date'
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
kwargs['format'] = '%Y-%m-%d'
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
class DayFilter(django_filters.DateFilter):
|
||||
def filter(self, qs, value):
|
||||
if value:
|
||||
qs = qs.filter(timestamp__gte=value, timestamp__lt=value + datetime.timedelta(days=1))
|
||||
return qs
|
||||
|
||||
|
||||
class JournalFilterSet(django_filters.FilterSet):
|
||||
timestamp = DayFilter(widget=DateWidget())
|
||||
agenda = django_filters.ModelChoiceFilter(queryset=Agenda.objects.all())
|
||||
action_type = django_filters.ChoiceFilter(
|
||||
choices=(
|
||||
('booking', _('Booking')),
|
||||
('check', _('Checking')),
|
||||
('invoice', _('Invoicing')),
|
||||
)
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = AuditEntry
|
||||
fields = []
|
|
@ -0,0 +1,6 @@
|
|||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = []
|
||||
operations = []
|
|
@ -0,0 +1,52 @@
|
|||
# Generated by Django 3.2.16 on 2024-04-23 11:49
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('agendas', '0171_snapshot_models'),
|
||||
('journal', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='AuditEntry',
|
||||
fields=[
|
||||
(
|
||||
'id',
|
||||
models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
('timestamp', models.DateTimeField(auto_now_add=True, verbose_name='Date')),
|
||||
('action_type', models.CharField(max_length=100, verbose_name='Action type')),
|
||||
('action_code', models.CharField(max_length=100, verbose_name='Action code')),
|
||||
('extra_data', models.JSONField(blank=True, default=dict)),
|
||||
(
|
||||
'agenda',
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name='audit_entries',
|
||||
to='agendas.agenda',
|
||||
),
|
||||
),
|
||||
(
|
||||
'user',
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name='User',
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'ordering': ('-timestamp',),
|
||||
},
|
||||
),
|
||||
]
|
|
@ -0,0 +1,57 @@
|
|||
# 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 _
|
||||
|
||||
MESSAGES = {
|
||||
'booking:accept': _('acceptation of booking (%(booking_id)s) in event "%(event)s"'),
|
||||
'booking:cancel': _('cancellation of booking (%(booking_id)s) in event "%(event)s"'),
|
||||
'booking:create': _('created booking (%(booking_id)s) for event %(event)s'),
|
||||
'booking:suspend': _('suspension of booking (%(booking_id)s) in event "%(event)s"'),
|
||||
'check:mark': _('marked event %(event)s as checked'),
|
||||
'check:mark-unchecked-absent': _('marked unchecked users as absent in %(event)s'),
|
||||
'check:reset': _('reset check of %(user_name)s in %(event)s'),
|
||||
'check:lock': _('marked event %(event)s as locked for checks'),
|
||||
'check:unlock': _('unmarked event %(event)s as locked for checks'),
|
||||
'check:absence': _('marked absence of %(user_name)s in %(event)s'),
|
||||
'check:presence': _('marked presence of %(user_name)s in %(event)s'),
|
||||
'invoice:mark': _('marked event %(event)s as invoiced'),
|
||||
'invoice:unmark': _('unmarked event %(event)s as invoiced'),
|
||||
}
|
||||
|
||||
|
||||
class AuditEntry(models.Model):
|
||||
timestamp = models.DateTimeField(verbose_name=_('Date'), auto_now_add=True)
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL, verbose_name=_('User'), on_delete=models.SET_NULL, null=True
|
||||
)
|
||||
action_type = models.CharField(verbose_name=_('Action type'), max_length=100)
|
||||
action_code = models.CharField(verbose_name=_('Action code'), max_length=100)
|
||||
agenda = models.ForeignKey(
|
||||
'agendas.Agenda', on_delete=models.SET_NULL, null=True, related_name='audit_entries'
|
||||
)
|
||||
extra_data = models.JSONField(blank=True, default=dict)
|
||||
|
||||
class Meta:
|
||||
ordering = ('-timestamp',)
|
||||
|
||||
def get_action_text(self):
|
||||
try:
|
||||
return MESSAGES[f'{self.action_type}:{self.action_code}'] % self.extra_data
|
||||
except KeyError:
|
||||
return _('Unknown entry (%s:%s)') % (self.action_type, self.action_code)
|
|
@ -0,0 +1,49 @@
|
|||
{% extends "chrono/manager_base.html" %}
|
||||
{% load gadjo i18n %}
|
||||
|
||||
{% block breadcrumb %}
|
||||
{{ block.super }}
|
||||
<a href="{% url 'chrono-manager-audit-journal' %}">{% trans "Audit journal" %}</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block appbar %}
|
||||
<h2>{% trans "Audit journal" %}</h2>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<table class="main">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "Date" %}</th>
|
||||
<th>{% trans "User" %}</th>
|
||||
<th>{% trans "Agenda" %}</th>
|
||||
<th>{% trans "Action" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for line in object_list %}
|
||||
<tr>
|
||||
<td>{{ line.timestamp }}</td>
|
||||
<td>{{ line.user.get_full_name }}</td>
|
||||
<td>{{ line.agenda }}</td>
|
||||
<td>{{ line.get_action_text }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% include "gadjo/pagination.html" %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block sidebar %}
|
||||
<aside id="sidebar">
|
||||
<h3>{% trans "Search" %}</h3>
|
||||
<form action=".">
|
||||
{{ filter.form|with_template }}
|
||||
<div class="buttons">
|
||||
<button>{% trans "Search" %}</button>
|
||||
</div>
|
||||
</form>
|
||||
</aside>
|
||||
{% endblock %}
|
|
@ -0,0 +1,24 @@
|
|||
# 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.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
path('', views.journal_home, name='chrono-manager-audit-journal'),
|
||||
]
|
|
@ -0,0 +1,39 @@
|
|||
# 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.contrib.auth import get_user_model
|
||||
|
||||
from .models import AuditEntry
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
def audit(action, request=None, user=None, agenda=None, extra_data=None):
|
||||
action_type, action_code = action.split(':', 1)
|
||||
extra_data = extra_data or {}
|
||||
if 'event' in extra_data:
|
||||
extra_data['event_id'] = extra_data['event'].id
|
||||
extra_data['event'] = extra_data['event'].get_journal_label()
|
||||
if 'booking' in extra_data:
|
||||
extra_data['booking_id'] = extra_data['booking'].id
|
||||
extra_data['booking'] = extra_data['booking'].get_journal_label()
|
||||
return AuditEntry.objects.create(
|
||||
user=request.user if request and isinstance(request.user, User) else user,
|
||||
action_type=action_type,
|
||||
action_code=action_code,
|
||||
agenda=agenda,
|
||||
extra_data=extra_data,
|
||||
)
|
|
@ -0,0 +1,42 @@
|
|||
# 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.core.exceptions import PermissionDenied
|
||||
from django.views.generic import ListView
|
||||
|
||||
from .forms import JournalFilterSet
|
||||
|
||||
|
||||
class JournalHomeView(ListView):
|
||||
template_name = 'chrono/journal/home.html'
|
||||
paginate_by = 10
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
if not request.user.is_staff:
|
||||
raise PermissionDenied()
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def get_queryset(self):
|
||||
self.filterset = JournalFilterSet(self.request.GET)
|
||||
return self.filterset.qs
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context['filter'] = self.filterset
|
||||
return context
|
||||
|
||||
|
||||
journal_home = JournalHomeView.as_view()
|
|
@ -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.SET_NULL, 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.SET_NULL, 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.SET_NULL, 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.SET_NULL, 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.SET_NULL, to=settings.AUTH_USER_MODEL
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'ordering': ('-timestamp',),
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
]
|
|
@ -0,0 +1,182 @@
|
|||
# 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):
|
||||
return 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.SET_NULL, 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()
|
||||
return snapshot
|
||||
|
||||
def get_instance(self):
|
||||
try:
|
||||
# try reusing existing instance
|
||||
instance = self.get_instance_model().snapshots.get(snapshot=self)
|
||||
except self.get_instance_model().DoesNotExist:
|
||||
instance = self.load_instance(self.serialization, snapshot=self)
|
||||
instance.slug = self.serialization['slug'] # restore slug
|
||||
return instance
|
||||
|
||||
def load_instance(self, json_instance, snapshot=None):
|
||||
return self.get_instance_model().import_json(json_instance, snapshot=snapshot)[1]
|
||||
|
||||
def load_history(self):
|
||||
if self.instance is None:
|
||||
self._history = []
|
||||
return
|
||||
history = type(self).objects.filter(instance=self.instance)
|
||||
self._history = [s.id for s in history]
|
||||
|
||||
@property
|
||||
def previous(self):
|
||||
if not hasattr(self, '_history'):
|
||||
self.load_history()
|
||||
|
||||
try:
|
||||
idx = self._history.index(self.id)
|
||||
except ValueError:
|
||||
return None
|
||||
if idx == 0:
|
||||
return None
|
||||
return self._history[idx - 1]
|
||||
|
||||
@property
|
||||
def next(self):
|
||||
if not hasattr(self, '_history'):
|
||||
self.load_history()
|
||||
|
||||
try:
|
||||
idx = self._history.index(self.id)
|
||||
except ValueError:
|
||||
return None
|
||||
try:
|
||||
return self._history[idx + 1]
|
||||
except IndexError:
|
||||
return None
|
||||
|
||||
@property
|
||||
def first(self):
|
||||
if not hasattr(self, '_history'):
|
||||
self.load_history()
|
||||
|
||||
return self._history[0]
|
||||
|
||||
@property
|
||||
def last(self):
|
||||
if not hasattr(self, '_history'):
|
||||
self.load_history()
|
||||
|
||||
return self._history[-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',
|
||||
)
|
|
@ -0,0 +1,213 @@
|
|||
# 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 difflib
|
||||
import json
|
||||
import re
|
||||
|
||||
from django.http import Http404, HttpResponseRedirect
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.template import loader
|
||||
from django.urls import reverse
|
||||
from django.utils.formats import date_format
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.generic import DetailView, ListView
|
||||
from lxml.html.diff import htmldiff
|
||||
from pyquery import PyQuery as pq
|
||||
|
||||
from chrono.utils.timezone import localtime
|
||||
|
||||
|
||||
class InstanceWithSnapshotHistoryView(ListView):
|
||||
def get_queryset(self):
|
||||
self.instance = get_object_or_404(self.model.get_instance_model(), pk=self.kwargs['pk'])
|
||||
return self.instance.instance_snapshots.all().select_related('user')
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
kwargs[self.instance_context_key] = self.instance
|
||||
kwargs['object'] = self.instance
|
||||
current_date = None
|
||||
context = super().get_context_data(**kwargs)
|
||||
day_snapshot = None
|
||||
for snapshot in context['object_list']:
|
||||
if snapshot.timestamp.date() != current_date:
|
||||
current_date = snapshot.timestamp.date()
|
||||
snapshot.new_day = True
|
||||
snapshot.day_other_count = 0
|
||||
day_snapshot = snapshot
|
||||
else:
|
||||
day_snapshot.day_other_count += 1
|
||||
return context
|
||||
|
||||
|
||||
class InstanceWithSnapshotHistoryCompareView(DetailView):
|
||||
def get_snapshots_from_application(self):
|
||||
version1 = self.request.GET.get('version1')
|
||||
version2 = self.request.GET.get('version2')
|
||||
if not version1 or not version2:
|
||||
raise Http404
|
||||
|
||||
snapshot_for_app1 = (
|
||||
self.model.get_snapshot_model()
|
||||
.objects.filter(
|
||||
instance=self.object,
|
||||
application_slug=self.request.GET['application'],
|
||||
application_version=self.request.GET['version1'],
|
||||
)
|
||||
.order_by('timestamp')
|
||||
.last()
|
||||
)
|
||||
snapshot_for_app2 = (
|
||||
self.model.get_snapshot_model()
|
||||
.objects.filter(
|
||||
instance=self.object,
|
||||
application_slug=self.request.GET['application'],
|
||||
application_version=self.request.GET['version2'],
|
||||
)
|
||||
.order_by('timestamp')
|
||||
.last()
|
||||
)
|
||||
return snapshot_for_app1, snapshot_for_app2
|
||||
|
||||
def get_snapshots(self):
|
||||
if 'application' in self.request.GET:
|
||||
return self.get_snapshots_from_application()
|
||||
|
||||
id1 = self.request.GET.get('version1')
|
||||
id2 = self.request.GET.get('version2')
|
||||
if not id1 or not id2:
|
||||
raise Http404
|
||||
|
||||
snapshot1 = get_object_or_404(self.model.get_snapshot_model(), pk=id1, instance=self.object)
|
||||
snapshot2 = get_object_or_404(self.model.get_snapshot_model(), pk=id2, instance=self.object)
|
||||
|
||||
return snapshot1, snapshot2
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
kwargs[self.instance_context_key] = self.object
|
||||
|
||||
mode = self.request.GET.get('mode') or 'json'
|
||||
if mode not in ['json', 'inspect']:
|
||||
raise Http404
|
||||
|
||||
snapshot1, snapshot2 = self.get_snapshots()
|
||||
if not snapshot1 or not snapshot2:
|
||||
return redirect(reverse(self.history_view, args=[self.object.pk]))
|
||||
if snapshot1.timestamp > snapshot2.timestamp:
|
||||
snapshot1, snapshot2 = snapshot2, snapshot1
|
||||
|
||||
kwargs['mode'] = mode
|
||||
kwargs['snapshot1'] = snapshot1
|
||||
kwargs['snapshot2'] = snapshot2
|
||||
kwargs['fromdesc'] = self.get_snapshot_desc(snapshot1)
|
||||
kwargs['todesc'] = self.get_snapshot_desc(snapshot2)
|
||||
kwargs.update(getattr(self, 'get_compare_%s_context' % mode)(snapshot1, snapshot2))
|
||||
|
||||
return super().get_context_data(**kwargs)
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
self.object = self.get_object()
|
||||
context = self.get_context_data(object=self.object)
|
||||
if isinstance(context, HttpResponseRedirect):
|
||||
return context
|
||||
return self.render_to_response(context)
|
||||
|
||||
def get_compare_inspect_context(self, snapshot1, snapshot2):
|
||||
instance1 = snapshot1.get_instance()
|
||||
instance2 = snapshot2.get_instance()
|
||||
|
||||
def get_context(instance):
|
||||
return {
|
||||
'object': instance,
|
||||
}
|
||||
|
||||
def fix_result(panel_diff):
|
||||
if not panel_diff:
|
||||
return panel_diff
|
||||
panel = pq(panel_diff)
|
||||
# remove "Link" added by htmldiff
|
||||
for link in panel.find('a'):
|
||||
d = pq(link)
|
||||
text = d.html()
|
||||
new_text = re.sub(r' Link: .*$', '', text)
|
||||
d.html(new_text)
|
||||
# remove empty ins and del tags
|
||||
for elem in panel.find('ins, del'):
|
||||
d = pq(elem)
|
||||
if not (d.html() or '').strip():
|
||||
d.remove()
|
||||
# prevent auto-closing behaviour of pyquery .html() method
|
||||
for elem in panel.find('span, ul, div'):
|
||||
d = pq(elem)
|
||||
if not d.html():
|
||||
d.html(' ')
|
||||
return panel.html()
|
||||
|
||||
inspect1 = loader.render_to_string(self.inspect_template_name, get_context(instance1), self.request)
|
||||
d1 = pq(str(inspect1))
|
||||
inspect2 = loader.render_to_string(self.inspect_template_name, get_context(instance2), self.request)
|
||||
d2 = pq(str(inspect2))
|
||||
panels_attrs = [tab.attrib for tab in d1('[role="tabpanel"]')]
|
||||
panels1 = list(d1('[role="tabpanel"]'))
|
||||
panels2 = list(d2('[role="tabpanel"]'))
|
||||
|
||||
# build tab list (merge version 1 and version2)
|
||||
tabs1 = d1.find('[role="tab"]')
|
||||
tabs2 = d2.find('[role="tab"]')
|
||||
tabs_order = [t.get('id') for t in panels_attrs]
|
||||
tabs = {}
|
||||
for tab in tabs1 + tabs2:
|
||||
tab_id = pq(tab).attr('aria-controls')
|
||||
tabs[tab_id] = pq(tab).outer_html()
|
||||
tabs = [tabs[k] for k in tabs_order if k in tabs]
|
||||
|
||||
# build diff of each panel
|
||||
panels_diff = list(map(htmldiff, panels1, panels2))
|
||||
panels_diff = [fix_result(t) for t in panels_diff]
|
||||
|
||||
return {
|
||||
'tabs': tabs,
|
||||
'panels': zip(panels_attrs, panels_diff),
|
||||
'tab_class_names': d1('.pk-tabs').attr('class'),
|
||||
}
|
||||
|
||||
def get_compare_json_context(self, snapshot1, snapshot2):
|
||||
s1 = json.dumps(snapshot1.serialization, sort_keys=True, indent=2)
|
||||
s2 = json.dumps(snapshot2.serialization, sort_keys=True, indent=2)
|
||||
diff_serialization = difflib.HtmlDiff(wrapcolumn=160).make_table(
|
||||
fromlines=s1.splitlines(True),
|
||||
tolines=s2.splitlines(True),
|
||||
)
|
||||
|
||||
return {
|
||||
'diff_serialization': diff_serialization,
|
||||
}
|
||||
|
||||
def get_snapshot_desc(self, snapshot):
|
||||
label_or_comment = ''
|
||||
if snapshot.label:
|
||||
label_or_comment = snapshot.label
|
||||
elif snapshot.comment:
|
||||
label_or_comment = snapshot.comment
|
||||
if snapshot.application_version:
|
||||
label_or_comment += ' (%s)' % _('Version %s') % snapshot.application_version
|
||||
return '{name} ({pk}) - {label_or_comment} ({user}{timestamp})'.format(
|
||||
name=_('Snapshot'),
|
||||
pk=snapshot.id,
|
||||
label_or_comment=label_or_comment,
|
||||
user='%s ' % snapshot.user if snapshot.user_id else '',
|
||||
timestamp=date_format(localtime(snapshot.timestamp), format='DATETIME_FORMAT'),
|
||||
)
|
|
@ -0,0 +1,23 @@
|
|||
# chrono - agendas system
|
||||
# Copyright (C) 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.urls import path
|
||||
|
||||
from . import api_views
|
||||
|
||||
urlpatterns = [
|
||||
path('save', api_views.save_preference, name='api-user-preferences'),
|
||||
]
|
|
@ -0,0 +1,43 @@
|
|||
# chrono - agendas system
|
||||
# Copyright (C) 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 json
|
||||
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.http import HttpResponse, HttpResponseBadRequest
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
|
||||
from . import models
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@login_required
|
||||
def save_preference(request):
|
||||
user_pref, dummy = models.UserPreferences.objects.get_or_create(user=request.user)
|
||||
|
||||
if len(request.body) > 1000:
|
||||
return HttpResponseBadRequest(_('Payload is too large'))
|
||||
try:
|
||||
prefs = json.loads(request.body)
|
||||
except json.JSONDecodeError:
|
||||
return HttpResponseBadRequest(_('Bad format'))
|
||||
if not isinstance(prefs, dict) or len(prefs) != 1:
|
||||
return HttpResponseBadRequest(_('Bad format'))
|
||||
|
||||
user_pref.preferences.update(prefs)
|
||||
user_pref.save()
|
||||
return HttpResponse('', status=204)
|
|
@ -0,0 +1,32 @@
|
|||
# Generated by Django 3.2.18 on 2024-04-11 15:30
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='UserPreferences',
|
||||
fields=[
|
||||
(
|
||||
'id',
|
||||
models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
('preferences', models.JSONField(default=dict, verbose_name='Preferences')),
|
||||
(
|
||||
'user',
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
|
@ -0,0 +1,24 @@
|
|||
# chrono - agendas system
|
||||
# Copyright (C) 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 UserPreferences(models.Model):
|
||||
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
|
||||
preferences = models.JSONField(_('Preferences'), default=dict)
|
File diff suppressed because it is too large
Load Diff
|
@ -47,6 +47,7 @@ from chrono.agendas.models import (
|
|||
AgendaNotificationsSettings,
|
||||
AgendaReminderSettings,
|
||||
Booking,
|
||||
BookingCheck,
|
||||
Category,
|
||||
Desk,
|
||||
Event,
|
||||
|
@ -81,7 +82,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:
|
||||
|
@ -521,6 +522,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 +545,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):
|
||||
|
@ -574,16 +589,21 @@ class BookingCheckPresenceForm(forms.Form):
|
|||
|
||||
def __init__(self, *args, **kwargs):
|
||||
agenda = kwargs.pop('agenda')
|
||||
subscription = kwargs.pop('subscription', False)
|
||||
super().__init__(*args, **kwargs)
|
||||
check_types = get_agenda_check_types(agenda)
|
||||
self.presence_check_types = [ct for ct in check_types if ct.kind == 'presence']
|
||||
self.fields['check_type'].choices = [('', '---------')] + [
|
||||
(ct.slug, ct.label) for ct in self.presence_check_types
|
||||
]
|
||||
if not self.initial and subscription:
|
||||
unexpected_presences = [ct for ct in check_types if ct.unexpected_presence]
|
||||
if unexpected_presences:
|
||||
self.initial['check_type'] = unexpected_presences[0].slug
|
||||
|
||||
|
||||
class PartialBookingCheckForm(forms.ModelForm):
|
||||
user_was_present = forms.NullBooleanField(
|
||||
presence = forms.NullBooleanField(
|
||||
label=_('Status'),
|
||||
widget=forms.RadioSelect(
|
||||
choices=(
|
||||
|
@ -598,19 +618,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,40 +640,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
|
||||
self.instance.refresh_computed_times()
|
||||
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):
|
||||
|
@ -717,17 +777,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(',')
|
||||
|
@ -795,20 +867,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(
|
||||
|
@ -844,6 +917,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:
|
||||
|
|
|
@ -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,50 +787,165 @@ 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;
|
||||
}
|
||||
|
||||
// Month view, table element
|
||||
&-month {
|
||||
width: 100%;
|
||||
border-spacing: 0;
|
||||
& col.we {
|
||||
background-color: var(--zebra-color);
|
||||
}
|
||||
& col.today {
|
||||
background-image: linear-gradient(
|
||||
135deg,
|
||||
hsl(65, 65%, 94%) 20%,
|
||||
hsl(65, 55%, 92%) 70%,
|
||||
hsl(65, 50%, 90%) 90%);
|
||||
}
|
||||
&--day {
|
||||
padding: .33em;
|
||||
a {
|
||||
color: var(--font-color);
|
||||
font-weight: normal;
|
||||
text-decoration: none;
|
||||
}
|
||||
&.today a {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
& .registrant {
|
||||
&--name {
|
||||
box-sizing: border-box;
|
||||
text-align: right;
|
||||
padding: .66rem;
|
||||
font-size: 130%;
|
||||
color: #505050;
|
||||
font-weight: normal;
|
||||
width: var(--registrant-name-width);
|
||||
}
|
||||
&--day-cell {
|
||||
border-left: var(--separator-size) solid var(--separator-color);
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
padding: .33em;
|
||||
line-height: 0;
|
||||
& .booking {
|
||||
display: inline-block;
|
||||
width: Min(100%, 1.75em);
|
||||
height: 1.75em;
|
||||
--booking-color: #1066bc;
|
||||
background-color: var(--booking-color);
|
||||
&.present {
|
||||
background: var(--green);
|
||||
}
|
||||
&.absent {
|
||||
background: var(--red);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
&--registrant:nth-child(odd) {
|
||||
& th, & td {
|
||||
background-color: var(--zebra-color);
|
||||
}
|
||||
}
|
||||
&--registrant:nth-child(even) {
|
||||
& th, & td {
|
||||
--separator-color: var(--zebra-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.agenda-table.partial-bookings .booking {
|
||||
height: 70%;
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 15%;
|
||||
background: #1066bc;
|
||||
&.present {
|
||||
background: hsl(120, 57%, 35%);
|
||||
}
|
||||
&.absent {
|
||||
background: hsl(355, 80%, 45%);
|
||||
.partial-booking--check-icon {
|
||||
border: 0;
|
||||
&::before {
|
||||
content: "\f017"; /* clock */
|
||||
font-family: FontAwesome;
|
||||
padding-left: 1ex;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -774,3 +994,22 @@ 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;
|
||||
}
|
||||
|
||||
.snapshots-list .collapsed {
|
||||
display: none;
|
||||
}
|
||||
|
|
|
@ -1,4 +1,23 @@
|
|||
$(function() {
|
||||
const foldableClassObserver = new MutationObserver((mutations) => {
|
||||
mutations.forEach(mu => {
|
||||
const old_folded = (mu.oldValue.indexOf('folded') != -1);
|
||||
const new_folded = mu.target.classList.contains('folded')
|
||||
if (old_folded == new_folded) { return; }
|
||||
var pref_message = Object();
|
||||
pref_message[mu.target.dataset.sectionFoldedPrefName] = new_folded;
|
||||
fetch('/api/user-preferences/save', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {'Accept': 'application/json', 'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(pref_message)
|
||||
});
|
||||
});
|
||||
});
|
||||
document.querySelectorAll('[data-section-folded-pref-name]').forEach(
|
||||
elt => foldableClassObserver.observe(elt, {attributes: true, attributeFilter: ['class'], attributeOldValue: true})
|
||||
);
|
||||
|
||||
$('[data-total]').each(function() {
|
||||
var total = $(this).data('total');
|
||||
var booked = $(this).data('booked');
|
||||
|
|
|
@ -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 %}
|
|
@ -0,0 +1,20 @@
|
|||
<p class="snapshot-description">{{ fromdesc|safe }} ➔ {{ todesc|safe }}</p>
|
||||
<div class="snapshot-diff">
|
||||
{% if mode == 'json' %}
|
||||
{{ diff_serialization|safe }}
|
||||
{% else %}
|
||||
<div class="{{ tab_class_names }}">
|
||||
<div class="pk-tabs--tab-list" role="tablist">
|
||||
{% for tab in tabs %}{{ tab|safe }}{% endfor %}
|
||||
{{ tab_list|safe }}
|
||||
</div>
|
||||
<div class="pk-tabs--container">
|
||||
{% for attrs, panel in panels %}
|
||||
<div{% for k, v in attrs.items %} {{ k }}="{{ v }}"{% endfor %}>
|
||||
{{ panel|safe }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
|
@ -0,0 +1,63 @@
|
|||
{% load i18n %}
|
||||
<div>
|
||||
<form action="{{ compare_url }}" method="get">
|
||||
{% if object_list|length > 1 %}
|
||||
<p><button>{% trans "Show differences" %}</button></p>
|
||||
{% endif %}
|
||||
<table class="main">
|
||||
<thead>
|
||||
<th>{% trans 'Identifier' %}</th>
|
||||
<th>{% trans 'Compare' %}</th>
|
||||
<th>{% trans 'Date' %}</th>
|
||||
<th>{% trans 'Description' %}</th>
|
||||
<th>{% trans 'User' %}</th>
|
||||
<th>{% trans 'Actions' %}</th>
|
||||
</thead>
|
||||
<tbody class="snapshots-list">
|
||||
{% for snapshot in object_list %}
|
||||
<tr data-day="{{ snapshot.timestamp|date:"Y-m-d" }}" class="{% if snapshot.new_day %}new-day{% else %}collapsed{% endif %}">
|
||||
<td><span class="counter">#{{ snapshot.pk }}</span></td>
|
||||
<td>
|
||||
{% if object_list|length > 1 %}
|
||||
{% if not forloop.last %}<input type="radio" name="version1" value="{{ snapshot.pk }}" {% if forloop.first %}checked="checked"{% endif %} />{% else %} {% endif %}
|
||||
{% if not forloop.first %}<input type="radio" name="version2" value="{{ snapshot.pk }}" {% if forloop.counter == 2 %}checked="checked"{% endif %}/>{% else %} {% endif %}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{{ snapshot.timestamp }}
|
||||
{% if snapshot.new_day and snapshot.day_other_count %} — <a class="reveal" href="#day-{{ snapshot.timestamp|date:"Y-m-d"}}">
|
||||
{% if snapshot.day_other_count >= 50 %}<strong>{% endif %}
|
||||
{% blocktrans trimmed count counter=snapshot.day_other_count %}
|
||||
1 other this day
|
||||
{% plural %}
|
||||
{{ counter }} others
|
||||
{% endblocktrans %}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if snapshot.label %}
|
||||
<strong>{{ snapshot.label }}</strong>
|
||||
{% elif snapshot.comment %}
|
||||
{{ snapshot.comment }}
|
||||
{% endif %}
|
||||
{% if snapshot.application_version %}({% blocktrans with version=snapshot.application_version %}Version {{ version }}{% endblocktrans %}){% endif %}
|
||||
</td>
|
||||
<td>{% if snapshot.user %} {{ snapshot.user.get_full_name }}{% endif %}</td>
|
||||
<td>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
$(function() {
|
||||
$('tr.new-day a.reveal').on('click', function() {
|
||||
var day = $(this).parents('tr.new-day').data('day');
|
||||
$('.snapshots-list tr[data-day="' + day + '"]:not(.new-day)').toggleClass('collapsed');
|
||||
return false;
|
||||
});
|
||||
});
|
||||
</script>
|
|
@ -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">
|
||||
|
|
|
@ -0,0 +1,19 @@
|
|||
{% extends "chrono/manager_agenda_settings.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block appbar %}
|
||||
<h2>{% trans 'Agenda history' %} - {{ agenda }}</h2>
|
||||
{% endblock %}
|
||||
|
||||
{% block breadcrumb %}
|
||||
{{ block.super }}
|
||||
<a href="{% url 'chrono-manager-agenda-history' pk=agenda.pk %}">{% trans "History" %}</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% url 'chrono-manager-agenda-history-compare' pk=agenda.pk as compare_url %}
|
||||
{% include 'chrono/includes/snapshot_history_fragment.html' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block sidebar %}
|
||||
{% endblock %}
|
|
@ -0,0 +1,29 @@
|
|||
{% extends "chrono/manager_agenda_history.html" %}
|
||||
{% load gadjo static i18n %}
|
||||
|
||||
{% block css %}
|
||||
{{ block.super }}
|
||||
<link rel="stylesheet" type="text/css" media="all" href="{% static "css/gadjo.snapshotdiff.css" %}?{% start_timestamp %}"/>
|
||||
{% endblock %}
|
||||
|
||||
{% block extrascripts %}
|
||||
{{ block.super }}
|
||||
<script src="{% static "js/gadjo.snapshotdiff.js" %}?{% start_timestamp %}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block appbar %}
|
||||
<h2>{% trans 'Compare snapshots' %} ({% if mode == 'json' %}{% trans "JSON" %}{% else %}{% trans "Inspect" %}{% endif %})</h2>
|
||||
<span class="actions">
|
||||
<a href="?version1={{ snapshot1.pk }}&version2={{ snapshot2.pk }}&mode=inspect">{% trans "Compare inspect" %}</a>
|
||||
<a href="?version1={{ snapshot1.pk }}&version2={{ snapshot2.pk }}&mode=json">{% trans "Compare JSON" %}</a>
|
||||
</span>
|
||||
{% endblock %}
|
||||
|
||||
{% block breadcrumb %}
|
||||
{{ block.super }}
|
||||
<a href="{% url 'chrono-manager-agenda-history-compare' pk=agenda.pk %}">{% trans "Compare snapshots" %}</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include 'chrono/includes/snapshot_compare_fragment.html' %}
|
||||
{% endblock %}
|
|
@ -0,0 +1,19 @@
|
|||
{% extends "chrono/manager_agenda_settings.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block appbar %}
|
||||
<h2>{% trans 'Inspect' %}</h2>
|
||||
{% endblock %}
|
||||
|
||||
{% block breadcrumb %}
|
||||
{{ block.super }}
|
||||
<a href="{% url 'chrono-manager-agenda-inspect' pk=agenda.pk %}">{% trans "Inspect" %}</a>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
{% include 'chrono/manager_agenda_inspect_fragment.html' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block sidebar %}
|
||||
{% endblock %}
|
|
@ -0,0 +1,322 @@
|
|||
{% load i18n %}
|
||||
<div class="pk-tabs">
|
||||
<div class="pk-tabs--tab-list" role="tablist">
|
||||
<button aria-controls="panel-information" aria-selected="true" id="tab-information" role="tab" tabindex="0">{% trans "Information" %}</button>
|
||||
<button aria-controls="panel-settings" aria-selected="false" id="tab-settings" role="tab" tabindex="-1">{% trans "Settings" %}</button>
|
||||
<button aria-controls="panel-permissions" aria-selected="false" id="tab-permissions" role="tab" tabindex="-1">{% trans "Permissions" %}</button>
|
||||
{% if object.kind == 'events' %}
|
||||
<button aria-controls="panel-events" aria-selected="false" id="tab-events" role="tab" tabindex="-1">{% trans "Events" %}</button>
|
||||
<button aria-controls="panel-exceptions" aria-selected="false" id="tab-exceptions" role="tab" tabindex="-1">{% trans "Recurrence exceptions" %}</button>
|
||||
{% elif object.kind == 'meetings' %}
|
||||
<button aria-controls="panel-meeting-types" aria-selected="false" id="tab-meeting-types" role="tab" tabindex="-1">{% trans "Meeting Types" %}</button>
|
||||
<button aria-controls="panel-desks" aria-selected="false" id="tab-desks" role="tab" tabindex="-1">{% trans "Desks" %}</button>
|
||||
<button aria-controls="panel-resources" aria-selected="false" id="tab-resources" role="tab" tabindex="-1">{% trans "Resources" %}</button>
|
||||
{% elif object.kind == 'virtual' %}
|
||||
<button aria-controls="panel-agendas" aria-selected="false" id="tab-agendas" role="tab" tabindex="-1">{% trans "Included Agendas" %}</button>
|
||||
<button aria-controls="panel-time-periods" aria-selected="false" id="tab-time-periods" role="tab" tabindex="-1">{% trans "Excluded Periods" %}</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="pk-tabs--container">
|
||||
|
||||
<div aria-labelledby="tab-information" id="panel-information" role="tabpanel" tabindex="0">
|
||||
<div class="section">
|
||||
<ul>
|
||||
{% for label, value in object.get_inspect_fields %}
|
||||
<li class="parameter-{{ label|slugify }}">
|
||||
<span class="parameter">{% blocktrans %}{{ label }}:{% endblocktrans %}</span>
|
||||
{{ value }}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div aria-labelledby="tab-settings" hidden id="panel-settings" role="tabpanel" tabindex="0">
|
||||
<div class="section">
|
||||
|
||||
{% if object.kind != 'virtual' %}
|
||||
<h4>{% trans "Display options" %}</h4>
|
||||
<ul>
|
||||
{% for label, value in object.get_display_inspect_fields %}
|
||||
<li class="parameter-{{ label|slugify }}">
|
||||
<span class="parameter">{% blocktrans %}{{ label }}:{% endblocktrans %}</span>
|
||||
{{ value }}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
{% if object.kind == 'events' %}
|
||||
<h4>{% trans "Booking check options" %}</h4>
|
||||
<ul>
|
||||
{% for label, value in object.get_booking_check_inspect_fields %}
|
||||
<li class="parameter-{{ label|slugify }}">
|
||||
<span class="parameter">{% blocktrans %}{{ label }}:{% endblocktrans %}</span>
|
||||
{{ value }}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
{% if object.kind == 'events' %}
|
||||
{% if agenda.partial_bookings %}
|
||||
<h4>{% trans "Invoicing options" %}</h4>
|
||||
<ul>
|
||||
{% for label, value in object.get_invoicing_inspect_fields %}
|
||||
<li class="parameter-{{ label|slugify }}">
|
||||
<span class="parameter">{% blocktrans %}{{ label }}:{% endblocktrans %}</span>
|
||||
{{ value }}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<h4>{% trans "Management notifications" %}</h4>
|
||||
<ul>
|
||||
{% for label, value in object.get_notifications_inspect_fields %}
|
||||
<li class="parameter-{{ label|slugify }}">
|
||||
<span class="parameter">{% blocktrans %}{{ label }}:{% endblocktrans %}</span>
|
||||
{{ value }}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if object.kind != 'virtual' and not object.partial_bookings %}
|
||||
<h4>{% trans "Booking reminders" %}</h4>
|
||||
<ul>
|
||||
{% for label, value in object.get_reminder_inspect_fields %}
|
||||
<li class="parameter-{{ label|slugify }}">
|
||||
<span class="parameter">{% blocktrans %}{{ label }}:{% endblocktrans %}</span>
|
||||
{{ value }}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
<h4>{% trans "Booking Delays" %}</h4>
|
||||
<ul>
|
||||
{% for label, value in object.get_booking_delays_inspect_fields %}
|
||||
<li class="parameter-{{ label|slugify }}">
|
||||
<span class="parameter">{% blocktrans %}{{ label }}:{% endblocktrans %}</span>
|
||||
{{ value }}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div aria-labelledby="tab-permissions" hidden id="panel-permissions" role="tabpanel" tabindex="0">
|
||||
<div class="section">
|
||||
<ul>
|
||||
{% for label, value in object.get_permissions_inspect_fields %}
|
||||
<li class="parameter-{{ label|slugify }}">
|
||||
<span class="parameter">{% blocktrans %}{{ label }}:{% endblocktrans %}</span>
|
||||
{{ value }}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if object.kind == 'events' %}
|
||||
|
||||
<div aria-labelledby="tab-events" hidden id="panel-events" role="tabpanel" tabindex="0">
|
||||
<div class="section">
|
||||
{% for event in object.event_set.all %}
|
||||
<h4>{{ event }}</h4>
|
||||
<ul>
|
||||
{% for label, value in event.get_inspect_fields %}
|
||||
<li class="parameter-{{ label|slugify }}">
|
||||
<span class="parameter">{% blocktrans %}{{ label }}:{% endblocktrans %}</span>
|
||||
{{ value }}
|
||||
</li>
|
||||
{% endfor %}
|
||||
{% if object.events_type %}
|
||||
<li class="parameter-custom-fields">
|
||||
<span class="parameter">{% trans "Custom fields:" %}</span>
|
||||
<ul>
|
||||
{% for value in object.events_type.get_custom_fields %}
|
||||
<li class="parameter-custom-field-{{ value.varname }}">
|
||||
<span class="parameter">{% blocktrans with label=value.label %}{{ label }}:{% endblocktrans %}</span>
|
||||
{{ event.get_custom_fields|get:value.varname|default:"" }}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div aria-labelledby="tab-exceptions" hidden id="panel-exceptions" role="tabpanel" tabindex="0">
|
||||
<div class="section">
|
||||
{% for desk in object.desk_set.all %}{% if desk.slug == '_exceptions_holder' %}
|
||||
<h4>{% trans "Unavailability calendars" %}</h4>
|
||||
<ul>
|
||||
{% for unavailability_calendar in desk.unavailability_calendars.all %}
|
||||
<li class="parameter-unavailability-calendar }}">
|
||||
{{ unavailability_calendar }}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
<h4>{% trans "Exception sources" %}</h4>
|
||||
{% for source in desk.timeperiodexceptionsource_set.all %}
|
||||
<h5>{{ source }}</h5>
|
||||
<ul>
|
||||
{% for label, value in source.get_inspect_fields %}
|
||||
<li class="parameter-{{ label|slugify }}">
|
||||
<span class="parameter">{% blocktrans %}{{ label }}:{% endblocktrans %}</span>
|
||||
{{ value }}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endfor %}
|
||||
|
||||
<h4>{% trans "Exceptions" %}</h4>
|
||||
{% for exception in desk.timeperiodexception_set.all %}
|
||||
<h5>{{ exception }}</h5>
|
||||
<ul>
|
||||
{% for label, value in exception.get_inspect_fields %}
|
||||
<li class="parameter-{{ label|slugify }}">
|
||||
<span class="parameter">{% blocktrans %}{{ label }}:{% endblocktrans %}</span>
|
||||
{{ value }}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endfor %}
|
||||
{% endif %}{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% elif object.kind == 'meetings' %}
|
||||
|
||||
<div aria-labelledby="tab-meeting-types" hidden id="panel-meeting-types" role="tabpanel" tabindex="0">
|
||||
<div class="section">
|
||||
{% for meeting_type in object.meetingtype_set.all %}
|
||||
<h4>{{ meeting_type }}</h4>
|
||||
<ul>
|
||||
{% for label, value in meeting_type.get_inspect_fields %}
|
||||
<li class="parameter-{{ label|slugify }}">
|
||||
<span class="parameter">{% blocktrans %}{{ label }}:{% endblocktrans %}</span>
|
||||
{{ value }}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div aria-labelledby="tab-desks" hidden id="panel-desks" role="tabpanel" tabindex="0">
|
||||
<div class="section">
|
||||
{% for desk in object.desk_set.all %}
|
||||
<h4>{{ desk }}</h4>
|
||||
<ul>
|
||||
{% for label, value in desk.get_inspect_fields %}
|
||||
<li class="parameter-{{ label|slugify }}">
|
||||
<span class="parameter">{% blocktrans %}{{ label }}:{% endblocktrans %}</span>
|
||||
{{ value }}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
<h5>{% trans "Opening hours" %}</h5>
|
||||
{% for time_period in desk.timeperiod_set.all %}
|
||||
<h6>{{ time_period }}</h6>
|
||||
<ul>
|
||||
{% for label, value in time_period.get_inspect_fields %}
|
||||
<li class="parameter-{{ label|slugify }}">
|
||||
<span class="parameter">{% blocktrans %}{{ label }}:{% endblocktrans %}</span>
|
||||
{{ value }}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endfor %}
|
||||
|
||||
<h5>{% trans "Unavailability calendars" %}</h5>
|
||||
<ul>
|
||||
{% for unavailability_calendar in desk.unavailability_calendars.all %}
|
||||
<li class="parameter-unavailability-calendar }}">
|
||||
{{ unavailability_calendar }}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
<h5>{% trans "Exception sources" %}</h5>
|
||||
{% for source in desk.timeperiodexceptionsource_set.all %}
|
||||
<h6>{{ source }}</h6>
|
||||
<ul>
|
||||
{% for label, value in source.get_inspect_fields %}
|
||||
<li class="parameter-{{ label|slugify }}">
|
||||
<span class="parameter">{% blocktrans %}{{ label }}:{% endblocktrans %}</span>
|
||||
{{ value }}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endfor %}
|
||||
|
||||
<h5>{% trans "Exceptions" %}</h5>
|
||||
{% for exception in desk.timeperiodexception_set.all %}
|
||||
<h6>{{ exception }}</h6>
|
||||
<ul>
|
||||
{% for label, value in exception.get_inspect_fields %}
|
||||
<li class="parameter-{{ label|slugify }}">
|
||||
<span class="parameter">{% blocktrans %}{{ label }}:{% endblocktrans %}</span>
|
||||
{{ value }}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endfor %}
|
||||
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div aria-labelledby="tab-resources" hidden id="panel-resources" role="tabpanel" tabindex="0">
|
||||
<div class="section">
|
||||
<ul>
|
||||
{% for resource in object.resources.all %}
|
||||
<li class="parameter-resource }}">
|
||||
{{ resource }}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% elif object.kind == "virtual" %}
|
||||
|
||||
<div aria-labelledby="tab-agendas" hidden id="panel-agendas" role="tabpanel" tabindex="0">
|
||||
<div class="section">
|
||||
<ul>
|
||||
{% for agenda in object.real_agendas.all %}
|
||||
<li class="parameter-agenda }}">
|
||||
{{ agenda }}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div aria-labelledby="tab-time-periods" hidden id="panel-time-periods" role="tabpanel" tabindex="0">
|
||||
<div class="section">
|
||||
{% for time_period in object.excluded_timeperiods.all %}
|
||||
<h4>{{ time_period }}</h4>
|
||||
<ul>
|
||||
{% for label, value in time_period.get_inspect_fields %}
|
||||
<li class="parameter-{{ label|slugify }}">
|
||||
<span class="parameter">{% blocktrans %}{{ label }}:{% endblocktrans %}</span>
|
||||
{{ value }}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</div>
|
|
@ -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>
|
||||
|
@ -115,3 +109,25 @@
|
|||
</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 %}
|
||||
|
||||
<h3>{% trans "Navigation" %}</h3>
|
||||
{% if show_history %}
|
||||
<a class="button button-paragraph" href="{% url 'chrono-manager-agenda-history' pk=agenda.pk %}">{% trans 'History' %}</a>
|
||||
{% endif %}
|
||||
<a class="button button-paragraph" href="{% url 'chrono-manager-agenda-inspect' pk=agenda.pk %}">{% trans 'Inspect' %}</a>
|
||||
{% 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,12 +1,33 @@
|
|||
{% 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>
|
||||
{% else %}
|
||||
<h2>{% trans "New Category" %}</h2>
|
||||
{% endif %}
|
||||
{% if category %}
|
||||
<span class="actions">
|
||||
<a href="{% url 'chrono-manager-category-inspect' pk=category.pk %}">{% trans 'Inspect' %}</a>
|
||||
{% if show_history %}
|
||||
<a href="{% url 'chrono-manager-category-history' pk=category.pk %}">{% trans 'History' %}</a>
|
||||
{% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
@ -20,3 +41,6 @@
|
|||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
{% block sidebar %}
|
||||
{% endblock %}
|
||||
|
|
|
@ -0,0 +1,19 @@
|
|||
{% extends "chrono/manager_category_form.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block appbar %}
|
||||
<h2>{% trans 'Category history' %} - {{ category }}</h2>
|
||||
{% endblock %}
|
||||
|
||||
{% block breadcrumb %}
|
||||
{{ block.super }}
|
||||
<a href="{% url 'chrono-manager-category-history' pk=category.pk %}">{% trans "History" %}</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% url 'chrono-manager-category-history-compare' pk=category.pk as compare_url %}
|
||||
{% include 'chrono/includes/snapshot_history_fragment.html' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block sidebar %}
|
||||
{% endblock %}
|
|
@ -0,0 +1,29 @@
|
|||
{% extends "chrono/manager_category_history.html" %}
|
||||
{% load gadjo static i18n %}
|
||||
|
||||
{% block css %}
|
||||
{{ block.super }}
|
||||
<link rel="stylesheet" type="text/css" media="all" href="{% static "css/gadjo.snapshotdiff.css" %}?{% start_timestamp %}"/>
|
||||
{% endblock %}
|
||||
|
||||
{% block extrascripts %}
|
||||
{{ block.super }}
|
||||
<script src="{% static "js/gadjo.snapshotdiff.js" %}?{% start_timestamp %}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block appbar %}
|
||||
<h2>{% trans 'Compare snapshots' %} ({% if mode == 'json' %}{% trans "JSON" %}{% else %}{% trans "Inspect" %}{% endif %})</h2>
|
||||
<span class="actions">
|
||||
<a href="?version1={{ snapshot1.pk }}&version2={{ snapshot2.pk }}&mode=inspect">{% trans "Compare inspect" %}</a>
|
||||
<a href="?version1={{ snapshot1.pk }}&version2={{ snapshot2.pk }}&mode=json">{% trans "Compare JSON" %}</a>
|
||||
</span>
|
||||
{% endblock %}
|
||||
|
||||
{% block breadcrumb %}
|
||||
{{ block.super }}
|
||||
<a href="{% url 'chrono-manager-category-history-compare' pk=category.pk %}">{% trans "Compare snapshots" %}</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include 'chrono/includes/snapshot_compare_fragment.html' %}
|
||||
{% endblock %}
|
|
@ -0,0 +1,18 @@
|
|||
{% extends "chrono/manager_category_form.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block appbar %}
|
||||
<h2>{% trans 'Inspect' %}</h2>
|
||||
{% endblock %}
|
||||
|
||||
{% block breadcrumb %}
|
||||
{{ block.super }}
|
||||
<a href="{% url 'chrono-manager-category-inspect' pk=category.pk %}">{% trans "Inspect" %}</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include 'chrono/manager_category_inspect_fragment.html' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block sidebar %}
|
||||
{% endblock %}
|
|
@ -0,0 +1,21 @@
|
|||
{% load i18n %}
|
||||
<div class="pk-tabs">
|
||||
<div class="pk-tabs--tab-list" role="tablist">
|
||||
<button aria-controls="panel-information" aria-selected="true" id="tab-information" role="tab" tabindex="0">{% trans "Information" %}</button>
|
||||
</div>
|
||||
<div class="pk-tabs--container">
|
||||
|
||||
<div aria-labelledby="tab-information" id="panel-information" role="tabpanel" tabindex="0">
|
||||
<div class="section">
|
||||
<ul>
|
||||
{% for label, value in object.get_inspect_fields %}
|
||||
<li class="parameter-{{ label|slugify }}">
|
||||
<span class="parameter">{% blocktrans %}{{ label }}:{% endblocktrans %}</span>
|
||||
{{ value }}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
|
@ -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,13 @@
|
|||
{% 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>
|
||||
<a class="button button-paragraph" href="{{ lingo_url }}">{% trans 'Pricing' context 'pricing' %}</a>
|
||||
{% endif %}{% endwith %}
|
||||
{% 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 %}
|
||||
|
@ -16,6 +20,14 @@
|
|||
{% else %}
|
||||
<h2>{% trans "New events type" %}</h2>
|
||||
{% endif %}
|
||||
{% if object.pk %}
|
||||
<span class="actions">
|
||||
<a href="{% url 'chrono-manager-events-type-inspect' pk=object.pk %}">{% trans 'Inspect' %}</a>
|
||||
{% if show_history %}
|
||||
<a href="{% url 'chrono-manager-events-type-history' pk=object.pk %}">{% trans 'History' %}</a>
|
||||
{% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
@ -55,3 +67,6 @@
|
|||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
{% block sidebar %}
|
||||
{% endblock %}
|
||||
|
|
|
@ -0,0 +1,19 @@
|
|||
{% extends "chrono/manager_events_type_form.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block appbar %}
|
||||
<h2>{% trans 'Events type history' %} - {{ events_type }}</h2>
|
||||
{% endblock %}
|
||||
|
||||
{% block breadcrumb %}
|
||||
{{ block.super }}
|
||||
<a href="{% url 'chrono-manager-events-type-history' pk=events_type.pk %}">{% trans "History" %}</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% url 'chrono-manager-events-type-history-compare' pk=events_type.pk as compare_url %}
|
||||
{% include 'chrono/includes/snapshot_history_fragment.html' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block sidebar %}
|
||||
{% endblock %}
|
|
@ -0,0 +1,29 @@
|
|||
{% extends "chrono/manager_events_type_history.html" %}
|
||||
{% load gadjo static i18n %}
|
||||
|
||||
{% block css %}
|
||||
{{ block.super }}
|
||||
<link rel="stylesheet" type="text/css" media="all" href="{% static "css/gadjo.snapshotdiff.css" %}?{% start_timestamp %}"/>
|
||||
{% endblock %}
|
||||
|
||||
{% block extrascripts %}
|
||||
{{ block.super }}
|
||||
<script src="{% static "js/gadjo.snapshotdiff.js" %}?{% start_timestamp %}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block appbar %}
|
||||
<h2>{% trans 'Compare snapshots' %} ({% if mode == 'json' %}{% trans "JSON" %}{% else %}{% trans "Inspect" %}{% endif %})</h2>
|
||||
<span class="actions">
|
||||
<a href="?version1={{ snapshot1.pk }}&version2={{ snapshot2.pk }}&mode=inspect">{% trans "Compare inspect" %}</a>
|
||||
<a href="?version1={{ snapshot1.pk }}&version2={{ snapshot2.pk }}&mode=json">{% trans "Compare JSON" %}</a>
|
||||
</span>
|
||||
{% endblock %}
|
||||
|
||||
{% block breadcrumb %}
|
||||
{{ block.super }}
|
||||
<a href="{% url 'chrono-manager-events-type-history-compare' pk=events_type.pk %}">{% trans "Compare snapshots" %}</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include 'chrono/includes/snapshot_compare_fragment.html' %}
|
||||
{% endblock %}
|
|
@ -0,0 +1,18 @@
|
|||
{% extends "chrono/manager_events_type_form.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block appbar %}
|
||||
<h2>{% trans 'Inspect' %}</h2>
|
||||
{% endblock %}
|
||||
|
||||
{% block breadcrumb %}
|
||||
{{ block.super }}
|
||||
<a href="{% url 'chrono-manager-events-type-inspect' pk=events_type.pk %}">{% trans "Inspect" %}</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include 'chrono/manager_events_type_inspect_fragment.html' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block sidebar %}
|
||||
{% endblock %}
|
|
@ -0,0 +1,47 @@
|
|||
{% load i18n %}
|
||||
<div class="pk-tabs">
|
||||
<div class="pk-tabs--tab-list" role="tablist">
|
||||
<button aria-controls="panel-information" aria-selected="true" id="tab-information" role="tab" tabindex="0">{% trans "Information" %}</button>
|
||||
<button aria-controls="panel-custom-fields" aria-selected="false" id="tab-custom-fields" role="tab" tabindex="-1">{% trans "Custom fields" %}</button>
|
||||
</div>
|
||||
<div class="pk-tabs--container">
|
||||
|
||||
<div aria-labelledby="tab-information" id="panel-information" role="tabpanel" tabindex="0">
|
||||
<div class="section">
|
||||
<ul>
|
||||
{% for label, value in object.get_inspect_fields %}
|
||||
<li class="parameter-{{ label|slugify }}">
|
||||
<span class="parameter">{% blocktrans %}{{ label }}:{% endblocktrans %}</span>
|
||||
{{ value }}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div aria-labelledby="tab-custom-fields" hidden id="panel-custom-fields" role="tabpanel" tabindex="0">
|
||||
<div class="section">
|
||||
{% for value in object.get_custom_fields %}
|
||||
<h4>{{ value.label }}</h4>
|
||||
<ul>
|
||||
<li class="parameter-varname">
|
||||
<span class="parameter">{% trans "Field slug:" %}</span>
|
||||
{{ value.varname }}
|
||||
</li>
|
||||
<li class="parameter-label">
|
||||
<span class="parameter">{% trans "Field label:" %}</span>
|
||||
{{ value.label }}
|
||||
</li>
|
||||
<li class="parameter-field-type">
|
||||
<span class="parameter">{% trans "Field type:" %}</span>
|
||||
{% if value.field_type == 'text' %}{% trans "Text" %}{% endif %}
|
||||
{% if value.field_type == 'textarea' %}{% trans "Textarea" %}{% endif %}
|
||||
{% if value.field_type == 'textbool' %}{% trans "Boolean" %}{% endif %}
|
||||
</li>
|
||||
</ul>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
|
@ -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,36 +1,14 @@
|
|||
{% extends "chrono/manager_base.html" %}
|
||||
{% load i18n %}
|
||||
{% load i18n thumbnail chrono %}
|
||||
|
||||
{% 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 %}
|
||||
</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 %}
|
||||
|
@ -38,16 +16,26 @@
|
|||
{% if object_list %}
|
||||
{% regroup object_list by category as agenda_groups %}
|
||||
{% for group in agenda_groups %}
|
||||
<div class="section">
|
||||
{% 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>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% with i=group.grouper.id|stringformat:"s" %}
|
||||
{% with foldname='foldable-manager-category-group-'|add:i %}
|
||||
<div class="section foldable {% if user|get_preference:foldname %}folded{% endif %}" data-section-folded-pref-name="{{foldname}}">
|
||||
{% endwith %}
|
||||
{% endwith %}
|
||||
{% 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>
|
||||
{% 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
|
||||
|
@ -57,3 +45,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 user.is_staff and audit_journal_enabled %}
|
||||
<a class="button button-paragraph" href="{% url 'chrono-manager-audit-journal' %}">{% trans 'Audit journal' %}</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>
|
||||
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue