agendas: use custom urls in bookings (#56820)

This commit is contained in:
Emmanuel Cazenave 2021-09-21 14:20:26 +02:00
parent 9bf248a095
commit 3b9c1370c9
18 changed files with 310 additions and 16 deletions

View File

@ -49,6 +49,7 @@ from django.utils.translation import ugettext_lazy as _
from django.utils.translation import ungettext
from chrono.interval import Interval, IntervalSet
from chrono.utils.publik_urls import translate_from_publik_url
from chrono.utils.requests_wrapper import requests as requests_wrapper
AGENDA_KINDS = (
@ -1898,6 +1899,12 @@ class Booking(models.Model):
else:
return ugettext('booked')
def get_form_url(self):
return translate_from_publik_url(self.form_url)
def get_backoffice_url(self):
return translate_from_publik_url(self.backoffice_url)
OpeningHour = collections.namedtuple('OpeningHour', ['begin', 'end'])

View File

@ -30,7 +30,7 @@ You have a booking for event "{{ event }}", on {{ date }} at {{ time }}.
{% if booking.form_url %}
{% with _("Edit or cancel booking") as button_label %}
{% include "emails/button-link.html" with url=booking.form_url label=button_label %}
{% include "emails/button-link.html" with url=booking.get_form_url label=button_label %}
{% endwith %}
{% endif %}
{% endblock %}

View File

@ -20,7 +20,7 @@ You have a booking for event "{{ event }}", on {{ date }} at {{ time }}.
{% trans "More information:" %} {{ booking.event.url }}
{% endif %}
{% if booking.form_url %}
{% trans "If in need to cancel it, you can do so here:" %} {{ booking.form_url }}
{% trans "If in need to cancel it, you can do so here:" %} {{ booking.get_form_url }}
{% endif %}
{% endautoescape %}
{% endblock %}

View File

@ -52,6 +52,7 @@ from chrono.agendas.models import (
from chrono.api import serializers
from chrono.api.utils import APIError, Response
from chrono.interval import IntervalSet
from chrono.utils.publik_urls import translate_to_publik_url
def format_response_datetime(dt):
@ -667,9 +668,9 @@ def make_booking(event, payload, extra_data, primary_booking=None, in_waiting_li
user_last_name=payload.get('user_last_name') or payload.get('user_name') or '',
user_email=payload.get('user_email', ''),
user_phone_number=payload.get('user_phone_number', ''),
form_url=payload.get('form_url', ''),
backoffice_url=payload.get('backoffice_url', ''),
cancel_callback_url=payload.get('cancel_callback_url', ''),
form_url=translate_to_publik_url(payload.get('form_url', '')),
backoffice_url=translate_to_publik_url(payload.get('backoffice_url', '')),
cancel_callback_url=translate_to_publik_url(payload.get('cancel_callback_url', '')),
user_display_label=payload.get('user_display_label', ''),
extra_data=extra_data,
color=color,

View File

@ -7,15 +7,15 @@
{% block content %}
<form method="post">
{% if object.backoffice_url and not object.cancel_callback_url %}
{% if object.get_backoffice_url and not object.cancel_callback_url %}
{% if not user.is_staff %}
<p>{% trans "This booking has no callback url configured, cancellation must be handled from corresponding form." %}</p>
<p><a href="{{ object.backoffice_url }}">{% trans "Open form" %}</a></p>
<p><a href="{{ object.get_backoffice_url }}">{% trans "Open form" %}</a></p>
{% else %}
<div class="warningnotice">
<p>{% trans "This booking has no callback url configured, cancellation should be handled from corresponding form in order to garantee a coherent situation." %}</p>
</div>
<p><a href="{{ object.backoffice_url }}">{% trans "Open form" %}</a></p>
<p><a href="{{ object.get_backoffice_url }}">{% trans "Open form" %}</a></p>
<p>{% trans "However, since you are an administrator, you can choose to cancel it anyway." %}</p>
{% endif %}
{% else %}
@ -26,7 +26,7 @@
</p>
{% endif %}
{% if not object.backoffice_url or object.cancel_callback_url or user.is_staff %}
{% if not object.get_backoffice_url or object.cancel_callback_url or user.is_staff %}
{% csrf_token %}
<input type="hidden" name="next" value="{% firstof request.POST.next request.GET.next %}">
{{ form.as_p }}

View File

@ -21,7 +21,7 @@
<p>{% trans "Cancellation failed for the following bookings:" %}</p>
<ul>
{% for booking, error in errors.items %}
<li><a href="{{ booking.backoffice_url }}">{{ booking.events_display }}</a>: {{ error }}</li>
<li><a href="{{ booking.get_backoffice_url }}">{{ booking.events_display }}</a>: {{ error }}</li>
{% endfor %}
</ul>
{% endblock %}

View File

@ -29,7 +29,7 @@
<ul class="objects-list single-links">
{% for booking in booked %}
<li>
<a {% if booking.backoffice_url %}href="{{ booking.backoffice_url }}"{% endif %}>{{ booking.events_display }}</a>
<a {% if booking.get_backoffice_url %}href="{{ booking.get_backoffice_url }}"{% endif %}>{{ booking.events_display }}</a>
{% if not booking.primary_booking %}
<a rel="popup" class="delete" href="{% url 'chrono-manager-booking-cancel' pk=agenda.id booking_pk=booking.id %}?next={{ request.path }}">{% trans "Cancel" %}</a>
{% else %}
@ -53,7 +53,7 @@
<div>
<ul class="objects-list single-links">
{% for booking in waiting %}
<li><a {% if booking.backoffice_url %}href="{{ booking.backoffice_url }}"{% endif %}>{{ booking.events_display }}</a></li>
<li><a {% if booking.get_backoffice_url %}href="{{ booking.get_backoffice_url }}"{% endif %}>{{ booking.events_display }}</a></li>
{% endfor %}
</ul>
</div>

View File

@ -46,7 +46,7 @@
<div class="booking{% if booking.color %} booking-color-{{ booking.color.index }}{% endif %}"
style="height: {{ booking.css_height }}%; min-height: {{ booking.css_height }}%; top: {{ booking.css_top }}%;"
><span class="start-time">{{booking.event.start_datetime|date:"TIME_FORMAT"}}</span>
<a {% if booking.backoffice_url %}href="{{booking.backoffice_url}}"{% endif %}>{{ booking.meetings_display }}</a>
<a {% if booking.get_backoffice_url %}href="{{booking.get_backoffice_url}}"{% endif %}>{{ booking.meetings_display }}</a>
<a rel="popup" class="cancel" href="{% url 'chrono-manager-booking-cancel' pk=booking.event.agenda_id booking_pk=booking.pk %}?next={{ request.path }}">{% trans "Cancel" %}</a>
{% if booking.color %}<span class="booking-color-label booking-bg-color-{{ booking.color.index }}">{{ booking.color }}</span>{% endif %}
</div>

View File

@ -36,7 +36,7 @@
{% for slot in day.infos.booked_slots %}
<div class="booking{% if slot.booking.color %} booking-color-{{ slot.booking.color.index }}{% endif %}" style="left:{{ slot.css_left|stringformat:".1f" }}%;height:{{ slot.css_height|stringformat:".1f" }}%;min-height:{{ slot.css_height|stringformat:".1f" }}%;top:{{ slot.css_top|stringformat:".1f" }}%;width:{{ slot.css_width|stringformat:".1f" }}%;">
<span class="start-time">{{slot.booking.event.start_datetime|date:"TIME_FORMAT"}}</span>
<a {% if slot.booking.backoffice_url %}href="{{slot.booking.backoffice_url}}"{% endif %}>{{ slot.booking.meetings_display }}</a>
<a {% if slot.booking.get_backoffice_url %}href="{{slot.booking.get_backoffice_url}}"{% endif %}>{{ slot.booking.meetings_display }}</a>
<a rel="popup" class="cancel" href="{% url 'chrono-manager-booking-cancel' pk=slot.booking.event.agenda_id booking_pk=slot.booking.id %}?next={{ request.path }}">{% trans "Cancel" %}</a>
{% if not single_desk %}<span class="desk">{{ slot.desk }}</span>{% endif %}
{% if slot.booking.color %}<span class="booking-color-label booking-bg-color-{{ slot.booking.color.index }}">{{ slot.booking.color }}</span>{% endif %}

View File

@ -40,7 +40,7 @@
<div class="booking"
style="height: {{ booking.css_height }}%; min-height: {{ booking.css_height }}%; top: {{ booking.css_top }}%;"
><span class="start-time">{{ booking.event.start_datetime|date:"TIME_FORMAT" }}</span>
<a {% if booking.backoffice_url %}href="{{ booking.backoffice_url }}"{% endif %}>{{ booking.meetings_display }}</a>
<a {% if booking.get_backoffice_url %}href="{{ booking.get_backoffice_url }}"{% endif %}>{{ booking.meetings_display }}</a>
</div>
{% endfor %}
</td>

View File

@ -49,7 +49,7 @@
{% for slot in day.infos.booked_slots %}
<div class="booking" style="height:{{ slot.css_height|stringformat:".1f" }}%;min-height:{{ slot.css_height|stringformat:".1f" }}%;top:{{ slot.css_top|stringformat:".1f" }}%">
<span class="start-time">{{ slot.booking.event.start_datetime|date:"TIME_FORMAT" }}</span>
<a {% if slot.booking.backoffice_url %}href="{{ slot.booking.backoffice_url }}"{% endif %}>{{ booking.meetings_display }}</a>
<a {% if slot.booking.get_backoffice_url %}href="{{ slot.booking.get_backoffice_url }}"{% endif %}>{{ booking.meetings_display }}</a>
</div>
{% endfor %}
{% endif %}

View File

@ -0,0 +1,53 @@
# chrono - agendas system
# Copyright (C) 2021 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 urllib.parse
from django.conf import settings
def translate_from_publik_url(url):
if not url:
return ''
source_url = urllib.parse.urlparse(url)
if source_url.scheme != 'publik':
return url
known_services = getattr(settings, 'KNOWN_SERVICES', None)
if not known_services:
return url
for data in known_services.values():
for slug, service_data in data.items():
if slug == source_url.netloc:
service_url = urllib.parse.urlparse(service_data['url'])
return urllib.parse.urlunparse(
(service_url.scheme, service_url.netloc, source_url.path, '', '', None)
)
return ''
def translate_to_publik_url(url):
if not url:
return ''
known_services = getattr(settings, 'KNOWN_SERVICES', None)
if not known_services:
return url
source_url = urllib.parse.urlparse(url)
for data in known_services.values():
for slug, service_data in data.items():
service_url = urllib.parse.urlparse(service_data['url'])
if source_url.netloc == service_url.netloc and source_url.scheme == service_url.scheme:
return str(urllib.parse.urlunparse(('publik', slug, source_url.path, '', '', None)))
return url

View File

@ -27,6 +27,7 @@ from requests import Response
from requests import Session as RequestsSession
from requests.auth import AuthBase
from .publik_urls import translate_from_publik_url
from .signature import sign_url
@ -45,6 +46,7 @@ class PublikSignature(AuthBase):
class Requests(RequestsSession):
def request(self, method, url, **kwargs):
url = translate_from_publik_url(url)
remote_service = kwargs.pop('remote_service', None)
cache_duration = kwargs.pop('cache_duration', 15)
invalidate_cache = kwargs.pop('invalidate_cache', False)

View File

@ -2507,3 +2507,37 @@ def test_api_events_fillslots_multiple_agendas(app, user):
resp.json['err_desc']
== 'Some events belong to agendas that are not present in querystring: second-agenda'
)
def test_url_translation(app, some_data, user):
app.authorization = ('Basic', ('john.doe', 'password'))
agenda_id = Agenda.objects.filter(label='Foo bar')[0].id
assert Booking.objects.count() == 0
resp = app.get('/api/agenda/%s/datetimes/' % agenda_id)
fillslot_url = resp.json['data'][0]['api']['fillslot_url']
params = {
'backoffice_url': 'https://demarches.example.com/backoffice/foo/bar',
'cancel_callback_url': 'https://demarches.example.com/foo/bar/jump',
'form_url': 'https://demarches.example.com/foo/bar',
}
# https://demarches.example.com not in KNOWN_SERVICES, no URL translation
resp = app.post_json(fillslot_url, params=params)
booking = Booking.objects.get(pk=resp.json['booking_id'])
assert booking.backoffice_url == 'https://demarches.example.com/backoffice/foo/bar'
assert booking.cancel_callback_url == 'https://demarches.example.com/foo/bar/jump'
assert booking.form_url == 'https://demarches.example.com/foo/bar'
# http://example.org/ is in KNOWN_SERVICES, translation happens
params = {
'backoffice_url': 'http://example.org/backoffice/foo/bar',
'cancel_callback_url': 'http://example.org/foo/bar/jump',
'form_url': 'http://example.org/foo/bar',
}
resp = app.post_json(fillslot_url, params=params)
booking = Booking.objects.get(pk=resp.json['booking_id'])
assert booking.backoffice_url == 'publik://default/backoffice/foo/bar'
assert booking.cancel_callback_url == 'publik://default/foo/bar/jump'
assert booking.form_url == 'publik://default/foo/bar'

View File

@ -1195,6 +1195,62 @@ def test_agenda_day_view(app, admin_user, manager_user, api_user):
assert resp.pyquery.find('.exception-hours span')[1].text == 'Exception for the afternoon'
@pytest.mark.parametrize(
'view',
(
'/manage/agendas/%(agenda)s/%(year)d/%(month)d/%(day)d/',
'/manage/agendas/%(agenda)s/%(year)d/%(month)d/',
),
)
def test_agenda_day_month_view_backoffice_url_translation(
app, admin_user, manager_user, api_user, settings, view
):
agenda = Agenda.objects.create(label='New Example', kind='meetings')
desk = Desk.objects.create(agenda=agenda, label='New Desk')
desk.save()
today = datetime.date.today()
meetingtype = MeetingType(agenda=agenda, label='Bar', duration=30)
meetingtype.save()
timeperiod = TimePeriod.objects.create(
desk=desk, weekday=today.weekday(), start_time=datetime.time(10, 0), end_time=datetime.time(18, 30)
)
timeperiod.save()
app.authorization = ('Basic', ('john.doe', 'password'))
login(app)
resp = app.get('/api/agenda/%s/meetings/%s/datetimes/' % (agenda.slug, meetingtype.slug))
booking_url = resp.json['data'][0]['api']['fillslot_url']
# unkown service, backoffice url stored and displayed as is
backoffice_url = 'http://example.net/foo/bar/'
resp = app.post(booking_url, params={'backoffice_url': backoffice_url})
cancel_url = resp.json['api']['cancel_url']
booking_id = resp.json['booking_id']
booking = Booking.objects.get(pk=booking_id)
assert booking.backoffice_url == backoffice_url
date = booking.event.start_datetime
url = view % {'agenda': agenda.id, 'year': date.year, 'month': date.month, 'day': date.day}
resp = app.get(url)
assert resp.text.count('div class="booking') == 1
assert backoffice_url in resp.text
# reset booking
resp = app.post(cancel_url)
assert resp.json['err'] == 0
# known service, backoffice url stored translated and displayed as it was passed
backoffice_url = 'http://example.org/backoffice/bar/'
resp = app.post(booking_url, params={'backoffice_url': backoffice_url})
cancel_url = resp.json['api']['cancel_url']
booking_id = resp.json['booking_id']
booking = Booking.objects.get(pk=booking_id)
assert booking.backoffice_url == 'publik://default/backoffice/bar/'
date = booking.event.start_datetime
resp = app.get(url)
assert resp.text.count('div class="booking') == 1
assert backoffice_url in resp.text
@pytest.mark.parametrize('kind', ['meetings', 'virtual'])
def test_agenda_day_view_late_meeting(app, admin_user, kind):
today = datetime.date.today()
@ -2673,6 +2729,38 @@ def test_booking_cancellation_meetings_agenda(app, admin_user, manager_user, man
app.get('/manage/agendas/%s/bookings/%s/cancel' % (agenda.pk, booking4.pk), status=404)
def test_booking_cancellation_meetings_agenda_backoffice_url_translation(
app, admin_user, manager_user, managers_group, api_user
):
agenda = Agenda.objects.create(label='Passeports', kind='meetings', view_role=managers_group)
desk = Desk.objects.create(agenda=agenda, label='Desk A')
meetingtype = MeetingType(agenda=agenda, label='passeport', duration=20)
meetingtype.save()
today = datetime.date(2018, 11, 10) # fixed day
timeperiod_weekday = today.weekday()
timeperiod = TimePeriod(
desk=desk, weekday=timeperiod_weekday, start_time=datetime.time(10, 0), end_time=datetime.time(18, 0)
)
timeperiod.save()
# book a slot
app.authorization = ('Basic', ('john.doe', 'password'))
bookings_resp = app.get('/api/agenda/%s/meetings/%s/datetimes/' % (agenda.slug, meetingtype.slug))
booking_url = bookings_resp.json['data'][0]['api']['fillslot_url']
booking_json = app.post_json(booking_url, params={'backoffice_url': 'http://example.org/'}).json
booking = Booking.objects.get(pk=booking_json['booking_id'])
assert booking.backoffice_url == 'publik://default/'
date = booking.event.start_datetime
month_view_url = '/manage/agendas/%s/%d/%d/' % (agenda.id, date.year, date.month)
app.reset()
login(app, username='admin', password='admin')
resp = app.get(month_view_url)
resp = resp.click('Cancel')
assert 'http://example.org/' in resp.text
def test_agenda_notifications(app, admin_user, managers_group):
agenda = Agenda.objects.create(label='Events', kind='events')
Desk.objects.create(agenda=agenda, slug='_exceptions_holder')

View File

@ -413,6 +413,18 @@ def test_event_classes(app, admin_user):
assert 'overbooking' in resp.text
def test_event_detail_backoffice_url_translation(app, admin_user):
agenda = Agenda(label='Foo bar')
agenda.save()
Desk.objects.create(agenda=agenda, slug='_exceptions_holder')
event = Event(start_datetime=make_aware(datetime.datetime(2016, 2, 15, 17, 0)), places=10, agenda=agenda)
event.save()
Booking.objects.create(event=event, backoffice_url='publik://default/foo/')
app = login(app)
resp = app.get('/manage/agendas/%s/events/%s/' % (agenda.pk, event.pk))
assert 'http://example.org/foo/' in resp.text
def test_delete_event(app, admin_user):
agenda = Agenda(label='Foo bar')
agenda.save()
@ -988,6 +1000,52 @@ def test_event_cancellation_error_report(app, admin_user):
assert Booking.objects.filter(cancellation_datetime__isnull=False).count() == 5
def test_event_cancellation_error_report_backofice_url_translation(app, admin_user):
agenda = Agenda.objects.create(label='Events', kind='events')
event = Event.objects.create(
label='xyz', start_datetime=now() + datetime.timedelta(days=1), places=10, agenda=agenda
)
day = event.start_datetime
def mocked_requests_connection_error(*args, **kwargs):
raise requests.exceptions.ConnectionError('unreachable')
for _ in range(5):
Booking.objects.create(
event=event,
cancel_callback_url='http://example.org/jump/trigger/',
backoffice_url='publik://default/',
)
login(app)
resp = app.get('/manage/agendas/%s/%d/%d/' % (agenda.id, day.year, day.month))
resp = resp.click('Cancellation error reports')
assert 'No error report' in resp.text
resp = app.get('/manage/agendas/%d/events/%d/cancel' % (agenda.pk, event.pk))
resp = resp.form.submit().follow()
assert 'Cancellation in progress' in resp.text
with mock.patch('chrono.utils.requests_wrapper.RequestsSession.send') as mock_send:
mock_response = mock.Mock(status_code=200)
mock_send.return_value = mock_response
mock_send.side_effect = mocked_requests_connection_error
call_command('cancel_events')
event.refresh_from_db()
assert not event.cancelled and not event.cancellation_scheduled
assert not Booking.objects.filter(cancellation_datetime__isnull=False).exists()
resp = app.get('/manage/agendas/%s/%d/%d/' % (agenda.id, day.year, day.month))
assert 'Errors occured during cancellation of event "xyz".' in resp.text
resp = resp.click('Cancellation error reports')
assert '(5 failures)' in resp.text
resp = resp.click(str(event))
assert 'http://example.org/' in resp.text
def test_event_cancellation_forbidden(app, admin_user):
agenda = Agenda.objects.create(label='Events', kind='events')
event = Event.objects.create(

View File

@ -560,3 +560,41 @@ def test_meetings_agenda_resources(app, admin_user):
resp = resp.follow()
assert '/manage/resource/%s/' % resource.pk not in resp.text
assert '/manage/agendas/%s/resource/%s/delete/' % (agenda.pk, resource.pk) not in resp.text
@pytest.mark.parametrize(
'view',
(
'/manage/resource/%(resource)s/%(year)d/%(month)d/%(day)d/',
'/manage/resource/%(resource)s/%(year)d/%(month)d/',
),
)
def test_agenda_day_month_view_backoffice_url_translation(
app, admin_user, manager_user, api_user, settings, view
):
today = datetime.date.today()
resource = Resource.objects.create(label='Foo bar')
agenda = Agenda.objects.create(label='Agenda', kind='meetings')
desk = Desk.objects.create(agenda=agenda, label='Desk')
meetingtype = MeetingType.objects.create(agenda=agenda, label='Bar', duration=30)
TimePeriod.objects.create(
desk=desk, weekday=today.weekday(), start_time=datetime.time(10, 0), end_time=datetime.time(18, 30)
)
login(app)
url = view % {'resource': resource.id, 'year': today.year, 'month': today.month, 'day': today.day}
# book some slots
for hour, minute in [(10, 30), (14, 0)]:
event = Event.objects.create(
agenda=agenda,
places=1,
desk=desk,
meeting_type=meetingtype,
start_datetime=now().replace(hour=hour, minute=minute),
)
event.resources.add(resource)
Booking.objects.create(event=event, backoffice_url='publik://default/foo/')
resp = app.get(url)
assert 'http://example.org/foo/' in resp.text

View File

@ -1784,6 +1784,19 @@ def test_agenda_reminders_email_content(mailoutbox, freezer):
assert 'Edit or cancel booking' in mail.alternatives[0][0]
assert 'href="https://example.org/"' in mail.alternatives[0][0]
# check url translation
Booking.objects.all().delete()
mailoutbox.clear()
freezer.move_to('2020-01-01 14:00')
Booking.objects.create(event=event, user_email='t@test.org', form_url='publik://default/someform/1/')
freezer.move_to('2020-01-02 15:00')
call_command('send_booking_reminders')
mail = mailoutbox[0]
assert 'If in need to cancel it, you can do so here: http://example.org/someform/1/' in mail.body
assert 'Edit or cancel booking' in mail.alternatives[0][0]
assert 'href="http://example.org/someform/1/"' in mail.alternatives[0][0]
@override_settings(SMS_URL='https://passerelle.test.org/sms/send/', SMS_SENDER='EO', TIME_ZONE='UTC')
def test_agenda_reminders_sms_content(freezer):