diff --git a/teamtemp/responses/admin.py b/teamtemp/responses/admin.py index 2f829f20..725b8653 100644 --- a/teamtemp/responses/admin.py +++ b/teamtemp/responses/admin.py @@ -30,6 +30,8 @@ class WordCloudImageAdmin(admin.ModelAdmin): list_display = ( "id", "word_hash", + "width", + "height", "image_url", "creation_date", "modified_date") @@ -39,6 +41,8 @@ class WordCloudImageAdmin(admin.ModelAdmin): "creation_date", "modified_date", "word_list", + "width", + "height", "word_hash") search_fields = ("id", "word_hash", "word_list") diff --git a/teamtemp/responses/migrations/0026_auto_20200513_1412.py b/teamtemp/responses/migrations/0026_auto_20200513_1412.py new file mode 100644 index 00000000..f6c1d3fb --- /dev/null +++ b/teamtemp/responses/migrations/0026_auto_20200513_1412.py @@ -0,0 +1,32 @@ +# Generated by Django 2.2.12 on 2020-05-13 04:12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('responses', '0025_auto_20200429_0918'), + ] + + operations = [ + migrations.AddField( + model_name='wordcloudimage', + name='height', + field=models.PositiveSmallIntegerField(default=400), + ), + migrations.AddField( + model_name='wordcloudimage', + name='width', + field=models.PositiveSmallIntegerField(default=400), + ), + migrations.AlterField( + model_name='wordcloudimage', + name='word_hash', + field=models.CharField(max_length=40), + ), + migrations.AlterUniqueTogether( + name='wordcloudimage', + unique_together={('word_hash', 'width', 'height')}, + ), + ] diff --git a/teamtemp/responses/migrations/0027_auto_20200513_2253.py b/teamtemp/responses/migrations/0027_auto_20200513_2253.py new file mode 100644 index 00000000..7e49b3ca --- /dev/null +++ b/teamtemp/responses/migrations/0027_auto_20200513_2253.py @@ -0,0 +1,23 @@ +# Generated by Django 2.2.12 on 2020-05-13 12:53 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('responses', '0026_auto_20200513_1412'), + ] + + operations = [ + migrations.AlterField( + model_name='wordcloudimage', + name='height', + field=models.PositiveSmallIntegerField(null=True), + ), + migrations.AlterField( + model_name='wordcloudimage', + name='width', + field=models.PositiveSmallIntegerField(null=True), + ), + ] diff --git a/teamtemp/responses/migrations/0028_auto_20200513_2254.py b/teamtemp/responses/migrations/0028_auto_20200513_2254.py new file mode 100644 index 00000000..76375cf7 --- /dev/null +++ b/teamtemp/responses/migrations/0028_auto_20200513_2254.py @@ -0,0 +1,23 @@ +# Generated by Django 2.2.12 on 2020-05-13 12:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('responses', '0027_auto_20200513_2253'), + ] + + operations = [ + migrations.AlterField( + model_name='wordcloudimage', + name='height', + field=models.PositiveSmallIntegerField(blank=True, null=True), + ), + migrations.AlterField( + model_name='wordcloudimage', + name='width', + field=models.PositiveSmallIntegerField(blank=True, null=True), + ), + ] diff --git a/teamtemp/responses/models.py b/teamtemp/responses/models.py index 1a1018a0..96b0d85a 100644 --- a/teamtemp/responses/models.py +++ b/teamtemp/responses/models.py @@ -13,18 +13,25 @@ @python_2_unicode_compatible class WordCloudImage(models.Model): + class Meta(object): + unique_together = ("word_hash", "width", "height") + id = models.AutoField(primary_key=True) - word_hash = models.CharField(max_length=40, unique=True) + word_hash = models.CharField(max_length=40) word_list = models.CharField(max_length=5000) + width = models.PositiveSmallIntegerField(null=True, blank=True) + height = models.PositiveSmallIntegerField(null=True, blank=True) image_url = models.CharField(max_length=255, null=True, blank=True) creation_date = models.DateTimeField(auto_now_add=True) modified_date = models.DateTimeField(auto_now=True, db_index=True) def __str__(self): - return "{}: {} {} {} {}".format( + return "{}: {} {} {} {} {} {}".format( self.id, self.creation_date, self.word_hash, + self.width, + self.height, self.word_list, self.image_url) diff --git a/teamtemp/responses/serializers.py b/teamtemp/responses/serializers.py index 93506378..0d5aa3b1 100644 --- a/teamtemp/responses/serializers.py +++ b/teamtemp/responses/serializers.py @@ -23,6 +23,8 @@ class Meta(object): 'id', 'creation_date', 'word_list', + 'width', + 'height', 'word_hash', 'image_url') diff --git a/teamtemp/static/style.css b/teamtemp/static/style.css index 897a8375..ebece7f2 100644 --- a/teamtemp/static/style.css +++ b/teamtemp/static/style.css @@ -39,7 +39,7 @@ div.help-block { color: green; border: 2px solid grey; border-radius: 25px; - min-height: 400px; + min-height: 350px; background-color: #eee; word-spacing: 10px; line-height: 200%; @@ -50,9 +50,15 @@ div.help-block { display: none; } +#wordcloud-image { + height: 350px; + width: 300px; + margin-top: 13px; +} + .centregauge { width: 100%; - min-height: 400px; + min-height: 350px; display: block; margin-left: auto; margin-right: auto; @@ -64,7 +70,7 @@ div.help-block { margin-right: auto; page-break-after: always; page-break-inside: avoid; - min-height: 400px; + min-height: 350px; padding-top: 10px; padding-bottom: 10px; } @@ -75,6 +81,18 @@ div.help-block { } } +@media only screen and (min-device-width: 768px) { + #wordcloud-image { + width: 400px !important; + } +} + +@media only screen and (min-device-width: 992px) { + #wordcloud-image { + width: 500px !important; + } +} + ul { list-style-type: none; padding: 0; diff --git a/teamtemp/templates/bvc.html b/teamtemp/templates/bvc.html index 71095fae..ddabb892 100644 --- a/teamtemp/templates/bvc.html +++ b/teamtemp/templates/bvc.html @@ -7,6 +7,17 @@ {% block form_extra_head %} + {% endblock %} {% block bootstrap3_extra_script %} @@ -46,7 +57,7 @@ ]); var gauge_options = { - width: 400, height: 400, + width: 350, height: 350, redFrom: 0, redTo: 2.5, yellowFrom: 2.5, yellowTo: 5, greenFrom: 7.5, greenTo: 10, @@ -284,12 +295,12 @@
-

{{ bvc_data.stats.count }} Response{% if bvc_data.stats.count != 1 %}s{% endif %}:

+

{{ bvc_data.stats.count }} Response{% if bvc_data.stats.count != 1 %}s{% endif %}:

- {% if bvc_data.word_cloud_url %} - - + {% if bvc_data.word_cloud_medium_url %} + +
{% elif bvc_data.word_list %}
{{ bvc_data.word_list }}
diff --git a/teamtemp/tests/factories.py b/teamtemp/tests/factories.py index 319a306f..f79de3cf 100644 --- a/teamtemp/tests/factories.py +++ b/teamtemp/tests/factories.py @@ -13,6 +13,7 @@ from teamtemp import utils from teamtemp.responses.models import TeamResponseHistory, Teams, \ TeamTemperature, TemperatureResponse, User, WordCloudImage +from teamtemp.views import DEFAULT_WORDCLOUD_HEIGHT, DEFAULT_WORDCLOUD_WIDTH fake = Faker() @@ -88,6 +89,8 @@ class Meta(object): model = WordCloudImage word_list = ' '.join(fake.words(nb=random.randint(1, 25))) + width = DEFAULT_WORDCLOUD_WIDTH + height = DEFAULT_WORDCLOUD_HEIGHT image_url = "/%s/%s" % (fake.uri_path(), fake.file_name(category='image')) creation_date = factory.LazyFunction(timezone.now) diff --git a/teamtemp/tests/models/test_word_cloud_image.py b/teamtemp/tests/models/test_word_cloud_image.py index 8fd9fc12..4d062abd 100644 --- a/teamtemp/tests/models/test_word_cloud_image.py +++ b/teamtemp/tests/models/test_word_cloud_image.py @@ -6,7 +6,7 @@ class WordCloudImageTestCases(TestCase): def test_wordcloud(self): - wordcloud = WordCloudImageFactory() + wordcloud = WordCloudImageFactory(width=666, height=333) self.assertTrue(len(wordcloud.word_list) > 0) self.assertTrue(len(wordcloud.word_hash) > 0) self.assertTrue(len(wordcloud.image_url) > 0) @@ -14,9 +14,11 @@ def test_wordcloud(self): self.assertIsNotNone(wordcloud.modified_date) self.assertEqual( str(wordcloud), - "%s: %s %s %s %s" % + "%s: %s %s %s %s %s %s" % (wordcloud.id, wordcloud.creation_date, wordcloud.word_hash, + wordcloud.width, + wordcloud.height, wordcloud.word_list, wordcloud.image_url)) diff --git a/teamtemp/tests/view/test_wordcloud_view.py b/teamtemp/tests/view/test_wordcloud_view.py index 731c3586..a99409be 100644 --- a/teamtemp/tests/view/test_wordcloud_view.py +++ b/teamtemp/tests/view/test_wordcloud_view.py @@ -1,3 +1,5 @@ +import sys + from django.urls import reverse from django.test import TestCase from rest_framework import status @@ -6,20 +8,11 @@ class WordcloudViewTestCases(TestCase): - def assertWordCloudImage( - self, - response, - expected_url='/media/blank.png', - status_code=status.HTTP_302_FOUND): - self.assertRedirects( - response, - expected_url=expected_url, - status_code=status_code) + def assertWordCloudImage(self, response, expected_url='/media/blank.png', status_code=status.HTTP_302_FOUND): + self.assertRedirects(response, expected_url=expected_url, status_code=status_code) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response['Content-Type'], 'image/png') - self.assertEqual( - response['Cache-Control'], - 'public, max-age=315360000') + self.assertEqual(response['Cache-Control'], 'public, max-age=315360000') self.assertGreater(len(response.getvalue()), 0) def test_wordcloud_view_blank(self): @@ -52,3 +45,19 @@ def test_wordcloud_view_found(self): response, word_cloud_image.image_url, status.HTTP_302_FOUND) + + def test_wordcloud_view_found_with_size(self): + word_cloud_image = WordCloudImageFactory(image_url='/media/test.png', width=600, height=300) + response = self.client.get( + reverse( + 'wordcloud', + kwargs={ + 'word_hash': word_cloud_image.word_hash, + 'width': 600, + 'height': 300 + }), + follow=True) + self.assertWordCloudImage( + response, + word_cloud_image.image_url, + status.HTTP_302_FOUND) diff --git a/teamtemp/urls.py b/teamtemp/urls.py index 7dc090ba..d13fdfef 100644 --- a/teamtemp/urls.py +++ b/teamtemp/urls.py @@ -72,6 +72,8 @@ re_path(r'^team/(?P[0-9a-zA-Z]{8})/(?P[-\w]{1,64})$', team_view, name='team'), re_path(r'^team/(?P[0-9a-zA-Z]{8})/?$', team_view, name='team'), re_path(r'^wordcloud/(?P[a-f0-9]{40})?$', wordcloud_view, name='wordcloud'), + re_path(r'^wordcloud/(?P[1-9][0-9]{2,3})x(?P[1-9][0-9]{2,3})/(?P[a-f0-9]{40})?$', wordcloud_view, + name='wordcloud'), re_path(r'^static/(.*)$', serve_static, {'document_root': settings.STATIC_ROOT}, name='static'), re_path(r'^media/(.*)$', media_view, {'document_root': settings.MEDIA_ROOT}, name='media'), re_path(r'^healthcheck/?$', health_check_view, name='healthcheck'), diff --git a/teamtemp/views.py b/teamtemp/views.py index 96a69344..2148b90b 100644 --- a/teamtemp/views.py +++ b/teamtemp/views.py @@ -8,6 +8,8 @@ import errno import sys import time +import string +import random import gviz_api import os @@ -20,7 +22,7 @@ from django.contrib.auth.hashers import check_password, make_password from django.contrib.auth.decorators import login_required from django.db import transaction -from django.http import Http404, HttpResponse +from django.http import Http404, HttpResponse, HttpResponseBadRequest from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse from django.views.static import serve as serve_static @@ -37,6 +39,11 @@ from urllib.parse import urlparse +DEFAULT_WORDCLOUD_HEIGHT = 350 +DEFAULT_WORDCLOUD_WIDTH = 500 +MAX_WORDCLOUD_WIDTH = 1000 +MAX_WORDCLOUD_HEIGHT = 1000 + class WordCloudImageViewSet(viewsets.ModelViewSet): queryset = WordCloudImage.objects.all() @@ -47,7 +54,7 @@ class WordCloudImageViewSet(viewsets.ModelViewSet): filters.OrderingFilter, ) filter_fields = ('creation_date', 'word_hash', 'image_url',) - order_fields = ('id', 'creation_date', 'word_hash') + order_fields = ('id', 'creation_date', 'word_hash', 'width', 'height') search_fields = ('word_list', 'word_hash', 'image_url') @@ -536,15 +543,15 @@ def admin_view(request, survey_id, team_name=''): }) -def generate_wordcloud(word_list, word_hash): - print("Start Word Cloud Generation: [%s] %s" % - (word_hash, word_list), file=sys.stderr) +def generate_wordcloud(word_list, word_hash, width=DEFAULT_WORDCLOUD_WIDTH, height=DEFAULT_WORDCLOUD_HEIGHT): + print("Start Word Cloud Generation: [%s] %s x %s '%s'" % + (word_hash, width, height, word_list), file=sys.stderr) wordcloud = WordCloud( max_words=1000, margin=20, - width=settings.WORDCLOUD_WIDTH, - height=settings.WORDCLOUD_HEIGHT, + width=width, + height=height, background_color="white", prefer_horizontal=0.7, regexp=r"[^\s]+", @@ -559,7 +566,7 @@ def generate_wordcloud(word_list, word_hash): print("Finish Word Cloud Generation: [%s]" % (word_hash), file=sys.stderr) - return save_image(image, "%s_%d.png" % (word_hash, time.time())) + return save_image(image, "%s_%dx%d_%d.png" % (word_hash, width, height, time.time())), width, height def require_dir(path): @@ -594,20 +601,36 @@ def media_file(src, basename=None): return filename +def randomword(length): + letters = string.ascii_letters + string.digits + return ''.join(random.choice(letters) for i in range(length)) + + +def media_tempfile(src, basename=None): + image_name = media_filename(src, basename) + require_dir(settings.MEDIA_ROOT) + temp_image_name = ".%s.%s.tmp" % (image_name, randomword(8)) + temp_filename = os.path.join(settings.MEDIA_ROOT, temp_image_name) + return temp_filename + + def save_image(image, basename): return_url = media_url(basename) filename = media_file(basename) + temp_filename = media_tempfile(basename) - print("Saving Word Cloud: %s as %s" % (basename, filename), file=sys.stderr) + print("Saving Word Cloud: %s as %s" % (basename, temp_filename), file=sys.stderr) - if not os.path.exists(filename): - try: - image.save(filename, format='png', optimize=True) - except IOError as exc: - print("Failed Saving Word Cloud: IOError:%s %s as %s" % - (str(exc), url, filename), file=sys.stderr) - return None + try: + image.save(temp_filename, format='png', optimize=True) + print("Renaming Word Cloud: %s to %s" % (temp_filename, filename), file=sys.stderr) + os.rename(temp_filename, filename) + except IOError as exc: + print("Failed Saving Word Cloud: IOError:%s %s as %s" % + (str(exc), return_url, filename), file=sys.stderr) + return None + print("Returning Word Cloud: %s url %s" % (basename, return_url), file=sys.stderr) return return_url @@ -658,6 +681,7 @@ def prune_word_cloud_cache(_): utc_timestamp(), file=sys.stderr) rows_deleted = 0 + rows_checked = 0 yesterday = timezone.now() + timedelta(days=-1) @@ -665,6 +689,7 @@ def prune_word_cloud_cache(_): modified_date__lte=yesterday) for word_cloud_image in old_word_cloud_images: + rows_checked += 1 if word_cloud_image.image_url: fname = media_file(word_cloud_image.image_url) if os.path.isfile(fname): @@ -677,14 +702,16 @@ def prune_word_cloud_cache(_): rows_deleted += rows for word_cloud_image in WordCloudImage.objects.all(): - if word_cloud_image.image_url and not os.path.isfile( - media_file(word_cloud_image.image_url)): - rows, _ = word_cloud_image.delete() - rows_deleted += rows + rows_checked += 1 + if word_cloud_image.image_url: + image_filename = media_file(word_cloud_image.image_url) + if not os.path.isfile(image_filename) or os.path.getsize(image_filename) == 0: + rows, _ = word_cloud_image.delete() + rows_deleted += rows print( - "prune_word_cloud_cache: %d rows deleted" % - rows_deleted, + "prune_word_cloud_cache: %d rows deleted, %d rows checked" % + (rows_deleted, rows_checked), file=sys.stderr) print("prune_word_cloud_cache: Stop at %s" % utc_timestamp(), file=sys.stderr) @@ -1151,31 +1178,51 @@ def populate_bvc_data( return bvc_data -def cached_word_cloud(word_list=None, word_hash=None, generate=True): +def cached_word_cloud(word_list=None, word_hash=None, generate=True, width=None, height=None): + if not width: + width = DEFAULT_WORDCLOUD_WIDTH + if not height: + height= DEFAULT_WORDCLOUD_HEIGHT + word_cloud_image = None if word_list: word_hash = hashlib.sha1(word_list.encode('utf-8')).hexdigest() word_cloud_image, _ = WordCloudImage.objects.get_or_create( - word_hash=word_hash, word_list=word_list) + word_hash=word_hash, width=width, height=height, word_list=word_list) elif word_hash: try: - word_cloud_image = WordCloudImage.objects.get(word_hash=word_hash) + word_cloud_image = WordCloudImage.objects.get(word_hash=word_hash, width=width, height=height) word_list = word_cloud_image.word_list except WordCloudImage.DoesNotExist: - return None - else: + pass + + if not word_cloud_image: + try: + word_cloud_image_wrong_size = WordCloudImage.objects.filter(word_hash=word_hash)[0] + word_list = word_cloud_image_wrong_size.word_list + except IndexError: + pass + + if word_list: + word_cloud_image, _ = WordCloudImage.objects.get_or_create( + word_hash=word_hash, width=width, height=height, word_list=word_list) + + if not word_cloud_image: return None if word_cloud_image.image_url: filename = media_file(word_cloud_image.image_url) - if os.path.isfile(filename): + if os.path.isfile(filename) and os.path.getsize(filename) > 0: return word_cloud_image else: word_cloud_image.image_url = None if generate and not word_cloud_image.image_url: - word_cloud_image.image_url = generate_wordcloud(word_list, word_hash) + word_cloud_image.image_url, word_cloud_image.width, word_cloud_image.height = generate_wordcloud(word_list, + word_hash, + width=width, + height=height) word_cloud_image.full_clean() word_cloud_image.save() @@ -1260,13 +1307,32 @@ def calc_multi_iteration_average( return None +def is_multiple_of_50(x): + if x == 0: + return False + return x % 50 == 0 + + @no_cache() @csp_exempt -def wordcloud_view(request, word_hash=''): +def wordcloud_view(request, word_hash='', width=None, height=None): + if width: + width = int(width) + if not (0 < width <= MAX_WORDCLOUD_WIDTH and is_multiple_of_50(width)): + return HttpResponseBadRequest(reason="width out of range") + if height: + height = int(height) + if not (0 < height <= MAX_WORDCLOUD_HEIGHT and is_multiple_of_50(height)): + return HttpResponseBadRequest(reason="height out of range") + # Cached word cloud if word_hash: word_cloud_image = cached_word_cloud( - word_hash=word_hash, generate=True) + word_hash=word_hash, + width=width, + height=height, + generate=True + ) if word_cloud_image and word_cloud_image.image_url: return redirect(word_cloud_image.image_url) @@ -1325,11 +1391,18 @@ def bvc_view( # Cached word cloud if bvc_data['word_list']: word_cloud_image = cached_word_cloud( - bvc_data['word_list'], generate=False) - bvc_data['word_cloud_url'] = word_cloud_image.image_url or reverse( - 'wordcloud', kwargs={'word_hash': word_cloud_image.word_hash}) - bvc_data['word_cloud_width'] = settings.WORDCLOUD_WIDTH - bvc_data['word_cloud_height'] = settings.WORDCLOUD_HEIGHT + bvc_data['word_list'], width=DEFAULT_WORDCLOUD_WIDTH, height=DEFAULT_WORDCLOUD_HEIGHT, generate=False) + + word_hash = word_cloud_image.word_hash + + bvc_data['word_cloud_small_url'] = reverse('wordcloud', + kwargs={'word_hash': word_hash, 'width': 300, 'height': 350}) + bvc_data['word_cloud_medium_url'] = word_cloud_image.image_url or reverse('wordcloud', + kwargs={'word_hash': word_hash, + 'width': DEFAULT_WORDCLOUD_WIDTH, + 'height': DEFAULT_WORDCLOUD_HEIGHT}) + bvc_data['word_cloud_large_url'] = reverse('wordcloud', + kwargs={'word_hash': word_hash, 'width': 750, 'height': 500}) all_dept_names = set() all_region_names = set()