From d7bab3cbe94b9d0f9b525a6e77cccd39796da6d6 Mon Sep 17 00:00:00 2001
From: Emmanuel Cazenave <ecazenave@entrouvert.com>
Date: Tue, 21 Sep 2021 14:20:26 +0200
Subject: [PATCH] agendas: use custom urls in bookings (#56820)

---
 chrono/agendas/models.py                      |  7 ++
 .../agendas/events_reminder_body.html         |  2 +-
 .../agendas/events_reminder_body.txt          |  2 +-
 chrono/api/views.py                           | 25 +++++-
 .../manager_confirm_booking_cancellation.html |  8 +-
 .../manager_event_cancellation_report.html    |  2 +-
 .../chrono/manager_event_detail_fragment.html |  4 +-
 .../manager_meetings_agenda_day_view.html     |  2 +-
 .../manager_meetings_agenda_month_view.html   |  2 +-
 .../chrono/manager_resource_day_view.html     |  2 +-
 .../chrono/manager_resource_month_view.html   |  2 +-
 chrono/utils/requests_wrapper.py              | 17 ++++
 tests/api/test_fillslot.py                    | 34 +++++++
 tests/manager/test_all.py                     | 88 +++++++++++++++++++
 tests/manager/test_event.py                   | 58 ++++++++++++
 tests/manager/test_resource.py                | 38 ++++++++
 tests/test_agendas.py                         | 13 +++
 17 files changed, 290 insertions(+), 16 deletions(-)

diff --git a/chrono/agendas/models.py b/chrono/agendas/models.py
index 94d604b..4dff67e 100644
--- a/chrono/agendas/models.py
+++ b/chrono/agendas/models.py
@@ -50,6 +50,7 @@ from django.utils.translation import ungettext
 
 from chrono.interval import Interval, IntervalSet
 from chrono.utils.requests_wrapper import requests as requests_wrapper
+from chrono.utils.requests_wrapper import translate_url
 
 AGENDA_KINDS = (
     ('events', _('Events')),
@@ -1898,6 +1899,12 @@ class Booking(models.Model):
         else:
             return ugettext('booked')
 
+    def get_form_url(self):
+        return translate_url(self.form_url)
+
+    def get_backoffice_url(self):
+        return translate_url(self.backoffice_url)
+
 
 OpeningHour = collections.namedtuple('OpeningHour', ['begin', 'end'])
 
diff --git a/chrono/agendas/templates/agendas/events_reminder_body.html b/chrono/agendas/templates/agendas/events_reminder_body.html
index d396149..25390e0 100644
--- a/chrono/agendas/templates/agendas/events_reminder_body.html
+++ b/chrono/agendas/templates/agendas/events_reminder_body.html
@@ -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 %}
diff --git a/chrono/agendas/templates/agendas/events_reminder_body.txt b/chrono/agendas/templates/agendas/events_reminder_body.txt
index 3d3da21..7b4bbd9 100644
--- a/chrono/agendas/templates/agendas/events_reminder_body.txt
+++ b/chrono/agendas/templates/agendas/events_reminder_body.txt
@@ -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 %}
diff --git a/chrono/api/views.py b/chrono/api/views.py
index f733a78..b8b3358 100644
--- a/chrono/api/views.py
+++ b/chrono/api/views.py
@@ -17,6 +17,7 @@
 import collections
 import datetime
 import itertools
+import urllib.parse
 import uuid
 
 from django.conf import settings
@@ -644,6 +645,24 @@ def get_start_and_end_datetime_from_request(request):
 
 
 def make_booking(event, payload, extra_data, primary_booking=None, in_waiting_list=False, color=None):
+    def translate_url(field_name):
+        res = payload.get(field_name, '')
+        if res:
+            source_url = urllib.parse.urlparse(res)
+            for data in settings.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:
+                        res = str(urllib.parse.urlunparse(('publik', slug, source_url.path, '', '', None)))
+                        break
+        return res
+
+    backoffice_url, cancel_callback_url, form_url = (
+        translate_url('backoffice_url'),
+        translate_url('cancel_callback_url'),
+        translate_url('form_url'),
+    )
+
     return Booking(
         event_id=event.pk,
         in_waiting_list=getattr(event, 'in_waiting_list', in_waiting_list),
@@ -654,9 +673,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=form_url,
+        backoffice_url=backoffice_url,
+        cancel_callback_url=cancel_callback_url,
         user_display_label=payload.get('user_display_label', ''),
         extra_data=extra_data,
         color=color,
diff --git a/chrono/manager/templates/chrono/manager_confirm_booking_cancellation.html b/chrono/manager/templates/chrono/manager_confirm_booking_cancellation.html
index 4e4e844..c5fdb7f 100644
--- a/chrono/manager/templates/chrono/manager_confirm_booking_cancellation.html
+++ b/chrono/manager/templates/chrono/manager_confirm_booking_cancellation.html
@@ -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 }}
diff --git a/chrono/manager/templates/chrono/manager_event_cancellation_report.html b/chrono/manager/templates/chrono/manager_event_cancellation_report.html
index b9bc8ae..11acdb7 100644
--- a/chrono/manager/templates/chrono/manager_event_cancellation_report.html
+++ b/chrono/manager/templates/chrono/manager_event_cancellation_report.html
@@ -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 %}
diff --git a/chrono/manager/templates/chrono/manager_event_detail_fragment.html b/chrono/manager/templates/chrono/manager_event_detail_fragment.html
index be3e3de..82fc792 100644
--- a/chrono/manager/templates/chrono/manager_event_detail_fragment.html
+++ b/chrono/manager/templates/chrono/manager_event_detail_fragment.html
@@ -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>
diff --git a/chrono/manager/templates/chrono/manager_meetings_agenda_day_view.html b/chrono/manager/templates/chrono/manager_meetings_agenda_day_view.html
index 1f2ff35..8bcca32 100644
--- a/chrono/manager/templates/chrono/manager_meetings_agenda_day_view.html
+++ b/chrono/manager/templates/chrono/manager_meetings_agenda_day_view.html
@@ -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>
diff --git a/chrono/manager/templates/chrono/manager_meetings_agenda_month_view.html b/chrono/manager/templates/chrono/manager_meetings_agenda_month_view.html
index bc53264..ec15861 100644
--- a/chrono/manager/templates/chrono/manager_meetings_agenda_month_view.html
+++ b/chrono/manager/templates/chrono/manager_meetings_agenda_month_view.html
@@ -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 %}
diff --git a/chrono/manager/templates/chrono/manager_resource_day_view.html b/chrono/manager/templates/chrono/manager_resource_day_view.html
index 4e026bc..d32f122 100644
--- a/chrono/manager/templates/chrono/manager_resource_day_view.html
+++ b/chrono/manager/templates/chrono/manager_resource_day_view.html
@@ -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>
diff --git a/chrono/manager/templates/chrono/manager_resource_month_view.html b/chrono/manager/templates/chrono/manager_resource_month_view.html
index 01b2203..5b2bf2d 100644
--- a/chrono/manager/templates/chrono/manager_resource_month_view.html
+++ b/chrono/manager/templates/chrono/manager_resource_month_view.html
@@ -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 %}
diff --git a/chrono/utils/requests_wrapper.py b/chrono/utils/requests_wrapper.py
index 747819a..53beba8 100644
--- a/chrono/utils/requests_wrapper.py
+++ b/chrono/utils/requests_wrapper.py
@@ -30,6 +30,22 @@ from requests.auth import AuthBase
 from .signature import sign_url
 
 
+def translate_url(url):
+    if not url:
+        return ''
+    source_url = parse.urlparse(url)
+    if source_url.scheme != 'publik':
+        return url
+    for data in settings.KNOWN_SERVICES.values():
+        for slug, service_data in data.items():
+            if slug == source_url.netloc:
+                service_url = parse.urlparse(service_data['url'])
+                return parse.urlunparse(
+                    (service_url.scheme, service_url.netloc, source_url.path, '', '', None)
+                )
+    return ''
+
+
 class NothingInCacheException(Exception):
     pass
 
@@ -45,6 +61,7 @@ class PublikSignature(AuthBase):
 
 class Requests(RequestsSession):
     def request(self, method, url, **kwargs):
+        url = translate_url(url)
         remote_service = kwargs.pop('remote_service', None)
         cache_duration = kwargs.pop('cache_duration', 15)
         invalidate_cache = kwargs.pop('invalidate_cache', False)
diff --git a/tests/api/test_fillslot.py b/tests/api/test_fillslot.py
index ee83ded..bddf2b3 100644
--- a/tests/api/test_fillslot.py
+++ b/tests/api/test_fillslot.py
@@ -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'
diff --git a/tests/manager/test_all.py b/tests/manager/test_all.py
index e2caa56..59a9faf 100644
--- a/tests/manager/test_all.py
+++ b/tests/manager/test_all.py
@@ -1180,6 +1180,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()
@@ -2658,6 +2714,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')
diff --git a/tests/manager/test_event.py b/tests/manager/test_event.py
index be39270..d0e042f 100644
--- a/tests/manager/test_event.py
+++ b/tests/manager/test_event.py
@@ -412,6 +412,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()
@@ -987,6 +999,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(
diff --git a/tests/manager/test_resource.py b/tests/manager/test_resource.py
index 66f86aa..e172bad 100644
--- a/tests/manager/test_resource.py
+++ b/tests/manager/test_resource.py
@@ -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
diff --git a/tests/test_agendas.py b/tests/test_agendas.py
index 7642dc7..c33603c 100644
--- a/tests/test_agendas.py
+++ b/tests/test_agendas.py
@@ -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):
-- 
2.30.2

