Compare commits

..

6 Commits

Author SHA1 Message Date
Emmanuel Cazenave 2f48b1057f wip 2023-05-15 15:36:25 +02:00
Benjamin Dauvergne 8748be2b22 agendas: implements free time calculation (#76335)
gitea/chrono/pipeline/head This commit looks good Details
SharedTimePeriod gets a get_intervals(mintime, maxtime) method returning the
list of intervals of open time between mintime and maxtime.

Agenda gets a get_free_time(mintime, maxtime) method returning the
list of intervals of open time between mintime and maxtim.
2023-04-21 13:31:42 +02:00
Benjamin Dauvergne ae99d87e27 tests: add helper functions to manage meetings agendas (#76335) 2023-04-21 13:31:42 +02:00
Benjamin Dauvergne 33b4c807b4 utils: add IntervalSet.__add__ (#76335)
Most algo around agendas amounts to adding a bunch of intervals them
removing some. The method to add them was missing.
2023-04-21 13:30:54 +02:00
Benjamin Dauvergne 246e62d96b misc: move interval module in chrono.utils (#76335)
Just some cleaning.
2023-04-21 13:30:53 +02:00
Benjamin Dauvergne 60b1608f93 agendas: move get_all_slots() and get_min/max_datetime() as Agenda's methods (#76335) 2023-04-21 13:29:20 +02:00
238 changed files with 4677 additions and 23646 deletions

View File

@ -21,5 +21,3 @@ e07c450d7c8a5f80aafe185c85ebed73fe39d9e7
b38f5f901e1bef556bd95f45bcc041b092b1a617 b38f5f901e1bef556bd95f45bcc041b092b1a617
# misc: bump djhtml version (#75442) # misc: bump djhtml version (#75442)
34309253eddc15f17a280656a3ffec072e79731a 34309253eddc15f17a280656a3ffec072e79731a
# misc: apply double-quote-string-fixer (#79866)
b71dc670c7f90c675edb510643b992aaf69f852a

View File

@ -1,10 +1,6 @@
# See https://pre-commit.com for more information # See https://pre-commit.com for more information
# See https://pre-commit.com/hooks.html for more hooks # See https://pre-commit.com/hooks.html for more hooks
repos: 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 - repo: https://github.com/psf/black
rev: 23.1.0 rev: 23.1.0
hooks: hooks:
@ -31,6 +27,6 @@ repos:
- id: djhtml - id: djhtml
args: ['--tabwidth', '2'] args: ['--tabwidth', '2']
- repo: https://git.entrouvert.org/pre-commit-debian.git - repo: https://git.entrouvert.org/pre-commit-debian.git
rev: v0.3 rev: v0.1
hooks: hooks:
- id: pre-commit-debian - id: pre-commit-debian

7
Jenkinsfile vendored
View File

@ -2,11 +2,10 @@
pipeline { pipeline {
agent any agent any
options { disableConcurrentBuilds() }
stages { stages {
stage('Unit Tests') { stage('Unit Tests') {
steps { steps {
sh 'NUMPROCESSES=3 tox -rv' sh 'tox -rv -- --numprocesses 3'
} }
post { post {
always { always {
@ -32,9 +31,9 @@ pipeline {
''' '''
).trim() ).trim()
if (env.GIT_BRANCH == 'main' || env.GIT_BRANCH == 'origin/main') { 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/')) { } 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}"
} }
} }
} }

View File

@ -8,8 +8,6 @@ recursive-include chrono/manager/static *.css *.scss *.js
recursive-include chrono/api/templates *.html recursive-include chrono/api/templates *.html
recursive-include chrono/agendas/templates *.html *.txt recursive-include chrono/agendas/templates *.html *.txt
recursive-include chrono/manager/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) # sql (migrations)
recursive-include chrono/agendas/sql *.sql recursive-include chrono/agendas/sql *.sql

View File

@ -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()

View File

@ -59,7 +59,6 @@ class Command(BaseCommand):
event__start_datetime__lte=starts_before, event__start_datetime__lte=starts_before,
event__start_datetime__gte=starts_after, event__start_datetime__gte=starts_after,
in_waiting_list=False, in_waiting_list=False,
primary_booking__isnull=True,
**{f'{msg_type}_reminder_datetime__isnull': True}, **{f'{msg_type}_reminder_datetime__isnull': True},
).select_related('event', 'event__agenda', 'event__agenda__reminder_settings') ).select_related('event', 'event__agenda', 'event__agenda__reminder_settings')

View File

@ -18,7 +18,7 @@ import copy
from urllib.parse import urljoin from urllib.parse import urljoin
from django.conf import settings 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.core.management.base import BaseCommand
from django.db.transaction import atomic from django.db.transaction import atomic
from django.template.loader import render_to_string from django.template.loader import render_to_string
@ -72,12 +72,4 @@ class Command(BaseCommand):
with atomic(): with atomic():
setattr(event, status + '_notification_timestamp', timestamp) setattr(event, status + '_notification_timestamp', timestamp)
event.save() event.save()
mail_msg = EmailMultiAlternatives( send_mail(subject, body, settings.DEFAULT_FROM_EMAIL, recipients, html_message=html_body)
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()

View File

@ -11,7 +11,7 @@ class Migration(migrations.Migration):
operations = [ operations = [
migrations.AlterModelOptions( migrations.AlterModelOptions(
name='meetingtype', name='meetingtype',
options={'ordering': ['duration', 'label'], 'verbose_name': 'Meeting type'}, options={'ordering': ['duration', 'label']},
), ),
migrations.AddField( migrations.AddField(
model_name='timeperiodexception', model_name='timeperiodexception',

View File

@ -17,6 +17,6 @@ class Migration(migrations.Migration):
migrations.AddField( migrations.AddField(
model_name='event', model_name='event',
name='url', 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'),
), ),
] ]

View File

@ -10,6 +10,6 @@ class Migration(migrations.Migration):
migrations.AddField( migrations.AddField(
model_name='agenda', model_name='agenda',
name='desk_simple_management', name='desk_simple_management',
field=models.BooleanField(default=False, verbose_name='Global desk management'), field=models.BooleanField(default=False),
), ),
] ]

View File

@ -3,8 +3,6 @@
import django.contrib.postgres.fields import django.contrib.postgres.fields
from django.db import migrations, models from django.db import migrations, models
from chrono.agendas.models import WEEKDAY_CHOICES
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
@ -16,7 +14,9 @@ class Migration(migrations.Migration):
model_name='event', model_name='event',
name='recurrence_days', name='recurrence_days',
field=django.contrib.postgres.fields.ArrayField( 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, blank=True,
null=True, null=True,
size=None, size=None,

View File

@ -4,8 +4,6 @@ import django.contrib.postgres.fields
import django.db.models.deletion import django.db.models.deletion
from django.db import migrations, models from django.db import migrations, models
from chrono.agendas.models import WEEKDAY_CHOICES
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
@ -62,7 +60,17 @@ class Migration(migrations.Migration):
( (
'days', 'days',
django.contrib.postgres.fields.ArrayField( 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, size=None,
verbose_name='Days', verbose_name='Days',
), ),

View File

@ -18,7 +18,7 @@ class Migration(migrations.Migration):
blank=True, blank=True,
default=datetime.time(0, 0), default=datetime.time(0, 0),
null=True, 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', verbose_name='Booking opening time',
), ),
), ),

View File

@ -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',
),
),
]

View File

@ -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'),
),
]

View File

@ -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'),
),
]

View File

@ -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)]

View File

@ -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',
),
),
]

View File

@ -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),
]

View File

@ -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'),
),
]

View File

@ -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',
),
),
]

View File

@ -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),
),
]

View File

@ -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'],
},
),
]

View File

@ -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),
]

View File

@ -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',
),
]

View File

@ -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'
),
),
]

View File

@ -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),
),
]

View File

@ -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',
},
),
]

View File

@ -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'
),
),
]

View File

@ -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),
),
]

View File

@ -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),
),
]

View File

@ -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',
),
),
]

View File

@ -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

View File

@ -13,14 +13,6 @@ BEGIN
WHERE b.event_id = NEW.id AND b.cancellation_datetime IS NULL; WHERE b.event_id = NEW.id AND b.cancellation_datetime IS NULL;
END IF; 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 -- update almost_full field
IF (NEW.booked_places >= NEW.places * 0.9) THEN IF (NEW.booked_places >= NEW.places * 0.9) THEN
NEW.almost_full = true; NEW.almost_full = true;

View File

@ -1,6 +1,5 @@
import collections import collections
import datetime import datetime
import re
from django.contrib.auth.models import Group from django.contrib.auth.models import Group
from django.db import models, transaction from django.db import models, transaction
@ -43,15 +42,6 @@ class StringOrListField(serializers.ListField):
return super().to_internal_value(data) 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): class CommaSeparatedStringField(serializers.ListField):
def get_value(self, dictionary): def get_value(self, dictionary):
return super(serializers.ListField, self).get_value(dictionary) return super(serializers.ListField, self).get_value(dictionary)
@ -89,11 +79,11 @@ class FillSlotSerializer(serializers.Serializer):
exclude_user = serializers.BooleanField(default=False) exclude_user = serializers.BooleanField(default=False)
events = serializers.CharField(max_length=16, allow_blank=True) events = serializers.CharField(max_length=16, allow_blank=True)
bypass_delays = serializers.BooleanField(default=False) bypass_delays = serializers.BooleanField(default=False)
form_url = serializers.CharField(max_length=500, allow_blank=True) form_url = serializers.CharField(max_length=250, allow_blank=True)
backoffice_url = serializers.URLField(allow_blank=True, max_length=500) backoffice_url = serializers.URLField(allow_blank=True)
cancel_callback_url = serializers.URLField(allow_blank=True, max_length=500) cancel_callback_url = serializers.URLField(allow_blank=True)
presence_callback_url = serializers.URLField(allow_blank=True, max_length=500) presence_callback_url = serializers.URLField(allow_blank=True)
absence_callback_url = serializers.URLField(allow_blank=True, max_length=500) absence_callback_url = serializers.URLField(allow_blank=True)
count = serializers.IntegerField(min_value=1) count = serializers.IntegerField(min_value=1)
cancel_booking_id = serializers.CharField(max_length=250, allow_blank=True, allow_null=True) cancel_booking_id = serializers.CharField(max_length=250, allow_blank=True, allow_null=True)
force_waiting_list = serializers.BooleanField(default=False) force_waiting_list = serializers.BooleanField(default=False)
@ -101,24 +91,10 @@ class FillSlotSerializer(serializers.Serializer):
extra_emails = StringOrListField( extra_emails = StringOrListField(
required=False, child=serializers.EmailField(max_length=250, allow_blank=False) 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) required=False, child=serializers.CharField(max_length=16, allow_blank=False)
) )
check_overlaps = serializers.BooleanField(default=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): class SlotsSerializer(serializers.Serializer):
@ -236,65 +212,14 @@ class RecurringFillslotsSerializer(MultipleAgendasEventsFillSlotsSerializer):
% {'event_slug': event_slug, 'agenda_slug': agenda_slug} % {'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) slots[agenda_slug][event_slug].append(day)
return slots 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): 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_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) 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') 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', '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): def to_internal_value(self, data):
if 'color' in data: if 'color' in data:
# legacy # legacy
@ -347,53 +268,12 @@ class BookingSerializer(serializers.ModelSerializer):
ret.pop('user_absence_reason', None) ret.pop('user_absence_reason', None)
ret.pop('user_presence_reason', None) ret.pop('user_presence_reason', None)
else: else:
user_was_present = self.user_check.presence if self.user_check else None ret['user_absence_reason'] = (
ret['user_was_present'] = user_was_present self.instance.user_check_type_slug if self.instance.user_was_present is False else None
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 ret['user_presence_reason'] = (
if self.instance.event.agenda.kind == 'events' and self.instance.event.agenda.partial_bookings: self.instance.user_check_type_slug if self.instance.user_was_present is True else None
self.instance.user_check_start_time = self.user_check.start_time if self.user_check else None
self.instance.user_check_end_time = self.user_check.end_time if self.user_check else None
self.instance.computed_start_time = (
self.user_check.computed_start_time if self.user_check else None
) )
self.instance.computed_end_time = self.user_check.computed_end_time if self.user_check else None
# adjust start_time (in case of multi checks)
self.instance.adjusted_start_time = self.instance.start_time
if (
self.instance.start_time
and self.instance.computed_start_time
and self.instance.start_time < self.instance.computed_start_time
):
self.instance.adjusted_start_time = self.instance.computed_start_time
# and end_time
self.instance.adjusted_end_time = self.instance.end_time
if (
self.instance.end_time
and self.instance.computed_end_time
and self.instance.end_time > self.instance.computed_end_time
):
self.instance.adjusted_end_time = self.instance.computed_end_time
for key in ['', 'user_check_', 'computed_', 'adjusted_']:
start_key, end_key, minutes_key = (
'%sstart_time' % key,
'%send_time' % key,
'%sduration' % key,
)
ret[start_key] = getattr(self.instance, start_key)
ret[end_key] = getattr(self.instance, end_key)
ret[minutes_key] = None
if (
getattr(self.instance, start_key) is not None
and getattr(self.instance, end_key) is not None
):
start_minutes = (
getattr(self.instance, start_key).hour * 60 + getattr(self.instance, start_key).minute
)
end_minutes = (
getattr(self.instance, end_key).hour * 60 + getattr(self.instance, end_key).minute
)
ret[minutes_key] = end_minutes - start_minutes
return ret return ret
def _validate_check_type(self, kind, value): def _validate_check_type(self, kind, value):
@ -446,11 +326,6 @@ class ResizeSerializer(serializers.Serializer):
count = serializers.IntegerField(min_value=1) 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): class StatisticsFiltersSerializer(serializers.Serializer):
time_interval = serializers.ChoiceField(choices=('day', _('Day')), default='day') time_interval = serializers.ChoiceField(choices=('day', _('Day')), default='day')
start = serializers.DateTimeField(required=False, input_formats=['iso-8601', '%Y-%m-%d']) start = serializers.DateTimeField(required=False, input_formats=['iso-8601', '%Y-%m-%d'])
@ -467,13 +342,11 @@ class DateRangeSerializer(DateRangeMixin, serializers.Serializer):
class DatetimesSerializer(DateRangeSerializer): class DatetimesSerializer(DateRangeSerializer):
min_places = serializers.IntegerField(min_value=1, default=1) 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) 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) 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) events = serializers.CharField(required=False, max_length=32, allow_blank=True)
hide_disabled = serializers.BooleanField(default=False) hide_disabled = serializers.BooleanField(default=False)
bypass_delays = 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): def validate(self, attrs):
super().validate(attrs) super().validate(attrs)
@ -528,14 +401,6 @@ class AgendaOrSubscribedSlugsMixin(AgendaSlugsMixin):
attrs['agenda_slugs'] = [agenda.slug for agenda in agendas] attrs['agenda_slugs'] = [agenda.slug for agenda in agendas]
else: else:
attrs['agenda_slugs'] = self.agenda_slugs 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 return attrs
def validate_agendas(self, value): def validate_agendas(self, value):
@ -563,7 +428,7 @@ class AgendaOrSubscribedSlugsSerializer(AgendaOrSubscribedSlugsMixin, DateRangeM
class RecurringFillslotsQueryStringSerializer(AgendaOrSubscribedSlugsSerializer): 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): class RecurringEventsListSerializer(AgendaOrSubscribedSlugsSerializer):
@ -612,12 +477,11 @@ class EventSerializer(serializers.ModelSerializer):
field_classes = { field_classes = {
'text': serializers.CharField, 'text': serializers.CharField,
'textarea': serializers.CharField, 'textarea': serializers.CharField,
'bool': serializers.BooleanField, 'bool': serializers.NullBooleanField,
} }
field_options = { field_options = {
'text': {'allow_blank': True}, 'text': {'allow_blank': True},
'textarea': {'allow_blank': True}, 'textarea': {'allow_blank': True},
'bool': {'allow_null': True},
} }
for custom_field in self.instance.agenda.events_type.get_custom_fields(): for custom_field in self.instance.agenda.events_type.get_custom_fields():
field_class = field_classes[custom_field['field_type']] field_class = field_classes[custom_field['field_type']]
@ -628,10 +492,6 @@ class EventSerializer(serializers.ModelSerializer):
**(field_options.get(custom_field['field_type']) or {}), **(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): def validate(self, attrs):
if not self.instance.agenda.events_type: if not self.instance.agenda.events_type:
return attrs return attrs
@ -657,9 +517,6 @@ class EventSerializer(serializers.ModelSerializer):
def to_representation(self, instance): def to_representation(self, instance):
ret = super().to_representation(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: if not self.instance.agenda.events_type:
return ret return ret
defaults = { defaults = {
@ -690,7 +547,6 @@ class AgendaSerializer(serializers.ModelSerializer):
'slug', 'slug',
'label', 'label',
'kind', 'kind',
'partial_bookings',
'minimal_booking_delay', 'minimal_booking_delay',
'minimal_booking_delay_in_working_days', 'minimal_booking_delay_in_working_days',
'maximal_booking_delay', 'maximal_booking_delay',
@ -699,7 +555,6 @@ class AgendaSerializer(serializers.ModelSerializer):
'edit_role', 'edit_role',
'view_role', 'view_role',
'category', 'category',
'booking_form_url',
'mark_event_checked_auto', 'mark_event_checked_auto',
'disable_check_update', 'disable_check_update',
'booking_check_filters', 'booking_check_filters',
@ -741,10 +596,6 @@ class AgendaSerializer(serializers.ModelSerializer):
) )
if attrs.get('events_type') and attrs.get('kind', 'events') != 'events': if attrs.get('events_type') and attrs.get('kind', 'events') != 'events':
raise ValidationError({'events_type': _('Option not available on %s agenda') % attrs['kind']}) 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 return attrs

View File

@ -14,7 +14,7 @@
# You should have received a copy of the GNU Affero General Public License # 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/>. # 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 from . import views
@ -23,11 +23,6 @@ urlpatterns = [
path('agendas/datetimes/', views.agendas_datetimes, name='api-agendas-datetimes'), 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/', 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/', 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( path(
'agendas/events/', 'agendas/events/',
views.agendas_events, views.agendas_events,
@ -38,11 +33,6 @@ urlpatterns = [
views.agendas_events_fillslots, views.agendas_events_fillslots,
name='api-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( path(
'agendas/events/check-status/', 'agendas/events/check-status/',
views.agendas_events_check_status, views.agendas_events_check_status,
@ -67,6 +57,9 @@ urlpatterns = [
views.fillslot, views.fillslot,
name='api-fillslot', name='api-fillslot',
), ),
re_path(
r'^agenda/(?P<agenda_identifier>[\w-]+)/fillslots/$', views.fillslots, name='api-agenda-fillslots'
),
re_path( re_path(
r'^agenda/(?P<agenda_identifier>[\w-]+)/events/fillslots/$', r'^agenda/(?P<agenda_identifier>[\w-]+)/events/fillslots/$',
views.events_fillslots, views.events_fillslots,
@ -133,13 +126,7 @@ urlpatterns = [
views.subscription, views.subscription,
name='api-agenda-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/', 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>/', views.booking, name='api-booking'),
path('booking/<int:booking_pk>/cancel/', views.cancel_booking, name='api-cancel-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'), 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/', views.statistics_list, name='api-statistics-list'),
path('statistics/bookings/', views.bookings_statistics, name='api-statistics-bookings'), path('statistics/bookings/', views.bookings_statistics, name='api-statistics-bookings'),
path('ants/', include('chrono.apps.ants_hub.api_urls')),
path('user-preferences/', include('chrono.apps.user_preferences.api_urls')),
] ]

File diff suppressed because it is too large Load Diff

View File

@ -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'),
]

View File

@ -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}'}

View File

@ -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()

View File

@ -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')},
},
),
]

View File

@ -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'),
]

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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)

View File

@ -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))

View File

@ -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()

View File

@ -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')},
},
),
]

View File

@ -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

View File

@ -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',
),
]

View File

@ -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 = []

View File

@ -1,6 +0,0 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = []
operations = []

View File

@ -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',),
},
),
]

View File

@ -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)

View File

@ -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 %}

View File

@ -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'),
]

View File

@ -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,
)

View File

@ -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()

View File

@ -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()

View File

@ -1,7 +0,0 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = []
operations = []

View File

@ -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,
},
),
]

View File

@ -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',
)

View File

@ -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'),
)

View File

@ -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'),
]

View File

@ -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)

View File

@ -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
),
),
],
),
]

View File

@ -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

View File

@ -26,6 +26,7 @@ import django_filters
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from django import forms from django import forms
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import Group
from django.contrib.humanize.templatetags.humanize import ordinal from django.contrib.humanize.templatetags.humanize import ordinal
from django.core.exceptions import FieldDoesNotExist from django.core.exceptions import FieldDoesNotExist
from django.core.validators import URLValidator 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.formats import date_format
from django.utils.html import format_html, mark_safe from django.utils.html import format_html, mark_safe
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.utils.translation import pgettext
from chrono.agendas.models import ( from chrono.agendas.models import (
WEEK_CHOICES, WEEK_CHOICES,
@ -47,8 +47,6 @@ from chrono.agendas.models import (
AgendaNotificationsSettings, AgendaNotificationsSettings,
AgendaReminderSettings, AgendaReminderSettings,
Booking, Booking,
BookingCheck,
Category,
Desk, Desk,
Event, Event,
EventsType, EventsType,
@ -72,31 +70,21 @@ from chrono.utils.lingo import get_agenda_check_types
from chrono.utils.timezone import localtime, make_aware, now from chrono.utils.timezone import localtime, make_aware, now
from . import widgets from . import widgets
from .utils import get_role_queryset
from .widgets import SplitDateTimeField, WeekdaysWidget from .widgets import SplitDateTimeField, WeekdaysWidget
class AgendaAddForm(forms.ModelForm): class AgendaAddForm(forms.ModelForm):
edit_role = forms.ModelChoiceField(label=_('Edit Role'), required=False, queryset=get_role_queryset()) edit_role = forms.ModelChoiceField(
view_role = forms.ModelChoiceField(label=_('View Role'), required=False, queryset=get_role_queryset()) label=_('Edit Role'), required=False, queryset=Group.objects.all().order_by('name')
)
def __init__(self, *args, **kwargs): view_role = forms.ModelChoiceField(
super().__init__(*args, **kwargs) label=_('View Role'), required=False, queryset=Group.objects.all().order_by('name')
if 'kind' in self.fields and settings.PARTIAL_BOOKINGS_ENABLED: )
self.fields['kind'].choices += [('partial-bookings', _('Partial bookings'))]
class Meta: class Meta:
model = Agenda model = Agenda
fields = ['label', 'kind', 'category', 'edit_role', 'view_role'] 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): def save(self, *args, **kwargs):
create = self.instance.pk is None create = self.instance.pk is None
super().save() super().save()
@ -132,10 +120,6 @@ class AgendaEditForm(forms.ModelForm):
else: else:
if not EventsType.objects.exists(): if not EventsType.objects.exists():
del self.fields['events_type'] 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): class AgendaBookingDelaysForm(forms.ModelForm):
@ -147,9 +131,6 @@ class AgendaBookingDelaysForm(forms.ModelForm):
'maximal_booking_delay', 'maximal_booking_delay',
'minimal_booking_time', 'minimal_booking_time',
] ]
widgets = {
'minimal_booking_time': widgets.TimeWidget,
}
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@ -174,8 +155,12 @@ class UnavailabilityCalendarAddForm(forms.ModelForm):
model = UnavailabilityCalendar model = UnavailabilityCalendar
fields = ['label', 'edit_role', 'view_role'] fields = ['label', 'edit_role', 'view_role']
edit_role = forms.ModelChoiceField(label=_('Edit Role'), required=False, queryset=get_role_queryset()) edit_role = forms.ModelChoiceField(
view_role = forms.ModelChoiceField(label=_('View Role'), required=False, queryset=get_role_queryset()) 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): class UnavailabilityCalendarEditForm(UnavailabilityCalendarAddForm):
@ -208,7 +193,6 @@ class NewEventForm(forms.ModelForm):
fields = [ fields = [
'label', 'label',
'start_datetime', 'start_datetime',
'end_time',
'frequency', 'frequency',
'recurrence_days', 'recurrence_days',
'recurrence_week_interval', 'recurrence_week_interval',
@ -221,35 +205,14 @@ class NewEventForm(forms.ModelForm):
} }
widgets = { widgets = {
'recurrence_end_date': forms.DateInput(attrs={'type': 'date'}, format='%Y-%m-%d'), '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): def clean(self):
super().clean() super().clean()
if self.cleaned_data.get('frequency') == 'unique': if self.cleaned_data.get('frequency') == 'unique':
self.cleaned_data['recurrence_days'] = None self.cleaned_data['recurrence_days'] = None
self.cleaned_data['recurrence_end_date'] = 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): def clean_start_datetime(self):
start_datetime = self.cleaned_data['start_datetime'] start_datetime = self.cleaned_data['start_datetime']
if start_datetime.year < 2000: if start_datetime.year < 2000:
@ -285,7 +248,6 @@ class EventForm(NewEventForm):
protected_fields = ( protected_fields = (
'slug', 'slug',
'start_datetime', 'start_datetime',
'end_time',
'frequency', 'frequency',
'recurrence_days', 'recurrence_days',
'recurrence_week_interval', 'recurrence_week_interval',
@ -295,13 +257,11 @@ class EventForm(NewEventForm):
model = Event model = Event
widgets = { widgets = {
'recurrence_end_date': forms.DateInput(attrs={'type': 'date'}, format='%Y-%m-%d'), 'recurrence_end_date': forms.DateInput(attrs={'type': 'date'}, format='%Y-%m-%d'),
'end_time': widgets.TimeWidget,
} }
fields = [ fields = [
'label', 'label',
'slug', 'slug',
'start_datetime', 'start_datetime',
'end_time',
'frequency', 'frequency',
'recurrence_days', 'recurrence_days',
'recurrence_week_interval', 'recurrence_week_interval',
@ -330,8 +290,6 @@ class EventForm(NewEventForm):
) )
if self.instance.recurrence_days and self.instance.has_recurrences_booked(): if self.instance.recurrence_days and self.instance.has_recurrences_booked():
for field in self.protected_fields: for field in self.protected_fields:
if field not in self.fields:
continue
self.fields[field].disabled = True self.fields[field].disabled = True
self.fields[field].help_text = _( self.fields[field].help_text = _(
'This field cannot be modified because some recurrences have bookings attached to them.' '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): def save(self, *args, **kwargs):
with transaction.atomic(): with transaction.atomic():
self.instance = self.instance.duplicate( 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: if self.instance.recurrence_days:
self.instance.create_all_recurrences() self.instance.create_all_recurrences()
@ -522,20 +480,6 @@ class BookingCheckFilterSet(django_filters.FilterSet):
) )
self.filters['booking-status'].parent = self 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): def filter_booking_status(self, queryset, name, value):
if value == 'not-booked': if value == 'not-booked':
return queryset.none() return queryset.none()
@ -545,15 +489,15 @@ class BookingCheckFilterSet(django_filters.FilterSet):
if value == 'booked': if value == 'booked':
return queryset return queryset
if value == 'not-checked': if value == 'not-checked':
return queryset.filter(user_checks__isnull=True) return queryset.filter(user_was_present__isnull=True)
if value == 'presence': if value == 'presence':
return queryset.filter(user_checks__presence=True) return queryset.filter(user_was_present=True)
if value == 'absence': if value == 'absence':
return queryset.filter(user_checks__presence=False) return queryset.filter(user_was_present=False)
if value.startswith('absence::'): 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::'): 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 return queryset
def do_nothing(self, queryset, name, value): def do_nothing(self, queryset, name, value):
@ -589,129 +533,12 @@ class BookingCheckPresenceForm(forms.Form):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
agenda = kwargs.pop('agenda') agenda = kwargs.pop('agenda')
subscription = kwargs.pop('subscription', False)
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
check_types = get_agenda_check_types(agenda) check_types = get_agenda_check_types(agenda)
self.presence_check_types = [ct for ct in check_types if ct.kind == 'presence'] self.presence_check_types = [ct for ct in check_types if ct.kind == 'presence']
self.fields['check_type'].choices = [('', '---------')] + [ self.fields['check_type'].choices = [('', '---------')] + [
(ct.slug, ct.label) for ct in self.presence_check_types (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): class EventsTimesheetForm(forms.Form):
@ -777,29 +604,17 @@ class EventsTimesheetForm(forms.Form):
], ],
initial='portrait', 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): def __init__(self, *args, **kwargs):
self.agenda = kwargs.pop('agenda') self.agenda = kwargs.pop('agenda')
self.event = kwargs.pop('event', None) self.event = kwargs.pop('event', None)
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.with_subscriptions = self.agenda.subscriptions.exists()
if self.event is not None: if self.event is not None:
del self.fields['date_start'] del self.fields['date_start']
del self.fields['date_end'] del self.fields['date_end']
del self.fields['date_display'] del self.fields['date_display']
del self.fields['custom_nb_dates_per_page'] del self.fields['custom_nb_dates_per_page']
del self.fields['activity_display'] del self.fields['activity_display']
if not self.with_subscriptions:
del self.fields['booking_filter']
def get_slots(self): def get_slots(self):
extra_data = self.cleaned_data['extra_data'].split(',') extra_data = self.cleaned_data['extra_data'].split(',')
@ -867,21 +682,20 @@ class EventsTimesheetForm(forms.Form):
) )
users = {} users = {}
if self.with_subscriptions: subscriptions = self.agenda.subscriptions.filter(date_start__lt=max_start, date_end__gt=min_start)
subscriptions = self.agenda.subscriptions.filter(date_start__lt=max_start, date_end__gt=min_start) for subscription in subscriptions:
for subscription in subscriptions: if subscription.user_external_id in users:
if subscription.user_external_id in users: continue
continue users[subscription.user_external_id] = {
users[subscription.user_external_id] = { 'user_id': subscription.user_external_id,
'user_id': subscription.user_external_id, 'user_first_name': subscription.user_first_name,
'user_first_name': subscription.user_first_name, 'user_last_name': subscription.user_last_name,
'user_last_name': subscription.user_last_name, 'extra_data': {k: (subscription.extra_data or {}).get(k) or '' for k in all_extra_data},
'extra_data': {k: (subscription.extra_data or {}).get(k) or '' for k in all_extra_data}, 'events': copy.deepcopy(event_slots),
'events': copy.deepcopy(event_slots), }
}
booking_qs_kwargs = {} booking_qs_kwargs = {}
if not self.with_subscriptions: if not self.agenda.subscriptions.exists():
booking_qs_kwargs = {'cancellation_datetime__isnull': True} booking_qs_kwargs = {'cancellation_datetime__isnull': True}
booked_qs = ( booked_qs = (
Booking.objects.filter( Booking.objects.filter(
@ -917,19 +731,6 @@ class EventsTimesheetForm(forms.Form):
participants += 1 participants += 1
break 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': if self.cleaned_data['sort'] == 'lastname,firstname':
sort_fields = ['user_last_name', 'user_first_name'] sort_fields = ['user_last_name', 'user_first_name']
else: else:
@ -1033,7 +834,7 @@ class MeetingTypeForm(forms.ModelForm):
and mt.duration == self.instance.duration and mt.duration == self.instance.duration
): ):
raise ValidationError( 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 events = None
def __init__(self, agenda, **kwargs): def __init__(self, agenda_pk, **kwargs):
self.agenda = agenda self.agenda_pk = agenda_pk
super().__init__(**kwargs) super().__init__(**kwargs)
def clean_events_csv_file(self): 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() content = self.cleaned_data['events_csv_file'].read()
if b'\0' in content: if b'\0' in content:
@ -1332,118 +1136,48 @@ class ImportEventsForm(forms.Form):
except csv.Error: except csv.Error:
dialect = None dialect = None
errors = [] events = []
self.events = [] warnings = {}
self.warnings = {} events_by_slug = {x.slug: x for x in Event.objects.filter(agenda=self.agenda_pk)}
self.events_by_slug = {x.slug: x for x in Event.objects.filter(agenda=self.agenda.pk)} event_ids_with_bookings = set(
self.event_ids_with_bookings = set(
Booking.objects.filter( 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) ).values_list('event_id', flat=True)
) )
self.seen_slugs = set(self.events_by_slug.keys()) seen_slugs = set(events_by_slug.keys())
line_offset = 1
for i, csvline in enumerate(csv.reader(StringIO(content), dialect=dialect)): for i, csvline in enumerate(csv.reader(StringIO(content), dialect=dialect)):
if not csvline: if not csvline:
continue continue
if len(csvline) < 3:
raise ValidationErrorWithOrdinal(_('Invalid file format. ({event_no} event)'), i)
if i == 0 and csvline[0].strip('#') in ('date', 'Date', _('date'), _('Date')): if i == 0 and csvline[0].strip('#') in ('date', 'Date', _('date'), _('Date')):
line_offset = 0
continue continue
try: # label needed to generate a slug
event = self.parse_csvline(csvline) label = None
except ValidationError as e: if len(csvline) >= 5:
for error in getattr(e, 'error_list', [e]): label = force_str(csvline[4])
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: # get or create event
errors = [_('Invalid file format:')] + errors event = None
raise ValidationError(errors) slug = None
if len(csvline) >= 6:
slug = force_str(csvline[5]) if csvline[5] else None
# get existing event if relevant
if slug and slug in seen_slugs:
event = events_by_slug[slug]
# update label
event.label = label
if event is None:
# new event
event = Event(agenda_id=self.agenda_pk, label=label)
# generate a slug if not provided
event.slug = slug or generate_slug(event, seen_slugs=seen_slugs, agenda=self.agenda_pk)
# maintain caches
seen_slugs.add(event.slug)
events_by_slug[event.slug] = event
def parse_csvline(self, csvline):
if len(csvline) < 3:
raise ValidationError(_('Not enough columns.'))
# label needed to generate a slug
label = None
if len(csvline) >= 5:
label = force_str(csvline[4])
# get or create event
event = None
slug = None
if len(csvline) >= 6:
slug = force_str(csvline[5]) if csvline[5] else None
# get existing event if relevant
if slug and slug in self.seen_slugs:
event = self.events_by_slug[slug]
# update label
event.label = label
if event is None:
# new event
event = Event(agenda_id=self.agenda.pk, label=label)
# generate a slug if not provided
event.slug = slug or generate_slug(event, seen_slugs=self.seen_slugs, agenda=self.agenda.pk)
# maintain caches
self.seen_slugs.add(event.slug)
self.events_by_slug[event.slug] = event
for datetime_fmt in (
'%Y-%m-%d %H:%M',
'%d/%m/%Y %H:%M',
'%d/%m/%Y %Hh%M',
'%Y-%m-%d %H:%M:%S',
'%d/%m/%Y %H:%M:%S',
):
try:
event_datetime = make_aware(
datetime.datetime.strptime('%s %s' % tuple(csvline[:2]), datetime_fmt)
)
except ValueError:
continue
if (
event.pk is not None
and event.start_datetime != event_datetime
and event.start_datetime > now()
and event.pk in self.event_ids_with_bookings
and event.pk not in self.warnings
):
# event start datetime has changed, event is not past and has not cancelled bookings
# => warn the user
self.warnings[event.pk] = event
event.start_datetime = event_datetime
break
else:
raise ValidationError(_('Wrong start date/time format.'))
try:
event.places = int(csvline[2])
except ValueError:
raise ValidationError(_('Number of places must be an integer.'))
if len(csvline) >= 4:
try:
event.waiting_list_places = int(csvline[3])
except ValueError:
raise ValidationError(_('Number of places in waiting list must be an integer.'))
column_index = 7
for more_attr in ('description', 'pricing', 'url'):
if len(csvline) >= column_index:
setattr(event, more_attr, csvline[column_index - 1])
column_index += 1
if len(csvline) >= 10 and csvline[9]: # publication date is optional
for datetime_fmt in ( for datetime_fmt in (
'%Y-%m-%d',
'%d/%m/%Y',
'%Y-%m-%d %H:%M', '%Y-%m-%d %H:%M',
'%d/%m/%Y %H:%M', '%d/%m/%Y %H:%M',
'%d/%m/%Y %Hh%M', '%d/%m/%Y %Hh%M',
@ -1451,39 +1185,87 @@ class ImportEventsForm(forms.Form):
'%d/%m/%Y %H:%M:%S', '%d/%m/%Y %H:%M:%S',
): ):
try: try:
event.publication_datetime = make_aware( event_datetime = make_aware(
datetime.datetime.strptime(csvline[9], datetime_fmt) datetime.datetime.strptime('%s %s' % tuple(csvline[:2]), datetime_fmt)
) )
break
except ValueError: except ValueError:
continue continue
if (
event.pk is not None
and event.start_datetime != event_datetime
and event.start_datetime > now()
and event.pk in event_ids_with_bookings
and event.pk not in warnings
):
# event start datetime has changed, event is not past and has not cancelled bookings
# => warn the user
warnings[event.pk] = event
event.start_datetime = event_datetime
break
else: else:
raise ValidationError(_('Wrong publication 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(_('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(
_('Invalid file format. (number of places in waiting list, {event_no} event)'), i
)
column_index = 7
for more_attr in ('description', 'pricing', 'url'):
if len(csvline) >= column_index:
setattr(event, more_attr, csvline[column_index - 1])
column_index += 1
if len(csvline) >= 10 and csvline[9]: # publication date is optional
for datetime_fmt in (
'%Y-%m-%d',
'%d/%m/%Y',
'%Y-%m-%d %H:%M',
'%d/%m/%Y %H:%M',
'%d/%m/%Y %Hh%M',
'%Y-%m-%d %H:%M:%S',
'%d/%m/%Y %H:%M:%S',
):
try:
event.publication_datetime = make_aware(
datetime.datetime.strptime(csvline[9], datetime_fmt)
)
break
except ValueError:
continue
else:
raise ValidationError(_('Invalid file format. (date/time format, {event_no} event)'), i)
if self.agenda.partial_bookings:
if len(csvline) < 11 or not csvline[10]:
raise ValidationError(_('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 if len(csvline) >= 11 and csvline[10]: # duration is optional
try: try:
event.duration = int(csvline[10]) event.duration = int(csvline[10])
except ValueError: except ValueError:
raise ValidationError(_('Duration must be an integer.')) raise ValidationError(_('Invalid file format. (duration, {event_no} event)'), i)
try: try:
event.full_clean(exclude=self.exclude_from_validation) event.full_clean(exclude=['desk', 'meeting_type', 'primary_event'])
except ValidationError as e: except ValidationError as e:
errors = [] errors = [_('Invalid file format:\n')]
for label, field_errors in e.message_dict.items(): for label, field_errors in e.message_dict.items():
label_name = self.get_verbose_name(label) label_name = self.get_verbose_name(label)
msg = _('%s: ') % label_name if label_name else '' msg = _('%s: ') % label_name if label_name else ''
msg += ', '.join(field_errors) msg += _('%(errors)s (line %(line)d)') % {
errors.append(msg) 'errors': ', '.join(field_errors),
raise ValidationError(errors) 'line': i + 1,
}
return event errors.append(msg)
raise ValidationError(errors)
events.append(event)
self.events = events
self.warnings = warnings
@staticmethod @staticmethod
def get_verbose_name(field_name): def get_verbose_name(field_name):
@ -1617,12 +1399,9 @@ class AgendaDisplaySettingsForm(forms.ModelForm):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
if kwargs['instance'].kind == 'events': if kwargs['instance'].kind == 'events':
if self.instance.partial_bookings: self.fields['booking_user_block_template'].help_text = (
del self.fields['booking_user_block_template'] _('Displayed for each booking in event page and check page'),
else: )
self.fields['booking_user_block_template'].help_text = (
_('Displayed for each booking in event page and check page'),
)
else: else:
self.fields['booking_user_block_template'].help_text = ( self.fields['booking_user_block_template'].help_text = (
_('Displayed for each booking in agenda view pages'), _('Displayed for each booking in agenda view pages'),
@ -1642,26 +1421,6 @@ class AgendaBookingCheckSettingsForm(forms.ModelForm):
] ]
widgets = {'booking_extra_user_block_template': forms.Textarea(attrs={'rows': 3})} 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 AgendaNotificationsForm(forms.ModelForm):
class Meta: class Meta:
@ -1754,7 +1513,7 @@ class AgendaReminderTestForm(forms.Form):
class AgendasExportForm(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) resources = forms.BooleanField(label=_('Resources'), required=False, initial=True)
unavailability_calendars = forms.BooleanField( unavailability_calendars = forms.BooleanField(
label=_('Unavailability calendars'), required=False, initial=True 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'].initial = False
self.fields['shared_custody'].widget = forms.HiddenInput() 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): class SharedCustodyRuleForm(forms.ModelForm):
guardian = forms.ModelChoiceField(label=_('Guardian'), queryset=Person.objects.none()) guardian = forms.ModelChoiceField(label=_('Guardian'), queryset=Person.objects.none())
@ -1884,7 +1639,7 @@ class SharedCustodyPeriodForm(forms.ModelForm):
class SharedCustodySettingsForm(forms.ModelForm): class SharedCustodySettingsForm(forms.ModelForm):
management_role = forms.ModelChoiceField( 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: class Meta:

View File

@ -201,18 +201,6 @@ table.agenda-table {
text-align: center; text-align: center;
} }
&.booking { &.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; left: 0;
color: hsl(210, 84%, 40%); color: hsl(210, 84%, 40%);
padding: 1ex; padding: 1ex;
@ -332,6 +320,9 @@ span.buttons-group {
} }
div#appbar > h2.date-nav { div#appbar > h2.date-nav {
display: inline-block;
margin: 0;
padding: 0;
font-size: 100%; font-size: 100%;
position: static; position: static;
.date-title { .date-title {
@ -432,8 +423,7 @@ div.event-title-meta span.tag {
color: white; color: white;
} }
div.ui-dialog form p span.datetime input, div.ui-dialog form p span.datetime input {
div.ui-dialog form input[type=time] {
width: auto; width: auto;
} }
@ -574,18 +564,6 @@ div.agenda-settings .pk-tabs--container {
#event_details { #event_details {
margin: 1em 0; 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 { @media print {
@ -617,399 +595,3 @@ span.togglable {
.extra-user-block { .extra-user-block {
padding-left: 2em; 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;
}

View File

@ -1,43 +1,14 @@
$(function() { $(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() { $('[data-total]').each(function() {
var total = $(this).data('total'); var total = $(this).data('total');
var booked = $(this).data('booked'); var booked = $(this).data('booked');
$(this).find('.occupation-bar').css('max-width', 100 * booked / total + '%'); $(this).find('.occupation-bar').css('max-width', 100 * booked / total + '%');
}); });
$('.date-title').on('click', function() { $('.date-title').on('click', function() {
const $datePicker = $(this).parent().find('.date-picker'); $(this).parent().find('.date-picker').toggle();
$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();
}
}); });
$('.date-picker-opener').on('click', function() { $('.date-title').trigger('click'); }); $('.date-picker-opener').on('click', function() { $('.date-title').trigger('click'); });
$('.date-picker button').on('click', function() { $('.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()) { if ($('[name=day]').val()) {
window.location = '../../../' + $('[name=year]').val() + '/' + $('[name=month]').val() + '/' + $('[name=day]').val() + '/'; window.location = '../../../' + $('[name=year]').val() + '/' + $('[name=month]').val() + '/' + $('[name=day]').val() + '/';
} else if ($('[name=month]').val()) { } else if ($('[name=month]').val()) {

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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>

View File

@ -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 %}&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;{% endif %}
{% if not forloop.first %}<input type="radio" name="version2" value="{{ snapshot.pk }}" {% if forloop.counter == 2 %}checked="checked"{% endif %}/>{% else %}&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;{% 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>

View File

@ -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