Compare commits
6 Commits
main
...
wip/73526-
Author | SHA1 | Date |
---|---|---|
Emmanuel Cazenave | 2f48b1057f | |
Benjamin Dauvergne | 8748be2b22 | |
Benjamin Dauvergne | ae99d87e27 | |
Benjamin Dauvergne | 33b4c807b4 | |
Benjamin Dauvergne | 246e62d96b | |
Benjamin Dauvergne | 60b1608f93 |
|
@ -21,5 +21,3 @@ e07c450d7c8a5f80aafe185c85ebed73fe39d9e7
|
|||
b38f5f901e1bef556bd95f45bcc041b092b1a617
|
||||
# misc: bump djhtml version (#75442)
|
||||
34309253eddc15f17a280656a3ffec072e79731a
|
||||
# misc: apply double-quote-string-fixer (#79866)
|
||||
b71dc670c7f90c675edb510643b992aaf69f852a
|
||||
|
|
|
@ -1,10 +1,6 @@
|
|||
# See https://pre-commit.com for more information
|
||||
# See https://pre-commit.com/hooks.html for more hooks
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.4.0
|
||||
hooks:
|
||||
- id: double-quote-string-fixer
|
||||
- repo: https://github.com/psf/black
|
||||
rev: 23.1.0
|
||||
hooks:
|
||||
|
@ -31,6 +27,6 @@ repos:
|
|||
- id: djhtml
|
||||
args: ['--tabwidth', '2']
|
||||
- repo: https://git.entrouvert.org/pre-commit-debian.git
|
||||
rev: v0.3
|
||||
rev: v0.1
|
||||
hooks:
|
||||
- id: pre-commit-debian
|
||||
|
|
|
@ -2,11 +2,10 @@
|
|||
|
||||
pipeline {
|
||||
agent any
|
||||
options { disableConcurrentBuilds() }
|
||||
stages {
|
||||
stage('Unit Tests') {
|
||||
steps {
|
||||
sh 'NUMPROCESSES=3 tox -rv'
|
||||
sh 'tox -rv -- --numprocesses 3'
|
||||
}
|
||||
post {
|
||||
always {
|
||||
|
@ -32,9 +31,9 @@ pipeline {
|
|||
'''
|
||||
).trim()
|
||||
if (env.GIT_BRANCH == 'main' || env.GIT_BRANCH == 'origin/main') {
|
||||
sh "sudo -H -u eobuilder /usr/local/bin/eobuilder -d bullseye,bookworm ${SHORT_JOB_NAME}"
|
||||
sh "sudo -H -u eobuilder /usr/local/bin/eobuilder -d bullseye ${SHORT_JOB_NAME}"
|
||||
} else if (env.GIT_BRANCH.startsWith('hotfix/')) {
|
||||
sh "sudo -H -u eobuilder /usr/local/bin/eobuilder -d bullseye,bookworm --branch ${env.GIT_BRANCH} --hotfix ${SHORT_JOB_NAME}"
|
||||
sh "sudo -H -u eobuilder /usr/local/bin/eobuilder -d bullseye --branch ${env.GIT_BRANCH} --hotfix ${SHORT_JOB_NAME}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,8 +8,6 @@ recursive-include chrono/manager/static *.css *.scss *.js
|
|||
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
|
||||
|
|
|
@ -1,26 +0,0 @@
|
|||
# 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,7 +59,6 @@ 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 EmailMultiAlternatives
|
||||
from django.core.mail import send_mail
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db.transaction import atomic
|
||||
from django.template.loader import render_to_string
|
||||
|
@ -72,12 +72,4 @@ class Command(BaseCommand):
|
|||
with atomic():
|
||||
setattr(event, status + '_notification_timestamp', timestamp)
|
||||
event.save()
|
||||
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()
|
||||
send_mail(subject, body, settings.DEFAULT_FROM_EMAIL, recipients, html_message=html_body)
|
||||
|
|
|
@ -11,7 +11,7 @@ class Migration(migrations.Migration):
|
|||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='meetingtype',
|
||||
options={'ordering': ['duration', 'label'], 'verbose_name': 'Meeting type'},
|
||||
options={'ordering': ['duration', 'label']},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='timeperiodexception',
|
||||
|
|
|
@ -17,6 +17,6 @@ class Migration(migrations.Migration):
|
|||
migrations.AddField(
|
||||
model_name='event',
|
||||
name='url',
|
||||
field=models.URLField(blank=True, null=True, verbose_name='URL'),
|
||||
field=models.CharField(blank=True, max_length=200, 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, verbose_name='Global desk management'),
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
|
|
|
@ -3,8 +3,6 @@
|
|||
import django.contrib.postgres.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
from chrono.agendas.models import WEEKDAY_CHOICES
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
|
@ -16,7 +14,9 @@ class Migration(migrations.Migration):
|
|||
model_name='event',
|
||||
name='recurrence_days',
|
||||
field=django.contrib.postgres.fields.ArrayField(
|
||||
base_field=models.IntegerField(choices=WEEKDAY_CHOICES),
|
||||
base_field=models.IntegerField(
|
||||
choices=[(0, 'Mo'), (1, 'Tu'), (2, 'We'), (3, 'Th'), (4, 'Fr'), (5, 'Sa'), (6, 'Su')]
|
||||
),
|
||||
blank=True,
|
||||
null=True,
|
||||
size=None,
|
||||
|
|
|
@ -4,8 +4,6 @@ import django.contrib.postgres.fields
|
|||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
from chrono.agendas.models import WEEKDAY_CHOICES
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
|
@ -62,7 +60,17 @@ class Migration(migrations.Migration):
|
|||
(
|
||||
'days',
|
||||
django.contrib.postgres.fields.ArrayField(
|
||||
base_field=models.IntegerField(choices=WEEKDAY_CHOICES),
|
||||
base_field=models.IntegerField(
|
||||
choices=[
|
||||
(0, 'Mo'),
|
||||
(1, 'Tu'),
|
||||
(2, 'We'),
|
||||
(3, 'Th'),
|
||||
(4, 'Fr'),
|
||||
(5, 'Sa'),
|
||||
(6, 'Su'),
|
||||
]
|
||||
),
|
||||
size=None,
|
||||
verbose_name='Days',
|
||||
),
|
||||
|
|
|
@ -18,7 +18,7 @@ class Migration(migrations.Migration):
|
|||
blank=True,
|
||||
default=datetime.time(0, 0),
|
||||
null=True,
|
||||
help_text='If left empty, available events will be those that are later than the current time.',
|
||||
help_text='Ex.: 08:00:00. If left empty, available events will be those that are later than the current time.',
|
||||
verbose_name='Booking opening time',
|
||||
),
|
||||
),
|
||||
|
|
|
@ -1,21 +0,0 @@
|
|||
import django.db.models.expressions
|
||||
import django.db.models.functions.datetime
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('agendas', '0152_auto_20230331_0834'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddIndex(
|
||||
model_name='event',
|
||||
index=models.Index(
|
||||
django.db.models.functions.datetime.ExtractWeekDay('start_datetime'),
|
||||
django.db.models.expressions.F('start_datetime'),
|
||||
condition=models.Q(('cancelled', False)),
|
||||
name='start_datetime_dow_index',
|
||||
),
|
||||
),
|
||||
]
|
|
@ -0,0 +1,22 @@
|
|||
# Generated by Django 3.2.18 on 2023-05-10 16:22
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('agendas', '0152_auto_20230331_0834'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='meetingtype',
|
||||
name='date_end',
|
||||
field=models.DateField(blank=True, null=True, verbose_name='End'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='meetingtype',
|
||||
name='date_start',
|
||||
field=models.DateField(blank=True, null=True, verbose_name='Start'),
|
||||
),
|
||||
]
|
|
@ -1,32 +0,0 @@
|
|||
# Generated by Django 3.2.18 on 2023-05-31 08:16
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('agendas', '0153_event_index'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='agenda',
|
||||
name='partial_bookings',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='booking',
|
||||
name='end_time',
|
||||
field=models.TimeField(null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='booking',
|
||||
name='start_time',
|
||||
field=models.TimeField(null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='event',
|
||||
name='end_time',
|
||||
field=models.TimeField(null=True, verbose_name='End time'),
|
||||
),
|
||||
]
|
|
@ -1,18 +0,0 @@
|
|||
import os
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
with open(
|
||||
os.path.join(
|
||||
os.path.dirname(os.path.realpath(__file__)), '..', 'sql', 'event_booked_places_and_full_triggers.sql'
|
||||
)
|
||||
) as sql_file:
|
||||
sql_forwards = sql_file.read()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('agendas', '0154_partial_booking_fields'),
|
||||
]
|
||||
|
||||
operations = [migrations.RunSQL(sql=sql_forwards, reverse_sql=migrations.RunSQL.noop)]
|
|
@ -1,27 +0,0 @@
|
|||
# Generated by Django 3.2.18 on 2023-06-28 10:46
|
||||
|
||||
import django.db.models.expressions
|
||||
import django.db.models.functions.datetime
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('agendas', '0155_event_triggers'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveIndex(
|
||||
model_name='event',
|
||||
name='start_datetime_dow_index',
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='event',
|
||||
index=models.Index(
|
||||
django.db.models.functions.datetime.ExtractIsoWeekDay('start_datetime'),
|
||||
django.db.models.expressions.F('start_datetime'),
|
||||
condition=models.Q(('cancelled', False)),
|
||||
name='start_datetime_dow_index',
|
||||
),
|
||||
),
|
||||
]
|
|
@ -1,41 +0,0 @@
|
|||
# Generated by Django 3.2.18 on 2023-06-28 10:47
|
||||
|
||||
from django.db import migrations, models
|
||||
from django.db.models import F, Func, OuterRef, Subquery
|
||||
|
||||
|
||||
class ArraySubquery(Subquery):
|
||||
template = 'ARRAY(%(subquery)s)'
|
||||
|
||||
|
||||
def convert_week_days(apps, schema_editor):
|
||||
Event = apps.get_model('agendas', 'Event')
|
||||
SharedCustodyRule = apps.get_model('agendas', 'SharedCustodyRule') # TODO
|
||||
|
||||
events_with_days = (
|
||||
Event.objects.filter(pk=OuterRef('pk'))
|
||||
.annotate(week_day=Func(F('recurrence_days'), function='unnest', output_field=models.IntegerField()))
|
||||
.annotate(new_week_day=F('week_day') + 1)
|
||||
.values('new_week_day')
|
||||
)
|
||||
Event.objects.filter(recurrence_days__isnull=False).update(
|
||||
recurrence_days=ArraySubquery(events_with_days)
|
||||
)
|
||||
|
||||
rules_with_days = (
|
||||
SharedCustodyRule.objects.filter(pk=OuterRef('pk'))
|
||||
.annotate(week_day=Func(F('days'), function='unnest', output_field=models.IntegerField()))
|
||||
.annotate(new_week_day=F('week_day') + 1)
|
||||
.values('new_week_day')
|
||||
)
|
||||
SharedCustodyRule.objects.update(days=ArraySubquery(rules_with_days))
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('agendas', '0156_update_dow_index'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(convert_week_days, migrations.RunPython.noop),
|
||||
]
|
|
@ -1,22 +0,0 @@
|
|||
# Generated by Django 3.2.18 on 2023-07-05 10:04
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('agendas', '0157_convert_week_days'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='booking',
|
||||
name='user_check_end_time',
|
||||
field=models.TimeField(null=True, verbose_name='Departure'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='booking',
|
||||
name='user_check_start_time',
|
||||
field=models.TimeField(null=True, verbose_name='Arrival'),
|
||||
),
|
||||
]
|
|
@ -1,33 +0,0 @@
|
|||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('agendas', '0158_partial_booking_check_fields'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='agenda',
|
||||
name='invoicing_tolerance',
|
||||
field=models.PositiveSmallIntegerField(
|
||||
default=0, validators=[django.core.validators.MaxValueValidator(59)], verbose_name='Tolerance'
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='agenda',
|
||||
name='invoicing_unit',
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
('hour', 'Per hour'),
|
||||
('half_hour', 'Per half hour'),
|
||||
('quarter', 'Per quarter-hour'),
|
||||
('minute', 'Per minute'),
|
||||
],
|
||||
default='hour',
|
||||
max_length=10,
|
||||
verbose_name='Invoicing',
|
||||
),
|
||||
),
|
||||
]
|
|
@ -1,20 +0,0 @@
|
|||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('agendas', '0159_partial_bookings_invoicing'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='booking',
|
||||
name='computed_end_time',
|
||||
field=models.TimeField(null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='booking',
|
||||
name='computed_start_time',
|
||||
field=models.TimeField(null=True),
|
||||
),
|
||||
]
|
|
@ -1,40 +0,0 @@
|
|||
# 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'],
|
||||
},
|
||||
),
|
||||
]
|
|
@ -1,62 +0,0 @@
|
|||
# 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),
|
||||
]
|
|
@ -1,40 +0,0 @@
|
|||
# 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',
|
||||
),
|
||||
]
|
|
@ -1,20 +0,0 @@
|
|||
# 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'
|
||||
),
|
||||
),
|
||||
]
|
|
@ -1,20 +0,0 @@
|
|||
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),
|
||||
),
|
||||
]
|
|
@ -1,41 +0,0 @@
|
|||
# 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',
|
||||
},
|
||||
),
|
||||
]
|
|
@ -1,18 +0,0 @@
|
|||
# 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'
|
||||
),
|
||||
),
|
||||
]
|
|
@ -1,17 +0,0 @@
|
|||
# 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),
|
||||
),
|
||||
]
|
|
@ -1,37 +0,0 @@
|
|||
# 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),
|
||||
),
|
||||
]
|
|
@ -1,25 +0,0 @@
|
|||
# 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',
|
||||
),
|
||||
),
|
||||
]
|
|
@ -1,118 +0,0 @@
|
|||
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
|
@ -13,14 +13,6 @@ BEGIN
|
|||
WHERE b.event_id = NEW.id AND b.cancellation_datetime IS NULL;
|
||||
END IF;
|
||||
|
||||
-- for events agenda with partial bookings, event is never full
|
||||
PERFORM 1 FROM agendas_agenda a WHERE a.id = NEW.agenda_id AND a.partial_bookings IS TRUE;
|
||||
IF FOUND THEN
|
||||
NEW.almost_full = false;
|
||||
NEW.full = false;
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
-- update almost_full field
|
||||
IF (NEW.booked_places >= NEW.places * 0.9) THEN
|
||||
NEW.almost_full = true;
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import collections
|
||||
import datetime
|
||||
import re
|
||||
|
||||
from django.contrib.auth.models import Group
|
||||
from django.db import models, transaction
|
||||
|
@ -43,15 +42,6 @@ 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)
|
||||
|
@ -89,11 +79,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=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)
|
||||
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)
|
||||
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)
|
||||
|
@ -101,24 +91,10 @@ class FillSlotSerializer(serializers.Serializer):
|
|||
extra_emails = StringOrListField(
|
||||
required=False, child=serializers.EmailField(max_length=250, allow_blank=False)
|
||||
)
|
||||
extra_phone_numbers = PhoneNumbersStringOrListField(
|
||||
extra_phone_numbers = StringOrListField(
|
||||
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)
|
||||
use_partial_bookings = any(agenda.partial_bookings for agenda in self.context.get('agendas', []))
|
||||
if use_partial_bookings:
|
||||
if not attrs.get('start_time') or not attrs.get('end_time'):
|
||||
raise ValidationError(_('must include start_time and end_time for partial bookings agenda'))
|
||||
if attrs['start_time'] > attrs['end_time']:
|
||||
raise ValidationError(_('start_time must be before end_time'))
|
||||
return attrs
|
||||
|
||||
|
||||
class SlotsSerializer(serializers.Serializer):
|
||||
|
@ -236,65 +212,14 @@ class RecurringFillslotsSerializer(MultipleAgendasEventsFillSlotsSerializer):
|
|||
% {'event_slug': event_slug, 'agenda_slug': agenda_slug}
|
||||
)
|
||||
|
||||
# convert ISO day number to db lookup day number
|
||||
day = (day + 1) % 7 + 1
|
||||
slots[agenda_slug][event_slug].append(day)
|
||||
|
||||
return slots
|
||||
|
||||
|
||||
class RecurringFillslotsByDaySerializer(FillSlotSerializer):
|
||||
weekdays = {
|
||||
'monday': 1,
|
||||
'tuesday': 2,
|
||||
'wednesday': 3,
|
||||
'thursday': 4,
|
||||
'friday': 5,
|
||||
'saturday': 6,
|
||||
'sunday': 7,
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
for weekday in self.weekdays:
|
||||
self.fields[weekday] = CommaSeparatedStringField(
|
||||
child=serializers.TimeField(), required=False, min_length=2, max_length=2, allow_null=True
|
||||
)
|
||||
setattr(self, 'validate_%s' % weekday, self.validate_hour_range)
|
||||
|
||||
def validate_hour_range(self, value):
|
||||
if not value:
|
||||
return None
|
||||
|
||||
start_time, end_time = value
|
||||
if start_time >= end_time:
|
||||
raise ValidationError(_('Start hour must be before end hour.'))
|
||||
return value
|
||||
|
||||
def validate(self, attrs):
|
||||
agendas = self.context['agendas']
|
||||
if len(agendas) > 1:
|
||||
raise ValidationError('Multiple agendas are not supported.')
|
||||
agenda = agendas[0]
|
||||
|
||||
if not agenda.partial_bookings:
|
||||
raise ValidationError('Agenda kind must be partial bookings.')
|
||||
|
||||
attrs['hours_by_days'] = hours_by_days = {}
|
||||
for weekday, weekday_index in self.weekdays.items():
|
||||
if attrs.get(weekday):
|
||||
hours_by_days[weekday_index] = attrs[weekday]
|
||||
|
||||
days_by_event = collections.defaultdict(list)
|
||||
for event in agenda.get_open_recurring_events():
|
||||
for day in event.recurrence_days:
|
||||
if day in hours_by_days:
|
||||
days_by_event[event.slug].append(day)
|
||||
attrs['slots'] = {agenda.slug: days_by_event}
|
||||
|
||||
return attrs
|
||||
|
||||
|
||||
class BookingSerializer(serializers.ModelSerializer):
|
||||
user_was_present = serializers.BooleanField(required=False, allow_null=True)
|
||||
user_absence_reason = serializers.CharField(required=False, allow_blank=True, allow_null=True)
|
||||
user_presence_reason = serializers.CharField(required=False, allow_blank=True, allow_null=True)
|
||||
use_color_for = serializers.CharField(required=False, allow_blank=True, allow_null=True, source='color')
|
||||
|
@ -325,10 +250,6 @@ 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
|
||||
|
@ -347,53 +268,12 @@ class BookingSerializer(serializers.ModelSerializer):
|
|||
ret.pop('user_absence_reason', None)
|
||||
ret.pop('user_presence_reason', None)
|
||||
else:
|
||||
user_was_present = self.user_check.presence if self.user_check else None
|
||||
ret['user_was_present'] = user_was_present
|
||||
ret['user_absence_reason'] = self.user_check.type_slug if user_was_present is False else None
|
||||
ret['user_presence_reason'] = self.user_check.type_slug if user_was_present is True else None
|
||||
if self.instance.event.agenda.kind == 'events' and self.instance.event.agenda.partial_bookings:
|
||||
self.instance.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
|
||||
ret['user_absence_reason'] = (
|
||||
self.instance.user_check_type_slug if self.instance.user_was_present is False 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,
|
||||
'%sduration' % key,
|
||||
ret['user_presence_reason'] = (
|
||||
self.instance.user_check_type_slug if self.instance.user_was_present is True else None
|
||||
)
|
||||
ret[start_key] = getattr(self.instance, start_key)
|
||||
ret[end_key] = getattr(self.instance, end_key)
|
||||
ret[minutes_key] = None
|
||||
if (
|
||||
getattr(self.instance, start_key) is not None
|
||||
and getattr(self.instance, end_key) is not None
|
||||
):
|
||||
start_minutes = (
|
||||
getattr(self.instance, start_key).hour * 60 + getattr(self.instance, start_key).minute
|
||||
)
|
||||
end_minutes = (
|
||||
getattr(self.instance, end_key).hour * 60 + getattr(self.instance, end_key).minute
|
||||
)
|
||||
ret[minutes_key] = end_minutes - start_minutes
|
||||
return ret
|
||||
|
||||
def _validate_check_type(self, kind, value):
|
||||
|
@ -446,11 +326,6 @@ 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'])
|
||||
|
@ -467,13 +342,11 @@ 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)
|
||||
|
@ -528,14 +401,6 @@ class AgendaOrSubscribedSlugsMixin(AgendaSlugsMixin):
|
|||
attrs['agenda_slugs'] = [agenda.slug for agenda in agendas]
|
||||
else:
|
||||
attrs['agenda_slugs'] = self.agenda_slugs
|
||||
|
||||
if any(
|
||||
agenda.partial_bookings != attrs['agendas'][0].partial_bookings for agenda in attrs['agendas']
|
||||
):
|
||||
raise serializers.ValidationError(
|
||||
{'agendas': _('Cannot mix partial bookings agendas with other kinds.')}
|
||||
)
|
||||
|
||||
return attrs
|
||||
|
||||
def validate_agendas(self, value):
|
||||
|
@ -563,7 +428,7 @@ class AgendaOrSubscribedSlugsSerializer(AgendaOrSubscribedSlugsMixin, DateRangeM
|
|||
|
||||
|
||||
class RecurringFillslotsQueryStringSerializer(AgendaOrSubscribedSlugsSerializer):
|
||||
action = serializers.ChoiceField(required=True, choices=['update', 'update-from-date', 'book', 'unbook'])
|
||||
action = serializers.ChoiceField(required=True, choices=['update', 'book', 'unbook'])
|
||||
|
||||
|
||||
class RecurringEventsListSerializer(AgendaOrSubscribedSlugsSerializer):
|
||||
|
@ -612,12 +477,11 @@ class EventSerializer(serializers.ModelSerializer):
|
|||
field_classes = {
|
||||
'text': serializers.CharField,
|
||||
'textarea': serializers.CharField,
|
||||
'bool': serializers.BooleanField,
|
||||
'bool': serializers.NullBooleanField,
|
||||
}
|
||||
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']]
|
||||
|
@ -628,10 +492,6 @@ class EventSerializer(serializers.ModelSerializer):
|
|||
**(field_options.get(custom_field['field_type']) or {}),
|
||||
)
|
||||
|
||||
def validate_recurrence_days(self, value):
|
||||
# keep stable weekday numbering after switch to ISO in db
|
||||
return [i + 1 for i in value]
|
||||
|
||||
def validate(self, attrs):
|
||||
if not self.instance.agenda.events_type:
|
||||
return attrs
|
||||
|
@ -657,9 +517,6 @@ class EventSerializer(serializers.ModelSerializer):
|
|||
|
||||
def to_representation(self, instance):
|
||||
ret = super().to_representation(instance)
|
||||
if ret.get('recurrence_days'):
|
||||
# keep stable weekday numbering after switch to ISO in db
|
||||
ret['recurrence_days'] = [i - 1 for i in ret['recurrence_days']]
|
||||
if not self.instance.agenda.events_type:
|
||||
return ret
|
||||
defaults = {
|
||||
|
@ -690,7 +547,6 @@ class AgendaSerializer(serializers.ModelSerializer):
|
|||
'slug',
|
||||
'label',
|
||||
'kind',
|
||||
'partial_bookings',
|
||||
'minimal_booking_delay',
|
||||
'minimal_booking_delay_in_working_days',
|
||||
'maximal_booking_delay',
|
||||
|
@ -699,7 +555,6 @@ class AgendaSerializer(serializers.ModelSerializer):
|
|||
'edit_role',
|
||||
'view_role',
|
||||
'category',
|
||||
'booking_form_url',
|
||||
'mark_event_checked_auto',
|
||||
'disable_check_update',
|
||||
'booking_check_filters',
|
||||
|
@ -741,10 +596,6 @@ 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 include, path, re_path
|
||||
from django.urls import path, re_path
|
||||
|
||||
from . import views
|
||||
|
||||
|
@ -23,11 +23,6 @@ urlpatterns = [
|
|||
path('agendas/datetimes/', views.agendas_datetimes, name='api-agendas-datetimes'),
|
||||
path('agendas/recurring-events/', views.recurring_events_list, name='api-agenda-recurring-events'),
|
||||
path('agendas/recurring-events/fillslots/', views.recurring_fillslots, name='api-recurring-fillslots'),
|
||||
path(
|
||||
'agendas/recurring-events/fillslots-by-day/',
|
||||
views.recurring_fillslots_by_day,
|
||||
name='api-recurring-fillslots-by-day',
|
||||
),
|
||||
path(
|
||||
'agendas/events/',
|
||||
views.agendas_events,
|
||||
|
@ -38,11 +33,6 @@ 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,
|
||||
|
@ -67,6 +57,9 @@ urlpatterns = [
|
|||
views.fillslot,
|
||||
name='api-fillslot',
|
||||
),
|
||||
re_path(
|
||||
r'^agenda/(?P<agenda_identifier>[\w-]+)/fillslots/$', views.fillslots, name='api-agenda-fillslots'
|
||||
),
|
||||
re_path(
|
||||
r'^agenda/(?P<agenda_identifier>[\w-]+)/events/fillslots/$',
|
||||
views.events_fillslots,
|
||||
|
@ -133,13 +126,7 @@ 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'),
|
||||
path('booking/<int:booking_pk>/cancel/', views.cancel_booking, name='api-cancel-booking'),
|
||||
path('booking/<int:booking_pk>/accept/', views.accept_booking, name='api-accept-booking'),
|
||||
|
@ -155,6 +142,4 @@ 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')),
|
||||
]
|
||||
|
|
1159
chrono/api/views.py
1159
chrono/api/views.py
File diff suppressed because it is too large
Load Diff
|
@ -1,23 +0,0 @@
|
|||
# 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'),
|
||||
]
|
|
@ -1,88 +0,0 @@
|
|||
# chrono - agendas system
|
||||
# Copyright (C) 2023 Entr'ouvert
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it
|
||||
# under the terms of the GNU Affero General Public License as published
|
||||
# by the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import requests
|
||||
from django.conf import settings
|
||||
from django.utils.translation import gettext as _
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3.util.retry import Retry
|
||||
|
||||
|
||||
class AntsHubException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def make_http_session(retries=3):
|
||||
session = requests.Session()
|
||||
retry = Retry(
|
||||
total=retries,
|
||||
read=retries,
|
||||
connect=retries,
|
||||
backoff_factor=0.5,
|
||||
status_forcelist=(502, 503),
|
||||
)
|
||||
adapter = HTTPAdapter(max_retries=retry)
|
||||
session.mount('http://', adapter)
|
||||
session.mount('https://', adapter)
|
||||
return session
|
||||
|
||||
|
||||
def make_url(path):
|
||||
return f'{settings.CHRONO_ANTS_HUB_URL}{path}'
|
||||
|
||||
|
||||
def ping(timeout=1):
|
||||
session = make_http_session()
|
||||
try:
|
||||
response = session.get(make_url('ping/'), timeout=timeout)
|
||||
response.raise_for_status()
|
||||
err = response.json()['err']
|
||||
if err != 0:
|
||||
raise AntsHubException(err)
|
||||
except requests.Timeout:
|
||||
pass
|
||||
except (TypeError, KeyError, requests.RequestException) as e:
|
||||
raise AntsHubException(str(e))
|
||||
|
||||
|
||||
def push_rendez_vous_disponibles(payload):
|
||||
session = make_http_session()
|
||||
try:
|
||||
response = session.post(make_url('rendez-vous-disponibles/'), json=payload)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
err = data['err']
|
||||
if err != 0:
|
||||
raise AntsHubException(err)
|
||||
return data
|
||||
except requests.Timeout:
|
||||
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}'}
|
|
@ -1,29 +0,0 @@
|
|||
# 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.conf import settings
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from chrono.apps.ants_hub import models
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Synchronize agendas with the ANTS hub.'
|
||||
|
||||
def handle(self, **options):
|
||||
if not settings.CHRONO_ANTS_HUB_URL:
|
||||
return
|
||||
models.City.push()
|
|
@ -1,159 +0,0 @@
|
|||
# Generated by Django 3.2.18 on 2023-04-06 00:34
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
import chrono.apps.ants_hub.models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('agendas', '0152_auto_20230331_0834'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='City',
|
||||
fields=[
|
||||
(
|
||||
'id',
|
||||
models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
('name', chrono.apps.ants_hub.models.CharField(unique=True, verbose_name='Name')),
|
||||
(
|
||||
'url',
|
||||
chrono.apps.ants_hub.models.URLField(
|
||||
blank=True,
|
||||
default=chrono.apps.ants_hub.models.get_portal_url,
|
||||
verbose_name='Portal URL',
|
||||
),
|
||||
),
|
||||
('logo_url', chrono.apps.ants_hub.models.URLField(blank=True, verbose_name='Logo URL')),
|
||||
(
|
||||
'meeting_url',
|
||||
chrono.apps.ants_hub.models.URLField(
|
||||
blank=True,
|
||||
help_text='URL of the web form to make a booking.',
|
||||
verbose_name='Booking URL',
|
||||
),
|
||||
),
|
||||
(
|
||||
'management_url',
|
||||
chrono.apps.ants_hub.models.URLField(
|
||||
blank=True,
|
||||
help_text='Generic URL to find and manage an existing booking.',
|
||||
verbose_name='Booking management URL',
|
||||
),
|
||||
),
|
||||
(
|
||||
'cancel_url',
|
||||
chrono.apps.ants_hub.models.URLField(
|
||||
blank=True,
|
||||
help_text='Generic URL to find and cancel an existing booking.',
|
||||
verbose_name='Booking cancellation URL',
|
||||
),
|
||||
),
|
||||
('created', models.DateTimeField(auto_now_add=True, verbose_name='Created')),
|
||||
('last_update', models.DateTimeField(auto_now=True, verbose_name='Last update')),
|
||||
('full_sync', models.BooleanField(default=False, verbose_name='Full sync')),
|
||||
('last_sync', models.DateTimeField(editable=False, null=True, verbose_name='Last sync')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'City',
|
||||
'verbose_name_plural': 'Cities',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Place',
|
||||
fields=[
|
||||
(
|
||||
'id',
|
||||
models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
('name', chrono.apps.ants_hub.models.CharField(verbose_name='Name')),
|
||||
('address', chrono.apps.ants_hub.models.CharField(verbose_name='Address')),
|
||||
('zipcode', chrono.apps.ants_hub.models.CharField(verbose_name='Code postal')),
|
||||
('city_name', chrono.apps.ants_hub.models.CharField(verbose_name='City name')),
|
||||
('longitude', models.FloatField(default=2.476, verbose_name='Longitude')),
|
||||
('latitude', models.FloatField(default=46.596, verbose_name='Latitude')),
|
||||
('url', chrono.apps.ants_hub.models.URLField(blank=True, verbose_name='Portal URL')),
|
||||
('logo_url', chrono.apps.ants_hub.models.URLField(blank=True, verbose_name='Logo URL')),
|
||||
(
|
||||
'meeting_url',
|
||||
chrono.apps.ants_hub.models.URLField(
|
||||
blank=True,
|
||||
help_text='URL of the web form to make a booking.',
|
||||
verbose_name='Booking URL',
|
||||
),
|
||||
),
|
||||
(
|
||||
'management_url',
|
||||
chrono.apps.ants_hub.models.URLField(
|
||||
blank=True,
|
||||
help_text='Generic URL to find and manage an existing booking.',
|
||||
verbose_name='Booking management URL',
|
||||
),
|
||||
),
|
||||
(
|
||||
'cancel_url',
|
||||
chrono.apps.ants_hub.models.URLField(
|
||||
blank=True,
|
||||
help_text='Generic URL to find and cancel an existing booking.',
|
||||
verbose_name='Booking cancellation URL',
|
||||
),
|
||||
),
|
||||
('created', models.DateTimeField(auto_now_add=True, verbose_name='Created')),
|
||||
('last_update', models.DateTimeField(auto_now=True, verbose_name='Last update')),
|
||||
('full_sync', models.BooleanField(default=False, verbose_name='Full sync')),
|
||||
('last_sync', models.DateTimeField(editable=False, null=True, verbose_name='Last sync')),
|
||||
(
|
||||
'city',
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name='places',
|
||||
to='ants_hub.city',
|
||||
verbose_name='City',
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'place',
|
||||
'verbose_name_plural': 'places',
|
||||
'unique_together': {('city', 'name')},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='PlaceAgenda',
|
||||
fields=[
|
||||
(
|
||||
'id',
|
||||
models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
('setting', models.JSONField(blank=True, default=dict, verbose_name='Setting')),
|
||||
(
|
||||
'agenda',
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name='+',
|
||||
related_query_name='ants_place',
|
||||
to='agendas.agenda',
|
||||
verbose_name='Agenda',
|
||||
),
|
||||
),
|
||||
(
|
||||
'place',
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name='agendas',
|
||||
to='ants_hub.place',
|
||||
verbose_name='Place',
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('place', 'agenda')},
|
||||
},
|
||||
),
|
||||
]
|
|
@ -1,365 +0,0 @@
|
|||
# chrono - agendas system
|
||||
# Copyright (C) 2023 Entr'ouvert
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it
|
||||
# under the terms of the GNU Affero General Public License as published
|
||||
# by the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import collections
|
||||
import datetime
|
||||
|
||||
from django import forms
|
||||
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
|
||||
|
||||
from .hub import push_rendez_vous_disponibles
|
||||
|
||||
# fields without max_length problems
|
||||
|
||||
|
||||
class CharField(models.TextField):
|
||||
'''TextField using forms.TextInput as widget'''
|
||||
|
||||
def formfield(self, **kwargs):
|
||||
defaults = {'widget': forms.TextInput}
|
||||
defaults.update(**kwargs)
|
||||
return super().formfield(**defaults)
|
||||
|
||||
|
||||
class URLField(models.URLField):
|
||||
'''URLField using a TEXT column for storage'''
|
||||
|
||||
def get_internal_type(self):
|
||||
return 'TextField'
|
||||
|
||||
|
||||
class CityManager(models.Manager):
|
||||
def get_by_natural_key(self, name):
|
||||
return self.get(name=name)
|
||||
|
||||
|
||||
def get_portal_url():
|
||||
template_vars = getattr(settings, 'TEMPLATE_VARS', {})
|
||||
return template_vars.get('portal_url', '')
|
||||
|
||||
|
||||
class City(models.Model):
|
||||
name = CharField(verbose_name=_('Name'), unique=True)
|
||||
url = URLField(verbose_name=_('Portal URL'), default=get_portal_url, blank=True)
|
||||
logo_url = URLField(verbose_name=_('Logo URL'), blank=True)
|
||||
meeting_url = URLField(
|
||||
verbose_name=_('Booking URL'), help_text=_('URL of the web form to make a booking.'), blank=True
|
||||
)
|
||||
management_url = URLField(
|
||||
verbose_name=_('Booking management URL'),
|
||||
help_text=_('Generic URL to find and manage an existing booking.'),
|
||||
blank=True,
|
||||
)
|
||||
cancel_url = URLField(
|
||||
verbose_name=_('Booking cancellation URL'),
|
||||
help_text=_('Generic URL to find and cancel an existing booking.'),
|
||||
blank=True,
|
||||
)
|
||||
created = models.DateTimeField(verbose_name=_('Created'), auto_now_add=True)
|
||||
last_update = models.DateTimeField(verbose_name=_('Last update'), auto_now=True)
|
||||
full_sync = models.BooleanField(
|
||||
verbose_name=_('Full synchronization next time'), default=False, editable=False
|
||||
)
|
||||
last_sync = models.DateTimeField(verbose_name=_('Last synchronization'), null=True, editable=False)
|
||||
|
||||
objects = CityManager()
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.name}'
|
||||
|
||||
def natural_key(self):
|
||||
return (self.name,)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('chrono-manager-ants-hub')
|
||||
|
||||
def details(self):
|
||||
details = []
|
||||
for key in ['url', 'logo_url', 'management_url', 'cancel_url']:
|
||||
value = getattr(self, key, None)
|
||||
if value:
|
||||
verbose_name = type(self)._meta.get_field(key).verbose_name
|
||||
details.append((verbose_name, value))
|
||||
return details
|
||||
|
||||
@classmethod
|
||||
@transaction.atomic(savepoint=False)
|
||||
def push(cls):
|
||||
reference = now()
|
||||
# prevent concurrent pushes with locks
|
||||
cities = list(cls.objects.select_for_update())
|
||||
push_rendez_vous_disponibles(
|
||||
{
|
||||
'collectivites': [city.export_to_push() for city in cities],
|
||||
}
|
||||
)
|
||||
City.objects.update(last_sync=reference)
|
||||
Place.objects.update(last_sync=reference)
|
||||
|
||||
def export_to_push(self):
|
||||
payload = {
|
||||
'full': True,
|
||||
'id': str(self.pk),
|
||||
'nom': self.name,
|
||||
'url': self.url,
|
||||
'logo_url': self.logo_url,
|
||||
'rdv_url': self.meeting_url,
|
||||
'gestion_url': self.management_url,
|
||||
'annulation_url': self.cancel_url,
|
||||
'lieux': [place.export_to_push() for place in self.places.all()],
|
||||
}
|
||||
return payload
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('City')
|
||||
verbose_name_plural = _('Cities')
|
||||
|
||||
|
||||
class PlaceManager(models.Manager):
|
||||
def get_by_natural_key(self, name, *args):
|
||||
return self.get(name=name, city=City.objects.get_by_natural_key(*args))
|
||||
|
||||
|
||||
class Place(models.Model):
|
||||
city = models.ForeignKey(
|
||||
verbose_name=_('City'), to=City, related_name='places', on_delete=models.CASCADE, editable=False
|
||||
)
|
||||
name = CharField(verbose_name=_('Name'))
|
||||
address = CharField(verbose_name=_('Address'))
|
||||
zipcode = CharField(verbose_name=_('Code postal'))
|
||||
city_name = CharField(verbose_name=_('City name'))
|
||||
longitude = models.FloatField(verbose_name=_('Longitude'), default=2.476)
|
||||
latitude = models.FloatField(verbose_name=_('Latitude'), default=46.596)
|
||||
url = URLField(verbose_name=_('Portal URL'), blank=True)
|
||||
logo_url = URLField(verbose_name=_('Logo URL'), blank=True)
|
||||
meeting_url = URLField(
|
||||
verbose_name=_('Booking URL'), help_text=_('URL of the web form to make a booking.'), blank=True
|
||||
)
|
||||
management_url = URLField(
|
||||
verbose_name=_('Booking management URL'),
|
||||
help_text=_('Generic URL to find and manage an existing booking.'),
|
||||
blank=True,
|
||||
)
|
||||
cancel_url = URLField(
|
||||
verbose_name=_('Booking cancellation URL'),
|
||||
help_text=_('Generic URL to find and cancel an existing booking.'),
|
||||
blank=True,
|
||||
)
|
||||
created = models.DateTimeField(verbose_name=_('Created'), auto_now_add=True)
|
||||
last_update = models.DateTimeField(verbose_name=_('Last update'), auto_now=True)
|
||||
full_sync = models.BooleanField(verbose_name=_('Full synchronization'), default=False, editable=False)
|
||||
last_sync = models.DateTimeField(verbose_name=_('Last synchronization'), null=True, editable=False)
|
||||
|
||||
objects = PlaceManager()
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.name}'
|
||||
|
||||
def natural_key(self):
|
||||
return (self.name,) + self.city.natural_key()
|
||||
|
||||
natural_key.dependencies = ['ants_hub.city']
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('chrono-manager-ants-hub-place', kwargs={'city_pk': self.city_id, 'pk': self.pk})
|
||||
|
||||
def url_details(self):
|
||||
details = []
|
||||
for key in ['url', 'logo_url', 'management_url', 'cancel_url']:
|
||||
value = getattr(self, key, None)
|
||||
if value:
|
||||
verbose_name = type(self)._meta.get_field(key).verbose_name
|
||||
details.append((verbose_name, value))
|
||||
return details
|
||||
|
||||
def export_to_push(self):
|
||||
payload = {
|
||||
'full': True,
|
||||
'id': str(self.pk),
|
||||
'nom': self.name,
|
||||
'numero_rue': self.address,
|
||||
'code_postal': self.zipcode,
|
||||
'ville': self.city_name,
|
||||
'longitude': self.longitude,
|
||||
'latitude': self.latitude,
|
||||
'url': self.url,
|
||||
'rdv_url': self.meeting_url,
|
||||
'gestion_url': self.management_url,
|
||||
'annulation_url': self.cancel_url,
|
||||
'plages': list(self.iter_open_dates()),
|
||||
'rdvs': list(self.iter_predemandes()),
|
||||
'logo_url': self.logo_url,
|
||||
}
|
||||
return payload
|
||||
|
||||
def iter_open_dates(self):
|
||||
for place_agenda in self.agendas.all():
|
||||
yield from place_agenda.iter_open_dates()
|
||||
|
||||
def iter_predemandes(self):
|
||||
agendas = Agenda.objects.filter(ants_place__place=self)
|
||||
agendas |= Agenda.objects.filter(virtual_agendas__ants_place__place=self)
|
||||
agendas = set(agendas)
|
||||
bookings = (
|
||||
Booking.objects.filter(
|
||||
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(
|
||||
'extra_data__ants_identifiant_predemande', 'event__start_datetime', 'cancellation_datetime'
|
||||
)
|
||||
.order_by('event__start_datetime')
|
||||
)
|
||||
for identifiant_predemande_data, start_datetime, cancellation_datetime in bookings:
|
||||
if not isinstance(identifiant_predemande_data, str):
|
||||
continue
|
||||
# split data on commas, and remove trailing whitespaces
|
||||
identifiant_predemandes = filter(
|
||||
None, (part.strip() for part in identifiant_predemande_data.split(','))
|
||||
)
|
||||
for identifiant_predemande in identifiant_predemandes:
|
||||
rdv = {
|
||||
'id': identifiant_predemande,
|
||||
'date': start_datetime.isoformat(),
|
||||
}
|
||||
if cancellation_datetime is not None:
|
||||
rdv['annule'] = True
|
||||
yield rdv
|
||||
|
||||
class Meta:
|
||||
verbose_name = pgettext_lazy('location', 'place')
|
||||
verbose_name_plural = pgettext_lazy('location', 'places')
|
||||
unique_together = [
|
||||
('city', 'name'),
|
||||
]
|
||||
|
||||
|
||||
class ANTSMeetingType(models.IntegerChoices):
|
||||
CNI = 1, _('CNI')
|
||||
PASSPORT = 2, _('Passport')
|
||||
CNI_PASSPORT = 3, _('CNI and passport')
|
||||
|
||||
@property
|
||||
def ants_name(self):
|
||||
return super().name.replace('_', '-')
|
||||
|
||||
|
||||
class ANTSPersonsNumber(models.IntegerChoices):
|
||||
ONE = 1, _('1 person')
|
||||
TWO = 2, _('2 persons')
|
||||
THREE = 3, _('3 persons')
|
||||
FOUR = 4, _('4 persons')
|
||||
FIVE = 5, _('5 persons')
|
||||
|
||||
|
||||
class PlaceAgenda(models.Model):
|
||||
place = models.ForeignKey(
|
||||
verbose_name=_('Place'),
|
||||
to=Place,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='agendas',
|
||||
)
|
||||
agenda = models.ForeignKey(
|
||||
verbose_name=_('Agenda'),
|
||||
to='agendas.Agenda',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='+',
|
||||
related_query_name='ants_place',
|
||||
)
|
||||
setting = models.JSONField(verbose_name=_('Setting'), default=dict, blank=True)
|
||||
|
||||
def set_meeting_type_setting(self, meeting_type, key, value):
|
||||
assert key in ['ants_meeting_type', 'ants_persons_number']
|
||||
meeting_types = self.setting.setdefault('meeting-types', {})
|
||||
value = map(int, value)
|
||||
if key == 'ants_meeting_type':
|
||||
value = list(map(ANTSMeetingType, value))
|
||||
else:
|
||||
value = list(map(ANTSPersonsNumber, value))
|
||||
meeting_types.setdefault(str(meeting_type.slug), {})[key] = value
|
||||
|
||||
def get_meeting_type_setting(self, meeting_type, key):
|
||||
assert key in ['ants_meeting_type', 'ants_persons_number']
|
||||
meeting_types = self.setting.setdefault('meeting-types', {})
|
||||
value = meeting_types.get(str(meeting_type.slug), {}).get(key, [])
|
||||
value = map(int, value)
|
||||
if key == 'ants_meeting_type':
|
||||
value = list(map(ANTSMeetingType, value))
|
||||
else:
|
||||
value = list(map(ANTSPersonsNumber, value))
|
||||
return value
|
||||
|
||||
def iter_ants_meeting_types_and_persons(self):
|
||||
d = collections.defaultdict(set)
|
||||
for meeting_type in self.agenda.iter_meetingtypes():
|
||||
for ants_meeting_type in self.get_meeting_type_setting(meeting_type, 'ants_meeting_type'):
|
||||
for ants_persons_number in self.get_meeting_type_setting(meeting_type, 'ants_persons_number'):
|
||||
meeting_type.id = meeting_type.slug
|
||||
d[(meeting_type, ants_persons_number)].add(ants_meeting_type)
|
||||
for (meeting_type, ants_persons_number), ants_meeting_types in d.items():
|
||||
yield meeting_type, ants_persons_number, ants_meeting_types
|
||||
|
||||
@property
|
||||
def ants_properties(self):
|
||||
rdv = set()
|
||||
persons = set()
|
||||
for meeting_type in self.agenda.meetingtype_set.all():
|
||||
rdv.update(self.get_meeting_type_setting(meeting_type, 'ants_meeting_type'))
|
||||
persons.update(self.get_meeting_type_setting(meeting_type, 'ants_persons_number'))
|
||||
rdv = sorted(list(rdv))
|
||||
persons = sorted(list(persons))
|
||||
return [x.label for x in rdv] + [x.label for x in persons]
|
||||
|
||||
def __str__(self):
|
||||
return str(self.agenda)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return self.place.get_absolute_url()
|
||||
|
||||
def iter_open_dates(self):
|
||||
settings = list(self.iter_ants_meeting_types_and_persons())
|
||||
if not settings:
|
||||
return
|
||||
intervals = self.agenda.get_free_time(end_datetime=now() + datetime.timedelta(days=6 * 30))
|
||||
for start, end in intervals:
|
||||
for meeting_type, ants_persons_number, ants_meeting_types in settings:
|
||||
duration = datetime.timedelta(minutes=meeting_type.duration)
|
||||
if end - start < duration:
|
||||
continue
|
||||
yield {
|
||||
'date': localtime(start).date().isoformat(),
|
||||
# use naive local time representation
|
||||
'heure_debut': localtime(start).time().replace(tzinfo=None).isoformat(),
|
||||
'heure_fin': localtime(end).time().replace(tzinfo=None).isoformat(),
|
||||
'duree': meeting_type.duration,
|
||||
'personnes': int(ants_persons_number),
|
||||
'types_rdv': [x.ants_name for x in ants_meeting_types],
|
||||
}
|
||||
|
||||
class Meta:
|
||||
unique_together = [
|
||||
('place', 'agenda'),
|
||||
]
|
|
@ -1,57 +0,0 @@
|
|||
{% extends "chrono/manager_ants_hub_base.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block appbar %}
|
||||
<h2>{% trans 'ANTS Hub' %}</h2>
|
||||
<span class="actions">
|
||||
<a class="extra-actions-menu-opener"></a>
|
||||
<ul class="extra-actions-menu">
|
||||
<li><a rel="popup" href="{% url 'chrono-manager-ants-hub-synchronize' %}">{% trans 'Synchronize agendas' %}</a></li>
|
||||
</ul>
|
||||
<a rel="popup" href="{% url 'chrono-manager-ants-hub-city-add' %}">{% trans 'New city' %}</a>
|
||||
</span>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% if object_list %}
|
||||
{% for object in object_list %}
|
||||
<div class="section">
|
||||
<h3>
|
||||
<span>
|
||||
{{ object }}
|
||||
<a class="icon-edit" rel="popup" href="{% url 'chrono-manager-ants-hub-city-edit' pk=object.pk %}"></a>
|
||||
</span>
|
||||
<a class="button delete-button" rel="popup" href="{% url 'chrono-manager-ants-hub-city-delete' pk=object.pk %}">{% trans "Remove" %}</a>
|
||||
</h3>
|
||||
<div>
|
||||
<ul class="objects-list single-links">
|
||||
<p>
|
||||
{% if not object.last_sync %}{% trans "Never synchronized" %}{% else %}{% blocktrans with date=object.last_sync|date:"SHORT_DATETIME_FORMAT" %}Last synchronization on {{ date }}{% endblocktrans %}{% endif %}.
|
||||
</p>
|
||||
{% for label, value in object.details %}
|
||||
<p>{{ label }}: {{ value }}</p>
|
||||
{% endfor %}
|
||||
|
||||
{% for place in object.places.all %}
|
||||
<li>
|
||||
<a href="{% url 'chrono-manager-ants-hub-place' city_pk=object.pk pk=place.pk %}">{{ place }}
|
||||
{% if place.agendas.count %}<span class="identifier">({% blocktrans count counter=place.agendas.count %}1 agenda{% plural %}{{ counter }} agendas{% endblocktrans %})</span>{% endif %}</a>
|
||||
<a class="delete" rel="popup" href="{% url 'chrono-manager-ants-hub-place-delete' city_pk=object.pk pk=place.pk %}">{% trans "remove" %}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<p>
|
||||
<a class="button" rel="popup" href="{% url 'chrono-manager-ants-hub-place-add' pk=object.pk %}">{% trans "Add place" %}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="big-msg-info">
|
||||
{% blocktrans trimmed %}
|
||||
This site doesn't have any city yet. Click on the "New city" button in the top
|
||||
right of the page to add a first one.
|
||||
{% endblocktrans %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
|
@ -1,19 +0,0 @@
|
|||
{% extends "chrono/manager_home.html" %}
|
||||
{% load i18n gadjo %}
|
||||
|
||||
{% block appbar %}
|
||||
<h2>{% if object %}{{ object }}{% else %}{{ view.name }}{% endif %}</h2>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
{{ form|with_template }}
|
||||
<div class="buttons">
|
||||
<button class="submit-button">{% trans "Save" %}</button>
|
||||
<a class="cancel" href="{% url 'chrono-manager-ants-hub' %}">{% trans 'Cancel' %}</a>
|
||||
</div>
|
||||
{% block after-form %}
|
||||
{% endblock %}
|
||||
</form>
|
||||
{% endblock %}
|
|
@ -1,57 +0,0 @@
|
|||
{% extends "chrono/manager_home.html" %}
|
||||
{% load i18n gadjo %}
|
||||
|
||||
{% block appbar %}
|
||||
<h2><a href="{{ object.agenda.get_absolute_url }}">{{ object.agenda }}</a></h2>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
<p>{% blocktrans with url=object.agenda.get_absolute_url name=object.agenda %}Configure the mapping between meeting types and ANTS meeting types for agenda <a href="{{ url }}">{{ name }}</a>.{% endblocktrans %}</p>
|
||||
{% if form.meeting_types %}
|
||||
<table class="main ants-setting">
|
||||
<tbody>
|
||||
{% for label, fields in form.field_by_labels %}
|
||||
<tr>
|
||||
<td class="meeting-type">{{ label }}</td>
|
||||
<td>
|
||||
{% for field in fields %}
|
||||
{% if field.errors %}
|
||||
<div class="error"><p>
|
||||
{% for error in field.errors %}
|
||||
{{ error }}{% if not forloop.last %}<br>{% endif %}
|
||||
{% endfor %}
|
||||
</p></div>
|
||||
{% endif %}
|
||||
{{ field }}
|
||||
{% endfor %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="big-msg-info">{% blocktrans trimmed %}
|
||||
This agenda doesn't have any meeting type yet.
|
||||
{% endblocktrans %}</div>
|
||||
{% endif %}
|
||||
<script>
|
||||
$('form').on('click', 'label', function (event) {
|
||||
console.log(event);
|
||||
});
|
||||
$('form').on('change', 'input', function (event) {
|
||||
$(event.target).parent().toggleClass('checked');
|
||||
});
|
||||
$('form input:checked').each(function (i, elt) {
|
||||
$(elt).parent().toggleClass('checked');
|
||||
});
|
||||
</script>
|
||||
<div class="buttons">
|
||||
{% if form.meeting_types %}
|
||||
<button class="submit-button">{% trans "Save" %}</button>
|
||||
{% endif %}
|
||||
<a class="cancel" href="{% url 'chrono-manager-ants-hub' %}">{% trans 'Cancel' %}</a>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
|
@ -1,7 +0,0 @@
|
|||
{% extends "chrono/manager_base.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block breadcrumb %}
|
||||
{{ block.super }}
|
||||
<a href="{% url "chrono-manager-ants-hub" %}">{% trans "ANTS Hub" %}</a>
|
||||
{% endblock %}
|
|
@ -1,78 +0,0 @@
|
|||
{% extends "chrono/manager_ants_hub_base.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block breadcrumb %}
|
||||
{{ block.super }}
|
||||
<a href=".">{{ view.place }}</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block appbar %}
|
||||
<h2>{{ view.place }}</h2>
|
||||
<a rel="popup" class="delete-button" href="{% url 'chrono-manager-ants-hub-place-delete' city_pk=view.place.city_id pk=view.place.pk %}">{% trans "Remove" %}</a>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
<p>
|
||||
{% if not view.place.last_sync %}{% trans "Never synchronized" %}{% else %}{% blocktrans with date=view.place.last_sync|date:"SHORT_DATETIME_FORMAT" %}Last synchronization on {{ date }}{% endblocktrans %}{% endif %}.
|
||||
</p>
|
||||
<div class="section">
|
||||
<h3>
|
||||
{% trans "Address" %}
|
||||
<a class="button" rel="popup" href="{% url 'chrono-manager-ants-hub-place-edit' city_pk=view.place.city_id pk=view.place.pk %}">{% trans "Edit" %}</a>
|
||||
</h3>
|
||||
<div>
|
||||
<p>
|
||||
{{ view.place.address }}</br>
|
||||
{{ view.place.zipcode }} {{ view.place.city_name }}
|
||||
</p>
|
||||
<p>
|
||||
{% trans "Geolocation:" %} {{ view.place.longitude }} {{ view.place.latitude }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section">
|
||||
<h3>
|
||||
{% trans "URLs" %}
|
||||
<a class="button" rel="popup" href="{% url 'chrono-manager-ants-hub-place-url' city_pk=view.place.city_id pk=view.place.pk %}">{% trans "Edit" %}</a>
|
||||
</h3>
|
||||
<table class="main">
|
||||
<tbody>
|
||||
{% for label, value in view.place.url_details %}
|
||||
<tr><td>{{ label }}</td><td>{{ value }}</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="section">
|
||||
<h3>
|
||||
{% trans "Agendas" %}
|
||||
<a rel="popup" class="button" href="{% url 'chrono-manager-ants-hub-agenda-add' city_pk=view.place.city_id pk=view.place.pk %}">{% trans 'Add' %}</a>
|
||||
</h3>
|
||||
{% if object_list %}
|
||||
<ul class="objects-list single-links">
|
||||
{% for object in object_list %}
|
||||
<li {% if not object.ants_properties %}class="ants-setting-not-configured"{% endif %}>
|
||||
<a rel="popup" id="open-place-agenda-{{ object.pk }}" class="edit" href="{% url 'chrono-manager-ants-hub-agenda-edit' pk=object.pk city_pk=view.place.city_id place_pk=view.place.pk %}">
|
||||
<span class="label">{{ object }}</span>
|
||||
<span class="properties">({% if object.ants_properties %}{{ object.ants_properties|join:", " }}{% else %}{% trans "not configured" %}{% endif %})</span></a>
|
||||
<a class="delete" rel="popup" href="{% url 'chrono-manager-ants-hub-agenda-delete' pk=object.pk city_pk=view.place.city_id place_pk=view.place.pk %}">{% trans "remove" %}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if not object_list %}
|
||||
<div class="big-msg-info">
|
||||
{% blocktrans trimmed %}
|
||||
This site doesn't have any place yet. Click on the "New place" button in the top
|
||||
right of the page to add a first one.
|
||||
{% endblocktrans %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<script>
|
||||
setTimeout(function () {
|
||||
$(window.location.hash).click();
|
||||
}, 100);
|
||||
</script>
|
||||
{% endblock %}
|
|
@ -1,17 +0,0 @@
|
|||
{% extends "chrono/manager_ants_hub_add_form.html" %}
|
||||
{% load i18n gadjo %}
|
||||
|
||||
{% block after-form %}
|
||||
<script>
|
||||
$('form').on('change keyup', '#id_address, #id_zipcode, #id_city_name', function (event) {
|
||||
var q = $('#id_address').val() + ' ' + $('#id_zipcode').val() + ' ' + $('#id_city_name').val();
|
||||
console.log('q', q)
|
||||
var url = "https://api-adresse.data.gouv.fr/search/?q=" + encodeURIComponent(q);
|
||||
$.ajax(url).done(function (data) {
|
||||
var coords = data.features[0].geometry.coordinates;
|
||||
$('#id_longitude').val(coords[0]);
|
||||
$('#id_latitude').val(coords[1]);
|
||||
})
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
|
@ -1,19 +0,0 @@
|
|||
{% extends "chrono/manager_home.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block appbar %}
|
||||
<h2>{% trans "Synchronize agendas" %}</h2>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<p>
|
||||
{% blocktrans %}Are you sure you want to synchronize your agendas with the ANTS now?{% endblocktrans %}
|
||||
</p>
|
||||
<div class="buttons">
|
||||
<button class="button" >{% trans 'Synchronize' %}</button>
|
||||
<a class="cancel" href="{% url 'chrono-manager-ants-hub' %}">{% trans 'Cancel' %}</a>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
|
@ -1,87 +0,0 @@
|
|||
# chrono - agendas system
|
||||
# Copyright (C) 2023 Entr'ouvert
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it
|
||||
# under the terms of the GNU Affero General Public License as published
|
||||
# by the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import functools
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.shortcuts import redirect
|
||||
from django.urls import path
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from . import views
|
||||
|
||||
|
||||
def view_decorator(func):
|
||||
@functools.wraps(func)
|
||||
def wrapper(request, *args, **kwargs):
|
||||
if not request.user.is_staff:
|
||||
raise PermissionDenied
|
||||
if not settings.CHRONO_ANTS_HUB_URL:
|
||||
messages.info(
|
||||
request, _('Configure CHRONO_ANTS_HUB_URL to get access to ANTS-Hub configuration panel.')
|
||||
)
|
||||
return redirect('chrono-manager-homepage')
|
||||
return func(request, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path('', views.Homepage.as_view(), name='chrono-manager-ants-hub'),
|
||||
path('synchronize/', views.Synchronize.as_view(), name='chrono-manager-ants-hub-synchronize'),
|
||||
path('city/add/', views.CityAddView.as_view(), name='chrono-manager-ants-hub-city-add'),
|
||||
path('city/<int:pk>/edit/', views.CityEditView.as_view(), name='chrono-manager-ants-hub-city-edit'),
|
||||
path('city/<int:pk>/delete/', views.CityDeleteView.as_view(), name='chrono-manager-ants-hub-city-delete'),
|
||||
path('city/<int:pk>/place/add/', views.PlaceAddView.as_view(), name='chrono-manager-ants-hub-place-add'),
|
||||
path(
|
||||
'city/<int:city_pk>/place/<int:pk>/', views.PlaceView.as_view(), name='chrono-manager-ants-hub-place'
|
||||
),
|
||||
path(
|
||||
'city/<int:city_pk>/place/<int:pk>/edit/',
|
||||
views.PlaceEditView.as_view(),
|
||||
name='chrono-manager-ants-hub-place-edit',
|
||||
),
|
||||
path(
|
||||
'city/<int:city_pk>/place/<int:pk>/url/',
|
||||
views.PlaceUrlEditView.as_view(),
|
||||
name='chrono-manager-ants-hub-place-url',
|
||||
),
|
||||
path(
|
||||
'city/<int:city_pk>/place/<int:pk>/delete/',
|
||||
views.PlaceDeleteView.as_view(),
|
||||
name='chrono-manager-ants-hub-place-delete',
|
||||
),
|
||||
path(
|
||||
'city/<int:city_pk>/place/<int:pk>/agenda/add/',
|
||||
views.PlaceAgendaAddView.as_view(),
|
||||
name='chrono-manager-ants-hub-agenda-add',
|
||||
),
|
||||
path(
|
||||
'city/<int:city_pk>/place/<int:place_pk>/agenda/<int:pk>/edit/',
|
||||
views.PlaceAgendaEditView.as_view(),
|
||||
name='chrono-manager-ants-hub-agenda-edit',
|
||||
),
|
||||
path(
|
||||
'city/<int:city_pk>/place/<int:place_pk>/agenda/<int:pk>/delete/',
|
||||
views.PlaceAgendaDeleteView.as_view(),
|
||||
name='chrono-manager-ants-hub-agenda-delete',
|
||||
),
|
||||
]
|
||||
|
||||
for pattern in urlpatterns:
|
||||
pattern.callback = view_decorator(pattern.callback)
|
|
@ -1,288 +0,0 @@
|
|||
# chrono - agendas system
|
||||
# Copyright (C) 2023 Entr'ouvert
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it
|
||||
# under the terms of the GNU Affero General Public License as published
|
||||
# by the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import 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
|
||||
|
||||
|
||||
class Homepage(ListView):
|
||||
template_name = 'chrono/manager_ants_hub.html'
|
||||
model = models.City
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ok = cache.get('ants-hub-ok')
|
||||
if not ok:
|
||||
try:
|
||||
hub.ping()
|
||||
except hub.AntsHubException as e:
|
||||
messages.warning(self.request, _('ANTS Hub is down: "%s".') % e)
|
||||
else:
|
||||
messages.info(self.request, _('ANTS Hub is responding.'))
|
||||
cache.set('ants-hub-ok', True, 600)
|
||||
return ctx
|
||||
|
||||
|
||||
class CityAddView(CreateView):
|
||||
template_name = 'chrono/manager_ants_hub_add_form.html'
|
||||
model = models.City
|
||||
name = _('New city')
|
||||
fields = '__all__'
|
||||
|
||||
|
||||
class CityMixin:
|
||||
def dispatch(self, request, pk):
|
||||
self.city = get_object_or_404(models.City, pk=pk)
|
||||
return super().dispatch(request, pk=pk)
|
||||
|
||||
|
||||
class CityView(CityMixin, ListView):
|
||||
template_name = 'chrono/manager_ants_hub_city.html'
|
||||
model = models.Place
|
||||
|
||||
def get_queryset(self):
|
||||
return super().get_queryset().filter(city=self.city)
|
||||
|
||||
|
||||
class CityEditView(UpdateView):
|
||||
template_name = 'chrono/manager_ants_hub_add_form.html'
|
||||
model = models.City
|
||||
fields = '__all__'
|
||||
|
||||
|
||||
class CityDeleteView(DeleteView):
|
||||
template_name = 'chrono/manager_confirm_delete.html'
|
||||
model = models.City
|
||||
success_url = '../../../'
|
||||
|
||||
|
||||
class PlaceForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = models.Place
|
||||
exclude = ['city']
|
||||
|
||||
|
||||
class PlaceAddView(CityMixin, CreateView):
|
||||
template_name = 'chrono/manager_ants_hub_add_form.html'
|
||||
model = models.Place
|
||||
form_class = PlaceForm
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return _('New place in %s') % self.city
|
||||
|
||||
def dispatch(self, request, pk):
|
||||
self.city = get_object_or_404(models.City, pk=pk)
|
||||
return super().dispatch(request, pk)
|
||||
|
||||
def get_form_kwargs(self):
|
||||
kwargs = super().get_form_kwargs()
|
||||
kwargs['instance'] = models.Place(city=self.city)
|
||||
return kwargs
|
||||
|
||||
|
||||
class PlaceMixin:
|
||||
def dispatch(self, request, city_pk, pk):
|
||||
self.place = get_object_or_404(models.Place, pk=pk, city_id=city_pk)
|
||||
self.city = self.place.city
|
||||
return super().dispatch(request, pk=pk)
|
||||
|
||||
|
||||
class PlaceView(PlaceMixin, ListView):
|
||||
template_name = 'chrono/manager_ants_hub_place.html'
|
||||
model = models.PlaceAgenda
|
||||
|
||||
def get_queryset(self):
|
||||
return super().get_queryset().filter(place=self.place)
|
||||
|
||||
|
||||
class PlaceEditForm(PlaceForm):
|
||||
class Meta:
|
||||
model = models.Place
|
||||
fields = ['name', 'address', 'zipcode', 'city_name', 'longitude', 'latitude']
|
||||
|
||||
|
||||
class PlaceEditView(UpdateView):
|
||||
template_name = 'chrono/manager_ants_hub_place_edit_form.html'
|
||||
model = models.Place
|
||||
fields = ['zipcode', 'city_name', 'address', 'longitude', 'latitude']
|
||||
|
||||
|
||||
class PlaceDeleteView(DeleteView):
|
||||
template_name = 'chrono/manager_confirm_delete.html'
|
||||
model = models.Place
|
||||
success_url = '../../../../../'
|
||||
|
||||
|
||||
class PlaceUrlEditView(UpdateView):
|
||||
template_name = 'chrono/manager_ants_hub_add_form.html'
|
||||
model = models.Place
|
||||
fields = ['url', 'logo_url', 'meeting_url', 'management_url', 'cancel_url']
|
||||
|
||||
|
||||
class PlaceAgendaAddForm(forms.ModelForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# hide agendas already linked to the place
|
||||
self.fields['agenda'].queryset = self.fields['agenda'].queryset.exclude(
|
||||
ants_place__place=self.instance.place
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = models.PlaceAgenda
|
||||
exclude = ['place', 'setting']
|
||||
|
||||
|
||||
class PlaceAgendaAddView(PlaceMixin, CreateView):
|
||||
template_name = 'chrono/manager_ants_hub_add_form.html'
|
||||
model = models.PlaceAgenda
|
||||
form_class = PlaceAgendaAddForm
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return _('New agenda for %s') % self.place
|
||||
|
||||
def get_form_kwargs(self):
|
||||
kwargs = super().get_form_kwargs()
|
||||
kwargs['instance'] = models.PlaceAgenda(place=self.place)
|
||||
return kwargs
|
||||
|
||||
def get_success_url(self):
|
||||
return f'../../#open-place-agenda-{self.object.pk}'
|
||||
|
||||
|
||||
class PlaceAgendaEditForm(forms.ModelForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.meeting_types = self.instance.agenda.meetingtype_set.order_by('label')
|
||||
for meeting_type in self.instance.agenda.iter_meetingtypes():
|
||||
field_meeting_type = forms.TypedMultipleChoiceField(
|
||||
label=_('%(mt_label)s (%(mt_duration)s minutes)')
|
||||
% {'mt_label': meeting_type.label, 'mt_duration': meeting_type.duration},
|
||||
choices=models.ANTSMeetingType.choices,
|
||||
widget=forms.CheckboxSelectMultiple(attrs={'class': 'inline'}),
|
||||
required=False,
|
||||
initial=self.instance.get_meeting_type_setting(meeting_type, 'ants_meeting_type'),
|
||||
)
|
||||
field_meeting_type.meeting_type = meeting_type
|
||||
field_meeting_type.key = 'ants_meeting_type'
|
||||
field_persons_number = forms.TypedMultipleChoiceField(
|
||||
label=_('%(mt_label)s (%(mt_duration)s minutes)')
|
||||
% {'mt_label': meeting_type.label, 'mt_duration': meeting_type.duration},
|
||||
choices=models.ANTSPersonsNumber.choices,
|
||||
widget=forms.CheckboxSelectMultiple(attrs={'class': 'inline'}),
|
||||
required=False,
|
||||
initial=self.instance.get_meeting_type_setting(meeting_type, 'ants_persons_number'),
|
||||
)
|
||||
field_persons_number.meeting_type = meeting_type
|
||||
field_persons_number.key = 'ants_persons_number'
|
||||
self.fields[f'mt_{meeting_type.slug}_1'] = field_meeting_type
|
||||
self.fields[f'mt_{meeting_type.slug}_2'] = field_persons_number
|
||||
|
||||
def field_by_labels(self):
|
||||
d = {}
|
||||
for bound_field in self:
|
||||
d.setdefault(bound_field.label, []).append(bound_field)
|
||||
return list(d.items())
|
||||
|
||||
def clean(self):
|
||||
for key, field in self.fields.items():
|
||||
value = self.cleaned_data.get(key, [])
|
||||
self.instance.set_meeting_type_setting(field.meeting_type, field.key, value)
|
||||
return self.cleaned_data
|
||||
|
||||
class Meta:
|
||||
model = models.PlaceAgenda
|
||||
fields = []
|
||||
|
||||
|
||||
class PlaceAgendaEditView(UpdateView):
|
||||
template_name = 'chrono/manager_ants_hub_agenda_edit_form.html'
|
||||
model = models.PlaceAgenda
|
||||
form_class = PlaceAgendaEditForm
|
||||
success_url = '../../../'
|
||||
|
||||
|
||||
class PlaceAgendaDeleteView(DeleteView):
|
||||
template_name = 'chrono/manager_confirm_delete.html'
|
||||
model = models.PlaceAgenda
|
||||
success_url = '../../../'
|
||||
|
||||
|
||||
class Synchronize(TemplateView):
|
||||
template_name = 'chrono/manager_ants_hub_synchronize.html'
|
||||
|
||||
def post(self, request):
|
||||
self.synchronize()
|
||||
messages.info(request, _('Synchronization has been launched.'))
|
||||
return redirect('chrono-manager-ants-hub')
|
||||
|
||||
@classmethod
|
||||
def synchronize(cls):
|
||||
if 'uwsgi' in sys.modules:
|
||||
from django.db import connection
|
||||
|
||||
from chrono.utils.spooler import ants_hub_city_push
|
||||
|
||||
tenant = getattr(connection, 'tenant', None)
|
||||
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))
|
|
@ -1,464 +0,0 @@
|
|||
# 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()
|
|
@ -1,62 +0,0 @@
|
|||
# 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')},
|
||||
},
|
||||
),
|
||||
]
|
|
@ -1,134 +0,0 @@
|
|||
# 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
|
|
@ -1,47 +0,0 @@
|
|||
# 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',
|
||||
),
|
||||
]
|
|
@ -1,56 +0,0 @@
|
|||
# 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 = []
|
|
@ -1,6 +0,0 @@
|
|||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = []
|
||||
operations = []
|
|
@ -1,52 +0,0 @@
|
|||
# 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',),
|
||||
},
|
||||
),
|
||||
]
|
|
@ -1,57 +0,0 @@
|
|||
# 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)
|
|
@ -1,49 +0,0 @@
|
|||
{% 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 %}
|
|
@ -1,24 +0,0 @@
|
|||
# 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'),
|
||||
]
|
|
@ -1,39 +0,0 @@
|
|||
# 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,
|
||||
)
|
|
@ -1,42 +0,0 @@
|
|||
# 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()
|
|
@ -1,30 +0,0 @@
|
|||
# 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()
|
|
@ -1,7 +0,0 @@
|
|||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = []
|
||||
|
||||
operations = []
|
|
@ -1,186 +0,0 @@
|
|||
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,
|
||||
},
|
||||
),
|
||||
]
|
|
@ -1,182 +0,0 @@
|
|||
# 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',
|
||||
)
|
|
@ -1,213 +0,0 @@
|
|||
# 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'),
|
||||
)
|
|
@ -1,23 +0,0 @@
|
|||
# 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'),
|
||||
]
|
|
@ -1,43 +0,0 @@
|
|||
# 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)
|
|
@ -1,32 +0,0 @@
|
|||
# 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
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
|
@ -1,24 +0,0 @@
|
|||
# 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
|
@ -26,6 +26,7 @@ import django_filters
|
|||
from dateutil.relativedelta import relativedelta
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import Group
|
||||
from django.contrib.humanize.templatetags.humanize import ordinal
|
||||
from django.core.exceptions import FieldDoesNotExist
|
||||
from django.core.validators import URLValidator
|
||||
|
@ -37,7 +38,6 @@ from django.utils.encoding import force_str
|
|||
from django.utils.formats import date_format
|
||||
from django.utils.html import format_html, mark_safe
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.utils.translation import pgettext
|
||||
|
||||
from chrono.agendas.models import (
|
||||
WEEK_CHOICES,
|
||||
|
@ -47,8 +47,6 @@ from chrono.agendas.models import (
|
|||
AgendaNotificationsSettings,
|
||||
AgendaReminderSettings,
|
||||
Booking,
|
||||
BookingCheck,
|
||||
Category,
|
||||
Desk,
|
||||
Event,
|
||||
EventsType,
|
||||
|
@ -72,31 +70,21 @@ from chrono.utils.lingo import get_agenda_check_types
|
|||
from chrono.utils.timezone import localtime, make_aware, now
|
||||
|
||||
from . import widgets
|
||||
from .utils import get_role_queryset
|
||||
from .widgets import SplitDateTimeField, WeekdaysWidget
|
||||
|
||||
|
||||
class AgendaAddForm(forms.ModelForm):
|
||||
edit_role = forms.ModelChoiceField(label=_('Edit Role'), required=False, queryset=get_role_queryset())
|
||||
view_role = forms.ModelChoiceField(label=_('View Role'), required=False, queryset=get_role_queryset())
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if 'kind' in self.fields and settings.PARTIAL_BOOKINGS_ENABLED:
|
||||
self.fields['kind'].choices += [('partial-bookings', _('Partial bookings'))]
|
||||
edit_role = forms.ModelChoiceField(
|
||||
label=_('Edit Role'), required=False, queryset=Group.objects.all().order_by('name')
|
||||
)
|
||||
view_role = forms.ModelChoiceField(
|
||||
label=_('View Role'), required=False, queryset=Group.objects.all().order_by('name')
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Agenda
|
||||
fields = ['label', 'kind', 'category', 'edit_role', 'view_role']
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
if self.cleaned_data.get('kind') == 'partial-bookings':
|
||||
self.cleaned_data['kind'] = 'events'
|
||||
self.instance.partial_bookings = True
|
||||
self.instance.default_view = 'day'
|
||||
self.instance.enable_check_for_future_events = True
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
create = self.instance.pk is None
|
||||
super().save()
|
||||
|
@ -132,10 +120,6 @@ class AgendaEditForm(forms.ModelForm):
|
|||
else:
|
||||
if not EventsType.objects.exists():
|
||||
del self.fields['events_type']
|
||||
if kwargs['instance'].partial_bookings:
|
||||
self.fields['default_view'].choices = [
|
||||
(k, v) for k, v in self.fields['default_view'].choices if k not in ('open_events', 'week')
|
||||
]
|
||||
|
||||
|
||||
class AgendaBookingDelaysForm(forms.ModelForm):
|
||||
|
@ -147,9 +131,6 @@ class AgendaBookingDelaysForm(forms.ModelForm):
|
|||
'maximal_booking_delay',
|
||||
'minimal_booking_time',
|
||||
]
|
||||
widgets = {
|
||||
'minimal_booking_time': widgets.TimeWidget,
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
@ -174,8 +155,12 @@ class UnavailabilityCalendarAddForm(forms.ModelForm):
|
|||
model = UnavailabilityCalendar
|
||||
fields = ['label', 'edit_role', 'view_role']
|
||||
|
||||
edit_role = forms.ModelChoiceField(label=_('Edit Role'), required=False, queryset=get_role_queryset())
|
||||
view_role = forms.ModelChoiceField(label=_('View Role'), required=False, queryset=get_role_queryset())
|
||||
edit_role = forms.ModelChoiceField(
|
||||
label=_('Edit Role'), required=False, queryset=Group.objects.all().order_by('name')
|
||||
)
|
||||
view_role = forms.ModelChoiceField(
|
||||
label=_('View Role'), required=False, queryset=Group.objects.all().order_by('name')
|
||||
)
|
||||
|
||||
|
||||
class UnavailabilityCalendarEditForm(UnavailabilityCalendarAddForm):
|
||||
|
@ -208,7 +193,6 @@ class NewEventForm(forms.ModelForm):
|
|||
fields = [
|
||||
'label',
|
||||
'start_datetime',
|
||||
'end_time',
|
||||
'frequency',
|
||||
'recurrence_days',
|
||||
'recurrence_week_interval',
|
||||
|
@ -221,35 +205,14 @@ class NewEventForm(forms.ModelForm):
|
|||
}
|
||||
widgets = {
|
||||
'recurrence_end_date': forms.DateInput(attrs={'type': 'date'}, format='%Y-%m-%d'),
|
||||
'end_time': widgets.TimeWidget,
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if self.instance.agenda.partial_bookings:
|
||||
del self.fields['duration']
|
||||
del self.fields['recurrence_week_interval']
|
||||
else:
|
||||
del self.fields['end_time']
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
if self.cleaned_data.get('frequency') == 'unique':
|
||||
self.cleaned_data['recurrence_days'] = None
|
||||
self.cleaned_data['recurrence_end_date'] = None
|
||||
|
||||
end_time = self.cleaned_data.get('end_time')
|
||||
if end_time and self.cleaned_data['start_datetime'].time() > end_time:
|
||||
self.add_error('end_time', _('End time must be greater than start time.'))
|
||||
|
||||
if self.instance.agenda.partial_bookings and self.instance.agenda.event_overlaps(
|
||||
start_datetime=self.cleaned_data['start_datetime'],
|
||||
recurrence_days=self.cleaned_data['recurrence_days'],
|
||||
recurrence_end_date=self.cleaned_data['recurrence_end_date'],
|
||||
instance=self.instance,
|
||||
):
|
||||
raise ValidationError(_('There can only be one event per day.'))
|
||||
|
||||
def clean_start_datetime(self):
|
||||
start_datetime = self.cleaned_data['start_datetime']
|
||||
if start_datetime.year < 2000:
|
||||
|
@ -285,7 +248,6 @@ class EventForm(NewEventForm):
|
|||
protected_fields = (
|
||||
'slug',
|
||||
'start_datetime',
|
||||
'end_time',
|
||||
'frequency',
|
||||
'recurrence_days',
|
||||
'recurrence_week_interval',
|
||||
|
@ -295,13 +257,11 @@ class EventForm(NewEventForm):
|
|||
model = Event
|
||||
widgets = {
|
||||
'recurrence_end_date': forms.DateInput(attrs={'type': 'date'}, format='%Y-%m-%d'),
|
||||
'end_time': widgets.TimeWidget,
|
||||
}
|
||||
fields = [
|
||||
'label',
|
||||
'slug',
|
||||
'start_datetime',
|
||||
'end_time',
|
||||
'frequency',
|
||||
'recurrence_days',
|
||||
'recurrence_week_interval',
|
||||
|
@ -330,8 +290,6 @@ class EventForm(NewEventForm):
|
|||
)
|
||||
if self.instance.recurrence_days and self.instance.has_recurrences_booked():
|
||||
for field in self.protected_fields:
|
||||
if field not in self.fields:
|
||||
continue
|
||||
self.fields[field].disabled = True
|
||||
self.fields[field].help_text = _(
|
||||
'This field cannot be modified because some recurrences have bookings attached to them.'
|
||||
|
@ -419,7 +377,7 @@ class EventDuplicateForm(forms.ModelForm):
|
|||
def save(self, *args, **kwargs):
|
||||
with transaction.atomic():
|
||||
self.instance = self.instance.duplicate(
|
||||
label=self.cleaned_data['label'], start_datetime=self.cleaned_data['start_datetime']
|
||||
label=self.cleaned_data["label"], start_datetime=self.cleaned_data["start_datetime"]
|
||||
)
|
||||
if self.instance.recurrence_days:
|
||||
self.instance.create_all_recurrences()
|
||||
|
@ -522,20 +480,6 @@ 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()
|
||||
|
@ -545,15 +489,15 @@ class BookingCheckFilterSet(django_filters.FilterSet):
|
|||
if value == 'booked':
|
||||
return queryset
|
||||
if value == 'not-checked':
|
||||
return queryset.filter(user_checks__isnull=True)
|
||||
return queryset.filter(user_was_present__isnull=True)
|
||||
if value == 'presence':
|
||||
return queryset.filter(user_checks__presence=True)
|
||||
return queryset.filter(user_was_present=True)
|
||||
if value == 'absence':
|
||||
return queryset.filter(user_checks__presence=False)
|
||||
return queryset.filter(user_was_present=False)
|
||||
if value.startswith('absence::'):
|
||||
return queryset.filter(user_checks__presence=False, user_checks__type_slug=value.split('::')[1])
|
||||
return queryset.filter(user_was_present=False, user_check_type_slug=value.split('::')[1])
|
||||
if value.startswith('presence::'):
|
||||
return queryset.filter(user_checks__presence=True, user_checks__type_slug=value.split('::')[1])
|
||||
return queryset.filter(user_was_present=True, user_check_type_slug=value.split('::')[1])
|
||||
return queryset
|
||||
|
||||
def do_nothing(self, queryset, name, value):
|
||||
|
@ -589,129 +533,12 @@ 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):
|
||||
presence = forms.NullBooleanField(
|
||||
label=_('Status'),
|
||||
widget=forms.RadioSelect(
|
||||
choices=(
|
||||
(None, _('Not checked')),
|
||||
(True, _('Present')),
|
||||
(False, _('Absent')),
|
||||
)
|
||||
),
|
||||
required=False,
|
||||
)
|
||||
presence_check_type = forms.ChoiceField(label=_('Type'), required=False)
|
||||
absence_check_type = forms.ChoiceField(label=_('Type'), required=False)
|
||||
|
||||
class Meta:
|
||||
model = BookingCheck
|
||||
fields = ['presence', 'start_time', 'end_time', 'type_label', 'type_slug']
|
||||
widgets = {
|
||||
'start_time': widgets.TimeWidgetWithButton(
|
||||
step=60, button_label=_('Fill with booking start time')
|
||||
),
|
||||
'end_time': widgets.TimeWidgetWithButton(step=60, button_label=_('Fill with booking end time')),
|
||||
'type_label': forms.HiddenInput(),
|
||||
'type_slug': forms.HiddenInput(),
|
||||
}
|
||||
|
||||
def __init__(self, *args, 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']
|
||||
presence_check_types = [(ct.slug, ct.label) for ct in self.check_types if ct.kind == 'presence']
|
||||
|
||||
if presence_check_types:
|
||||
self.fields['presence_check_type'].choices = [(None, '---------')] + presence_check_types
|
||||
self.fields['presence_check_type'].initial = self.instance.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.type_slug
|
||||
else:
|
||||
del self.fields['absence_check_type']
|
||||
|
||||
if not self.instance.booking.start_time:
|
||||
self.fields['start_time'].widget = widgets.TimeWidget(step=60)
|
||||
self.fields['end_time'].widget = widgets.TimeWidget(step=60)
|
||||
self.fields['presence'].widget.choices = ((None, _('Not checked')), (True, _('Present')))
|
||||
self.fields.pop('absence_check_type', None)
|
||||
|
||||
def clean(self):
|
||||
if self.cleaned_data.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['presence'] is not None:
|
||||
kind = 'presence' if self.cleaned_data['presence'] else 'absence'
|
||||
if f'{kind}_check_type' in self.cleaned_data:
|
||||
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):
|
||||
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):
|
||||
|
@ -777,29 +604,17 @@ 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(',')
|
||||
|
@ -867,7 +682,6 @@ class EventsTimesheetForm(forms.Form):
|
|||
)
|
||||
|
||||
users = {}
|
||||
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:
|
||||
|
@ -881,7 +695,7 @@ class EventsTimesheetForm(forms.Form):
|
|||
}
|
||||
|
||||
booking_qs_kwargs = {}
|
||||
if not self.with_subscriptions:
|
||||
if not self.agenda.subscriptions.exists():
|
||||
booking_qs_kwargs = {'cancellation_datetime__isnull': True}
|
||||
booked_qs = (
|
||||
Booking.objects.filter(
|
||||
|
@ -917,19 +731,6 @@ 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:
|
||||
|
@ -1033,7 +834,7 @@ class MeetingTypeForm(forms.ModelForm):
|
|||
and mt.duration == self.instance.duration
|
||||
):
|
||||
raise ValidationError(
|
||||
_('This meetingtype is used by a virtual agenda: %s') % virtual_agenda
|
||||
_('This meetingtype is used by a virtual agenda: %s' % virtual_agenda)
|
||||
)
|
||||
|
||||
|
||||
|
@ -1306,12 +1107,15 @@ class ImportEventsForm(forms.Form):
|
|||
)
|
||||
events = None
|
||||
|
||||
def __init__(self, agenda, **kwargs):
|
||||
self.agenda = agenda
|
||||
def __init__(self, agenda_pk, **kwargs):
|
||||
self.agenda_pk = agenda_pk
|
||||
super().__init__(**kwargs)
|
||||
|
||||
def clean_events_csv_file(self):
|
||||
self.exclude_from_validation = ['desk', 'meeting_type', 'primary_event']
|
||||
class ValidationErrorWithOrdinal(ValidationError):
|
||||
def __init__(self, message, event_no):
|
||||
super().__init__(message)
|
||||
self.message = format_html(message, event_no=mark_safe(ordinal(event_no + 1)))
|
||||
|
||||
content = self.cleaned_data['events_csv_file'].read()
|
||||
if b'\0' in content:
|
||||
|
@ -1332,46 +1136,22 @@ class ImportEventsForm(forms.Form):
|
|||
except csv.Error:
|
||||
dialect = None
|
||||
|
||||
errors = []
|
||||
self.events = []
|
||||
self.warnings = {}
|
||||
self.events_by_slug = {x.slug: x for x in Event.objects.filter(agenda=self.agenda.pk)}
|
||||
self.event_ids_with_bookings = set(
|
||||
events = []
|
||||
warnings = {}
|
||||
events_by_slug = {x.slug: x for x in Event.objects.filter(agenda=self.agenda_pk)}
|
||||
event_ids_with_bookings = set(
|
||||
Booking.objects.filter(
|
||||
event__agenda=self.agenda.pk, cancellation_datetime__isnull=True
|
||||
event__agenda=self.agenda_pk, cancellation_datetime__isnull=True
|
||||
).values_list('event_id', flat=True)
|
||||
)
|
||||
self.seen_slugs = set(self.events_by_slug.keys())
|
||||
line_offset = 1
|
||||
seen_slugs = set(events_by_slug.keys())
|
||||
for i, csvline in enumerate(csv.reader(StringIO(content), dialect=dialect)):
|
||||
if not csvline:
|
||||
continue
|
||||
|
||||
if i == 0 and csvline[0].strip('#') in ('date', 'Date', _('date'), _('Date')):
|
||||
line_offset = 0
|
||||
continue
|
||||
|
||||
try:
|
||||
event = self.parse_csvline(csvline)
|
||||
except ValidationError as e:
|
||||
for error in getattr(e, 'error_list', [e]):
|
||||
errors.append(
|
||||
format_html(
|
||||
'{message} ({event_no} event)',
|
||||
message=error.message,
|
||||
event_no=mark_safe(ordinal(i + line_offset)),
|
||||
)
|
||||
)
|
||||
else:
|
||||
self.events.append(event)
|
||||
|
||||
if errors:
|
||||
errors = [_('Invalid file format:')] + errors
|
||||
raise ValidationError(errors)
|
||||
|
||||
def parse_csvline(self, csvline):
|
||||
if len(csvline) < 3:
|
||||
raise ValidationError(_('Not enough columns.'))
|
||||
raise ValidationErrorWithOrdinal(_('Invalid file format. ({event_no} event)'), i)
|
||||
if i == 0 and csvline[0].strip('#') in ('date', 'Date', _('date'), _('Date')):
|
||||
continue
|
||||
|
||||
# label needed to generate a slug
|
||||
label = None
|
||||
|
@ -1384,18 +1164,18 @@ class ImportEventsForm(forms.Form):
|
|||
if len(csvline) >= 6:
|
||||
slug = force_str(csvline[5]) if csvline[5] else None
|
||||
# get existing event if relevant
|
||||
if slug and slug in self.seen_slugs:
|
||||
event = self.events_by_slug[slug]
|
||||
if slug and slug in seen_slugs:
|
||||
event = events_by_slug[slug]
|
||||
# update label
|
||||
event.label = label
|
||||
if event is None:
|
||||
# new event
|
||||
event = Event(agenda_id=self.agenda.pk, label=label)
|
||||
event = Event(agenda_id=self.agenda_pk, label=label)
|
||||
# generate a slug if not provided
|
||||
event.slug = slug or generate_slug(event, seen_slugs=self.seen_slugs, agenda=self.agenda.pk)
|
||||
event.slug = slug or generate_slug(event, seen_slugs=seen_slugs, agenda=self.agenda_pk)
|
||||
# maintain caches
|
||||
self.seen_slugs.add(event.slug)
|
||||
self.events_by_slug[event.slug] = event
|
||||
seen_slugs.add(event.slug)
|
||||
events_by_slug[event.slug] = event
|
||||
|
||||
for datetime_fmt in (
|
||||
'%Y-%m-%d %H:%M',
|
||||
|
@ -1414,25 +1194,29 @@ class ImportEventsForm(forms.Form):
|
|||
event.pk is not None
|
||||
and event.start_datetime != event_datetime
|
||||
and event.start_datetime > now()
|
||||
and event.pk in self.event_ids_with_bookings
|
||||
and event.pk not in self.warnings
|
||||
and event.pk in event_ids_with_bookings
|
||||
and event.pk not in warnings
|
||||
):
|
||||
# event start datetime has changed, event is not past and has not cancelled bookings
|
||||
# => warn the user
|
||||
self.warnings[event.pk] = event
|
||||
warnings[event.pk] = event
|
||||
event.start_datetime = event_datetime
|
||||
break
|
||||
else:
|
||||
raise ValidationError(_('Wrong start date/time format.'))
|
||||
raise ValidationErrorWithOrdinal(
|
||||
_('Invalid file format. (date/time format, {event_no} event)'), i
|
||||
)
|
||||
try:
|
||||
event.places = int(csvline[2])
|
||||
except ValueError:
|
||||
raise ValidationError(_('Number of places must be an integer.'))
|
||||
raise ValidationError(_('Invalid file format. (number of places, {event_no} event)'), i)
|
||||
if len(csvline) >= 4:
|
||||
try:
|
||||
event.waiting_list_places = int(csvline[3])
|
||||
except ValueError:
|
||||
raise ValidationError(_('Number of places in waiting list must be an integer.'))
|
||||
raise ValidationError(
|
||||
_('Invalid file format. (number of places in waiting list, {event_no} event)'), i
|
||||
)
|
||||
|
||||
column_index = 7
|
||||
for more_attr in ('description', 'pricing', 'url'):
|
||||
|
@ -1458,32 +1242,30 @@ class ImportEventsForm(forms.Form):
|
|||
except ValueError:
|
||||
continue
|
||||
else:
|
||||
raise ValidationError(_('Wrong publication date/time format.'))
|
||||
raise ValidationError(_('Invalid file format. (date/time format, {event_no} event)'), i)
|
||||
|
||||
if self.agenda.partial_bookings:
|
||||
if len(csvline) < 11 or not csvline[10]:
|
||||
raise ValidationError(_('Missing end_time.'))
|
||||
event.end_time = csvline[10]
|
||||
else:
|
||||
self.exclude_from_validation.append('end_time')
|
||||
if len(csvline) >= 11 and csvline[10]: # duration is optional
|
||||
try:
|
||||
event.duration = int(csvline[10])
|
||||
except ValueError:
|
||||
raise ValidationError(_('Duration must be an integer.'))
|
||||
raise ValidationError(_('Invalid file format. (duration, {event_no} event)'), i)
|
||||
|
||||
try:
|
||||
event.full_clean(exclude=self.exclude_from_validation)
|
||||
event.full_clean(exclude=['desk', 'meeting_type', 'primary_event'])
|
||||
except ValidationError as e:
|
||||
errors = []
|
||||
errors = [_('Invalid file format:\n')]
|
||||
for label, field_errors in e.message_dict.items():
|
||||
label_name = self.get_verbose_name(label)
|
||||
msg = _('%s: ') % label_name if label_name else ''
|
||||
msg += ', '.join(field_errors)
|
||||
msg += _('%(errors)s (line %(line)d)') % {
|
||||
'errors': ', '.join(field_errors),
|
||||
'line': i + 1,
|
||||
}
|
||||
errors.append(msg)
|
||||
raise ValidationError(errors)
|
||||
|
||||
return event
|
||||
events.append(event)
|
||||
self.events = events
|
||||
self.warnings = warnings
|
||||
|
||||
@staticmethod
|
||||
def get_verbose_name(field_name):
|
||||
|
@ -1617,9 +1399,6 @@ class AgendaDisplaySettingsForm(forms.ModelForm):
|
|||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if kwargs['instance'].kind == 'events':
|
||||
if self.instance.partial_bookings:
|
||||
del self.fields['booking_user_block_template']
|
||||
else:
|
||||
self.fields['booking_user_block_template'].help_text = (
|
||||
_('Displayed for each booking in event page and check page'),
|
||||
)
|
||||
|
@ -1642,26 +1421,6 @@ class AgendaBookingCheckSettingsForm(forms.ModelForm):
|
|||
]
|
||||
widgets = {'booking_extra_user_block_template': forms.Textarea(attrs={'rows': 3})}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if self.instance.partial_bookings:
|
||||
del self.fields['enable_check_for_future_events']
|
||||
del self.fields['booking_extra_user_block_template']
|
||||
|
||||
|
||||
class AgendaInvoicingSettingsForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Agenda
|
||||
fields = [
|
||||
'invoicing_unit',
|
||||
'invoicing_tolerance',
|
||||
]
|
||||
|
||||
def save(self):
|
||||
super().save()
|
||||
self.instance.async_refresh_booking_computed_times()
|
||||
return self.instance
|
||||
|
||||
|
||||
class AgendaNotificationsForm(forms.ModelForm):
|
||||
class Meta:
|
||||
|
@ -1754,7 +1513,7 @@ class AgendaReminderTestForm(forms.Form):
|
|||
|
||||
|
||||
class AgendasExportForm(forms.Form):
|
||||
agendas = forms.ChoiceField(label=_('Agendas'), required=True)
|
||||
agendas = forms.BooleanField(label=_('Agendas'), required=False, initial=True)
|
||||
resources = forms.BooleanField(label=_('Resources'), required=False, initial=True)
|
||||
unavailability_calendars = forms.BooleanField(
|
||||
label=_('Unavailability calendars'), required=False, initial=True
|
||||
|
@ -1769,10 +1528,6 @@ class AgendasExportForm(forms.Form):
|
|||
self.fields['shared_custody'].initial = False
|
||||
self.fields['shared_custody'].widget = forms.HiddenInput()
|
||||
|
||||
self.fields['agendas'].choices = [('all', pgettext('agendas', 'All')), ('none', _('None'))] + [
|
||||
(x.id, x.label) for x in Category.objects.all()
|
||||
]
|
||||
|
||||
|
||||
class SharedCustodyRuleForm(forms.ModelForm):
|
||||
guardian = forms.ModelChoiceField(label=_('Guardian'), queryset=Person.objects.none())
|
||||
|
@ -1884,7 +1639,7 @@ class SharedCustodyPeriodForm(forms.ModelForm):
|
|||
|
||||
class SharedCustodySettingsForm(forms.ModelForm):
|
||||
management_role = forms.ModelChoiceField(
|
||||
label=_('Management role'), required=False, queryset=get_role_queryset()
|
||||
label=_('Management role'), required=False, queryset=Group.objects.all().order_by('name')
|
||||
)
|
||||
|
||||
class Meta:
|
||||
|
|
|
@ -201,18 +201,6 @@ 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;
|
||||
|
@ -332,6 +320,9 @@ span.buttons-group {
|
|||
}
|
||||
|
||||
div#appbar > h2.date-nav {
|
||||
display: inline-block;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-size: 100%;
|
||||
position: static;
|
||||
.date-title {
|
||||
|
@ -432,8 +423,7 @@ div.event-title-meta span.tag {
|
|||
color: white;
|
||||
}
|
||||
|
||||
div.ui-dialog form p span.datetime input,
|
||||
div.ui-dialog form input[type=time] {
|
||||
div.ui-dialog form p span.datetime input {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
|
@ -574,18 +564,6 @@ 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 {
|
||||
|
@ -617,399 +595,3 @@ span.togglable {
|
|||
.extra-user-block {
|
||||
padding-left: 2em;
|
||||
}
|
||||
|
||||
div#appbar a.active {
|
||||
background: #386ede;
|
||||
color: white;
|
||||
}
|
||||
|
||||
// Partial booking view
|
||||
div#main-content.partial-booking-dayview {
|
||||
// change default overflow to allow sticky hours list
|
||||
overflow: visible;
|
||||
}
|
||||
.partial-booking {
|
||||
--registrant-name-width: 15rem;
|
||||
--zebra-color: hsla(0,0%,0%,0.05);
|
||||
--separator-color: white;
|
||||
--separator-size: 2px;
|
||||
--padding: 0.5rem;
|
||||
|
||||
position: relative;
|
||||
background: white;
|
||||
padding: var(--padding);
|
||||
|
||||
&--hours-list {
|
||||
background: white;
|
||||
position: sticky;
|
||||
z-index: 2;
|
||||
top: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--nb-hours), 1fr);
|
||||
font-size: 80%;
|
||||
@media (min-width: 761px) {
|
||||
padding-left: var(--registrant-name-width);
|
||||
}
|
||||
}
|
||||
&--hour {
|
||||
text-align: center;
|
||||
transform: translateX(-50%);
|
||||
|
||||
&:first-child {
|
||||
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;
|
||||
flex-wrap: wrap;
|
||||
&:nth-child(odd) {
|
||||
background-color: var(--zebra-color);
|
||||
}
|
||||
&:nth-child(even) {
|
||||
--separator-color: var(--zebra-color);
|
||||
}
|
||||
.registrant {
|
||||
&--name {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: .66rem;
|
||||
font-size: 130%;
|
||||
color: #505050;
|
||||
font-weight: normal;
|
||||
@media (min-width: 761px) {
|
||||
flex: 0 0 var(--registrant-name-width);
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
&--datas {
|
||||
box-sizing: border-box;
|
||||
flex: 1 0 100%;
|
||||
padding: .33rem 0;
|
||||
@media (min-width: 761px) {
|
||||
flex-basis: auto;
|
||||
}
|
||||
background: linear-gradient(
|
||||
to left,
|
||||
var(--separator-color) var(--separator-size),
|
||||
transparent var(--separator-size),
|
||||
transparent 100%
|
||||
);
|
||||
background-size: calc(100% / var(--nb-hours)) 100%;
|
||||
@media (min-width: 761px) {
|
||||
border-left: var(--separator-size) solid var(--separator-color);
|
||||
}
|
||||
}
|
||||
&--bar-container {
|
||||
position: relative;
|
||||
margin: 0.33rem 0;
|
||||
}
|
||||
&--bar {
|
||||
box-sizing: border-box;
|
||||
display: inline-block;
|
||||
margin: 0;
|
||||
position: relative;
|
||||
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;
|
||||
}
|
||||
&.booking {
|
||||
--bar-color: #1066bc;
|
||||
.occasional {
|
||||
font-style: italic;
|
||||
font-size: 90%;
|
||||
}
|
||||
}
|
||||
&.check.present, &.computed.present {
|
||||
--bar-color: var(--green);
|
||||
}
|
||||
&.check.absent, &.computed.absent {
|
||||
--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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.partial-booking--check-icon {
|
||||
border: 0;
|
||||
&::before {
|
||||
content: "\f017"; /* clock */
|
||||
font-family: FontAwesome;
|
||||
padding-left: 1ex;
|
||||
}
|
||||
}
|
||||
|
||||
/* ants-hub */
|
||||
ul.objects-list.single-links li.ants-setting-not-configured a.edit {
|
||||
color: red;
|
||||
}
|
||||
|
||||
.ants-setting {
|
||||
.meeting-type {
|
||||
vertical-align: top;
|
||||
text-align: center;
|
||||
}
|
||||
ul.inline {
|
||||
display: flex;
|
||||
width: 40em;
|
||||
margin: 1ex;
|
||||
margin-block-start: 0em;
|
||||
padding-inline-start: 0em;
|
||||
}
|
||||
ul.inline li {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
border: 1px solid grey;
|
||||
border-width: 1px 0px 1px 1px;
|
||||
list-style: none;
|
||||
}
|
||||
ul.inline li:first-child {
|
||||
border-radius: 5px 0px 0px 5px;
|
||||
}
|
||||
li label {
|
||||
width: 100%;
|
||||
display: inline-block;
|
||||
}
|
||||
ul.inline li:last-child {
|
||||
border-radius: 0px 5px 5px 0px;
|
||||
border-width: 1px 1px 1px 1px;
|
||||
}
|
||||
ul.inline input {
|
||||
display: none;
|
||||
}
|
||||
label.checked {
|
||||
background: lightblue;
|
||||
}
|
||||
}
|
||||
|
||||
/* 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,43 +1,14 @@
|
|||
$(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');
|
||||
$(this).find('.occupation-bar').css('max-width', 100 * booked / total + '%');
|
||||
});
|
||||
$('.date-title').on('click', function() {
|
||||
const $datePicker = $(this).parent().find('.date-picker');
|
||||
$datePicker.toggle();
|
||||
if ($datePicker.css("display") !== "none" && document.body.classList.contains("dayview")) {
|
||||
const dateInput = document.querySelector('.date-picker--input');
|
||||
dateInput.focus();
|
||||
if (dateInput.showPicker) dateInput.showPicker();
|
||||
}
|
||||
$(this).parent().find('.date-picker').toggle();
|
||||
});
|
||||
$('.date-picker-opener').on('click', function() { $('.date-title').trigger('click'); });
|
||||
$('.date-picker button').on('click', function() {
|
||||
if (document.body.classList.contains("dayview")) {
|
||||
window.location = '../../../' + $('.date-picker--input').val().replaceAll("-", '/') + '/';
|
||||
return false;
|
||||
}
|
||||
if ($('[name=day]').val()) {
|
||||
window.location = '../../../' + $('[name=year]').val() + '/' + $('[name=month]').val() + '/' + $('[name=day]').val() + '/';
|
||||
} else if ($('[name=month]').val()) {
|
||||
|
|
|
@ -1,13 +0,0 @@
|
|||
{% 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 %}
|
|
@ -1,5 +0,0 @@
|
|||
{% 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 %}
|
|
@ -1,12 +0,0 @@
|
|||
{% 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 %}
|
|
@ -1,8 +0,0 @@
|
|||
{% 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 %}
|
|
@ -1,15 +0,0 @@
|
|||
{% 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 %}
|
|
@ -1,20 +0,0 @@
|
|||
<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>
|
|
@ -1,63 +0,0 @@
|
|||
{% 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>
|
|
@ -1,19 +0,0 @@
|
|||
{% extends "chrono/manager_agenda_view.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block appbar %}
|
||||
{% block navigation %}{% endblock %}
|
||||
|
||||
<span class="actions">
|
||||
<a class="extra-actions-menu-opener"></a>
|
||||
<ul class="extra-actions-menu">
|
||||
{% if user_can_manage %}
|
||||
<li><a href="{{ agenda.get_settings_url }}">{% trans 'Settings' %}</a></li>
|
||||
{% endif %}
|
||||
{% block agenda-extra-menu-actions %}{% endblock %}
|
||||
<li><a href="" onclick="window.print()">{% trans 'Print' %}</a></li>
|
||||
</ul>
|
||||
|
||||
{% include "chrono/manager_agenda_view_buttons_fragment.html" with active=kind %}
|
||||
</span>
|
||||
{% endblock %}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue