Add support for client side caching of document page images. The time
the images are cached is controlled by the new setting DOCUMENTS_PAGE_IMAGE_CACHE_TIME which defaults to 3600 seconds (1 hour). Signed-off-by: Roberto Rosario <roberto.rosario.gonzalez@gmail.com>
This commit is contained in:
@@ -19,6 +19,9 @@
|
|||||||
Whitenoise.
|
Whitenoise.
|
||||||
- Display error when attempting to recalculate the page count of an empty
|
- Display error when attempting to recalculate the page count of an empty
|
||||||
document (document stub that has no document version).
|
document (document stub that has no document version).
|
||||||
|
- Add support for client side caching of document page images. The time
|
||||||
|
the images are cached is controlled by the new setting
|
||||||
|
DOCUMENTS_PAGE_IMAGE_CACHE_TIME which defaults to 3600 seconds (1 hour).
|
||||||
|
|
||||||
3.0.1 (2018-07-08)
|
3.0.1 (2018-07-08)
|
||||||
=================
|
=================
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import logging
|
|||||||
|
|
||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
|
from django.views.decorators.cache import cache_control, patch_cache_control
|
||||||
|
|
||||||
from django_downloadview import DownloadMixin, VirtualFile
|
from django_downloadview import DownloadMixin, VirtualFile
|
||||||
from rest_framework import generics, status
|
from rest_framework import generics, status
|
||||||
@@ -34,6 +35,7 @@ from .serializers import (
|
|||||||
RecentDocumentSerializer, WritableDocumentSerializer,
|
RecentDocumentSerializer, WritableDocumentSerializer,
|
||||||
WritableDocumentTypeSerializer, WritableDocumentVersionSerializer
|
WritableDocumentTypeSerializer, WritableDocumentVersionSerializer
|
||||||
)
|
)
|
||||||
|
from .settings import settings_document_page_image_cache_time
|
||||||
from .storages import storage_documentimagecache
|
from .storages import storage_documentimagecache
|
||||||
from .tasks import task_generate_document_page_image
|
from .tasks import task_generate_document_page_image
|
||||||
|
|
||||||
@@ -147,93 +149,6 @@ class APIDocumentListView(generics.ListCreateAPIView):
|
|||||||
serializer.save(_user=self.request.user)
|
serializer.save(_user=self.request.user)
|
||||||
|
|
||||||
|
|
||||||
class APIDocumentVersionDownloadView(DownloadMixin, generics.RetrieveAPIView):
|
|
||||||
"""
|
|
||||||
get: Download a document version.
|
|
||||||
"""
|
|
||||||
lookup_url_kwarg = 'version_pk'
|
|
||||||
|
|
||||||
def get_document(self):
|
|
||||||
document = get_object_or_404(Document, pk=self.kwargs['pk'])
|
|
||||||
|
|
||||||
AccessControlList.objects.check_access(
|
|
||||||
permissions=(permission_document_download,), user=self.request.user,
|
|
||||||
obj=document
|
|
||||||
)
|
|
||||||
return document
|
|
||||||
|
|
||||||
def get_encoding(self):
|
|
||||||
return self.get_object().encoding
|
|
||||||
|
|
||||||
def get_file(self):
|
|
||||||
preserve_extension = self.request.GET.get(
|
|
||||||
'preserve_extension', self.request.POST.get(
|
|
||||||
'preserve_extension', False
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
preserve_extension = preserve_extension == 'true' or preserve_extension == 'True'
|
|
||||||
|
|
||||||
instance = self.get_object()
|
|
||||||
return VirtualFile(
|
|
||||||
instance.file, name=instance.get_rendered_string(
|
|
||||||
preserve_extension=preserve_extension
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_mimetype(self):
|
|
||||||
return self.get_object().mimetype
|
|
||||||
|
|
||||||
def get_serializer(self, *args, **kwargs):
|
|
||||||
return None
|
|
||||||
|
|
||||||
def get_serializer_class(self):
|
|
||||||
return None
|
|
||||||
|
|
||||||
def get_queryset(self):
|
|
||||||
return self.get_document().versions.all()
|
|
||||||
|
|
||||||
def retrieve(self, request, *args, **kwargs):
|
|
||||||
return self.render_to_response()
|
|
||||||
|
|
||||||
|
|
||||||
class APIDocumentView(generics.RetrieveUpdateDestroyAPIView):
|
|
||||||
"""
|
|
||||||
Returns the selected document details.
|
|
||||||
delete: Move the selected document to the thrash.
|
|
||||||
get: Return the details of the selected document.
|
|
||||||
patch: Edit the properties of the selected document.
|
|
||||||
put: Edit the properties of the selected document.
|
|
||||||
"""
|
|
||||||
mayan_object_permissions = {
|
|
||||||
'GET': (permission_document_view,),
|
|
||||||
'PUT': (permission_document_properties_edit,),
|
|
||||||
'PATCH': (permission_document_properties_edit,),
|
|
||||||
'DELETE': (permission_document_trash,)
|
|
||||||
}
|
|
||||||
permission_classes = (MayanPermission,)
|
|
||||||
queryset = Document.objects.all()
|
|
||||||
|
|
||||||
def get_serializer(self, *args, **kwargs):
|
|
||||||
if not self.request:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return super(APIDocumentView, self).get_serializer(*args, **kwargs)
|
|
||||||
|
|
||||||
def get_serializer_context(self):
|
|
||||||
return {
|
|
||||||
'format': self.format_kwarg,
|
|
||||||
'request': self.request,
|
|
||||||
'view': self
|
|
||||||
}
|
|
||||||
|
|
||||||
def get_serializer_class(self):
|
|
||||||
if self.request.method == 'GET':
|
|
||||||
return DocumentSerializer
|
|
||||||
else:
|
|
||||||
return WritableDocumentSerializer
|
|
||||||
|
|
||||||
|
|
||||||
class APIDocumentPageImageView(generics.RetrieveAPIView):
|
class APIDocumentPageImageView(generics.RetrieveAPIView):
|
||||||
"""
|
"""
|
||||||
get: Returns an image representation of the selected document.
|
get: Returns an image representation of the selected document.
|
||||||
@@ -267,6 +182,7 @@ class APIDocumentPageImageView(generics.RetrieveAPIView):
|
|||||||
def get_serializer_class(self):
|
def get_serializer_class(self):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@cache_control(private=True)
|
||||||
def retrieve(self, request, *args, **kwargs):
|
def retrieve(self, request, *args, **kwargs):
|
||||||
width = request.GET.get('width')
|
width = request.GET.get('width')
|
||||||
height = request.GET.get('height')
|
height = request.GET.get('height')
|
||||||
@@ -289,7 +205,12 @@ class APIDocumentPageImageView(generics.RetrieveAPIView):
|
|||||||
|
|
||||||
cache_filename = task.get(timeout=DOCUMENT_IMAGE_TASK_TIMEOUT)
|
cache_filename = task.get(timeout=DOCUMENT_IMAGE_TASK_TIMEOUT)
|
||||||
with storage_documentimagecache.open(cache_filename) as file_object:
|
with storage_documentimagecache.open(cache_filename) as file_object:
|
||||||
return HttpResponse(file_object.read(), content_type='image')
|
response = HttpResponse(file_object.read(), content_type='image')
|
||||||
|
if '_hash' in request.GET:
|
||||||
|
patch_cache_control(
|
||||||
|
response, max_age=settings_document_page_image_cache_time
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
class APIDocumentPageView(generics.RetrieveUpdateAPIView):
|
class APIDocumentPageView(generics.RetrieveUpdateAPIView):
|
||||||
@@ -395,6 +316,93 @@ class APIDocumentTypeDocumentListView(generics.ListAPIView):
|
|||||||
return document_type.documents.all()
|
return document_type.documents.all()
|
||||||
|
|
||||||
|
|
||||||
|
class APIDocumentVersionDownloadView(DownloadMixin, generics.RetrieveAPIView):
|
||||||
|
"""
|
||||||
|
get: Download a document version.
|
||||||
|
"""
|
||||||
|
lookup_url_kwarg = 'version_pk'
|
||||||
|
|
||||||
|
def get_document(self):
|
||||||
|
document = get_object_or_404(Document, pk=self.kwargs['pk'])
|
||||||
|
|
||||||
|
AccessControlList.objects.check_access(
|
||||||
|
permissions=(permission_document_download,), user=self.request.user,
|
||||||
|
obj=document
|
||||||
|
)
|
||||||
|
return document
|
||||||
|
|
||||||
|
def get_encoding(self):
|
||||||
|
return self.get_object().encoding
|
||||||
|
|
||||||
|
def get_file(self):
|
||||||
|
preserve_extension = self.request.GET.get(
|
||||||
|
'preserve_extension', self.request.POST.get(
|
||||||
|
'preserve_extension', False
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
preserve_extension = preserve_extension == 'true' or preserve_extension == 'True'
|
||||||
|
|
||||||
|
instance = self.get_object()
|
||||||
|
return VirtualFile(
|
||||||
|
instance.file, name=instance.get_rendered_string(
|
||||||
|
preserve_extension=preserve_extension
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_mimetype(self):
|
||||||
|
return self.get_object().mimetype
|
||||||
|
|
||||||
|
def get_serializer(self, *args, **kwargs):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_serializer_class(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return self.get_document().versions.all()
|
||||||
|
|
||||||
|
def retrieve(self, request, *args, **kwargs):
|
||||||
|
return self.render_to_response()
|
||||||
|
|
||||||
|
|
||||||
|
class APIDocumentView(generics.RetrieveUpdateDestroyAPIView):
|
||||||
|
"""
|
||||||
|
Returns the selected document details.
|
||||||
|
delete: Move the selected document to the thrash.
|
||||||
|
get: Return the details of the selected document.
|
||||||
|
patch: Edit the properties of the selected document.
|
||||||
|
put: Edit the properties of the selected document.
|
||||||
|
"""
|
||||||
|
mayan_object_permissions = {
|
||||||
|
'GET': (permission_document_view,),
|
||||||
|
'PUT': (permission_document_properties_edit,),
|
||||||
|
'PATCH': (permission_document_properties_edit,),
|
||||||
|
'DELETE': (permission_document_trash,)
|
||||||
|
}
|
||||||
|
permission_classes = (MayanPermission,)
|
||||||
|
queryset = Document.objects.all()
|
||||||
|
|
||||||
|
def get_serializer(self, *args, **kwargs):
|
||||||
|
if not self.request:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return super(APIDocumentView, self).get_serializer(*args, **kwargs)
|
||||||
|
|
||||||
|
def get_serializer_context(self):
|
||||||
|
return {
|
||||||
|
'format': self.format_kwarg,
|
||||||
|
'request': self.request,
|
||||||
|
'view': self
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_serializer_class(self):
|
||||||
|
if self.request.method == 'GET':
|
||||||
|
return DocumentSerializer
|
||||||
|
else:
|
||||||
|
return WritableDocumentSerializer
|
||||||
|
|
||||||
|
|
||||||
class APIRecentDocumentListView(generics.ListAPIView):
|
class APIRecentDocumentListView(generics.ListAPIView):
|
||||||
"""
|
"""
|
||||||
get: Return a list of the recent documents for the current user.
|
get: Return a list of the recent documents for the current user.
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
|
from furl import furl
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.files import File
|
from django.core.files import File
|
||||||
from django.core.files.base import ContentFile
|
from django.core.files.base import ContentFile
|
||||||
@@ -234,10 +236,10 @@ class Document(models.Model):
|
|||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return reverse('documents:document_preview', args=(self.pk,))
|
return reverse('documents:document_preview', args=(self.pk,))
|
||||||
|
|
||||||
def get_api_image_url(self):
|
def get_api_image_url(self, *args, **kwargs):
|
||||||
latest_version = self.latest_version
|
latest_version = self.latest_version
|
||||||
if latest_version:
|
if latest_version:
|
||||||
return latest_version.get_api_image_url()
|
return latest_version.get_api_image_url(*args, **kwargs)
|
||||||
else:
|
else:
|
||||||
return '#'
|
return '#'
|
||||||
|
|
||||||
@@ -454,10 +456,10 @@ class DocumentVersion(models.Model):
|
|||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return reverse('documents:document_version_view', args=(self.pk,))
|
return reverse('documents:document_version_view', args=(self.pk,))
|
||||||
|
|
||||||
def get_api_image_url(self):
|
def get_api_image_url(self, *args, **kwargs):
|
||||||
first_page = self.pages.first()
|
first_page = self.pages.first()
|
||||||
if first_page:
|
if first_page:
|
||||||
return first_page.get_api_image_url()
|
return first_page.get_api_image_url(*args, **kwargs)
|
||||||
else:
|
else:
|
||||||
return '#'
|
return '#'
|
||||||
|
|
||||||
@@ -774,6 +776,67 @@ class DocumentPage(models.Model):
|
|||||||
return self.document_version.document
|
return self.document_version.document
|
||||||
|
|
||||||
def generate_image(self, *args, **kwargs):
|
def generate_image(self, *args, **kwargs):
|
||||||
|
transformation_list = self.get_combined_transformation_list(*args, **kwargs)
|
||||||
|
|
||||||
|
cache_filename = '{}-{}'.format(
|
||||||
|
self.cache_filename, BaseTransformation.combine(transformation_list)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check is transformed image is available
|
||||||
|
logger.debug('transformations cache filename: %s', cache_filename)
|
||||||
|
|
||||||
|
if not setting_disable_transformed_image_cache.value and storage_documentimagecache.exists(cache_filename):
|
||||||
|
logger.debug(
|
||||||
|
'transformations cache file "%s" found', cache_filename
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.debug(
|
||||||
|
'transformations cache file "%s" not found', cache_filename
|
||||||
|
)
|
||||||
|
image = self.get_image(transformations=transformation_list)
|
||||||
|
with storage_documentimagecache.open(cache_filename, 'wb+') as file_object:
|
||||||
|
file_object.write(image.getvalue())
|
||||||
|
|
||||||
|
self.cached_images.create(filename=cache_filename)
|
||||||
|
|
||||||
|
return cache_filename
|
||||||
|
|
||||||
|
def get_absolute_url(self):
|
||||||
|
return reverse('documents:document_page_view', args=(self.pk,))
|
||||||
|
|
||||||
|
def get_api_image_url(self, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
Create an unique URL combining:
|
||||||
|
- the page's image URL
|
||||||
|
- the interactive argument
|
||||||
|
- a hash from the server side and interactive transformations
|
||||||
|
The purpose of this unique URL is to allow client side caching
|
||||||
|
if document page images.
|
||||||
|
"""
|
||||||
|
transformations_hash = BaseTransformation.combine(
|
||||||
|
self.get_combined_transformation_list(*args, **kwargs)
|
||||||
|
)
|
||||||
|
|
||||||
|
kwargs.pop('transformations', None)
|
||||||
|
|
||||||
|
final_url = furl()
|
||||||
|
final_url.args = kwargs
|
||||||
|
final_url.path = reverse(
|
||||||
|
'rest_api:documentpage-image', args=(
|
||||||
|
self.document.pk, self.document_version.pk, self.pk
|
||||||
|
)
|
||||||
|
)
|
||||||
|
final_url.args['_hash'] = transformations_hash
|
||||||
|
|
||||||
|
return final_url.tostr()
|
||||||
|
|
||||||
|
|
||||||
|
def get_combined_transformation_list(self, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
Return a list of transformation containing the server side
|
||||||
|
document page transformation as well as tranformations created
|
||||||
|
from the arguments as transient interactive transformation.
|
||||||
|
"""
|
||||||
# Convert arguments into transformations
|
# Convert arguments into transformations
|
||||||
transformations = kwargs.get('transformations', [])
|
transformations = kwargs.get('transformations', [])
|
||||||
|
|
||||||
@@ -815,38 +878,7 @@ class DocumentPage(models.Model):
|
|||||||
if zoom_level:
|
if zoom_level:
|
||||||
transformation_list.append(TransformationZoom(percent=zoom_level))
|
transformation_list.append(TransformationZoom(percent=zoom_level))
|
||||||
|
|
||||||
cache_filename = '{}-{}'.format(
|
return transformation_list
|
||||||
self.cache_filename, BaseTransformation.combine(transformation_list)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check is transformed image is available
|
|
||||||
logger.debug('transformations cache filename: %s', cache_filename)
|
|
||||||
|
|
||||||
if not setting_disable_transformed_image_cache.value and storage_documentimagecache.exists(cache_filename):
|
|
||||||
logger.debug(
|
|
||||||
'transformations cache file "%s" found', cache_filename
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
logger.debug(
|
|
||||||
'transformations cache file "%s" not found', cache_filename
|
|
||||||
)
|
|
||||||
image = self.get_image(transformations=transformation_list)
|
|
||||||
with storage_documentimagecache.open(cache_filename, 'wb+') as file_object:
|
|
||||||
file_object.write(image.getvalue())
|
|
||||||
|
|
||||||
self.cached_images.create(filename=cache_filename)
|
|
||||||
|
|
||||||
return cache_filename
|
|
||||||
|
|
||||||
def get_absolute_url(self):
|
|
||||||
return reverse('documents:document_page_view', args=(self.pk,))
|
|
||||||
|
|
||||||
def get_api_image_url(self):
|
|
||||||
return reverse(
|
|
||||||
'rest_api:documentpage-image', args=(
|
|
||||||
self.document.pk, self.document_version.pk, self.pk
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_image(self, transformations=None):
|
def get_image(self, transformations=None):
|
||||||
cache_filename = self.cache_filename
|
cache_filename = self.cache_filename
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ setting_display_height = namespace.add_setting(
|
|||||||
setting_display_width = namespace.add_setting(
|
setting_display_width = namespace.add_setting(
|
||||||
global_name='DOCUMENTS_DISPLAY_WIDTH', default='3600'
|
global_name='DOCUMENTS_DISPLAY_WIDTH', default='3600'
|
||||||
)
|
)
|
||||||
|
settings_document_page_image_cache_time = namespace.add_setting(
|
||||||
|
global_name='DOCUMENTS_PAGE_IMAGE_CACHE_TIME', default='3600'
|
||||||
|
)
|
||||||
setting_documentimagecache_storage = namespace.add_setting(
|
setting_documentimagecache_storage = namespace.add_setting(
|
||||||
global_name='DOCUMENTS_CACHE_STORAGE_BACKEND',
|
global_name='DOCUMENTS_CACHE_STORAGE_BACKEND',
|
||||||
default='django.core.files.storage.FileSystemStorage'
|
default='django.core.files.storage.FileSystemStorage'
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
{% load documents_tags %}
|
||||||
|
|
||||||
{% extends 'appearance/base_plain.html' %}
|
{% extends 'appearance/base_plain.html' %}
|
||||||
|
|
||||||
{% block title %}{{ title }}{% endblock title %}
|
{% block title %}{{ title }}{% endblock title %}
|
||||||
@@ -5,7 +7,7 @@
|
|||||||
{% block content_plain %}
|
{% block content_plain %}
|
||||||
{% for page in pages %}
|
{% for page in pages %}
|
||||||
<img
|
<img
|
||||||
src="{{ page.get_api_image_url }}?width={{ width }}&height={{ height }}" style="width: 100%;"
|
src="{% get_api_image_url page width=width height=height %}" style="width: 100%;"
|
||||||
/>
|
/>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
{% load documents_tags %}
|
||||||
|
|
||||||
<div class="instance-image-widget {{ container_class }}">
|
<div class="instance-image-widget {{ container_class }}">
|
||||||
<div class="spinner-container text-primary"
|
<div class="spinner-container text-primary"
|
||||||
style="height: {% if display_full_height %}100%{% else %}{{ display_height|default:150 }}px{% endif %};"
|
style="height: {% if display_full_height %}100%{% else %}{{ display_height|default:150 }}px{% endif %};"
|
||||||
@@ -6,7 +8,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<img
|
<img
|
||||||
class="thin_border {{ image_classes }}"
|
class="thin_border {{ image_classes }}"
|
||||||
data-url="{{ instance.get_api_image_url }}?width={{ image_width }}&height={{ image_height }}&zoom={{ image_zoom }}&rotation={{ image_rotation }}"
|
data-url="{% get_api_image_url instance width=image_width height=image_height zoom=image_zoom rotation=image_rotation %}"
|
||||||
src="#"
|
src="#"
|
||||||
style="{% if image_max_height %}max-height: {{ image_max_height }}; {% endif %}"
|
style="{% if image_max_height %}max-height: {{ image_max_height }}; {% endif %}"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
{% load documents_tags %}
|
||||||
|
|
||||||
<a
|
<a
|
||||||
class="fancybox"
|
class="fancybox"
|
||||||
{% if disable_title_link %}
|
{% if disable_title_link %}
|
||||||
@@ -5,7 +7,7 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
data-caption="<a class='a-caption' href='{{ instance.get_absolute_url }}'>{{ instance }} <i class='fa fa-external-link-alt'></i></a>"
|
data-caption="<a class='a-caption' href='{{ instance.get_absolute_url }}'>{{ instance }} <i class='fa fa-external-link-alt'></i></a>"
|
||||||
{% endif %}
|
{% endif %}
|
||||||
href="{{ instance.get_api_image_url }}?width={{ size_preview_width }}&height={{ size_preview_height }}"
|
href="{% get_api_image_url instance width=size_preview_width height=size_preview_height %}"
|
||||||
data-type="image"
|
data-type="image"
|
||||||
{% if gallery_name %}data-fancybox="{{ gallery_name }}"{% endif %}
|
{% if gallery_name %}data-fancybox="{{ gallery_name }}"{% endif %}
|
||||||
>
|
>
|
||||||
|
|||||||
0
mayan/apps/documents/templatetags/__init__.py
Normal file
0
mayan/apps/documents/templatetags/__init__.py
Normal file
10
mayan/apps/documents/templatetags/documents_tags.py
Normal file
10
mayan/apps/documents/templatetags/documents_tags.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
from django.template import Library
|
||||||
|
|
||||||
|
register = Library()
|
||||||
|
|
||||||
|
|
||||||
|
@register.simple_tag
|
||||||
|
def get_api_image_url(obj, **kwargs):
|
||||||
|
return obj.get_api_image_url(**kwargs)
|
||||||
Reference in New Issue
Block a user