Skip to content

Commit d1f81fe

Browse files
authored
Prevent duplicates when editing (sissbruecker#853)
* prevent creating duplicate URLs on edit * Prevent duplicates when editing
1 parent 7b405c0 commit d1f81fe

File tree

5 files changed

+97
-4
lines changed

5 files changed

+97
-4
lines changed

bookmarks/api/serializers.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,24 @@ def update(self, instance: Bookmark, validated_data):
111111

112112
return update_bookmark(instance, tag_string, self.context["user"])
113113

114+
def validate(self, attrs):
115+
# When creating a bookmark, the service logic prevents duplicate URLs by
116+
# updating the existing bookmark instead. When editing a bookmark,
117+
# there is no assumption that it would update a different bookmark if
118+
# the URL is a duplicate, so raise a validation error in that case.
119+
if self.instance and "url" in attrs:
120+
is_duplicate = (
121+
Bookmark.objects.filter(owner=self.instance.owner, url=attrs["url"])
122+
.exclude(pk=self.instance.pk)
123+
.exists()
124+
)
125+
if is_duplicate:
126+
raise serializers.ValidationError(
127+
{"url": "A bookmark with this URL already exists."}
128+
)
129+
130+
return attrs
131+
114132

115133
class TagSerializer(serializers.ModelSerializer):
116134
class Meta:

bookmarks/models.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,24 @@ def has_notes(self):
168168
self.instance and self.instance.notes
169169
)
170170

171+
def clean_url(self):
172+
# When creating a bookmark, the service logic prevents duplicate URLs by
173+
# updating the existing bookmark instead, which is also communicated in
174+
# the form's UI. When editing a bookmark, there is no assumption that
175+
# it would update a different bookmark if the URL is a duplicate, so
176+
# raise a validation error in that case.
177+
url = self.cleaned_data["url"]
178+
if self.instance.pk:
179+
is_duplicate = (
180+
Bookmark.objects.filter(owner=self.instance.owner, url=url)
181+
.exclude(pk=self.instance.pk)
182+
.exists()
183+
)
184+
if is_duplicate:
185+
raise forms.ValidationError("A bookmark with this URL already exists.")
186+
187+
return url
188+
171189

172190
class BookmarkSearch:
173191
SORT_ADDED_ASC = "added_asc"

bookmarks/tests/test_bookmark_edit_view.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,40 @@ def test_should_prefill_bookmark_form_fields(self):
141141
html,
142142
)
143143

144+
def test_should_prevent_duplicate_urls(self):
145+
edited_bookmark = self.setup_bookmark(url="http://example.com/edited")
146+
existing_bookmark = self.setup_bookmark(url="http://example.com/existing")
147+
other_user_bookmark = self.setup_bookmark(
148+
url="http://example.com/other-user", user=User.objects.create_user("other")
149+
)
150+
151+
# if the URL isn't modified it's not a duplicate
152+
form_data = self.create_form_data({"url": edited_bookmark.url})
153+
response = self.client.post(
154+
reverse("bookmarks:edit", args=[edited_bookmark.id]), form_data
155+
)
156+
self.assertEqual(response.status_code, 302)
157+
158+
# if the URL is already bookmarked by another user, it's not a duplicate
159+
form_data = self.create_form_data({"url": other_user_bookmark.url})
160+
response = self.client.post(
161+
reverse("bookmarks:edit", args=[edited_bookmark.id]), form_data
162+
)
163+
self.assertEqual(response.status_code, 302)
164+
165+
# if the URL is already bookmarked by the same user, it's a duplicate
166+
form_data = self.create_form_data({"url": existing_bookmark.url})
167+
response = self.client.post(
168+
reverse("bookmarks:edit", args=[edited_bookmark.id]), form_data
169+
)
170+
self.assertEqual(response.status_code, 422)
171+
self.assertInHTML(
172+
"<li>A bookmark with this URL already exists.</li>",
173+
response.content.decode(),
174+
)
175+
edited_bookmark.refresh_from_db()
176+
self.assertNotEqual(edited_bookmark.url, existing_bookmark.url)
177+
144178
def test_should_redirect_to_return_url(self):
145179
bookmark = self.setup_bookmark()
146180
form_data = self.create_form_data()

bookmarks/tests/test_bookmarks_api.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -685,6 +685,29 @@ def test_update_bookmark_adds_tags_from_auto_tagging(self):
685685
updated_bookmark = Bookmark.objects.get(id=bookmark.id)
686686
self.assertCountEqual(updated_bookmark.tags.all(), [tag1, tag2])
687687

688+
def test_update_bookmark_should_prevent_duplicate_urls(self):
689+
self.authenticate()
690+
edited_bookmark = self.setup_bookmark(url="https://example.com/edited")
691+
existing_bookmark = self.setup_bookmark(url="https://example.com/existing")
692+
other_user_bookmark = self.setup_bookmark(
693+
url="https://example.com/other", user=self.setup_user()
694+
)
695+
696+
# if the URL isn't modified it's not a duplicate
697+
data = {"url": edited_bookmark.url}
698+
url = reverse("bookmarks:bookmark-detail", args=[edited_bookmark.id])
699+
self.put(url, data, expected_status_code=status.HTTP_200_OK)
700+
701+
# if the URL is already bookmarked by another user, it's not a duplicate
702+
data = {"url": other_user_bookmark.url}
703+
url = reverse("bookmarks:bookmark-detail", args=[edited_bookmark.id])
704+
self.put(url, data, expected_status_code=status.HTTP_200_OK)
705+
706+
# if the URL is already bookmarked by the same user, it's a duplicate
707+
data = {"url": existing_bookmark.url}
708+
url = reverse("bookmarks:bookmark-detail", args=[edited_bookmark.id])
709+
self.put(url, data, expected_status_code=status.HTTP_400_BAD_REQUEST)
710+
688711
def test_patch_bookmark(self):
689712
self.authenticate()
690713
bookmark = self.setup_bookmark()

docs/src/content/docs/api.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -127,11 +127,9 @@ POST /api/bookmarks/
127127
Creates a new bookmark. Tags are simply assigned using their names. Including
128128
`is_archived: true` saves a bookmark directly to the archive.
129129

130-
If the title and description are not provided or empty, the application automatically tries to scrape them from the bookmarked website. This behavior can be disabled by adding the `disable_scraping` query parameter to the API request. If you have an application where you want to keep using scraped metadata, but also allow users to leave the title or description empty, you should:
130+
If the provided URL is already bookmarked, this silently updates the existing bookmark instead of creating a new one. If you are implementing a user interface, consider notifying users about this behavior. You can use the `/check` endpoint to check if a URL is already bookmarked and at the same time get the existing bookmark data. This behavior may change in the future to return an error instead.
131131

132-
- Fetch the scraped title and description using the `/check` endpoint.
133-
- Prefill the title and description fields in your app with the fetched values and allow users to clear those values.
134-
- Add the `disable_scraping` query parameter to prevent the API from adding them back again.
132+
If the title and description are not provided or empty, the application automatically tries to scrape them from the bookmarked website. This behavior can be disabled by adding the `disable_scraping` query parameter to the API request.
135133

136134
Example payload:
137135

@@ -164,6 +162,8 @@ When using `PATCH`, only the fields that should be updated need to be provided.
164162
Regardless which method is used, any field that is not provided is not modified.
165163
Tags are simply assigned using their names.
166164

165+
If the provided URL is already bookmarked this returns an error.
166+
167167
Example payload:
168168

169169
```json

0 commit comments

Comments
 (0)