Move more logic into bookmark form

This commit is contained in:
Sascha Ißbrücker
2025-03-09 23:11:48 +01:00
parent 9dfc9b03b4
commit b9bee24047
9 changed files with 119 additions and 116 deletions

94
bookmarks/forms.py Normal file
View File

@@ -0,0 +1,94 @@
from django import forms
from bookmarks.models import Bookmark, build_tag_string
from bookmarks.validators import BookmarkURLValidator
from bookmarks.type_defs import HttpRequest
from bookmarks.services.bookmarks import create_bookmark, update_bookmark
class BookmarkForm(forms.ModelForm):
# Use URLField for URL
url = forms.CharField(validators=[BookmarkURLValidator()])
tag_string = forms.CharField(required=False)
# Do not require title and description as they may be empty
title = forms.CharField(max_length=512, required=False)
description = forms.CharField(required=False, widget=forms.Textarea())
unread = forms.BooleanField(required=False)
shared = forms.BooleanField(required=False)
# Hidden field that determines whether to close window/tab after saving the bookmark
auto_close = forms.CharField(required=False)
class Meta:
model = Bookmark
fields = [
"url",
"tag_string",
"title",
"description",
"notes",
"unread",
"shared",
"auto_close",
]
def __init__(self, request: HttpRequest, instance: Bookmark = None):
self.request = request
initial = None
if instance is None and request.method == "GET":
initial = {
"url": request.GET.get("url"),
"title": request.GET.get("title"),
"description": request.GET.get("description"),
"notes": request.GET.get("notes"),
"auto_close": "auto_close" in request.GET,
"unread": request.user_profile.default_mark_unread,
}
if instance is not None and request.method == "GET":
initial = {"tag_string": build_tag_string(instance.tag_names, " ")}
data = request.POST if request.method == "POST" else None
super().__init__(data, instance=instance, initial=initial)
@property
def is_auto_close(self):
return self.data.get("auto_close", False) == "True" or self.initial.get(
"auto_close", False
)
@property
def has_notes(self):
return self.initial.get("notes", None) or (
self.instance and self.instance.notes
)
def save(self, commit=False):
tag_string = convert_tag_string(self.data["tag_string"])
bookmark = super().save(commit=False)
if self.instance.pk:
return update_bookmark(bookmark, tag_string, self.request.user)
else:
return create_bookmark(bookmark, tag_string, self.request.user)
def clean_url(self):
# When creating a bookmark, the service logic prevents duplicate URLs by
# updating the existing bookmark instead, which is also communicated in
# the form's UI. When editing a bookmark, there is no assumption that
# it would update a different bookmark if the URL is a duplicate, so
# raise a validation error in that case.
url = self.cleaned_data["url"]
if self.instance.pk:
is_duplicate = (
Bookmark.objects.filter(owner=self.instance.owner, url=url)
.exclude(pk=self.instance.pk)
.exists()
)
if is_duplicate:
raise forms.ValidationError("A bookmark with this URL already exists.")
return url
def convert_tag_string(tag_string: str):
# Tag strings coming from inputs are space-separated, however services.bookmarks functions expect comma-separated
# strings
return tag_string.replace(" ", ",")

View File

@@ -150,56 +150,6 @@ def bookmark_asset_deleted(sender, instance, **kwargs):
logger.error(f"Failed to delete asset file: {filepath}", exc_info=error) logger.error(f"Failed to delete asset file: {filepath}", exc_info=error)
class BookmarkForm(forms.ModelForm):
# Use URLField for URL
url = forms.CharField(validators=[BookmarkURLValidator()])
tag_string = forms.CharField(required=False)
# Do not require title and description as they may be empty
title = forms.CharField(max_length=512, required=False)
description = forms.CharField(required=False, widget=forms.Textarea())
unread = forms.BooleanField(required=False)
shared = forms.BooleanField(required=False)
# Hidden field that determines whether to close window/tab after saving the bookmark
auto_close = forms.CharField(required=False)
class Meta:
model = Bookmark
fields = [
"url",
"tag_string",
"title",
"description",
"notes",
"unread",
"shared",
"auto_close",
]
@property
def has_notes(self):
return self.initial.get("notes", None) or (
self.instance and self.instance.notes
)
def clean_url(self):
# When creating a bookmark, the service logic prevents duplicate URLs by
# updating the existing bookmark instead, which is also communicated in
# the form's UI. When editing a bookmark, there is no assumption that
# it would update a different bookmark if the URL is a duplicate, so
# raise a validation error in that case.
url = self.cleaned_data["url"]
if self.instance.pk:
is_duplicate = (
Bookmark.objects.filter(owner=self.instance.owner, url=url)
.exclude(pk=self.instance.pk)
.exists()
)
if is_duplicate:
raise forms.ValidationError("A bookmark with this URL already exists.")
return url
class BookmarkSearch: class BookmarkSearch:
SORT_ADDED_ASC = "added_asc" SORT_ADDED_ASC = "added_asc"
SORT_ADDED_DESC = "added_desc" SORT_ADDED_DESC = "added_desc"

View File

@@ -1,5 +1,4 @@
{% extends 'bookmarks/layout.html' %} {% extends 'bookmarks/layout.html' %}
{% load bookmarks %}
{% block content %} {% block content %}
<div class="bookmarks-form-page"> <div class="bookmarks-form-page">
@@ -9,7 +8,7 @@
</div> </div>
<form action="{% url 'linkding:bookmarks.edit' bookmark_id %}?return_url={{ return_url|urlencode }}" method="post" <form action="{% url 'linkding:bookmarks.edit' bookmark_id %}?return_url={{ return_url|urlencode }}" method="post"
novalidate> novalidate>
{% bookmark_form form return_url bookmark_id %} {% include 'bookmarks/form.html' %}
</form> </form>
</section> </section>
</div> </div>

View File

@@ -91,12 +91,12 @@
{% endif %} {% endif %}
<div class="divider"></div> <div class="divider"></div>
<div class="form-group d-flex justify-between"> <div class="form-group d-flex justify-between">
{% if auto_close %} {% if form.is_auto_close %}
<input type="submit" value="Save and close" class="btn btn-primary btn-wide"> <input type="submit" value="Save and close" class="btn btn-primary btn-wide">
{% else %} {% else %}
<input type="submit" value="Save" class="btn btn-primary btn btn-primary btn-wide"> <input type="submit" value="Save" class="btn btn-primary btn btn-primary btn-wide">
{% endif %} {% endif %}
<a href="{{ cancel_url }}" class="btn">Nevermind</a> <a href="{{ return_url }}" class="btn">Nevermind</a>
</div> </div>
<script type="application/javascript"> <script type="application/javascript">
/** /**
@@ -112,7 +112,7 @@
const unreadCheckbox = document.getElementById('{{ form.unread.id_for_label }}'); const unreadCheckbox = document.getElementById('{{ form.unread.id_for_label }}');
const sharedCheckbox = document.getElementById('{{ form.shared.id_for_label }}'); const sharedCheckbox = document.getElementById('{{ form.shared.id_for_label }}');
const bookmarkExistsHint = document.querySelector('.form-input-hint.bookmark-exists'); const bookmarkExistsHint = document.querySelector('.form-input-hint.bookmark-exists');
const editedBookmarkId = {{ bookmark_id }}; const editedBookmarkId = {{ form.instance.id|default:0 }};
let isTitleModified = !!titleInput.value; let isTitleModified = !!titleInput.value;
let isDescriptionModified = !!descriptionInput.value; let isDescriptionModified = !!descriptionInput.value;

View File

@@ -1,5 +1,4 @@
{% extends 'bookmarks/layout.html' %} {% extends 'bookmarks/layout.html' %}
{% load bookmarks %}
{% block content %} {% block content %}
<div class="bookmarks-form-page"> <div class="bookmarks-form-page">
@@ -8,7 +7,7 @@
<h2>New bookmark</h2> <h2>New bookmark</h2>
</div> </div>
<form action="{% url 'linkding:bookmarks.new' %}" method="post" novalidate> <form action="{% url 'linkding:bookmarks.new' %}" method="post" novalidate>
{% bookmark_form form return_url auto_close=auto_close %} {% include 'bookmarks/form.html' %}
</form> </form>
</section> </section>
</div> </div>

View File

@@ -3,7 +3,6 @@ from typing import List
from django import template from django import template
from bookmarks.models import ( from bookmarks.models import (
BookmarkForm,
BookmarkSearch, BookmarkSearch,
BookmarkSearchForm, BookmarkSearchForm,
User, User,
@@ -12,23 +11,6 @@ from bookmarks.models import (
register = template.Library() register = template.Library()
@register.inclusion_tag("bookmarks/form.html", name="bookmark_form", takes_context=True)
def bookmark_form(
context,
form: BookmarkForm,
cancel_url: str,
bookmark_id: int = 0,
auto_close: bool = False,
):
return {
"request": context["request"],
"form": form,
"auto_close": auto_close,
"bookmark_id": bookmark_id,
"cancel_url": cancel_url,
}
@register.inclusion_tag( @register.inclusion_tag(
"bookmarks/search.html", name="bookmark_search", takes_context=True "bookmarks/search.html", name="bookmark_search", takes_context=True
) )

View File

@@ -147,7 +147,7 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
html = response.content.decode() html = response.content.decode()
self.assertInHTML( self.assertInHTML(
'<input type="hidden" name="auto_close" id="id_auto_close">', '<input type="hidden" name="auto_close" value="False" id="id_auto_close">',
html, html,
) )
@@ -169,7 +169,7 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertRedirects(response, reverse("linkding:bookmarks.index")) self.assertRedirects(response, reverse("linkding:bookmarks.index"))
def test_auto_close_should_redirect_to_close_view(self): def test_auto_close_should_redirect_to_close_view(self):
form_data = self.create_form_data({"auto_close": "true"}) form_data = self.create_form_data({"auto_close": "True"})
response = self.client.post(reverse("linkding:bookmarks.new"), form_data) response = self.client.post(reverse("linkding:bookmarks.new"), form_data)

View File

@@ -2,8 +2,10 @@ import datetime
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.test import TestCase, override_settings from django.test import TestCase, override_settings
from django.test.client import RequestFactory
from bookmarks.models import BookmarkForm, Bookmark from bookmarks.forms import BookmarkForm
from bookmarks.models import Bookmark
from bookmarks.tests.helpers import BookmarkFactoryMixin from bookmarks.tests.helpers import BookmarkFactoryMixin
ENABLED_URL_VALIDATION_TEST_CASES = [ ENABLED_URL_VALIDATION_TEST_CASES = [
@@ -62,12 +64,15 @@ class BookmarkValidationTestCase(TestCase, BookmarkFactoryMixin):
self._run_bookmark_model_url_validity_checks(DISABLED_URL_VALIDATION_TEST_CASES) self._run_bookmark_model_url_validity_checks(DISABLED_URL_VALIDATION_TEST_CASES)
def test_bookmark_form_should_validate_required_fields(self): def test_bookmark_form_should_validate_required_fields(self):
form = BookmarkForm(data={"url": ""}) rf = RequestFactory()
request = rf.post("/", data={"url": ""})
form = BookmarkForm(request)
self.assertEqual(len(form.errors), 1) self.assertEqual(len(form.errors), 1)
self.assertIn("required", str(form.errors)) self.assertIn("required", str(form.errors))
form = BookmarkForm(data={"url": None}) request = rf.post("/", data={})
form = BookmarkForm(request)
self.assertEqual(len(form.errors), 1) self.assertEqual(len(form.errors), 1)
self.assertIn("required", str(form.errors)) self.assertIn("required", str(form.errors))
@@ -102,7 +107,9 @@ class BookmarkValidationTestCase(TestCase, BookmarkFactoryMixin):
def _run_bookmark_form_url_validity_checks(self, cases): def _run_bookmark_form_url_validity_checks(self, cases):
for case in cases: for case in cases:
url, expectation = case url, expectation = case
form = BookmarkForm(data={"url": url}) rf = RequestFactory()
request = rf.post("/", data={"url": url})
form = BookmarkForm(request)
if expectation: if expectation:
self.assertEqual(len(form.errors), 0) self.assertEqual(len(form.errors), 0)

View File

@@ -12,16 +12,13 @@ from django.shortcuts import render
from django.urls import reverse from django.urls import reverse
from bookmarks import queries, utils from bookmarks import queries, utils
from bookmarks.forms import BookmarkForm
from bookmarks.models import ( from bookmarks.models import (
Bookmark, Bookmark,
BookmarkForm,
BookmarkSearch, BookmarkSearch,
build_tag_string,
) )
from bookmarks.services import assets as asset_actions, tasks from bookmarks.services import assets as asset_actions, tasks
from bookmarks.services.bookmarks import ( from bookmarks.services.bookmarks import (
create_bookmark,
update_bookmark,
archive_bookmark, archive_bookmark,
archive_bookmarks, archive_bookmarks,
unarchive_bookmark, unarchive_bookmark,
@@ -151,37 +148,17 @@ def convert_tag_string(tag_string: str):
@login_required @login_required
def new(request: HttpRequest): def new(request: HttpRequest):
initial_auto_close = True if "auto_close" in request.GET else None form = BookmarkForm(request)
if request.method == "POST": if request.method == "POST":
form = BookmarkForm(request.POST)
auto_close = form.data["auto_close"]
if form.is_valid(): if form.is_valid():
current_user = request.user form.save()
tag_string = convert_tag_string(form.data["tag_string"]) if form.is_auto_close:
create_bookmark(form.save(commit=False), tag_string, current_user)
if auto_close:
return HttpResponseRedirect(reverse("linkding:bookmarks.close")) return HttpResponseRedirect(reverse("linkding:bookmarks.close"))
else: else:
return HttpResponseRedirect(reverse("linkding:bookmarks.index")) return HttpResponseRedirect(reverse("linkding:bookmarks.index"))
else:
form = BookmarkForm(
initial={
"url": request.GET.get("url"),
"title": request.GET.get("title"),
"description": request.GET.get("description"),
"notes": request.GET.get("notes"),
"auto_close": initial_auto_close,
"unread": request.user_profile.default_mark_unread,
}
)
status = 422 if request.method == "POST" and not form.is_valid() else 200 status = 422 if request.method == "POST" and not form.is_valid() else 200
context = { context = {"form": form, "return_url": reverse("linkding:bookmarks.index")}
"form": form,
"auto_close": initial_auto_close,
"return_url": reverse("linkding:bookmarks.index"),
}
return render(request, "bookmarks/new.html", context, status=status) return render(request, "bookmarks/new.html", context, status=status)
@@ -189,20 +166,15 @@ def new(request: HttpRequest):
@login_required @login_required
def edit(request: HttpRequest, bookmark_id: int): def edit(request: HttpRequest, bookmark_id: int):
bookmark = access.bookmark_write(request, bookmark_id) bookmark = access.bookmark_write(request, bookmark_id)
form = BookmarkForm(request, instance=bookmark)
return_url = get_safe_return_url( return_url = get_safe_return_url(
request.GET.get("return_url"), reverse("linkding:bookmarks.index") request.GET.get("return_url"), reverse("linkding:bookmarks.index")
) )
if request.method == "POST": if request.method == "POST":
form = BookmarkForm(request.POST, instance=bookmark)
if form.is_valid(): if form.is_valid():
tag_string = convert_tag_string(form.data["tag_string"]) form.save()
update_bookmark(form.save(commit=False), tag_string, request.user)
return HttpResponseRedirect(return_url) return HttpResponseRedirect(return_url)
else:
form = BookmarkForm(instance=bookmark)
form.fields["tag_string"].initial = build_tag_string(bookmark.tag_names, " ")
status = 422 if request.method == "POST" and not form.is_valid() else 200 status = 422 if request.method == "POST" and not form.is_valid() else 200
context = {"form": form, "bookmark_id": bookmark_id, "return_url": return_url} context = {"form": form, "bookmark_id": bookmark_id, "return_url": return_url}