From 148aebe35d2600dc604ab7db4f4457fb33208ccb Mon Sep 17 00:00:00 2001 From: Lova Andriarimalala <43842786+Xpirix@users.noreply.github.com> Date: Wed, 19 Jun 2024 10:12:22 +0300 Subject: [PATCH] Resources tags (#429) * Init tags feature for styles * Tags feature for styles works * Add tags feature for geopackages * Add tags feature for model * Add tags feature for 3d model * Add tags feature for layer definition * Update resources unit tests * Fix tags title for 3D Model and Layer Definition * Clean print in plugins view * Add more test cases for resources_tagcloud --- qgis-app/base/models/processing_models.py | 3 + qgis-app/base/views/processing_view.py | 6 ++ qgis-app/geopackages/forms.py | 3 + .../migrations/0010_geopackage_tags.py | 20 +++++ qgis-app/geopackages/tests/test_views.py | 6 ++ qgis-app/geopackages/urls.py | 2 + qgis-app/geopackages/views.py | 22 +++++ qgis-app/layerdefinitions/forms.py | 3 + .../migrations/0003_layerdefinition_tags.py | 20 +++++ qgis-app/layerdefinitions/tests/test_views.py | 9 ++ qgis-app/layerdefinitions/urls.py | 2 + qgis-app/layerdefinitions/views.py | 28 ++++++- qgis-app/models/forms.py | 3 + qgis-app/models/migrations/0008_model_tags.py | 20 +++++ .../models/templatetags/resources_tagcloud.py | 84 +++++++++++++++++++ qgis-app/models/tests/test_tagcloud.py | 50 +++++++++++ qgis-app/models/tests/test_views.py | 6 ++ qgis-app/models/urls.py | 2 + qgis-app/models/views.py | 23 +++++ qgis-app/plugins/views.py | 3 +- qgis-app/styles/forms.py | 6 +- qgis-app/styles/migrations/0016_style_tags.py | 20 +++++ .../styles/templates/styles/style_base.html | 1 + qgis-app/styles/tests/test_views.py | 7 ++ qgis-app/styles/urls.py | 2 + qgis-app/styles/views.py | 25 ++++++ qgis-app/templates/base/base.html | 5 +- .../includes/resources_tagcloud_include.html | 6 ++ .../resources_tagcloud_modal_include.html | 19 +++++ qgis-app/templates/base/update_form.html | 73 +++++++++++++++- qgis-app/templates/base/upload_form.html | 70 ++++++++++++++++ qgis-app/wavefronts/forms.py | 3 + .../migrations/0003_wavefront_tags.py | 20 +++++ qgis-app/wavefronts/tests/test_view.py | 8 ++ qgis-app/wavefronts/urls.py | 2 + qgis-app/wavefronts/views.py | 26 ++++++ 36 files changed, 603 insertions(+), 5 deletions(-) create mode 100644 qgis-app/geopackages/migrations/0010_geopackage_tags.py create mode 100644 qgis-app/layerdefinitions/migrations/0003_layerdefinition_tags.py create mode 100644 qgis-app/models/migrations/0008_model_tags.py create mode 100644 qgis-app/models/templatetags/resources_tagcloud.py create mode 100644 qgis-app/models/tests/test_tagcloud.py create mode 100644 qgis-app/styles/migrations/0016_style_tags.py create mode 100644 qgis-app/templates/base/includes/resources_tagcloud_include.html create mode 100644 qgis-app/templates/base/includes/resources_tagcloud_modal_include.html create mode 100644 qgis-app/wavefronts/migrations/0003_wavefront_tags.py diff --git a/qgis-app/base/models/processing_models.py b/qgis-app/base/models/processing_models.py index 9b53c625..067baffe 100644 --- a/qgis-app/base/models/processing_models.py +++ b/qgis-app/base/models/processing_models.py @@ -8,6 +8,7 @@ from django.contrib.auth.models import User from django.db import models from django.utils.translation import gettext_lazy as _ +from taggit_autosuggest.managers import TaggableManager class UnapprovedManager(models.Manager): @@ -123,6 +124,8 @@ class Resource(models.Model): unapproved_objects = UnapprovedManager() requireaction_objects = RequireActionManager() + tags = TaggableManager(blank=True) + class Meta: abstract = True diff --git a/qgis-app/base/views/processing_view.py b/qgis-app/base/views/processing_view.py index 442d9553..a250f38c 100644 --- a/qgis-app/base/views/processing_view.py +++ b/qgis-app/base/views/processing_view.py @@ -224,6 +224,8 @@ def get_context_data(self, **kwargs): context["url_delete"] = "%s_delete" % self.resource_name_url_base context["url_review"] = "%s_review" % self.resource_name_url_base context["url_detail"] = "%s_detail" % self.resource_name_url_base + context["app_label"] = self.model._meta.app_label + context["model_name"] = self.model._meta.model_name return context @@ -244,6 +246,8 @@ def form_valid(self, form): self.obj = form.save(commit=False) self.obj.creator = self.request.user self.obj.save() + # Without this next line the tags won't be saved. + form.save_m2m() resource_notify(self.obj, resource_type=self.resource_name) msg = _(self.success_message) messages.success(self.request, msg, "success", fail_silently=True) @@ -335,6 +339,8 @@ def form_valid(self, form): obj.require_action = False obj.approved = False obj.save() + # Without this next line the tags won't be saved. + form.save_m2m() resource_notify(obj, created=False, resource_type=self.resource_name) msg = _("The %s has been successfully updated." % self.resource_name) messages.success(self.request, msg, "success", fail_silently=True) diff --git a/qgis-app/geopackages/forms.py b/qgis-app/geopackages/forms.py index a70bb76d..11cd93a6 100644 --- a/qgis-app/geopackages/forms.py +++ b/qgis-app/geopackages/forms.py @@ -1,9 +1,11 @@ from base.forms.processing_forms import ResourceBaseCleanFileForm from django import forms from geopackages.models import Geopackage +from taggit.forms import TagField class ResourceFormMixin(forms.ModelForm): + tags = TagField(required=False) class Meta: model = Geopackage fields = [ @@ -11,6 +13,7 @@ class Meta: "thumbnail_image", "name", "description", + "tags" ] diff --git a/qgis-app/geopackages/migrations/0010_geopackage_tags.py b/qgis-app/geopackages/migrations/0010_geopackage_tags.py new file mode 100644 index 00000000..1b0d492f --- /dev/null +++ b/qgis-app/geopackages/migrations/0010_geopackage_tags.py @@ -0,0 +1,20 @@ +# Generated by Django 4.2.13 on 2024-06-12 06:04 + +from django.db import migrations +import taggit_autosuggest.managers + + +class Migration(migrations.Migration): + + dependencies = [ + ('taggit', '0006_rename_taggeditem_content_type_object_id_taggit_tagg_content_8fc721_idx'), + ('geopackages', '0009_alter_review_reviewer'), + ] + + operations = [ + migrations.AddField( + model_name='geopackage', + name='tags', + field=taggit_autosuggest.managers.TaggableManager(blank=True, help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags'), + ), + ] diff --git a/qgis-app/geopackages/tests/test_views.py b/qgis-app/geopackages/tests/test_views.py index 3521dd19..798e67c2 100644 --- a/qgis-app/geopackages/tests/test_views.py +++ b/qgis-app/geopackages/tests/test_views.py @@ -157,6 +157,7 @@ def test_upload_acceptable_size_file(self): "description": "Test upload an acceptable gpkg size", "thumbnail_image": uploaded_thumbnail, "file": uploaded_gpkg, + "tags": "gpkg,project,test" } response = self.client.post(url, data, follow=True) # should send email notify @@ -166,6 +167,11 @@ def test_upload_acceptable_size_file(self): ) gpkg = Geopackage.objects.first() self.assertEqual(gpkg.name, "spiky polygons") + # Check the tags + self.assertEqual( + gpkg.tags.filter( + name__in=['gpkg', 'project', 'test']).count(), + 3) url = reverse("geopackage_detail", kwargs={"pk": gpkg.id}) response = self.client.get(url) self.assertEqual(response.status_code, 200) diff --git a/qgis-app/geopackages/urls.py b/qgis-app/geopackages/urls.py index 78e14616..3effd49e 100644 --- a/qgis-app/geopackages/urls.py +++ b/qgis-app/geopackages/urls.py @@ -9,6 +9,7 @@ GeopackageReviewView, GeopackageUnapprovedListView, GeopackageUpdateView, + GeopackageByTagView, geopackage_nav_content, ) @@ -35,6 +36,7 @@ GeopackageRequireActionListView.as_view(), name="geopackage_require_action", ), + path("tags//", GeopackageByTagView.as_view(), name="geopackage_tag"), # JSON path("sidebarnav/", geopackage_nav_content, name="geopackage_nav_content"), ] diff --git a/qgis-app/geopackages/views.py b/qgis-app/geopackages/views.py index e92632a3..6500434d 100644 --- a/qgis-app/geopackages/views.py +++ b/qgis-app/geopackages/views.py @@ -12,6 +12,8 @@ ) from geopackages.forms import UpdateForm, UploadForm from geopackages.models import Geopackage, Review +from django.utils.translation import gettext_lazy as _ +from urllib.parse import unquote class ResourceMixin: @@ -67,6 +69,26 @@ class GeopackageReviewView(ResourceMixin, ResourceBaseReviewView): class GeopackageDownloadView(ResourceMixin, ResourceBaseDownload): """Download a GeoPackage""" +class GeopackageByTagView(GeopackageListView): + """Display GeopackageListView filtered on geopackage tag""" + + def get_filtered_queryset(self, qs): + response = qs.filter(tagged_items__tag__slug=unquote(self.kwargs["geopackage_tag"])) + return response + + def get_queryset(self): + qs = super().get_queryset() + return self.get_filtered_queryset(qs) + + def get_context_data(self, **kwargs): + context = super(GeopackageByTagView, self).get_context_data(**kwargs) + context.update( + { + "title": _("Geopackage tagged with: %s") % unquote(self.kwargs["geopackage_tag"]), + "page_title": _("Tag: %s") % unquote(self.kwargs["geopackage_tag"]) + } + ) + return context def geopackage_nav_content(request): model = ResourceMixin.model diff --git a/qgis-app/layerdefinitions/forms.py b/qgis-app/layerdefinitions/forms.py index c4954efd..485591cd 100644 --- a/qgis-app/layerdefinitions/forms.py +++ b/qgis-app/layerdefinitions/forms.py @@ -2,9 +2,11 @@ from django import forms from layerdefinitions.file_handler import validator from layerdefinitions.models import LayerDefinition +from taggit.forms import TagField class ResourceFormMixin(forms.ModelForm): + tags = TagField(required=False) class Meta: model = LayerDefinition fields = [ @@ -14,6 +16,7 @@ class Meta: "url_metadata", "description", "license", + "tags" ] diff --git a/qgis-app/layerdefinitions/migrations/0003_layerdefinition_tags.py b/qgis-app/layerdefinitions/migrations/0003_layerdefinition_tags.py new file mode 100644 index 00000000..98f7ff5a --- /dev/null +++ b/qgis-app/layerdefinitions/migrations/0003_layerdefinition_tags.py @@ -0,0 +1,20 @@ +# Generated by Django 4.2.13 on 2024-06-12 06:04 + +from django.db import migrations +import taggit_autosuggest.managers + + +class Migration(migrations.Migration): + + dependencies = [ + ('taggit', '0006_rename_taggeditem_content_type_object_id_taggit_tagg_content_8fc721_idx'), + ('layerdefinitions', '0002_alter_review_reviewer'), + ] + + operations = [ + migrations.AddField( + model_name='layerdefinition', + name='tags', + field=taggit_autosuggest.managers.TaggableManager(blank=True, help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags'), + ), + ] diff --git a/qgis-app/layerdefinitions/tests/test_views.py b/qgis-app/layerdefinitions/tests/test_views.py index 44f8dd28..bbeff493 100644 --- a/qgis-app/layerdefinitions/tests/test_views.py +++ b/qgis-app/layerdefinitions/tests/test_views.py @@ -78,9 +78,18 @@ def setUp(self): "thumbnail_image": self.uploaded_thumbnail, "file": self.uploaded_file, "license": "license", + "tags": "layerdefinition,test" } self.response = self.client.post(url, self.data, follow=True) + def test_tags_wavefront(self): + # Check the tags + qlr = LayerDefinition.objects.first() + self.assertEqual( + qlr.tags.filter( + name__in=['layerdefinition', 'test']).count(), + 2) + def test_upload_file_succeed_send_notification(self): self.assertEqual(self.response.status_code, 200) # should send email notify diff --git a/qgis-app/layerdefinitions/urls.py b/qgis-app/layerdefinitions/urls.py index 16a211cb..28488746 100644 --- a/qgis-app/layerdefinitions/urls.py +++ b/qgis-app/layerdefinitions/urls.py @@ -9,6 +9,7 @@ LayerDefinitionReviewView, LayerDefinitionUnapprovedListView, LayerDefinitionUpdateView, + LayerDefinitionByTagView, layerdefinition_nav_content, ) @@ -49,6 +50,7 @@ LayerDefinitionRequireActionListView.as_view(), name="layerdefinition_require_action", ), + path("tags//", LayerDefinitionByTagView.as_view(), name="layerdefinition_tag"), # JSON path( "sidebarnav/", layerdefinition_nav_content, name="layerdefinition_nav_content" diff --git a/qgis-app/layerdefinitions/views.py b/qgis-app/layerdefinitions/views.py index ba318fff..d058aa65 100644 --- a/qgis-app/layerdefinitions/views.py +++ b/qgis-app/layerdefinitions/views.py @@ -24,10 +24,12 @@ from layerdefinitions.forms import UpdateForm, UploadForm from layerdefinitions.license import zipped_with_license from layerdefinitions.models import LayerDefinition, Review +from django.utils.translation import gettext_lazy as _ +from urllib.parse import unquote class ResourceMixin: - """Mixin class for Geopackage.""" + """Mixin class for LayerDefinition.""" model = LayerDefinition @@ -52,6 +54,8 @@ def form_valid(self, form): obj.url_datasource = get_url_datasource(obj.file.file) obj.provider = get_provider(obj.file.file) obj.save() + # Without this next line the tags won't be saved. + form.save_m2m() resource_notify(obj, resource_type=self.resource_name) msg = _(self.success_message) messages.success(self.request, msg, "success", fail_silently=True) @@ -83,6 +87,8 @@ def form_valid(self, form): obj.url_datasource = get_url_datasource(obj.file.file) obj.provider = get_provider(obj.file.file) obj.save() + # Without this next line the tags won't be saved. + form.save_m2m() resource_notify(obj, created=False, resource_type=self.resource_name) msg = _("The %s has been successfully updated." % self.resource_name) messages.success(self.request, msg, "success", fail_silently=True) @@ -111,6 +117,26 @@ class LayerDefinitionDeleteView(ResourceMixin, ResourceBaseDeleteView): class LayerDefinitionReviewView(ResourceMixin, ResourceBaseReviewView): """Create a review.""" +class LayerDefinitionByTagView(LayerDefinitionListView): + """Display LayerDefinitionListView filtered on layerdefinition tag""" + + def get_filtered_queryset(self, qs): + response = qs.filter(tagged_items__tag__slug=unquote(self.kwargs["layerdefinition_tag"])) + return response + + def get_queryset(self): + qs = super().get_queryset() + return self.get_filtered_queryset(qs) + + def get_context_data(self, **kwargs): + context = super(LayerDefinitionByTagView, self).get_context_data(**kwargs) + context.update( + { + "title": _("LayerDefinition tagged with: %s") % unquote(self.kwargs["layerdefinition_tag"]), + "page_title": _("Tag: %s") % unquote(self.kwargs["layerdefinition_tag"]) + } + ) + return context class LayerDefinitionDownloadView(ResourceMixin, ResourceBaseDownload): """Download a Layer Definition File (.qlr).""" diff --git a/qgis-app/models/forms.py b/qgis-app/models/forms.py index 6e1e22b4..df69770e 100644 --- a/qgis-app/models/forms.py +++ b/qgis-app/models/forms.py @@ -1,9 +1,11 @@ from base.forms.processing_forms import ResourceBaseCleanFileForm from django import forms from models.models import Model +from taggit.forms import TagField class ResourceFormMixin(forms.ModelForm): + tags = TagField(required=False) class Meta: model = Model fields = [ @@ -11,6 +13,7 @@ class Meta: "thumbnail_image", "name", "description", + "tags" ] diff --git a/qgis-app/models/migrations/0008_model_tags.py b/qgis-app/models/migrations/0008_model_tags.py new file mode 100644 index 00000000..720690b2 --- /dev/null +++ b/qgis-app/models/migrations/0008_model_tags.py @@ -0,0 +1,20 @@ +# Generated by Django 4.2.13 on 2024-06-12 06:04 + +from django.db import migrations +import taggit_autosuggest.managers + + +class Migration(migrations.Migration): + + dependencies = [ + ('taggit', '0006_rename_taggeditem_content_type_object_id_taggit_tagg_content_8fc721_idx'), + ('models', '0007_alter_review_reviewer'), + ] + + operations = [ + migrations.AddField( + model_name='model', + name='tags', + field=taggit_autosuggest.managers.TaggableManager(blank=True, help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags'), + ), + ] diff --git a/qgis-app/models/templatetags/resources_tagcloud.py b/qgis-app/models/templatetags/resources_tagcloud.py new file mode 100644 index 00000000..de15276e --- /dev/null +++ b/qgis-app/models/templatetags/resources_tagcloud.py @@ -0,0 +1,84 @@ +""" +ABP: patched version of django-taggit-templatetags to deal with +unpublished resources: returns only approved_objects +""" + +from django import template +from django.conf import settings as django_settings +from django.core.exceptions import FieldError +from django.db.models import Count +from taggit.models import Tag, TaggedItem +from taggit_templatetags import settings +from django.apps import apps + +TAGCLOUD_COUNT_GTE = getattr(django_settings, "TAGCLOUD_COUNT_GTE", None) +T_MAX = getattr(settings, "TAGCLOUD_MAX", 6.0) +T_MIN = getattr(settings, "TAGCLOUD_MIN", 1.0) + +register = template.Library() + +def get_queryset(app_label, model): + # Get model class + model_class = apps.get_model(app_label, model) + # Filter tagged items based on approved objects + queryset = TaggedItem.objects.filter( + content_type__app_label=app_label.lower(), + content_type__model=model.lower(), + object_id__in=model_class.approved_objects.values_list("id", flat=True), + ) + # Get tag IDs + tag_ids = queryset.values_list("tag_id", flat=True) + # Filter tags + queryset = Tag.objects.filter(id__in=tag_ids) + + # Annotate with count of tagged items + try: + queryset = queryset.annotate(num_times=Count("taggeditem_items")) + except FieldError: + queryset = queryset.annotate(num_times=Count("taggit_taggeditem_items")) + + # Show only the tags that are used over a given times (defined by TAGCLOUD_COUNT_GTE) + # Commented this for now as the tagging feature is new for the resources + # if TAGCLOUD_COUNT_GTE: + # queryset = queryset.filter(num_times__gte=TAGCLOUD_COUNT_GTE) + + return queryset + +def get_weight_fun(t_min, t_max, f_min, f_max): + def weight_fun(f_i, t_min=t_min, t_max=t_max, f_min=f_min, f_max=f_max): + if f_max == f_min: + mult_fac = 1.0 + else: + mult_fac = float(t_max - t_min) / float(f_max - f_min) + return t_max - (f_max - f_i) * mult_fac + return weight_fun + +@register.simple_tag(takes_context=True) +def get_resources_tagcloud(context, app_label, model): + queryset = get_queryset(app_label, model) + num_times = queryset.values_list("num_times", flat=True) + + if not num_times: + return queryset + + weight_fun = get_weight_fun(T_MIN, T_MAX, min(num_times), max(num_times)) + queryset = queryset.order_by("name") + for tag in queryset: + tag.weight = weight_fun(tag.num_times) + + return queryset + +@register.inclusion_tag("base/includes/resources_tagcloud_modal_include.html", takes_context=True) +def include_resources_tagcloud_modal(context, app_label, model): + tags = get_resources_tagcloud(context, app_label, model) + tags_title = model[0].upper() + model[1:] + if str(model).lower() == "wavefront": + tags_title = "3D Model" + elif str(model).lower() == "layerdefinition": + tags_title = "Layer Definition" + + return { + 'tags': tags, + 'tags_title': tags_title + " Tags", + 'tags_list_url': model + "_tag" + } diff --git a/qgis-app/models/tests/test_tagcloud.py b/qgis-app/models/tests/test_tagcloud.py new file mode 100644 index 00000000..c8e15d93 --- /dev/null +++ b/qgis-app/models/tests/test_tagcloud.py @@ -0,0 +1,50 @@ +from django.test import TestCase, RequestFactory, override_settings +from taggit.models import Tag +from models.templatetags import resources_tagcloud +from models.models import Model +from models.tests.test_views import SetUpTest +from django.template import Context, Template +import tempfile + +@override_settings(MEDIA_ROOT=tempfile.mkdtemp()) +class TagCloudTests(SetUpTest, TestCase): + fixtures = ["fixtures/simplemenu.json"] + + def setUp(self): + super(TagCloudTests, self).setUp() + + self.tag1 = Tag.objects.create(name='model') + self.tag2 = Tag.objects.create(name='project') + + self.model_instance = Model.objects.create( + creator=self.creator, + name="flooded buildings extractor", + description="A Model for testing purpose", + thumbnail_image=self.thumbnail, + file=self.file, + approved=True + ) + self.model_instance.tags.add(self.tag1, self.tag2) + + self.factory = RequestFactory() + + def test_get_queryset(self): + queryset = resources_tagcloud.get_queryset('models', 'model') + self.assertEqual(list(queryset), [self.tag1, self.tag2]) + + def test_get_resources_tagcloud(self): + context = Context({'request': self.factory.get('/')}) + tags = resources_tagcloud.get_resources_tagcloud(context, 'models', 'model') + self.assertIn(self.tag1, tags) + self.assertIn(self.tag2, tags) + self.assertTrue(hasattr(tags.first(), 'weight')) + + def test_include_resources_tagcloud_modal(self): + context = Context({'request': self.factory.get('/')}) + rendered = Template( + '{% load resources_tagcloud %}{% include_resources_tagcloud_modal "models" "model" %}' + ).render(context) + + self.assertIn('model', rendered) + self.assertIn('project', rendered) + self.assertIn('Model Tags', rendered) diff --git a/qgis-app/models/tests/test_views.py b/qgis-app/models/tests/test_views.py index 81dfc7c5..ca375fb8 100644 --- a/qgis-app/models/tests/test_views.py +++ b/qgis-app/models/tests/test_views.py @@ -159,12 +159,18 @@ def test_upload_acceptable_model3_size_file(self): "description": "Test upload an acceptable model size", "thumbnail_image": uploaded_thumbnail, "file": uploaded_model, + "tags": "model,project,test" } response = self.client.post(url, data, follow=True) # should send email notify self.assertEqual(len(mail.outbox), 1) model = Model.objects.first() self.assertEqual(model.name, "flooded buildings extractor") + # Check the tags + self.assertEqual( + model.tags.filter( + name__in=['model', 'project', 'test']).count(), + 3) url = reverse("model_detail", kwargs={"pk": model.id}) response = self.client.get(url) self.assertEqual(response.status_code, 200) diff --git a/qgis-app/models/urls.py b/qgis-app/models/urls.py index f2945c7b..ad7234ec 100644 --- a/qgis-app/models/urls.py +++ b/qgis-app/models/urls.py @@ -9,6 +9,7 @@ ModelReviewView, ModelUnapprovedListView, ModelUpdateView, + ModelByTagView, model_nav_content, ) @@ -27,6 +28,7 @@ ModelRequireActionListView.as_view(), name="model_require_action", ), + path("tags//", ModelByTagView.as_view(), name="model_tag"), # JSON path("sidebarnav/", model_nav_content, name="model_nav_content"), ] diff --git a/qgis-app/models/views.py b/qgis-app/models/views.py index 85acc06a..0db350e6 100644 --- a/qgis-app/models/views.py +++ b/qgis-app/models/views.py @@ -12,6 +12,8 @@ ) from models.forms import UpdateForm, UploadForm from models.models import Model, Review +from django.utils.translation import gettext_lazy as _ +from urllib.parse import unquote class ResourceMixin: @@ -68,6 +70,27 @@ class ModelDownloadView(ResourceMixin, ResourceBaseDownload): """Download a Model""" +class ModelByTagView(ModelListView): + """Display ModelListView filtered on model tag""" + + def get_filtered_queryset(self, qs): + response = qs.filter(tagged_items__tag__slug=unquote(self.kwargs["model_tag"])) + return response + + def get_queryset(self): + qs = super().get_queryset() + return self.get_filtered_queryset(qs) + + def get_context_data(self, **kwargs): + context = super(ModelByTagView, self).get_context_data(**kwargs) + context.update( + { + "title": _("Model tagged with: %s") % unquote(self.kwargs["model_tag"]), + "page_title": _("Tag: %s") % unquote(self.kwargs["model_tag"]) + } + ) + return context + def model_nav_content(request): model = ResourceMixin.model response = resource_nav_content(request, model) diff --git a/qgis-app/plugins/views.py b/qgis-app/plugins/views.py index 99994ce0..43f10054 100644 --- a/qgis-app/plugins/views.py +++ b/qgis-app/plugins/views.py @@ -957,7 +957,8 @@ def get_context_data(self, **kwargs): class TagsPluginsList(PluginsList): def get_filtered_queryset(self, qs): - return qs.filter(tagged_items__tag__slug=unquote(self.kwargs["tags"])) + response = qs.filter(tagged_items__tag__slug=unquote(self.kwargs["tags"])) + return response def get_context_data(self, **kwargs): context = super(TagsPluginsList, self).get_context_data(**kwargs) diff --git a/qgis-app/styles/forms.py b/qgis-app/styles/forms.py index a23655ff..3fa93567 100644 --- a/qgis-app/styles/forms.py +++ b/qgis-app/styles/forms.py @@ -3,9 +3,10 @@ from django.utils.translation import gettext_lazy as _ from styles.file_handler import validator from styles.models import Style - +from taggit.forms import TagField class ResourceFormMixin(forms.ModelForm): + tags = TagField(required=False) class Meta: model = Style fields = [ @@ -13,6 +14,7 @@ class Meta: "thumbnail_image", "name", "description", + "tags" ] @@ -21,12 +23,14 @@ class UploadForm(forms.ModelForm): Style Upload Form. """ + tags = TagField(required=False) class Meta: model = Style fields = [ "file", "thumbnail_image", "description", + "tags" ] def clean_file(self): diff --git a/qgis-app/styles/migrations/0016_style_tags.py b/qgis-app/styles/migrations/0016_style_tags.py new file mode 100644 index 00000000..525ee56a --- /dev/null +++ b/qgis-app/styles/migrations/0016_style_tags.py @@ -0,0 +1,20 @@ +# Generated by Django 4.2.13 on 2024-06-12 06:04 + +from django.db import migrations +import taggit_autosuggest.managers + + +class Migration(migrations.Migration): + + dependencies = [ + ('taggit', '0006_rename_taggeditem_content_type_object_id_taggit_tagg_content_8fc721_idx'), + ('styles', '0015_alter_review_reviewer'), + ] + + operations = [ + migrations.AddField( + model_name='style', + name='tags', + field=taggit_autosuggest.managers.TaggableManager(blank=True, help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags'), + ), + ] diff --git a/qgis-app/styles/templates/styles/style_base.html b/qgis-app/styles/templates/styles/style_base.html index 22d07fef..025cc399 100644 --- a/qgis-app/styles/templates/styles/style_base.html +++ b/qgis-app/styles/templates/styles/style_base.html @@ -70,6 +70,7 @@

{% trans "Style Type" %}

$("form.navbar-search").attr("action", "/styles/"); }) + {% endblock %} {% block "credits" %} diff --git a/qgis-app/styles/tests/test_views.py b/qgis-app/styles/tests/test_views.py index 8aee501c..ca9b6e46 100644 --- a/qgis-app/styles/tests/test_views.py +++ b/qgis-app/styles/tests/test_views.py @@ -69,6 +69,7 @@ def test_upload_xml_file(self): "file": xml_file, "thumbnail_image": self.thumbnail, "description": "This style is for testing only purpose", + "tags": "xml,style,test" }, ) self.assertEqual(self.response.status_code, 200) @@ -85,6 +86,12 @@ def test_upload_xml_file(self): settings.EMAIL_HOST_USER ) + # Check the tags + self.assertEqual( + Style.objects.get(name='Cat Trail').tags.filter( + name__in=['xml', 'style', 'test']).count(), + 3) + # style should be in Waiting Review url = reverse("style_unapproved") self.response = self.client.get(url) diff --git a/qgis-app/styles/urls.py b/qgis-app/styles/urls.py index 17691328..9ee9312d 100644 --- a/qgis-app/styles/urls.py +++ b/qgis-app/styles/urls.py @@ -10,6 +10,7 @@ StyleReviewView, StyleUnapprovedListView, StyleUpdateView, + StyleByTagView, style_nav_content, style_type_list, ) @@ -29,6 +30,7 @@ ), path("types//", StyleByTypeListView.as_view(), name="style_by_type"), path("/review/", StyleReviewView.as_view(), name="style_review"), + path("tags//", StyleByTagView.as_view(), name="style_tag"), # JSON path("sidebarnav/", style_nav_content, name="style_nav_content"), path("sidebarnav_type/", style_type_list, name="style_nav_typelist"), diff --git a/qgis-app/styles/views.py b/qgis-app/styles/views.py index db156da4..67302014 100644 --- a/qgis-app/styles/views.py +++ b/qgis-app/styles/views.py @@ -24,6 +24,7 @@ from styles.file_handler import read_xml_style from styles.forms import UpdateForm, UploadForm from styles.models import Review, Style, StyleType +from urllib.parse import unquote class ResourceMixin: @@ -71,6 +72,8 @@ def form_valid(self, form): ) obj.style_type = style_type obj.save() + # Without this next line the tags won't be saved. + form.save_m2m() resource_notify(obj, self.resource_name) msg = _("The Style has been successfully created.") messages.success(self.request, msg, "success", fail_silently=True) @@ -101,6 +104,8 @@ def form_valid(self, form): ).first() obj.require_action = False obj.save() + # Without this next line the tags won't be saved. + form.save_m2m() resource_notify(obj, created=False, resource_type=self.resource_name) msg = _("The Style has been successfully updated.") messages.success(self.request, msg, "success", fail_silently=True) @@ -110,6 +115,26 @@ def form_valid(self, form): class StyleListView(ResourceMixin, ResourceBaseListView): """Style ListView.""" +class StyleByTagView(StyleListView): + """Display StyleListView filtered on style tag""" + + def get_filtered_queryset(self, qs): + response = qs.filter(tagged_items__tag__slug=unquote(self.kwargs["style_tag"])) + return response + + def get_queryset(self): + qs = super().get_queryset() + return self.get_filtered_queryset(qs) + + def get_context_data(self, **kwargs): + context = super(StyleByTagView, self).get_context_data(**kwargs) + context.update( + { + "title": _("Style tagged with: %s") % unquote(self.kwargs["style_tag"]), + "page_title": _("Tag: %s") % unquote(self.kwargs["style_tag"]) + } + ) + return context class StyleByTypeListView(StyleListView): """Display StyleListView filtered on style type""" diff --git a/qgis-app/templates/base/base.html b/qgis-app/templates/base/base.html index 50e92840..0ff1a0e7 100644 --- a/qgis-app/templates/base/base.html +++ b/qgis-app/templates/base/base.html @@ -1,5 +1,5 @@ {% extends BASE_TEMPLATE %}{% load i18n static thumbnail %} -{% load resources_custom_tags %} +{% load resources_custom_tags resources_tagcloud %} {% block extratitle %}{{ resource_name }}{% endblock %} {% block app_title %}

QGIS {{ resource_name }}

@@ -81,6 +81,9 @@

{% trans "Style Type" %}

}) +
+{% include_resources_tagcloud_modal app_label=app_label model=model_name %} + {% endblock %} {% block "credits" %} diff --git a/qgis-app/templates/base/includes/resources_tagcloud_include.html b/qgis-app/templates/base/includes/resources_tagcloud_include.html new file mode 100644 index 00000000..8aafe2f7 --- /dev/null +++ b/qgis-app/templates/base/includes/resources_tagcloud_include.html @@ -0,0 +1,6 @@ +
+{% for tag in tags %}{% if tag.slug %} +{{tag}} +{% endif %}{% endfor %} +
+
diff --git a/qgis-app/templates/base/includes/resources_tagcloud_modal_include.html b/qgis-app/templates/base/includes/resources_tagcloud_modal_include.html new file mode 100644 index 00000000..c94b808c --- /dev/null +++ b/qgis-app/templates/base/includes/resources_tagcloud_modal_include.html @@ -0,0 +1,19 @@ +{% load i18n resources_tagcloud %} + + + + {{ tags_title }} + + + + diff --git a/qgis-app/templates/base/update_form.html b/qgis-app/templates/base/update_form.html index ca5d9ce2..0087ccc3 100644 --- a/qgis-app/templates/base/update_form.html +++ b/qgis-app/templates/base/update_form.html @@ -1,4 +1,75 @@ -{% extends "base/base.html" %}{% load i18n %} +{% extends "base/base.html" %}{% load i18n static%} + +{% block extrajs %} +{{ block.super }} + + + + +{% endblock %} + +{% block extracss %} + +{{ block.super }} + + +{% endblock %} + {% block content %}

{% trans "Update" %} {{ resource_name }}: {{ object.name }}

{% trans "To update your" %} {{ resource_name }}{% trans ", you can change the value in the input field." %}

diff --git a/qgis-app/templates/base/upload_form.html b/qgis-app/templates/base/upload_form.html index b386a3d7..2a881aa3 100644 --- a/qgis-app/templates/base/upload_form.html +++ b/qgis-app/templates/base/upload_form.html @@ -1,4 +1,74 @@ {% extends "base/base.html" %}{% load i18n static %} + +{% block extrajs %} +{{ block.super }} + + + + +{% endblock %} + +{% block extracss %} + +{{ block.super }} + + +{% endblock %} {% block content %}

{% trans "Upload" %} {{ resource_name }}

diff --git a/qgis-app/wavefronts/forms.py b/qgis-app/wavefronts/forms.py index 0fa22ee6..9cc8a790 100644 --- a/qgis-app/wavefronts/forms.py +++ b/qgis-app/wavefronts/forms.py @@ -2,9 +2,11 @@ from django import forms from wavefronts.models import Wavefront from wavefronts.validator import WavefrontValidator +from taggit.forms import TagField class ResourceFormMixin(forms.ModelForm): + tags = TagField(required=False) class Meta: model = Wavefront fields = [ @@ -12,6 +14,7 @@ class Meta: "thumbnail_image", "name", "description", + "tags" ] diff --git a/qgis-app/wavefronts/migrations/0003_wavefront_tags.py b/qgis-app/wavefronts/migrations/0003_wavefront_tags.py new file mode 100644 index 00000000..8376ac0c --- /dev/null +++ b/qgis-app/wavefronts/migrations/0003_wavefront_tags.py @@ -0,0 +1,20 @@ +# Generated by Django 4.2.13 on 2024-06-12 06:04 + +from django.db import migrations +import taggit_autosuggest.managers + + +class Migration(migrations.Migration): + + dependencies = [ + ('taggit', '0006_rename_taggeditem_content_type_object_id_taggit_tagg_content_8fc721_idx'), + ('wavefronts', '0002_alter_review_reviewer'), + ] + + operations = [ + migrations.AddField( + model_name='wavefront', + name='tags', + field=taggit_autosuggest.managers.TaggableManager(blank=True, help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags'), + ), + ] diff --git a/qgis-app/wavefronts/tests/test_view.py b/qgis-app/wavefronts/tests/test_view.py index 380848e6..fa23380f 100644 --- a/qgis-app/wavefronts/tests/test_view.py +++ b/qgis-app/wavefronts/tests/test_view.py @@ -79,11 +79,19 @@ def setUp(self): "description": "Test upload a wavefront", "thumbnail_image": uploaded_thumbnail, "file": uploaded_file, + "tags": "3dmodel,wavefront,test" } self.client.post(url, data, follow=True) self.object = Wavefront.objects.first() self.client.logout() + def test_tags_wavefront(self): + # Check the tags + self.assertEqual( + self.object.tags.filter( + name__in=['3dmodel', 'wavefront', 'test']).count(), + 3) + def test_approve_wavefront(self): login = self.client.login(username="staff", password="password") self.assertTrue(login) diff --git a/qgis-app/wavefronts/urls.py b/qgis-app/wavefronts/urls.py index d9942ead..4ce521e3 100644 --- a/qgis-app/wavefronts/urls.py +++ b/qgis-app/wavefronts/urls.py @@ -9,6 +9,7 @@ WavefrontReviewView, WavefrontUnapprovedListView, WavefrontUpdateView, + WavefrontByTagView, wavefront_nav_content, ) @@ -33,6 +34,7 @@ WavefrontRequireActionListView.as_view(), name="wavefront_require_action", ), + path("tags//", WavefrontByTagView.as_view(), name="wavefront_tag"), # JSON path("sidebarnav/", wavefront_nav_content, name="wavefront_nav_content"), ] diff --git a/qgis-app/wavefronts/views.py b/qgis-app/wavefronts/views.py index 611a0fa7..ba841ea0 100644 --- a/qgis-app/wavefronts/views.py +++ b/qgis-app/wavefronts/views.py @@ -24,6 +24,8 @@ from wavefronts.forms import UpdateForm, UploadForm from wavefronts.models import Review, Wavefront from wavefronts.utilities import zipped_all_with_license +from django.utils.translation import gettext_lazy as _ +from urllib.parse import unquote class ResourceMixin: @@ -51,6 +53,8 @@ def form_valid(self, form): self.obj.creator = self.request.user self.obj.file.name = form.file_path self.obj.save() + # Without this next line the tags won't be saved. + form.save_m2m() resource_notify(self.obj, resource_type=self.resource_name) msg = _(self.success_message) messages.success(self.request, msg, "success", fail_silently=True) @@ -84,6 +88,8 @@ def form_valid(self, form): obj.require_action = False obj.approved = False obj.save() + # Without this next line the tags won't be saved. + form.save_m2m() resource_notify(obj, created=False, resource_type=self.resource_name) msg = _("The %s has been successfully updated." % self.resource_name) messages.success(self.request, msg, "success", fail_silently=True) @@ -140,6 +146,26 @@ def get(self, request, *args, **kwargs): ) return response +class WavefrontByTagView(WavefrontListView): + """Display WavefrontListView filtered on wavefront tag""" + + def get_filtered_queryset(self, qs): + response = qs.filter(tagged_items__tag__slug=unquote(self.kwargs["wavefront_tag"])) + return response + + def get_queryset(self): + qs = super().get_queryset() + return self.get_filtered_queryset(qs) + + def get_context_data(self, **kwargs): + context = super(WavefrontByTagView, self).get_context_data(**kwargs) + context.update( + { + "title": _("Wavefront tagged with: %s") % unquote(self.kwargs["wavefront_tag"]), + "page_title": _("Tag: %s") % unquote(self.kwargs["wavefront_tag"]) + } + ) + return context def wavefront_nav_content(request): model = ResourceMixin.model