forked from CGM_Public/pretix_original
Allow ticket output providers to handle downloads externally (#1402)
* TicketOutput-Providers: Make preview optional; download/attachable optional; optional specific target; update doc * Spelling fixes in doc * Changes after code-review * Changes after code-review * Commit missing template file * Allow for redirects instead of files * Return HTTPResponse with Content-Type text/uri-list on API * Update API-doc * Add viewable to spellinglist, fixing doc-test
This commit is contained in:
committed by
Raphael Michel
parent
27538d220e
commit
03c760c2bb
@@ -219,6 +219,11 @@ pdf_data object Data object req
|
|||||||
|
|
||||||
The value ``auto_checked_in`` has been added to the ``checkins``-attribute.
|
The value ``auto_checked_in`` has been added to the ``checkins``-attribute.
|
||||||
|
|
||||||
|
.. versionchanged:: 3.3
|
||||||
|
|
||||||
|
The ``url`` of a ticket ``download`` can now also return a ``text/uri-list`` instead of a file. See
|
||||||
|
:ref:`order-position-ticket-download` for details.
|
||||||
|
|
||||||
.. _order-payment-resource:
|
.. _order-payment-resource:
|
||||||
|
|
||||||
Order payment resource
|
Order payment resource
|
||||||
@@ -1483,6 +1488,8 @@ Fetching individual positions
|
|||||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
|
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
|
||||||
:statuscode 404: The requested order position does not exist.
|
:statuscode 404: The requested order position does not exist.
|
||||||
|
|
||||||
|
.. _`order-position-ticket-download`:
|
||||||
|
|
||||||
Order position ticket download
|
Order position ticket download
|
||||||
------------------------------
|
------------------------------
|
||||||
|
|
||||||
@@ -1492,6 +1499,11 @@ Order position ticket download
|
|||||||
Depending on the chosen output, the response might be a ZIP file, PDF file or something else. The order details
|
Depending on the chosen output, the response might be a ZIP file, PDF file or something else. The order details
|
||||||
response contains a list of output options for this particular order position.
|
response contains a list of output options for this particular order position.
|
||||||
|
|
||||||
|
Be aware that the output does not have to be a file, but can also be a regular HTTP response with a ``Content-Type``
|
||||||
|
set to ``text/uri-list``. In this case, the user is expected to navigate to that URL in order to access their ticket.
|
||||||
|
The referenced URL can provide a download or a regular, human-viewable website - so it is advised to open this URL
|
||||||
|
in a webbrowser and leave it up to the user to handle the result.
|
||||||
|
|
||||||
Tickets can be only downloaded if the order is paid and if ticket downloads are active. Also, depending on event
|
Tickets can be only downloaded if the order is paid and if ticket downloads are active. Also, depending on event
|
||||||
configuration downloads might be only unavailable for add-on products or non-admission products.
|
configuration downloads might be only unavailable for add-on products or non-admission products.
|
||||||
Note that in some cases the ticket file might not yet have been created. In that case, you will receive a status
|
Note that in some cases the ticket file might not yet have been created. In that case, you will receive a status
|
||||||
|
|||||||
@@ -69,3 +69,9 @@ The output class
|
|||||||
.. automethod:: generate_order
|
.. automethod:: generate_order
|
||||||
|
|
||||||
.. autoattribute:: download_button_text
|
.. autoattribute:: download_button_text
|
||||||
|
|
||||||
|
.. autoattribute:: download_button_icon
|
||||||
|
|
||||||
|
.. autoattribute:: preview_allowed
|
||||||
|
|
||||||
|
.. autoattribute:: javascript_required
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ deprovision
|
|||||||
discoverable
|
discoverable
|
||||||
django
|
django
|
||||||
dockerfile
|
dockerfile
|
||||||
|
downloadable
|
||||||
durations
|
durations
|
||||||
eu
|
eu
|
||||||
filename
|
filename
|
||||||
@@ -140,6 +141,7 @@ username
|
|||||||
url
|
url
|
||||||
versa
|
versa
|
||||||
versioning
|
versioning
|
||||||
|
viewable
|
||||||
viewset
|
viewset
|
||||||
viewsets
|
viewsets
|
||||||
waitinglist
|
waitinglist
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import pytz
|
|||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.db.models import F, Prefetch, Q
|
from django.db.models import F, Prefetch, Q
|
||||||
from django.db.models.functions import Coalesce, Concat
|
from django.db.models.functions import Coalesce, Concat
|
||||||
from django.http import FileResponse
|
from django.http import FileResponse, HttpResponse
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from django.utils.timezone import make_aware, now
|
from django.utils.timezone import make_aware, now
|
||||||
from django.utils.translation import ugettext as _
|
from django.utils.translation import ugettext as _
|
||||||
@@ -148,12 +148,16 @@ class OrderViewSet(viewsets.ModelViewSet):
|
|||||||
generate.apply_async(args=('order', order.pk, provider.identifier))
|
generate.apply_async(args=('order', order.pk, provider.identifier))
|
||||||
raise RetryException()
|
raise RetryException()
|
||||||
else:
|
else:
|
||||||
resp = FileResponse(ct.file.file, content_type=ct.type)
|
if ct.type == 'text/uri-list':
|
||||||
resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}{}"'.format(
|
resp = HttpResponse(ct.file.file.read(), content_type='text/uri-list')
|
||||||
self.request.event.slug.upper(), order.code,
|
return resp
|
||||||
provider.identifier, ct.extension
|
else:
|
||||||
)
|
resp = FileResponse(ct.file.file, content_type=ct.type)
|
||||||
return resp
|
resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}{}"'.format(
|
||||||
|
self.request.event.slug.upper(), order.code,
|
||||||
|
provider.identifier, ct.extension
|
||||||
|
)
|
||||||
|
return resp
|
||||||
|
|
||||||
@action(detail=True, methods=['POST'])
|
@action(detail=True, methods=['POST'])
|
||||||
def mark_paid(self, request, **kwargs):
|
def mark_paid(self, request, **kwargs):
|
||||||
@@ -759,12 +763,16 @@ class OrderPositionViewSet(mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewS
|
|||||||
generate.apply_async(args=('orderposition', pos.pk, provider.identifier))
|
generate.apply_async(args=('orderposition', pos.pk, provider.identifier))
|
||||||
raise RetryException()
|
raise RetryException()
|
||||||
else:
|
else:
|
||||||
resp = FileResponse(ct.file.file, content_type=ct.type)
|
if ct.type == 'text/uri-list':
|
||||||
resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}-{}{}"'.format(
|
resp = HttpResponse(ct.file.file.read(), content_type='text/uri-list')
|
||||||
self.request.event.slug.upper(), pos.order.code, pos.positionid,
|
return resp
|
||||||
provider.identifier, ct.extension
|
else:
|
||||||
)
|
resp = FileResponse(ct.file.file, content_type=ct.type)
|
||||||
return resp
|
resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}-{}{}"'.format(
|
||||||
|
self.request.event.slug.upper(), pos.order.code, pos.positionid,
|
||||||
|
provider.identifier, ct.extension
|
||||||
|
)
|
||||||
|
return resp
|
||||||
|
|
||||||
def perform_destroy(self, instance):
|
def perform_destroy(self, instance):
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ def generate_order(order: int, provider: str):
|
|||||||
prov = response(order.event)
|
prov = response(order.event)
|
||||||
if prov.identifier == provider:
|
if prov.identifier == provider:
|
||||||
filename, ttype, data = prov.generate_order(order)
|
filename, ttype, data = prov.generate_order(order)
|
||||||
|
if ttype == 'text/uri-list':
|
||||||
|
continue
|
||||||
|
|
||||||
path, ext = os.path.splitext(filename)
|
path, ext = os.path.splitext(filename)
|
||||||
for ct in CachedCombinedTicket.objects.filter(order=order, provider=provider):
|
for ct in CachedCombinedTicket.objects.filter(order=order, provider=provider):
|
||||||
ct.delete()
|
ct.delete()
|
||||||
@@ -124,6 +127,9 @@ def get_tickets_for_order(order, base_position=None):
|
|||||||
if not p.is_enabled:
|
if not p.is_enabled:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
if p.download_handled_by_frontend:
|
||||||
|
continue
|
||||||
|
|
||||||
if p.multi_download_enabled and not base_position:
|
if p.multi_download_enabled and not base_position:
|
||||||
try:
|
try:
|
||||||
if len(positions) == 0:
|
if len(positions) == 0:
|
||||||
|
|||||||
@@ -46,12 +46,19 @@ class BaseTicketOutput:
|
|||||||
filename, a file type and file content. The extension will be taken from the filename
|
filename, a file type and file content. The extension will be taken from the filename
|
||||||
which is otherwise ignored.
|
which is otherwise ignored.
|
||||||
|
|
||||||
|
Alternatively, you can pass a tuple consisting of an arbitrary string, ``text/uri-list``
|
||||||
|
and a single URL. In this case, the user will be redirected to this link instead of
|
||||||
|
being asked to download a generated file.
|
||||||
|
|
||||||
.. note:: If the event uses the event series feature (internally called subevents)
|
.. note:: If the event uses the event series feature (internally called subevents)
|
||||||
and your generated ticket contains information like the event name or date,
|
and your generated ticket contains information like the event name or date,
|
||||||
you probably want to display the properties of the subevent. A common pattern
|
you probably want to display the properties of the subevent. A common pattern
|
||||||
to do this would be a declaration ``ev = position.subevent or position.order.event``
|
to do this would be a declaration ``ev = position.subevent or position.order.event``
|
||||||
and then access properties that are present on both classes like ``ev.name`` or
|
and then access properties that are present on both classes like ``ev.name`` or
|
||||||
``ev.date_from``.
|
``ev.date_from``.
|
||||||
|
|
||||||
|
.. note:: Should you elect to use the URI redirection feature instead of offering downloads,
|
||||||
|
you should also set the ``multi_download_enabled``-property to ``False``.
|
||||||
"""
|
"""
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
|
|
||||||
@@ -161,3 +168,21 @@ class BaseTicketOutput:
|
|||||||
The Font Awesome icon on the download button in the frontend.
|
The Font Awesome icon on the download button in the frontend.
|
||||||
"""
|
"""
|
||||||
return 'fa-download'
|
return 'fa-download'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def preview_allowed(self) -> bool:
|
||||||
|
"""
|
||||||
|
By default, the ``generate()`` method is called for generating a preview in the pretix backend.
|
||||||
|
In case your plugin cannot generate previews for any reason, you can manually disable it here.
|
||||||
|
"""
|
||||||
|
return True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def javascript_required(self) -> bool:
|
||||||
|
"""
|
||||||
|
If this property is set to true, the download-button for this ticket-type will not be displayed
|
||||||
|
when the user's browser has JavaScript disabled.
|
||||||
|
|
||||||
|
Defaults to ``False``
|
||||||
|
"""
|
||||||
|
return False
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
<div class="panel panel-default ticketoutput-panel">
|
<div class="panel panel-default ticketoutput-panel">
|
||||||
<div class="panel-heading">
|
<div class="panel-heading">
|
||||||
<a href="{% url "control:event.settings.tickets.preview" event=request.event.slug organizer=request.organizer.slug output=provider.identifier %}"
|
<a href="{% url "control:event.settings.tickets.preview" event=request.event.slug organizer=request.organizer.slug output=provider.identifier %}"
|
||||||
class="btn btn-default btn-sm pull-right flip {% if not provider.preview_allowed %}disabled{% endif %}"
|
class="btn btn-default btn-sm pull-right flip {% if not provider.evaluated_preview_allowed %}disabled{% endif %}"
|
||||||
target="_blank">
|
target="_blank">
|
||||||
{% trans "Preview" %}
|
{% trans "Preview" %}
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -287,7 +287,7 @@
|
|||||||
{% for b in download_buttons %}
|
{% for b in download_buttons %}
|
||||||
<form action="{% url "control:event.order.download.ticket" code=order.code event=request.event.slug organizer=request.event.organizer.slug position=line.pk output=b.identifier %}"
|
<form action="{% url "control:event.order.download.ticket" code=order.code event=request.event.slug organizer=request.event.organizer.slug position=line.pk output=b.identifier %}"
|
||||||
method="post" data-asynctask data-asynctask-download
|
method="post" data-asynctask data-asynctask-download
|
||||||
class="form-inline helper-display-inline">
|
class="form-inline helper-display-inline{% if b.javascript_required %} requirejs{% endif %}">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<button type="submit"
|
<button type="submit"
|
||||||
class="btn btn-xs btn-default">
|
class="btn btn-xs btn-default">
|
||||||
|
|||||||
@@ -736,11 +736,14 @@ class TicketSettings(EventSettingsViewMixin, EventPermissionRequiredMixin, FormV
|
|||||||
provider.settings_content = provider.settings_content_render(self.request)
|
provider.settings_content = provider.settings_content_render(self.request)
|
||||||
provider.form.prepare_fields()
|
provider.form.prepare_fields()
|
||||||
|
|
||||||
provider.preview_allowed = True
|
provider.evaluated_preview_allowed = True
|
||||||
for k, v in provider.settings_form_fields.items():
|
if not provider.preview_allowed:
|
||||||
if v.required and not self.request.event.settings.get('ticketoutput_%s_%s' % (provider.identifier, k)):
|
provider.evaluated_preview_allowed = False
|
||||||
provider.preview_allowed = False
|
else:
|
||||||
break
|
for k, v in provider.settings_form_fields.items():
|
||||||
|
if v.required and not self.request.event.settings.get('ticketoutput_%s_%s' % (provider.identifier, k)):
|
||||||
|
provider.evaluated_preview_allowed = False
|
||||||
|
break
|
||||||
|
|
||||||
providers.append(provider)
|
providers.append(provider)
|
||||||
return providers
|
return providers
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ from django.db.models import (
|
|||||||
)
|
)
|
||||||
from django.forms import formset_factory
|
from django.forms import formset_factory
|
||||||
from django.http import (
|
from django.http import (
|
||||||
FileResponse, Http404, HttpResponseNotAllowed, JsonResponse,
|
FileResponse, Http404, HttpResponseNotAllowed, HttpResponseRedirect,
|
||||||
|
JsonResponse,
|
||||||
)
|
)
|
||||||
from django.shortcuts import get_object_or_404, redirect, render
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
@@ -225,7 +226,8 @@ class OrderDetail(OrderView):
|
|||||||
'text': provider.download_button_text or 'Ticket',
|
'text': provider.download_button_text or 'Ticket',
|
||||||
'icon': provider.download_button_icon or 'fa-download',
|
'icon': provider.download_button_icon or 'fa-download',
|
||||||
'identifier': provider.identifier,
|
'identifier': provider.identifier,
|
||||||
'multi': provider.multi_download_enabled
|
'multi': provider.multi_download_enabled,
|
||||||
|
'javascript_required': provider.javascript_required
|
||||||
})
|
})
|
||||||
return buttons
|
return buttons
|
||||||
|
|
||||||
@@ -340,12 +342,16 @@ class OrderDownload(AsyncAction, OrderView):
|
|||||||
'message': str(self.get_success_message(value))
|
'message': str(self.get_success_message(value))
|
||||||
})
|
})
|
||||||
if isinstance(value, CachedTicket):
|
if isinstance(value, CachedTicket):
|
||||||
resp = FileResponse(value.file.file, content_type=value.type)
|
if value.type == 'text/uri-list':
|
||||||
resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}-{}{}"'.format(
|
resp = HttpResponseRedirect(value.file.file.read())
|
||||||
self.request.event.slug.upper(), self.order.code, self.order_position.positionid,
|
return resp
|
||||||
self.output.identifier, value.extension
|
else:
|
||||||
)
|
resp = FileResponse(value.file.file, content_type=value.type)
|
||||||
return resp
|
resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}-{}{}"'.format(
|
||||||
|
self.request.event.slug.upper(), self.order.code, self.order_position.positionid,
|
||||||
|
self.output.identifier, value.extension
|
||||||
|
)
|
||||||
|
return resp
|
||||||
elif isinstance(value, CachedCombinedTicket):
|
elif isinstance(value, CachedCombinedTicket):
|
||||||
resp = FileResponse(value.file.file, content_type=value.type)
|
resp = FileResponse(value.file.file, content_type=value.type)
|
||||||
resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}{}"'.format(
|
resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}{}"'.format(
|
||||||
|
|||||||
@@ -81,7 +81,7 @@
|
|||||||
{% if line.generate_ticket %}
|
{% if line.generate_ticket %}
|
||||||
{% for b in download_buttons %}
|
{% for b in download_buttons %}
|
||||||
<form action="{% if position_page and line.addon_to %}{% eventurl event "presale:event.order.position.download" secret=line.addon_to.web_secret order=order.code output=b.identifier pid=line.pk position=line.addon_to.positionid %}{% elif position_page %}{% eventurl event "presale:event.order.position.download" secret=line.web_secret order=order.code output=b.identifier pid=line.pk position=line.positionid %}{% else %}{% eventurl event "presale:event.order.download" secret=order.secret order=order.code output=b.identifier position=line.pk %}{% endif %}"
|
<form action="{% if position_page and line.addon_to %}{% eventurl event "presale:event.order.position.download" secret=line.addon_to.web_secret order=order.code output=b.identifier pid=line.pk position=line.addon_to.positionid %}{% elif position_page %}{% eventurl event "presale:event.order.position.download" secret=line.web_secret order=order.code output=b.identifier pid=line.pk position=line.positionid %}{% else %}{% eventurl event "presale:event.order.download" secret=order.secret order=order.code output=b.identifier position=line.pk %}{% endif %}"
|
||||||
method="post" data-asynctask data-asynctask-download class="download-btn-form">
|
method="post" data-asynctask data-asynctask-download class="download-btn-form{% if b.javascript_required %} requirejs{% endif %}">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<button type="submit"
|
<button type="submit"
|
||||||
class="btn btn-sm {% if b.identifier == "pdf" %}btn-primary{% else %}btn-default{% endif %}">
|
class="btn btn-sm {% if b.identifier == "pdf" %}btn-primary{% else %}btn-default{% endif %}">
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ from django.contrib import messages
|
|||||||
from django.core.files import File
|
from django.core.files import File
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.db.models import Exists, OuterRef, Q, Sum
|
from django.db.models import Exists, OuterRef, Q, Sum
|
||||||
from django.http import FileResponse, Http404, JsonResponse
|
from django.http import (
|
||||||
|
FileResponse, Http404, HttpResponseRedirect, JsonResponse,
|
||||||
|
)
|
||||||
from django.shortcuts import get_object_or_404, redirect
|
from django.shortcuts import get_object_or_404, redirect
|
||||||
from django.utils.decorators import method_decorator
|
from django.utils.decorators import method_decorator
|
||||||
from django.utils.functional import cached_property
|
from django.utils.functional import cached_property
|
||||||
@@ -155,7 +157,8 @@ class OrderDetails(EventViewMixin, OrderDetailMixin, CartMixin, TicketPageMixin,
|
|||||||
'text': provider.download_button_text or 'Download',
|
'text': provider.download_button_text or 'Download',
|
||||||
'icon': provider.download_button_icon or 'fa-download',
|
'icon': provider.download_button_icon or 'fa-download',
|
||||||
'identifier': provider.identifier,
|
'identifier': provider.identifier,
|
||||||
'multi': provider.multi_download_enabled
|
'multi': provider.multi_download_enabled,
|
||||||
|
'javascript_required': provider.javascript_required
|
||||||
})
|
})
|
||||||
return buttons
|
return buttons
|
||||||
|
|
||||||
@@ -249,7 +252,8 @@ class OrderPositionDetails(EventViewMixin, OrderPositionDetailMixin, CartMixin,
|
|||||||
'text': provider.download_button_text or 'Download',
|
'text': provider.download_button_text or 'Download',
|
||||||
'icon': provider.download_button_icon or 'fa-download',
|
'icon': provider.download_button_icon or 'fa-download',
|
||||||
'identifier': provider.identifier,
|
'identifier': provider.identifier,
|
||||||
'multi': provider.multi_download_enabled
|
'multi': provider.multi_download_enabled,
|
||||||
|
'javascript_required': provider.javascript_required
|
||||||
})
|
})
|
||||||
return buttons
|
return buttons
|
||||||
|
|
||||||
@@ -793,12 +797,16 @@ class OrderDownloadMixin:
|
|||||||
'message': str(self.get_success_message(value))
|
'message': str(self.get_success_message(value))
|
||||||
})
|
})
|
||||||
if isinstance(value, CachedTicket):
|
if isinstance(value, CachedTicket):
|
||||||
resp = FileResponse(value.file.file, content_type=value.type)
|
if value.type == 'text/uri-list':
|
||||||
resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}-{}{}"'.format(
|
resp = HttpResponseRedirect(value.file.file.read())
|
||||||
self.request.event.slug.upper(), self.order.code, self.order_position.positionid,
|
return resp
|
||||||
self.output.identifier, value.extension
|
else:
|
||||||
)
|
resp = FileResponse(value.file.file, content_type=value.type)
|
||||||
return resp
|
resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}-{}{}"'.format(
|
||||||
|
self.request.event.slug.upper(), self.order.code, self.order_position.positionid,
|
||||||
|
self.output.identifier, value.extension
|
||||||
|
)
|
||||||
|
return resp
|
||||||
elif isinstance(value, CachedCombinedTicket):
|
elif isinstance(value, CachedCombinedTicket):
|
||||||
resp = FileResponse(value.file.file, content_type=value.type)
|
resp = FileResponse(value.file.file, content_type=value.type)
|
||||||
resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}{}"'.format(
|
resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}{}"'.format(
|
||||||
|
|||||||
@@ -667,6 +667,10 @@ h1 .label {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nojs .requirejs {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
@import "../../pretixbase/scss/_rtl.scss";
|
@import "../../pretixbase/scss/_rtl.scss";
|
||||||
@import "../../bootstrap/scss/_rtl.scss";
|
@import "../../bootstrap/scss/_rtl.scss";
|
||||||
@import "_rtl.scss";
|
@import "_rtl.scss";
|
||||||
@@ -288,6 +288,10 @@ h2 .label {
|
|||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nojs .requirejs {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
@import "_iframe.scss";
|
@import "_iframe.scss";
|
||||||
@import "_a11y.scss";
|
@import "_a11y.scss";
|
||||||
@import "_print.scss";
|
@import "_print.scss";
|
||||||
|
|||||||
Reference in New Issue
Block a user