From 81ace968c78053b4b2739f61d96c70fcde2ef472 Mon Sep 17 00:00:00 2001 From: keyvan majidi Date: Mon, 16 Oct 2023 13:04:46 +0330 Subject: [PATCH] Avatar API Added (#232) * Add Avatar API support * extend rest_framework to INSTALLED_APPS * add api path to urls * add requirements.txt to api app * add self describe to assign_width_or_height function * add django-avatar api docs * remove unused files * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Johannes Wilm --- avatar/api/__init__.py | 1 + avatar/api/apps.py | 6 + avatar/api/conf.py | 7 + avatar/api/docs/Makefile | 20 ++ avatar/api/docs/avatar.rst | 191 ++++++++++++++++++ avatar/api/docs/conf.py | 33 ++++ avatar/api/docs/index.rst | 310 ++++++++++++++++++++++++++++++ avatar/api/docs/make.bat | 35 ++++ avatar/api/migrations/__init__.py | 0 avatar/api/requirements.txt | 1 + avatar/api/serializers.py | 126 ++++++++++++ avatar/api/shortcut.py | 27 +++ avatar/api/signals.py | 56 ++++++ avatar/api/urls.py | 8 + avatar/api/utils.py | 52 +++++ avatar/api/views.py | 134 +++++++++++++ test_proj/test_proj/settings.py | 1 + test_proj/test_proj/urls.py | 10 +- 18 files changed, 1014 insertions(+), 4 deletions(-) create mode 100644 avatar/api/__init__.py create mode 100644 avatar/api/apps.py create mode 100644 avatar/api/conf.py create mode 100644 avatar/api/docs/Makefile create mode 100644 avatar/api/docs/avatar.rst create mode 100644 avatar/api/docs/conf.py create mode 100644 avatar/api/docs/index.rst create mode 100644 avatar/api/docs/make.bat create mode 100644 avatar/api/migrations/__init__.py create mode 100644 avatar/api/requirements.txt create mode 100644 avatar/api/serializers.py create mode 100644 avatar/api/shortcut.py create mode 100644 avatar/api/signals.py create mode 100644 avatar/api/urls.py create mode 100644 avatar/api/utils.py create mode 100644 avatar/api/views.py diff --git a/avatar/api/__init__.py b/avatar/api/__init__.py new file mode 100644 index 0000000..d087506 --- /dev/null +++ b/avatar/api/__init__.py @@ -0,0 +1 @@ +import avatar.api.signals diff --git a/avatar/api/apps.py b/avatar/api/apps.py new file mode 100644 index 0000000..f139ac4 --- /dev/null +++ b/avatar/api/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ApiConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "avatar.api" diff --git a/avatar/api/conf.py b/avatar/api/conf.py new file mode 100644 index 0000000..8e601c5 --- /dev/null +++ b/avatar/api/conf.py @@ -0,0 +1,7 @@ +from appconf import AppConf +from django.conf import settings + + +class AvatarAPIConf(AppConf): + # allow updating avatar image in put method + AVATAR_CHANGE_IMAGE = False diff --git a/avatar/api/docs/Makefile b/avatar/api/docs/Makefile new file mode 100644 index 0000000..d4bb2cb --- /dev/null +++ b/avatar/api/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = . +BUILDDIR = _build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/avatar/api/docs/avatar.rst b/avatar/api/docs/avatar.rst new file mode 100644 index 0000000..f311ee9 --- /dev/null +++ b/avatar/api/docs/avatar.rst @@ -0,0 +1,191 @@ + +API Descriptions +================ + +Avatar List +^^^^^^^^^^^ + + +send a request for listing user avatars as shown below. + +``GET`` ``/api/avatar/`` + + + + default response of avatar list : :: + + { + "message": "You haven't uploaded an avatar yet. Please upload one now.", + "default_avatar": { + "src": "https://seccdn.libravatar.org/avatar/4a9328d595472d0728195a7c8191a50b", + "width": "80", + "height": "80", + "alt": "User Avatar" + } + } + + + if you have an avatar object : :: + + [ + { + "id": "image_id", + "avatar_url": "https://example.com/api/avatar/1/", + "avatar": "https://example.com/media/avatars/1/first_avatar.png", + "primary": true + }, + ] + + + +----------------------------------------------- + +Create Avatar +^^^^^^^^^^^^^ + + +send a request for creating user avatar as shown below . + +``POST`` ``/api/avatar/`` + + + Request : :: + + { + "avatar": "image file", + "primary": true + } + + ``Note`` : avatar field is required. + + Response : :: + + { + "message": "Successfully uploaded a new avatar.", + "data": { + "id": "image_id", + "avatar_url": "https://example.com/api/avatar/1/", + "avatar": "https://example.com/media/avatars/1/example.png", + "primary": true + } + } + + + +----------------------------------------------- + +Avatar Detail +^^^^^^^^^^^^^ + + +send a request for retrieving user avatar. + +``GET`` ``/api/avatar/image_id/`` + + + Response : :: + + { + "id": "image_id", + "avatar": "https://example.com/media/avatars/1/example.png", + "primary": true + } + + + +----------------------------------------------- + +Update Avatar +^^^^^^^^^^^^^ + + +send a request for updating user avatar. + +``PUT`` ``/api/avatar/image_id/`` + + + Request : :: + + { + "avatar":"image file" + "primary": true + } + + ``Note`` : for update avatar image set ``API_AVATAR_CHANGE_IMAGE = True`` in your settings file and set ``primary = True``. + + Response : :: + + { + "message": "Successfully updated your avatar.", + "data": { + "id": "image_id", + "avatar": "https://example.com/media/avatars/1/custom_admin_en.png", + "primary": true + } + } + +----------------------------------------------- + +Delete Avatar +^^^^^^^^^^^^^ + + +send a request for deleting user avatar. + +``DELETE`` ``/api/avatar/image_id/`` + + + Response : :: + + "Successfully deleted the requested avatars." + + + + +----------------------------------------------- + +Render Primary Avatar +^^^^^^^^^^^^^^^^^^^^^ + +send a request for retrieving resized primary avatar . + + +default sizes ``80``: + +``GET`` ``/api/avatar/render_primary/`` + + Response : :: + + { + "image_url": "https://example.com/media/avatars/1/resized/80/80/example.png" + } + +custom ``width`` and ``height`` : + +``GET`` ``/api/avatar/render_primary/?width=width_size&height=height_size`` + + Response : :: + + { + "image_url": "http://127.0.0.1:8000/media/avatars/1/resized/width_size/height_size/python.png" + } + + +If the entered parameter is one of ``width`` or ``height``, it will be considered for both . + +``GET`` ``/api/avatar/render_primary/?width=size`` : + + Response : :: + + { + "image_url": "http://127.0.0.1:8000/media/avatars/1/resized/size/size/python.png" + } + +``Note`` : Resize parameters not working for default avatar. + +API Setting +=========== + +.. py:data:: API_AVATAR_CHANGE_IMAGE + + It Allows the user to Change the avatar image in ``PUT`` method. Default is ``False``. diff --git a/avatar/api/docs/conf.py b/avatar/api/docs/conf.py new file mode 100644 index 0000000..743b82a --- /dev/null +++ b/avatar/api/docs/conf.py @@ -0,0 +1,33 @@ +# Configuration file for the Sphinx documentation builder. +# +# For the full list of built-in configuration values, see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information + +project = "django-avatar" +copyright = "2013, django-avatar developers" +author = "keyvan" + +# -- General configuration --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration + +extensions = [] + +templates_path = ["_templates"] +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] + +htmlhelp_basename = "django-avatar apidoc" + +source_suffix = ".rst" + +master_doc = "index" +pygments_style = "sphinx" + +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output + +html_theme = "sphinx_rtd_theme" +html_static_path = ["_static"] +epub_show_urls = "footnote" diff --git a/avatar/api/docs/index.rst b/avatar/api/docs/index.rst new file mode 100644 index 0000000..e28688c --- /dev/null +++ b/avatar/api/docs/index.rst @@ -0,0 +1,310 @@ + +django-avatar +============= + +Django-avatar is a reusable application for handling user avatars. It has the +ability to default to avatars provided by third party services (like Gravatar_ +or Facebook) if no avatar is found for a certain user. Django-avatar +automatically generates thumbnails and stores them to your default file +storage backend for retrieval later. + +.. _Gravatar: https://gravatar.com + +Installation +------------ + +If you have pip_ installed, you can simply run the following command +to install django-avatar:: + + pip install django-avatar + +Included with this application is a file named ``setup.py``. It's possible to +use this file to install this application to your system, by invoking the +following command:: + + python setup.py install + +Once that's done, you should be able to begin using django-avatar at will. + +Usage +----- + +To integrate ``django-avatar`` with your site, there are relatively few things +that are required. A minimal integration can work like this: + +1. List this application in the ``INSTALLED_APPS`` portion of your settings + file. Your settings file will look something like:: + + INSTALLED_APPS = ( + # ... + 'avatar', + ) + +2. Migrate your database:: + + python manage.py migrate + +3. Add the avatar urls to the end of your root urlconf. Your urlconf + will look something like:: + + urlpatterns = [ + # ... + path('avatar/', include('avatar.urls')), + ] + +4. Somewhere in your template navigation scheme, link to the change avatar + page:: + + Change your avatar + +5. Wherever you want to display an avatar for a user, first load the avatar + template tags:: + + {% load avatar_tags %} + + Then, use the ``avatar`` tag to display an avatar of a default size:: + + {% avatar user %} + + Or specify a size (in pixels) explicitly:: + + {% avatar user 65 %} + + Or specify a width and height (in pixels) explicitly:: + + {% avatar user 65 50 %} + + Example for customize the attribute of the HTML ``img`` tag:: + + {% avatar user 65 class="img-circle img-responsive" id="user_avatar" %} + +Template tags and filter +------------------------ + +To begin using these template tags, you must first load the tags into the +template rendering system:: + + {% load avatar_tags %} + +``{% avatar_url user [size in pixels] %}`` + Renders the URL of the avatar for the given user. User can be either a + ``django.contrib.auth.models.User`` object instance or a username. + +``{% avatar user [size in pixels] **kwargs %}`` + Renders an HTML ``img`` tag for the given user for the specified size. User + can be either a ``django.contrib.auth.models.User`` object instance or a + username. The (key, value) pairs in kwargs will be added to ``img`` tag + as its attributes. + +``{% render_avatar avatar [size in pixels] %}`` + Given an actual ``avatar.models.Avatar`` object instance, renders an HTML + ``img`` tag to represent that avatar at the requested size. + +``{{ request.user|has_avatar }}`` + Given a user object returns a boolean if the user has an avatar. + +Global Settings +--------------- + +There are a number of settings available to easily customize the avatars that +appear on the site. Listed below are those settings: + +.. py:data:: AVATAR_AUTO_GENERATE_SIZES + + An iterable of integers and/or sequences in the format ``(width, height)`` + representing the sizes of avatars to generate on upload. This can save + rendering time later on if you pre-generate the resized versions. Defaults + to ``(80,)``. + +.. py:data:: AVATAR_CACHE_ENABLED + + Set to ``False`` if you completely disable avatar caching. Defaults to ``True``. + +.. py:data:: AVATAR_DEFAULT_URL + + The default URL to default to if the + :py:class:`~avatar.providers.GravatarAvatarProvider` is not used and there + is no ``Avatar`` instance found in the system for the given user. + +.. py:data:: AVATAR_EXPOSE_USERNAMES + + Puts the User's username field in the URL path when ``True``. Set to + ``False`` to use the User's primary key instead, preventing their email + from being searchable on the web. Defaults to ``False``. + +.. py:data:: AVATAR_FACEBOOK_GET_ID + + A callable or string path to a callable that will return the user's + Facebook ID. The callable should take a ``User`` object and return a + string. If you want to use this then make sure you included + :py:class:`~avatar.providers.FacebookAvatarProvider` in :py:data:`AVATAR_PROVIDERS`. + +.. py:data:: AVATAR_GRAVATAR_DEFAULT + + A string determining the default Gravatar. Can be a URL to a custom image + or a style of Gravatar. Ex. `retro`. All Available options listed in the + `Gravatar documentation `_. Defaults to ``None``. + +.. py:data:: AVATAR_GRAVATAR_FORCEDEFAULT + + A bool indicating whether or not to always use the default Gravitar. More + details can be found in the `Gravatar documentation + `_. Defaults + to ``False``. + +.. py:data:: AVATAR_GRAVATAR_FIELD + + The name of the user's field containing the gravatar email. For example, + if you set this to ``gravatar`` then django-avatar will get the user's + gravatar in ``user.gravatar``. Defaults to ``email``. + +.. py:data:: AVATAR_MAX_SIZE + + File size limit for avatar upload. Default is ``1024 * 1024`` (1 MB). + gravatar in ``user.gravatar``. + +.. py:data:: AVATAR_MAX_AVATARS_PER_USER + + The maximum number of avatars each user can have. Default is ``42``. + +.. py:data:: AVATAR_PATH_HANDLER + + Path to a method for avatar file path handling. Default is + ``avatar.models.avatar_path_handler``. + +.. py:data:: AVATAR_PROVIDERS + + Tuple of classes that are tried in the given order for returning avatar + URLs. + Defaults to:: + + ( + 'avatar.providers.PrimaryAvatarProvider', + 'avatar.providers.LibRAvatarProvider', + 'avatar.providers.GravatarAvatarProvider', + 'avatar.providers.DefaultAvatarProvider', + ) + + If you want to implement your own provider, it must provide a class method + ``get_avatar_url(user, width, height)``. + + .. py:class:: avatar.providers.PrimaryAvatarProvider + + Returns the primary avatar stored for the given user. + + .. py:class:: avatar.providers.GravatarAvatarProvider + + Adds support for the Gravatar service and will always return an avatar + URL. If the user has no avatar registered with Gravatar a default will + be used (see :py:data:`AVATAR_GRAVATAR_DEFAULT`). + + .. py:class:: avatar.providers.FacebookAvatarProvider + + Add this provider to :py:data:`AVATAR_PROVIDERS` in order to add + support for profile images from Facebook. Note that you also need to + set the :py:data:`AVATAR_FACEBOOK_GET_ID` setting. + + .. py:class:: avatar.providers.DefaultAvatarProvider + + Provides a fallback avatar defined in :py:data:`AVATAR_DEFAULT_URL`. + +.. py:data:: AVATAR_RESIZE_METHOD + + The method to use when resizing images, based on the options available in + Pillow. Defaults to ``Image.LANCZOS``. + +.. py:data:: AVATAR_STORAGE_DIR + + The directory under ``MEDIA_ROOT`` to store the images. If using a + non-filesystem storage device, this will simply be appended to the beginning + of the file name. Defaults to ``avatars``. + Pillow. Defaults to ``Image.ANTIALIAS``. + +.. py:data:: AVATAR_THUMB_FORMAT + + The file format of thumbnails, based on the options available in + Pillow. Defaults to `PNG`. + +.. py:data:: AVATAR_THUMB_QUALITY + + The quality of thumbnails, between 0 (worst) to 95 (best) or the string + "keep" (only JPEG) as provided by Pillow. Defaults to `85`. + +.. py:data:: AVATAR_THUMB_MODES + + A sequence of acceptable modes for thumbnails as provided by Pillow. If the mode + of the image is not in the list, the thumbnail will be converted to the + first mode in the list. Defaults to `('RGB', 'RGBA')`. + +.. py:data:: AVATAR_CLEANUP_DELETED + + ``True`` if the avatar image files should be deleted when an avatar is + deleted from the database. Defaults to ``True``. + +.. py:data:: AVATAR_ADD_TEMPLATE + + Path to the Django template to use for adding a new avatar. Defaults to + ``avatar/add.html``. + +.. py:data:: AVATAR_CHANGE_TEMPLATE + + Path to the Django template to use for changing a user's avatar. Defaults to ``avatar/change.html``. + +.. py:data:: AVATAR_DELETE_TEMPLATE + + Path to the Django template to use for confirming a delete of a user's + avatar. Defaults to ``avatar/avatar/confirm_delete.html``. + +.. py:data:: AVATAR_ALLOWED_MIMETYPES + + Limit allowed avatar image uploads by their actual content payload and what image codecs we wish to support. + This limits website user content site attack vectors against image codec buffer overflow and similar bugs. + `You must have python-imaging library installed `_. + Suggested safe setting: ``("image/png", "image/gif", "image/jpeg")``. + When enabled you'll get the following error on the form upload *File content is invalid. Detected: image/tiff Allowed content types are: image/png, image/gif, image/jpg*. + + +Management Commands +------------------- + +This application does include one management command: ``rebuild_avatars``. It +takes no arguments and, when run, re-renders all of the thumbnails for all of +the avatars for the pixel sizes specified in the +:py:data:`AVATAR_AUTO_GENERATE_SIZES` setting. + + +.. _pip: https://www.pip-installer.org/ + +----------------------------------------------- + + +API +--- + +To use API there are relatively few things that are required. + +after `Installation <#installation>`_ . + +1. in your ``INSTALLED_APPS`` of your settings file : :: + + INSTALLED_APPS = ( + # ... + 'avatar', + 'rest_framework' + ) + + +2. Add the avatar api urls to the end of your root url config : :: + + urlpatterns = [ + # ... + path('api/', include('avatar.api.urls')), + ] + +----------------------------------------------- + +.. toctree:: + :maxdepth: 1 + + avatar diff --git a/avatar/api/docs/make.bat b/avatar/api/docs/make.bat new file mode 100644 index 0000000..954237b --- /dev/null +++ b/avatar/api/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=. +set BUILDDIR=_build + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.https://www.sphinx-doc.org/ + exit /b 1 +) + +if "%1" == "" goto help + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/avatar/api/migrations/__init__.py b/avatar/api/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/avatar/api/requirements.txt b/avatar/api/requirements.txt new file mode 100644 index 0000000..6f1c1f3 --- /dev/null +++ b/avatar/api/requirements.txt @@ -0,0 +1 @@ +djangorestframework diff --git a/avatar/api/serializers.py b/avatar/api/serializers.py new file mode 100644 index 0000000..6dc1b62 --- /dev/null +++ b/avatar/api/serializers.py @@ -0,0 +1,126 @@ +import os + +from django.template.defaultfilters import filesizeformat +from django.utils.translation import gettext_lazy as _ +from PIL import Image, ImageOps +from rest_framework import serializers + +from avatar.conf import settings +from avatar.conf import settings as api_setting +from avatar.models import Avatar + + +class AvatarSerializer(serializers.ModelSerializer): + avatar_url = serializers.HyperlinkedIdentityField( + view_name="avatar-detail", + ) + user = serializers.HiddenField(default=serializers.CurrentUserDefault()) + + class Meta: + model = Avatar + fields = ["id", "avatar_url", "avatar", "primary", "user"] + extra_kwargs = {"avatar": {"required": True}} + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + request = kwargs.get("context").get("request", None) + + self.user = request.user + + def get_fields(self, *args, **kwargs): + fields = super(AvatarSerializer, self).get_fields(*args, **kwargs) + request = self.context.get("request", None) + + # remove avatar url field in detail page + if bool(self.context.get("view").kwargs): + fields.pop("avatar_url") + + # remove avatar field in put method + if request and getattr(request, "method", None) == "PUT": + # avatar updates only when primary=true and API_AVATAR_CHANGE_IMAGE = True + if ( + not api_setting.API_AVATAR_CHANGE_IMAGE + or self.instance + and not self.instance.primary + ): + fields.pop("avatar") + else: + fields.get("avatar", None).required = False + return fields + + def validate_avatar(self, value): + data = value + + if settings.AVATAR_ALLOWED_MIMETYPES: + try: + import magic + except ImportError: + raise ImportError( + "python-magic library must be installed in order to use uploaded file content limitation" + ) + + # Construct 256 bytes needed for mime validation + magic_buffer = bytes() + for chunk in data.chunks(): + magic_buffer += chunk + if len(magic_buffer) >= 256: + break + + # https://github.com/ahupp/python-magic#usage + mime = magic.from_buffer(magic_buffer, mime=True) + if mime not in settings.AVATAR_ALLOWED_MIMETYPES: + raise serializers.ValidationError( + _( + "File content is invalid. Detected: %(mimetype)s Allowed content types are: %(valid_mime_list)s" + ) + % { + "valid_mime_list": ", ".join(settings.AVATAR_ALLOWED_MIMETYPES), + "mimetype": mime, + } + ) + + if settings.AVATAR_ALLOWED_FILE_EXTS: + root, ext = os.path.splitext(data.name.lower()) + if ext not in settings.AVATAR_ALLOWED_FILE_EXTS: + valid_exts = ", ".join(settings.AVATAR_ALLOWED_FILE_EXTS) + error = _( + "%(ext)s is an invalid file extension. " + "Authorized extensions are : %(valid_exts_list)s" + ) + raise serializers.ValidationError( + error % {"ext": ext, "valid_exts_list": valid_exts} + ) + + if data.size > settings.AVATAR_MAX_SIZE: + error = _( + "Your file is too big (%(size)s), " + "the maximum allowed size is %(max_valid_size)s" + ) + raise serializers.ValidationError( + error + % { + "size": filesizeformat(data.size), + "max_valid_size": filesizeformat(settings.AVATAR_MAX_SIZE), + } + ) + + try: + image = Image.open(data) + ImageOps.exif_transpose(image) + except TypeError: + raise serializers.ValidationError(_("Corrupted image")) + + count = Avatar.objects.filter(user=self.user).count() + if 1 < settings.AVATAR_MAX_AVATARS_PER_USER <= count: + error = _( + "You already have %(nb_avatars)d avatars, " + "and the maximum allowed is %(nb_max_avatars)d." + ) + raise serializers.ValidationError( + error + % { + "nb_avatars": count, + "nb_max_avatars": settings.AVATAR_MAX_AVATARS_PER_USER, + } + ) + return data diff --git a/avatar/api/shortcut.py b/avatar/api/shortcut.py new file mode 100644 index 0000000..a5b5b62 --- /dev/null +++ b/avatar/api/shortcut.py @@ -0,0 +1,27 @@ +from django.shortcuts import _get_queryset + + +def get_object_or_none(klass, *args, **kwargs): + """ + Use get() to return an object, or return None if the object + does not exist. + + klass may be a Model, Manager, or QuerySet object. All other passed + arguments and keyword arguments are used in the get() query. + + Like with QuerySet.get(), MultipleObjectsReturned is raised if more than + one object is found. + """ + queryset = _get_queryset(klass) + if not hasattr(queryset, "get"): + klass__name = ( + klass.__name__ if isinstance(klass, type) else klass.__class__.__name__ + ) + raise ValueError( + "First argument to get_object_or_404() must be a Model, Manager, " + "or QuerySet, not '%s'." % klass__name + ) + try: + return queryset.get(*args, **kwargs) + except queryset.model.DoesNotExist: + return None diff --git a/avatar/api/signals.py b/avatar/api/signals.py new file mode 100644 index 0000000..14c5dbf --- /dev/null +++ b/avatar/api/signals.py @@ -0,0 +1,56 @@ +import os + +from django.db.models import signals + +from avatar.api.conf import settings as api_settings +from avatar.api.shortcut import get_object_or_none +from avatar.conf import settings +from avatar.models import Avatar, invalidate_avatar_cache + + +def create_default_thumbnails(sender, instance, created=False, **kwargs): + invalidate_avatar_cache(sender, instance) + + if not created: + for size in settings.AVATAR_AUTO_GENERATE_SIZES: + if isinstance(size, int): + if not instance.thumbnail_exists(size, size): + instance.create_thumbnail(size, size) + else: + # Size is specified with height and width. + if not instance.thumbnail_exists(size[0, size[0]]): + instance.create_thumbnail(size[0], size[1]) + + +def remove_previous_avatar_images_when_update( + sender, instance=None, created=False, update_main_avatar=True, **kwargs +): + if not created: + old_instance = get_object_or_none(Avatar, pk=instance.pk) + if old_instance and not old_instance.avatar == instance.avatar: + base_filepath = old_instance.avatar.name + path, filename = os.path.split(base_filepath) + # iterate through resized avatars directories and delete resized avatars + resized_path = os.path.join(path, "resized") + try: + resized_widths, _ = old_instance.avatar.storage.listdir(resized_path) + for width in resized_widths: + resized_width_path = os.path.join(resized_path, width) + resized_heights, _ = old_instance.avatar.storage.listdir( + resized_width_path + ) + for height in resized_heights: + if old_instance.thumbnail_exists(width, height): + old_instance.avatar.storage.delete( + old_instance.avatar_name(width, height) + ) + if update_main_avatar: + if old_instance.avatar.storage.exists(old_instance.avatar.name): + old_instance.avatar.storage.delete(old_instance.avatar.name) + except FileNotFoundError as e: + pass + + +if api_settings.API_AVATAR_CHANGE_IMAGE: + signals.pre_save.connect(remove_previous_avatar_images_when_update, sender=Avatar) + signals.post_save.connect(create_default_thumbnails, sender=Avatar) diff --git a/avatar/api/urls.py b/avatar/api/urls.py new file mode 100644 index 0000000..91dce0d --- /dev/null +++ b/avatar/api/urls.py @@ -0,0 +1,8 @@ +from rest_framework.routers import SimpleRouter + +from avatar.api.views import AvatarViewSets + +router = SimpleRouter() +router.register("avatar", AvatarViewSets) + +urlpatterns = router.urls diff --git a/avatar/api/utils.py b/avatar/api/utils.py new file mode 100644 index 0000000..9784fc6 --- /dev/null +++ b/avatar/api/utils.py @@ -0,0 +1,52 @@ +from html.parser import HTMLParser + +from avatar.conf import settings + + +class HTMLTagParser(HTMLParser): + """ + URL parser for getting (url ,width ,height) from avatar templatetags + """ + + def __init__(self, output=None): + HTMLParser.__init__(self) + if output is None: + self.output = {} + else: + self.output = output + + def handle_starttag(self, tag, attrs): + self.output.update(dict(attrs)) + + +def assign_width_or_height(query_params): + """ + Getting width and height in url parameters and specifying them + """ + avatar_default_size = settings.AVATAR_DEFAULT_SIZE + + width = query_params.get("width", avatar_default_size) + height = query_params.get("height", avatar_default_size) + + if width == "": + width = avatar_default_size + if height == "": + height = avatar_default_size + + if height == avatar_default_size and height != "": + height = width + elif width == avatar_default_size and width != "": + width = height + + width = int(width) + height = int(height) + + context = {"width": width, "height": height} + return context + + +def set_new_primary(query_set, instance): + queryset = query_set.exclude(id=instance.id).first() + if queryset: + queryset.primary = True + queryset.save() diff --git a/avatar/api/views.py b/avatar/api/views.py new file mode 100644 index 0000000..7b41a4f --- /dev/null +++ b/avatar/api/views.py @@ -0,0 +1,134 @@ +from django.db.models import QuerySet +from django.utils.translation import gettext_lazy as _ +from rest_framework import permissions, status, viewsets +from rest_framework.decorators import action +from rest_framework.exceptions import ValidationError +from rest_framework.response import Response + +from avatar.api.serializers import AvatarSerializer +from avatar.api.utils import HTMLTagParser, assign_width_or_height, set_new_primary +from avatar.models import Avatar +from avatar.templatetags.avatar_tags import avatar +from avatar.utils import get_default_avatar_url, get_primary_avatar, invalidate_cache + + +class AvatarViewSets(viewsets.ModelViewSet): + serializer_class = AvatarSerializer + permission_classes = [permissions.IsAuthenticated] + queryset = Avatar.objects.select_related("user").order_by( + "-primary", "-date_uploaded" + ) + + @property + def parse_html_to_json(self): + default_avatar = avatar(self.request.user) + html_parser = HTMLTagParser() + html_parser.feed(default_avatar) + return html_parser.output + + def get_queryset(self): + assert self.queryset is not None, ( + "'%s' should either include a `queryset` attribute, " + "or override the `get_queryset()` method." % self.__class__.__name__ + ) + + queryset = self.queryset + if isinstance(queryset, QuerySet): + # Ensure queryset is re-evaluated on each request. + queryset = queryset.filter(user=self.request.user) + return queryset + + def list(self, request, *args, **kwargs): + queryset = self.filter_queryset(self.get_queryset()) + if queryset: + page = self.paginate_queryset(queryset) + if page is not None: + serializer = self.get_serializer(page, many=True) + return self.get_paginated_response(serializer.data) + + serializer = self.get_serializer(queryset, many=True) + data = serializer.data + return Response(data) + + return Response( + { + "message": "You haven't uploaded an avatar yet. Please upload one now.", + "default_avatar": self.parse_html_to_json, + } + ) + + def create(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + headers = self.get_success_headers(serializer.data) + message = _("Successfully uploaded a new avatar.") + + context_data = {"message": message, "data": serializer.data} + return Response(context_data, status=status.HTTP_201_CREATED, headers=headers) + + def destroy(self, request, *args, **kwargs): + instance = self.get_object() + if instance.primary is True: + # Find the next avatar, and set it as the new primary + set_new_primary(self.get_queryset(), instance) + self.perform_destroy(instance) + message = _("Successfully deleted the requested avatars.") + return Response(message, status=status.HTTP_204_NO_CONTENT) + + def update(self, request, *args, **kwargs): + partial = kwargs.pop("partial", False) + instance = self.get_object() + serializer = self.get_serializer(instance, data=request.data, partial=partial) + serializer.is_valid(raise_exception=True) + avatar_image = serializer.validated_data.get("avatar") + primary_avatar = serializer.validated_data.get("primary") + if not primary_avatar and avatar_image: + raise ValidationError("You cant update an avatar image that is not primary") + + if instance.primary is True: + # Find the next avatar, and set it as the new primary + set_new_primary(self.get_queryset(), instance) + + self.perform_update(serializer) + invalidate_cache(request.user) + message = _("Successfully updated your avatar.") + if getattr(instance, "_prefetched_objects_cache", None): + # If 'prefetch_related' has been applied to a queryset, we need to + # forcibly invalidate the prefetch cache on the instance. + instance._prefetched_objects_cache = {} + context_data = {"message": message, "data": serializer.data} + return Response(context_data) + + @action( + ["GET"], detail=False, url_path="render_primary", name="Render Primary Avatar" + ) + def render_primary(self, request, *args, **kwargs): + """ + + URL Example : + + 1 - render_primary/ + 2 - render_primary/?width=400 or render_primary/?height=400 + 3 - render_primary/?width=500&height=400 + """ + context_data = {} + avatar_size = assign_width_or_height(request.query_params) + + width = avatar_size.get("width") + height = avatar_size.get("height") + + primary_avatar = get_primary_avatar(request.user, width=width, height=height) + + if primary_avatar and primary_avatar.primary: + url = primary_avatar.avatar_url(width, height) + + else: + url = get_default_avatar_url() + if bool(request.query_params): + context_data.update( + {"message": "Resize parameters not working for default avatar"} + ) + + context_data.update({"image_url": request.build_absolute_uri(url)}) + return Response(context_data) diff --git a/test_proj/test_proj/settings.py b/test_proj/test_proj/settings.py index cd24a2b..ea8507d 100644 --- a/test_proj/test_proj/settings.py +++ b/test_proj/test_proj/settings.py @@ -38,6 +38,7 @@ INSTALLED_APPS = [ "django.contrib.messages", "django.contrib.staticfiles", "avatar", + "rest_framework", ] MIDDLEWARE = [ diff --git a/test_proj/test_proj/urls.py b/test_proj/test_proj/urls.py index e874078..93b0308 100644 --- a/test_proj/test_proj/urls.py +++ b/test_proj/test_proj/urls.py @@ -1,16 +1,18 @@ from django.conf import settings -from django.conf.urls import include, url +from django.conf.urls import include from django.contrib import admin +from django.urls import re_path from django.views.static import serve urlpatterns = [ - url(r"^admin/", admin.site.urls), - url(r"^avatar/", include("avatar.urls")), + re_path(r"^admin/", admin.site.urls), + re_path(r"^avatar/", include("avatar.urls")), + re_path(r"^api/", include("avatar.api.urls")), ] if settings.DEBUG: # static files (images, css, javascript, etc.) urlpatterns += [ - url(r"^media/(?P.*)$", serve, {"document_root": settings.MEDIA_ROOT}) + re_path(r"^media/(?P.*)$", serve, {"document_root": settings.MEDIA_ROOT}) ]