@@ -207,7 +207,7 @@
{% trans "Suggest Books" %}
{% endif %}
-
{% if not suggested_books %}
diff --git a/bookwyrm/tests/connectors/test_abstract_connector.py b/bookwyrm/tests/connectors/test_abstract_connector.py
index 901cb5af2f..26742c05e4 100644
--- a/bookwyrm/tests/connectors/test_abstract_connector.py
+++ b/bookwyrm/tests/connectors/test_abstract_connector.py
@@ -101,6 +101,7 @@ def test_get_or_create_book_existing(self):
result = self.connector.get_or_create_book(
f"https://{DOMAIN}/book/{self.book.id}"
)
+
self.assertEqual(models.Book.objects.count(), 1)
self.assertEqual(result, self.book)
diff --git a/bookwyrm/urls.py b/bookwyrm/urls.py
index bf035312fd..9b2b68f1bf 100644
--- a/bookwyrm/urls.py
+++ b/bookwyrm/urls.py
@@ -391,6 +391,9 @@
re_path(
r"^group/(?P
\d+)(.json)?/?$", views.Group.as_view(), name="group"
),
+ re_path(
+ rf"^group/(?P\d+){regex.SLUG}/?$", views.Group.as_view(), name="group"
+ ),
re_path(
r"^group/delete/(?P\d+)/?$", views.delete_group, name="delete-group"
),
@@ -417,7 +420,10 @@
re_path(rf"{USER_PATH}/lists/?$", views.UserLists.as_view(), name="user-lists"),
re_path(r"^list/?$", views.Lists.as_view(), name="lists"),
re_path(r"^list/saved/?$", views.SavedLists.as_view(), name="saved-lists"),
- re_path(r"^list/(?P\d+)(.json)?/?$", views.List.as_view(), name="list"),
+ re_path(r"^list/(?P\d+)(\.json)?/?$", views.List.as_view(), name="list"),
+ re_path(
+ rf"^list/(?P\d+){regex.SLUG}/?$", views.List.as_view(), name="list"
+ ),
re_path(
r"^list/(?P\d+)/item/(?P\d+)/?$",
views.ListItem.as_view(),
@@ -487,6 +493,7 @@
re_path(r"^unblock/(?P\d+)/?$", views.unblock),
# statuses
re_path(rf"{STATUS_PATH}(.json)?/?$", views.Status.as_view(), name="status"),
+ re_path(rf"{STATUS_PATH}{regex.SLUG}/?$", views.Status.as_view(), name="status"),
re_path(rf"{STATUS_PATH}/activity/?$", views.Status.as_view(), name="status"),
re_path(
rf"{STATUS_PATH}/replies(.json)?/?$", views.Replies.as_view(), name="replies"
@@ -523,6 +530,7 @@
re_path(r"^unboost/(?P\d+)/?$", views.Unboost.as_view()),
# books
re_path(rf"{BOOK_PATH}(.json)?/?$", views.Book.as_view(), name="book"),
+ re_path(rf"{BOOK_PATH}{regex.SLUG}/?$", views.Book.as_view(), name="book"),
re_path(
rf"{BOOK_PATH}/(?Preview|comment|quote)/?$",
views.Book.as_view(),
@@ -580,6 +588,11 @@
re_path(
r"^author/(?P\d+)(.json)?/?$", views.Author.as_view(), name="author"
),
+ re_path(
+ rf"^author/(?P\d+){regex.SLUG}/?$",
+ views.Author.as_view(),
+ name="author",
+ ),
re_path(
r"^author/(?P\d+)/edit/?$",
views.EditAuthor.as_view(),
diff --git a/bookwyrm/utils/regex.py b/bookwyrm/utils/regex.py
index f9036cda11..38d097f7f3 100644
--- a/bookwyrm/utils/regex.py
+++ b/bookwyrm/utils/regex.py
@@ -6,5 +6,6 @@
USERNAME = rf"{LOCALNAME}(@{DOMAIN})?"
STRICT_USERNAME = rf"\B{STRICT_LOCALNAME}(@{DOMAIN})?\b"
FULL_USERNAME = rf"{LOCALNAME}@{DOMAIN}\b"
+SLUG = r"/s/(?P[-_a-z0-9]*)"
# should match (BookWyrm/1.0.0; or (BookWyrm/99.1.2;
BOOKWYRM_USER_AGENT = r"\(BookWyrm/[0-9]+\.[0-9]+\.[0-9]+;"
diff --git a/bookwyrm/views/author.py b/bookwyrm/views/author.py
index 47845ab6b0..c7b17310c9 100644
--- a/bookwyrm/views/author.py
+++ b/bookwyrm/views/author.py
@@ -11,20 +11,24 @@
from bookwyrm.activitypub import ActivitypubResponse
from bookwyrm.connectors import connector_manager
from bookwyrm.settings import PAGE_LENGTH
-from bookwyrm.views.helpers import is_api_request
+from bookwyrm.views.helpers import is_api_request, maybe_redirect_local_path
# pylint: disable= no-self-use
class Author(View):
"""this person wrote a book"""
- def get(self, request, author_id):
+ # pylint: disable=unused-argument
+ def get(self, request, author_id, slug=None):
"""landing page for an author"""
author = get_object_or_404(models.Author, id=author_id)
if is_api_request(request):
return ActivitypubResponse(author.to_activity())
+ if redirect_local_path := maybe_redirect_local_path(request, author):
+ return redirect_local_path
+
books = (
models.Work.objects.filter(editions__authors=author)
.order_by("created_date")
diff --git a/bookwyrm/views/books/books.py b/bookwyrm/views/books/books.py
index a010c300f5..565220b6ea 100644
--- a/bookwyrm/views/books/books.py
+++ b/bookwyrm/views/books/books.py
@@ -15,14 +15,14 @@
from bookwyrm.connectors import connector_manager, ConnectorException
from bookwyrm.connectors.abstract_connector import get_image
from bookwyrm.settings import PAGE_LENGTH
-from bookwyrm.views.helpers import is_api_request
+from bookwyrm.views.helpers import is_api_request, maybe_redirect_local_path
# pylint: disable=no-self-use
class Book(View):
"""a book! this is the stuff"""
- def get(self, request, book_id, user_statuses=False, update_error=False):
+ def get(self, request, book_id, **kwargs):
"""info about a book"""
if is_api_request(request):
book = get_object_or_404(
@@ -30,7 +30,11 @@ def get(self, request, book_id, user_statuses=False, update_error=False):
)
return ActivitypubResponse(book.to_activity())
- user_statuses = user_statuses if request.user.is_authenticated else False
+ user_statuses = (
+ kwargs.get("user_statuses", False)
+ if request.user.is_authenticated
+ else False
+ )
# it's safe to use this OR because edition and work and subclasses of the same
# table, so they never have clashing IDs
@@ -46,6 +50,11 @@ def get(self, request, book_id, user_statuses=False, update_error=False):
if not book or not book.parent_work:
raise Http404()
+ if redirect_local_path := not user_statuses and maybe_redirect_local_path(
+ request, book
+ ):
+ return redirect_local_path
+
# all reviews for all editions of the book
reviews = models.Review.privacy_filter(request.user).filter(
book__parent_work__editions=book
@@ -80,7 +89,7 @@ def get(self, request, book_id, user_statuses=False, update_error=False):
else None,
"rating": reviews.aggregate(Avg("rating"))["rating__avg"],
"lists": lists,
- "update_error": update_error,
+ "update_error": kwargs.get("update_error", False),
}
if request.user.is_authenticated:
diff --git a/bookwyrm/views/feed.py b/bookwyrm/views/feed.py
index a8d8edd5e8..2814e93998 100644
--- a/bookwyrm/views/feed.py
+++ b/bookwyrm/views/feed.py
@@ -15,7 +15,7 @@
from bookwyrm.settings import PAGE_LENGTH, STREAMS
from bookwyrm.suggested_users import suggested_users
from .helpers import filter_stream_by_status_type, get_user_from_username
-from .helpers import is_api_request, is_bookwyrm_request
+from .helpers import is_api_request, is_bookwyrm_request, maybe_redirect_local_path
from .annual_summary import get_annual_summary_year
@@ -113,7 +113,8 @@ def get(self, request, username=None):
class Status(View):
"""get posting"""
- def get(self, request, username, status_id):
+ # pylint: disable=unused-argument
+ def get(self, request, username, status_id, slug=None):
"""display a particular status (and replies, etc)"""
user = get_user_from_username(request.user, username)
status = get_object_or_404(
@@ -130,6 +131,9 @@ def get(self, request, username, status_id):
status.to_activity(pure=not is_bookwyrm_request(request))
)
+ if redirect_local_path := maybe_redirect_local_path(request, status):
+ return redirect_local_path
+
visible_thread = (
models.Status.privacy_filter(request.user)
.filter(thread_id=status.thread_id)
diff --git a/bookwyrm/views/group.py b/bookwyrm/views/group.py
index 8939298560..9c282e48fd 100644
--- a/bookwyrm/views/group.py
+++ b/bookwyrm/views/group.py
@@ -14,17 +14,22 @@
from bookwyrm import forms, models
from bookwyrm.suggested_users import suggested_users
-from .helpers import get_user_from_username
+from .helpers import get_user_from_username, maybe_redirect_local_path
# pylint: disable=no-self-use
class Group(View):
"""group page"""
- def get(self, request, group_id):
+ # pylint: disable=unused-argument
+ def get(self, request, group_id, slug=None):
"""display a group"""
group = get_object_or_404(models.Group, id=group_id)
group.raise_visible_to_user(request.user)
+
+ if redirect_local_path := maybe_redirect_local_path(request, group):
+ return redirect_local_path
+
lists = (
models.List.privacy_filter(request.user)
.filter(group=group)
@@ -80,7 +85,8 @@ def post(self, request, group_id):
class UserGroups(View):
"""a user's groups page"""
- def get(self, request, username):
+ # pylint: disable=unused-argument
+ def get(self, request, username, slug=None):
"""display a group"""
user = get_user_from_username(request.user, username)
groups = (
diff --git a/bookwyrm/views/helpers.py b/bookwyrm/views/helpers.py
index 74d867b66b..a2c4b20d34 100644
--- a/bookwyrm/views/helpers.py
+++ b/bookwyrm/views/helpers.py
@@ -8,6 +8,7 @@
from requests import HTTPError
from django.db.models import Q
from django.conf import settings as django_settings
+from django.shortcuts import redirect
from django.http import Http404
from django.utils import translation
@@ -201,3 +202,21 @@ def filter_stream_by_status_type(activities, allowed_types=None):
)
return activities
+
+
+def maybe_redirect_local_path(request, model):
+ """
+ if the request had an invalid path, return a permanent redirect response to the
+ correct one, including a slug if any.
+ if path is valid, returns False.
+ """
+
+ # don't redirect empty path for unit tests which currently have this
+ if request.path in ("/", model.local_path):
+ return False
+
+ new_path = model.local_path
+ if len(request.GET) > 0:
+ new_path = f"{model.local_path}?{request.GET.urlencode()}"
+
+ return redirect(new_path, permanent=True)
diff --git a/bookwyrm/views/list/list.py b/bookwyrm/views/list/list.py
index dc1843fa12..e66f94feca 100644
--- a/bookwyrm/views/list/list.py
+++ b/bookwyrm/views/list/list.py
@@ -18,21 +18,27 @@
from bookwyrm import book_search, forms, models
from bookwyrm.activitypub import ActivitypubResponse
from bookwyrm.settings import PAGE_LENGTH
-from bookwyrm.views.helpers import is_api_request
+from bookwyrm.views.helpers import is_api_request, maybe_redirect_local_path
# pylint: disable=no-self-use
class List(View):
"""book list page"""
- def get(self, request, list_id, add_failed=False, add_succeeded=False):
+ def get(self, request, list_id, **kwargs):
"""display a book list"""
+ add_failed = kwargs.get("add_failed", False)
+ add_succeeded = kwargs.get("add_succeeded", False)
+
book_list = get_object_or_404(models.List, id=list_id)
book_list.raise_visible_to_user(request.user)
if is_api_request(request):
return ActivitypubResponse(book_list.to_activity(**request.GET))
+ if r := maybe_redirect_local_path(request, book_list):
+ return r
+
query = request.GET.get("q")
suggestions = None