From 403848a1757ee2df34c6bb16d1abe5559f61cfa2 Mon Sep 17 00:00:00 2001 From: Jonathan Weth <git@jonathanweth.de> Date: Tue, 9 Jun 2020 20:17:44 +0200 Subject: [PATCH] Add universally usable progress bar for Celery tasks Close #287 --- aleksis/core/settings.py | 2 +- aleksis/core/static/js/progress.js | 67 +++++++++++++ aleksis/core/templates/core/progress.html | 46 +++++++++ aleksis/core/urls.py | 4 + aleksis/core/util/celery_progress.py | 34 +++++++ aleksis/core/util/core_helpers.py | 116 +++++++++++++++++++++- 6 files changed, 266 insertions(+), 3 deletions(-) create mode 100644 aleksis/core/static/js/progress.js create mode 100644 aleksis/core/templates/core/progress.html create mode 100644 aleksis/core/util/celery_progress.py diff --git a/aleksis/core/settings.py b/aleksis/core/settings.py index 756b14f7b..d8b50c495 100644 --- a/aleksis/core/settings.py +++ b/aleksis/core/settings.py @@ -419,7 +419,7 @@ if _settings.get("twilio.sid", None): TWILIO_CALLER_ID = _settings.get("twilio.callerid") if _settings.get("celery.enabled", False): - INSTALLED_APPS += ("django_celery_beat", "django_celery_results") + INSTALLED_APPS += ("django_celery_beat", "django_celery_results", "celery_progress") CELERY_BROKER_URL = _settings.get("celery.broker", "redis://localhost") CELERY_RESULT_BACKEND = "django-db" CELERY_CACHE_BACKEND = "django-cache" diff --git a/aleksis/core/static/js/progress.js b/aleksis/core/static/js/progress.js new file mode 100644 index 000000000..4c3e52c98 --- /dev/null +++ b/aleksis/core/static/js/progress.js @@ -0,0 +1,67 @@ +const OPTIONS = getJSONScript("progress_options"); + +const STYLE_CLASSES = { + 10: 'info', + 20: 'info', + 25: 'success', + 30: 'warning', + 40: 'error', +}; + +const ICONS = { + 10: 'info', + 20: 'info', + 25: 'check_circle', + 30: 'warning', + 40: 'error', +}; + +function setProgress(progress) { + $("#progress-bar").css("width", progress + "%"); +} + +function renderMessageBox(level, text) { + return '<div class="alert ' + STYLE_CLASSES[level] + '"><p><i class="material-icons left">' + ICONS[level] + '</i>' + text + '</p></div>'; +} + +function customProgress(progressBarElement, progressBarMessageElement, progress) { + setProgress(progress.percent); + + if (progress.hasOwnProperty("messages")) { + const messagesBox = $("#messages"); + + // Clear container + messagesBox.html("") + + // Render message boxes + $.each(progress.messages, function (i, message) { + messagesBox.append(renderMessageBox(message[0], message[1])); + }) + } +} + + +function customSuccess(progressBarElement, progressBarMessageElement) { + setProgress(100); + $("#result-alert").addClass("success"); + $("#result-icon").text("check_circle"); + $("#result-text").text(OPTIONS.success); + $("#result-box").show(); +} + +function customError(progressBarElement, progressBarMessageElement) { + setProgress(100); + $("#result-alert").addClass("error"); + $("#result-icon").text("error"); + $("#result-text").text(OPTIONS.error); + $("#result-box").show(); +} + +$(document).ready(function () { + var progressUrl = Urls["celeryProgress:taskStatus"](OPTIONS.task_id); + CeleryProgressBar.initProgressBar(progressUrl, { + onProgress: customProgress, + onSuccess: customSuccess, + onError: customError, + }); +}); diff --git a/aleksis/core/templates/core/progress.html b/aleksis/core/templates/core/progress.html new file mode 100644 index 000000000..26d078d9f --- /dev/null +++ b/aleksis/core/templates/core/progress.html @@ -0,0 +1,46 @@ +{% extends "core/base.html" %} +{% load i18n static %} + +{% block browser_title %} + {{ title }} +{% endblock %} +{% block page_title %} + {{ title }} +{% endblock %} + +{% block content %} + + <div class="container"> + <div class="row"> + <div class="progress center"> + <div class="determinate" style="width: 0;" id="progress-bar"></div> + </div> + <h6 class="center"> + {{ progress.title }} + </h6> + </div> + <div class="row"> + <div id="messages"></div> + + <div id="result-box" style="display: none;"> + <div class="alert" id="result-alert"> + <div> + <i class="material-icons left" id="result-icon">check_circle</i> + <p id="result-text"></p> + </div> + </div> + + {% url "index" as index_url %} + <a class="btn waves-effect waves-light" href="{{ back_url|default:index_url }}"> + <i class="material-icons left">arrow_back</i> + {% trans "Go back" %} + </a> + </div> + </div> + </div> + + {{ progress|json_script:"progress_options" }} + <script src="{% static "js/helper.js" %}"></script> + <script src="{% static "celery_progress/celery_progress.js" %}"></script> + <script src="{% static "js/progress.js" %}"></script> +{% endblock %} diff --git a/aleksis/core/urls.py b/aleksis/core/urls.py index 07736f7b8..766275b5d 100644 --- a/aleksis/core/urls.py +++ b/aleksis/core/urls.py @@ -12,6 +12,7 @@ from django_js_reverse.views import urls_js from two_factor.urls import urlpatterns as tf_urls from . import views +from .util.core_helpers import is_celery_enabled urlpatterns = [ path("", include("pwa.urls"), name="pwa"), @@ -158,6 +159,9 @@ if hasattr(settings, "TWILIO_ACCOUNT_SID"): urlpatterns += [path("", include(tf_twilio_urls))] +if is_celery_enabled(): + urlpatterns.append(path("celery_progress/", include("celery_progress.urls"))) + # Serve javascript-common if in development if settings.DEBUG: urlpatterns.append(path("__debug__/", include(debug_toolbar.urls))) diff --git a/aleksis/core/util/celery_progress.py b/aleksis/core/util/celery_progress.py new file mode 100644 index 000000000..9de760eeb --- /dev/null +++ b/aleksis/core/util/celery_progress.py @@ -0,0 +1,34 @@ +from decimal import Decimal +from typing import Union + +from celery_progress.backend import PROGRESS_STATE, AbstractProgressRecorder + + +class ProgressRecorder(AbstractProgressRecorder): + def __init__(self, task): + self.task = task + self.messages = [] + self.total = 100 + self.current = 0 + + def set_progress(self, current: Union[int, float], **kwargs): + self.current = current + + percent = 0 + if self.total > 0: + percent = (Decimal(current) / Decimal(self.total)) * Decimal(100) + percent = float(round(percent, 2)) + + self.task.update_state( + state=PROGRESS_STATE, + meta={ + "current": current, + "total": self.total, + "percent": percent, + "messages": self.messages, + }, + ) + + def add_message(self, level: int, message: str, **kwargs): + self.messages.append((level, message)) + self.set_progress(self.current) diff --git a/aleksis/core/util/core_helpers.py b/aleksis/core/util/core_helpers.py index dc62a7990..635ff0226 100644 --- a/aleksis/core/util/core_helpers.py +++ b/aleksis/core/util/core_helpers.py @@ -1,5 +1,6 @@ import os import pkgutil +import time from datetime import datetime, timedelta from importlib import import_module from itertools import groupby @@ -14,6 +15,10 @@ from django.shortcuts import get_object_or_404 from django.utils import timezone from django.utils.functional import lazy +from django_global_request.middleware import get_request + +from aleksis.core.util import messages + def copyright_years(years: Sequence[int], seperator: str = ", ", joiner: str = "–") -> str: """Take a sequence of integegers and produces a string with ranges. @@ -170,6 +175,11 @@ def has_person(obj: Union[HttpRequest, Model]) -> bool: return True +def is_celery_enabled(): + """Check whether celery support is enabled.""" + return hasattr(settings, "CELERY_RESULT_BACKEND") + + def celery_optional(orig: Callable) -> Callable: """Add a decorator that makes Celery optional for a function. @@ -177,13 +187,13 @@ def celery_optional(orig: Callable) -> Callable: and calls its delay method when invoked; if not, it leaves it untouched and it is executed synchronously. """ - if hasattr(settings, "CELERY_RESULT_BACKEND"): + if is_celery_enabled(): from ..celery import app # noqa task = app.task(orig) def wrapped(*args, **kwargs): - if hasattr(settings, "CELERY_RESULT_BACKEND"): + if is_celery_enabled(): task.delay(*args, **kwargs) else: orig(*args, **kwargs) @@ -191,6 +201,108 @@ def celery_optional(orig: Callable) -> Callable: return wrapped +class DummyRecorder: + def set_progress(self, *args, **kwargs): + pass + + def add_message(self, level: int, message: str, **kwargs) -> Optional[Any]: + request = get_request() + return messages.add_message(request, level, message, **kwargs) + + +def celery_optional_progress(orig: Callable) -> Callable: + """Add a decorator that makes Celery with progress bar support optional for a function. + + If Celery is configured and available, it wraps the function in a Task + and calls its delay method when invoked; if not, it leaves it untouched + and it is executed synchronously. + + Additionally, it adds a recorder class as first argument + (`ProgressRecorder` if Celery is enabled, else `DummyRecoder`). + + This recorder provides the functions `set_progress` and `add_message` + which can be used to track the status of the task. + For further information, see the respective recorder classes. + + How to use + ---------- + 1. Write a function and include tracking methods + + :: + + from django.contrib import messages + + from aleksis.core.util.core_helpers import celery_optional_progress + + @celery_optional_progress + def do_something(recorder: Union[ProgressRecorder, DummyRecorder], foo, bar, baz=None): + # ... + recorder.total = len(list_with_data) + + for i, item in list_with_data: + # ... + recorder.set_progress(i + 1) + # ... + + recorder.add_message(messages.SUCCESS, "All data were imported successfully.") + + 2. Track process in view: + + :: + + def my_view(request): + context = {} + # ... + result = do_something(foo, bar, baz=baz) + + if result: + context = { + "title": _("Progress: Import data"), + "back_url": reverse("index"), + "progress": { + "task_id": result.task_id, + "title": _("Import objects …"), + "success": _("The import was done successfully."), + "error": _("There was a problem while importing data."), + }, + } + + # Render progress view + return render(request, "core/progress.html", context) + + # Render other view if Celery isn't enabled + return render(request, "my-app/other-view.html", context) + """ + + def recorder_func(self, *args, **kwargs): + if is_celery_enabled(): + from .celery_progress import ProgressRecorder # noqa + + recorder = ProgressRecorder(self) + else: + recorder = DummyRecorder() + orig(recorder, *args, **kwargs) + + # Needed to ensure that all messages are displayed by frontend + time.sleep(0.7) + + var_name = f"{orig.__module__}.{orig.__name__}" + + if is_celery_enabled(): + from ..celery import app # noqa + + task = app.task(recorder_func, bind=True, name=var_name) + + def wrapped(*args, **kwargs): + if is_celery_enabled(): + return task.delay(*args, **kwargs) + else: + recorder_func(None, *args, **kwargs) + return None + + return wrapped + + def path_and_rename(instance, filename: str, upload_to: str = "files") -> str: """Update path of an uploaded file and renames it to a random UUID in Django FileField.""" _, ext = os.path.splitext(filename) -- GitLab