diff --git a/links/forms.py b/links/forms.py index ddd969f..5079336 100644 --- a/links/forms.py +++ b/links/forms.py @@ -1,11 +1,54 @@ from django import forms -from .models import Link, Page, Post, ImageCollection, Image, Tag -from simplemde.fields import SimpleMDEField from django.utils.translation import gettext_lazy as _ from django.core.validators import URLValidator from urllib.parse import quote import re +from .models import Link, Page, Post, ImageCollection, Image, Tag +from simplemde.fields import SimpleMDEField + + +class TagInputField(forms.ModelMultipleChoiceField): + """A ModelMultipleChoiceField for tags that accepts: + - existing PKs (as normal), and + - raw text strings for tags that don't yet exist (created on save). + + Select2 foreground config (`tags: true` + `createTag`) submits the + raw text as the value for free-typed tags. This field treats any + non-numeric submitted value as a new tag name. + """ + + def to_python(self, value): + if value in self.empty_values: + return None + raw = str(value).strip() + if not raw: + return None + # Existing tags are submitted as their PK + if raw.isdigit(): + return self.queryset.filter(pk=int(raw)).first() + # New-tag text: lowercase so the tag namespace stays consistent and + # case-insensitive duplicates are avoided. + name = raw.lower() + tag, _ = Tag.objects.get_or_create( + name__iexact=name, + defaults={'name': name}, + ) + return tag + + def _check_values(self, value): + """Override the strict PK-only queryset filter so mixed lists of + existing PKs and new-tag text resolve cleanly to Tag objects.""" + if not value: + return [] + result = [] + for single in value: + tag = self.to_python(single) + if tag is not None: + result.append(tag) + return result + + class LinkForm(forms.ModelForm): text = SimpleMDEField() @@ -18,6 +61,17 @@ class LinkForm(forms.ModelForm): 'tags': forms.SelectMultiple(attrs={'class': 'select2'}), } + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # Replace the auto-generated ModelMultipleChoiceField with our + # tolerant variant so newly-typed tags don't fail validation + # before clean_tags() runs. + self.fields['tags'] = TagInputField( + queryset=Tag.objects.all(), + required=False, + widget=forms.SelectMultiple(attrs={'class': 'select2'}), + ) + def clean_original_url(self): url = self.cleaned_data.get('original_url') if not url: diff --git a/links/templates/links/link_form.html b/links/templates/links/link_form.html index fda40fd..5c240df 100644 --- a/links/templates/links/link_form.html +++ b/links/templates/links/link_form.html @@ -156,7 +156,7 @@ tags: true, // Allow creating new tags tokenSeparators: [',', ' '], createTag: function(params) { - var term = $.trim(params.term); + var term = $.trim(params.term).toLowerCase(); if (term === '') { return null; } diff --git a/tests/test_links_api.py b/tests/test_links_api.py index e84820c..2576c20 100644 --- a/tests/test_links_api.py +++ b/tests/test_links_api.py @@ -5,7 +5,7 @@ Links are managed via Django template views, tested through the Django test clie import pytest -from links.models import Link +from links.models import Link, Tag @pytest.mark.django_db @@ -137,3 +137,100 @@ class TestTemplateLinkFormValidation: follow=True, ) assert not Link.objects.filter(alias="bad-url").exists() + + +@pytest.mark.django_db +class TestLinkTagAutoCreate: + """Select2 submits either existing tag PKs or raw text for new tags. + New tags must be created server-side and lowercased.""" + + def _create(self, client, tags): + return client.post( + "/create/", + { + "alias": "tagged-link", + "original_url": "https://example.com", + "link_type": Link.LinkType.LINK, + "description": "", + "text": "", + "tags": tags, + }, + follow=True, + ) + + def test_new_tag_is_created_and_lowercased(self, client): + existing = Tag.objects.count() + response = self._create(client, ["Python"]) + assert response.status_code == 200 + assert Link.objects.filter(alias="tagged-link").exists() + + link = Link.objects.get(alias="tagged-link") + created = Tag.objects.exclude(pk__in=[t.pk for t in link.tags.all()]) + assert Tag.objects.count() == existing + 1 + tag = Tag.objects.filter(name__iexact="python").first() + assert tag is not None + assert tag.name == "python" + assert tag.slug == "python" + assert tag in link.tags.all() + + def test_multiple_new_tags_lowercased(self, client): + before = Tag.objects.count() + response = self._create(client, ["Python", "Django", "WEB"]) + assert response.status_code == 200 + + link = Link.objects.get(alias="tagged-link") + names = sorted(t.name for t in link.tags.all()) + assert names == ["django", "python", "web"] + assert Tag.objects.count() == before + 3 + + def test_existing_tag_reused_no_duplicate(self, client): + existing = Tag.objects.create(name="python", slug="python") + before = Tag.objects.count() + + response = self._create(client, ["Python"]) + assert response.status_code == 200 + link = Link.objects.get(alias="tagged-link") + assert link.tags.count() == 1 + assert link.tags.first().pk == existing.pk + # No duplicate tag created + assert Tag.objects.count() == before + assert Tag.objects.filter(name="python").count() == 1 + + def test_existing_tag_pk_and_new_tag_mixed(self, client): + existing = Tag.objects.create(name="existing", slug="existing") + before = Tag.objects.count() + + response = self._create(client, [str(existing.pk), "NewTag"]) + assert response.status_code == 200 + link = Link.objects.get(alias="tagged-link") + names = sorted(t.name for t in link.tags.all()) + assert names == ["existing", "newtag"] + assert Tag.objects.count() == before + 1 + + def test_no_tags_submitted_creates_no_tags(self, client): + before = Tag.objects.count() + response = self._create(client, []) + assert response.status_code == 200 + link = Link.objects.get(alias="tagged-link") + assert link.tags.count() == 0 + assert Tag.objects.count() == before + + def test_empty_and_whitespace_values_ignored(self, client): + before = Tag.objects.count() + response = self._create(client, ["", " ", "real"]) + assert response.status_code == 200 + link = Link.objects.get(alias="tagged-link") + assert link.tags.count() == 1 + assert link.tags.first().name == "real" + assert Tag.objects.count() == before + 1 + + def test_whitespace_in_tag_name_is_stripped(self, client): + before = Tag.objects.count() + # Select2's createTag trims, but be defensive server-side too + response = self._create(client, [" spaced out "]) + assert response.status_code == 200 + link = Link.objects.get(alias="tagged-link") + tag = link.tags.first() + assert tag.name == "spaced out" + assert tag.slug == "spaced-out" + assert Tag.objects.count() == before + 1