diff --git a/app/logger/tests/test_user_profile.py b/app/logger/tests/test_user_profile.py index b8be1431..db8286c9 100644 --- a/app/logger/tests/test_user_profile.py +++ b/app/logger/tests/test_user_profile.py @@ -7,7 +7,6 @@ from ..factories import TripFactory from ..models import Caver, Trip -from ..views.userprofile import UserProfile as UserProfileView User = get_user_model() @@ -63,34 +62,6 @@ def setUp(self): notes="User2 trip notes", ) - def test_user_profile_page_trip_list(self): - """Test the trip list on the user profile page""" - self.client.force_login(self.user) - response = self.client.get(reverse("log:user", args=[self.user.username])) - self.assertEqual(response.status_code, 200) - - # Test pagination and that the correct trips are displayed - for i in range(1, 50): - self.assertContains(response, f"User1 Cave {i}") - self.assertNotContains(response, f"User2 Cave {i}") - - for i in range(51, 100): - self.assertNotContains(response, f"User1 Cave {i}") - self.assertNotContains(response, f"User2 Cave {i}") - - # Test edit links appear - for trip in self.user.trips.order_by("-start")[:50]: - self.assertContains(response, reverse("log:trip_update", args=[trip.uuid])) - - # Test the next page - response = self.client.get( - reverse("log:user", args=[self.user.username]) + "?page=2", - ) - self.assertEqual(response.status_code, 200) - for i in range(51, 100): - self.assertContains(response, f"User1 Cave {i}") - self.assertNotContains(response, f"User2 Cave {i}") - def test_user_profile_page_title(self): """Test the user profile page title""" self.client.force_login(self.user) @@ -135,23 +106,6 @@ def test_friend_only_trips_do_not_appear_on_profile_page_trip_list(self): self.assertEqual(response.status_code, 200) self.assertNotContains(response, "User1 Cave") - @tag("privacy") - def test_that_friend_only_trips_appear_to_friends(self): - """Test that friend only trips appear on the user profile page to friends""" - for trip in self.user.trips: - trip.privacy = Trip.FRIENDS - trip.save() - - self.user.friends.add(self.user2) - self.user2.friends.add(self.user) - - self.client.force_login(self.user2) - response = self.client.get(reverse("log:user", args=[self.user.username])) - self.assertEqual(response.status_code, 200) - - for trip in self.user.trips.order_by("-start")[:50]: - self.assertContains(response, trip.cave_name) - @tag("privacy") def test_user_profile_page_with_various_privacy_settings(self): """Test the user profile page with various privacy settings""" @@ -274,85 +228,6 @@ def test_no_friends_appear_for_an_unauthenticated_user(self): self.assertNotContains(response, self.user3.get_absolute_url()) self.assertNotContains(response, "Friends") - def test_user_profile_page_loads_with_all_allowed_ordering(self): - """Test that the user profile page loads with all allowed ordering""" - self.client.force_login(self.user) - for order in UserProfileView.allowed_ordering: - # Ascending - response = self.client.get(self.user.get_absolute_url() + "?sort=" + order) - self.assertEqual(response.status_code, 200) - - # Descending - response = self.client.get(self.user.get_absolute_url() + "?sort=-" + order) - self.assertEqual(response.status_code, 200) - - @tag("privacy") - def test_user_profile_htmx_search_view_does_not_show_private_trips(self): - """Test that the user profile HTMX search view does not show private trips""" - self.client.force_login(self.user) - trip = TripFactory(user=self.user2, privacy=Trip.PUBLIC) - response = self.client.post( - reverse("log:user_search", args=[self.user2.username]), - {"query": trip.cave_name}, - ) - self.assertEqual(response.status_code, 200) - self.assertContains(response, trip.get_absolute_url()) - - trip.privacy = Trip.PRIVATE - trip.save() - response = self.client.post( - reverse("log:user_search", args=[self.user2.username]), - {"query": trip.cave_name}, - ) - self.assertEqual(response.status_code, 200) - self.assertNotContains(response, trip.get_absolute_url()) - - @tag("privacy") - def test_user_profile_htmx_search_view_does_not_show_friend_only_trips(self): - """Test that the user profile search view does not show friend only trips""" - # Test the trip is shown when public - self.client.force_login(self.user) - trip = TripFactory(user=self.user2, privacy=Trip.PUBLIC) - response = self.client.post( - reverse("log:user_search", args=[self.user2.username]), - {"query": trip.cave_name}, - ) - self.assertEqual(response.status_code, 200) - self.assertContains(response, trip.get_absolute_url()) - - # Test the trip is not shown when friends only - trip.privacy = Trip.FRIENDS - trip.save() - response = self.client.post( - reverse("log:user_search", args=[self.user2.username]), - {"query": trip.cave_name}, - ) - self.assertEqual(response.status_code, 200) - self.assertNotContains(response, trip.get_absolute_url()) - - # Test the trip is shown when friends - self.user.friends.add(self.user2) - self.user2.friends.add(self.user) - - response = self.client.post( - reverse("log:user_search", args=[self.user2.username]), - {"query": trip.cave_name}, - ) - self.assertEqual(response.status_code, 200) - self.assertContains(response, trip.get_absolute_url()) - - @tag("privacy") - def test_user_profile_htmx_search_view_respects_profile_privacy(self): - """Test that the user profile search view respects profile privacy""" - self.user2.privacy = User.PRIVATE - self.user2.save() - - self.client.force_login(self.user) - response = self.client.post( - reverse("log:user_search", args=[self.user2.username]), {"query": "query"} - ) - self.assertEqual(response.status_code, 403) - @tag("privacy") def test_cave_location_privacy(self): """Test that the cave location shows for a user""" diff --git a/app/logger/urls.py b/app/logger/urls.py index db6443dc..ca86fb30 100644 --- a/app/logger/urls.py +++ b/app/logger/urls.py @@ -8,9 +8,9 @@ path("", views.Index.as_view(), name="index"), path("u//", views.UserProfile.as_view(), name="user"), path( - "u//search/,", - views.HTMXTripListSearchView.as_view(), - name="user_search", + "u//trip-table/", + views.ProfileTripsTable.as_view(), + name="profile_trips_table", ), path("trips/", views.TripsRedirect.as_view(), name="trip_list"), path("trip/edit//", views.TripUpdate.as_view(), name="trip_update"), diff --git a/app/logger/views/userprofile.py b/app/logger/views/userprofile.py index 55ae30bc..b9b82aab 100644 --- a/app/logger/views/userprofile.py +++ b/app/logger/views/userprofile.py @@ -1,9 +1,8 @@ -from django.core.exceptions import PermissionDenied -from django.db.models import Count, Q -from django.shortcuts import get_object_or_404, render +from django.db.models import Q +from django.shortcuts import get_object_or_404 +from django.urls import reverse from django.utils.decorators import method_decorator -from django.views import View -from django.views.generic import ListView +from django.views.generic import ListView, TemplateView from django_ratelimit.decorators import ratelimit from stats import statistics from users.models import CavingUser as User @@ -12,11 +11,55 @@ @method_decorator(ratelimit(key="user_or_ip", rate="500/h"), name="dispatch") -class UserProfile(ListView): +class UserProfile(TemplateView): """List all of a user's trips and their profile information""" model = Trip template_name = "logger/profile.html" + slug_field = "username" + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.profile_user = None + + def setup(self, *args, **kwargs): + """Assign self.profile_user and perform permissions checks""" + super().setup(*args, **kwargs) + self.profile_user = get_object_or_404(User, username=self.kwargs["username"]) + + def get_context_data(self, **kwargs): + context = super().get_context_data() + context["profile_user"] = self.profile_user + context["page_title"] = self.get_page_title() + context["mutual_friends"] = self.profile_user.mutual_friends(self.request.user) + context["user_has_trips"] = self.profile_user.trips.exists() + + if self.request.user not in self.profile_user.friends.all(): + if self.profile_user.allow_friend_username: + context["can_add_friend"] = True + + if not self.profile_user.is_viewable_by(self.request.user): + context["private_profile"] = True + + if self.profile_user.public_statistics: + context["stats"] = statistics.yearly( + self.profile_user.trips.exclude(type=Trip.SURFACE) + ) + + return context + + def get_page_title(self): + if self.profile_user.page_title: + return self.profile_user.page_title + else: + return f"{self.profile_user.name}'s trips" + + +class ProfileTripsTable(ListView): + """List all of a user's trips and their profile information""" + + model = Trip + template_name = "logger/profile_trips_table.html" context_object_name = "trips" slug_field = "username" paginate_by = 50 @@ -33,6 +76,8 @@ class UserProfile(ListView): def __init__(self, **kwargs): super().__init__(**kwargs) self.profile_user = None + self.allow_sort = True + self.query = None def setup(self, *args, **kwargs): """Assign self.profile_user and perform permissions checks""" @@ -40,20 +85,37 @@ def setup(self, *args, **kwargs): self.profile_user = get_object_or_404(User, username=self.kwargs["username"]) def get_queryset(self): + query = self.request.GET.get("query", "") + if len(query) < 3: + query = None + self.query = query + trips = ( Trip.objects.filter(user=self.profile_user) .select_related("user") .prefetch_related("photos") - .order_by(*self.get_ordering()) - ).annotate( - photo_count=Count( - "photos", - filter=Q(photos__is_valid=True, photos__deleted_at=None), - distinct=True, - ), - comment_count=Count("comments", distinct=True), ) + if query: + distinct_query = self.get_ordering() + distinct_query = [x.replace("-", "") for x in distinct_query] + + trips = ( + trips.filter( + Q(cave_name__unaccent__icontains=query) + | Q(cave_entrance__unaccent__icontains=query) + | Q(cave_exit__unaccent__icontains=query) + | Q(cavers__name__unaccent__icontains=query) + | Q(clubs__unaccent__icontains=query) + | Q(expedition__unaccent__icontains=query) + ) + .distinct(*distinct_query) + .order_by(*self.get_ordering()) + ) + self.allow_sort = False + else: + trips = trips.order_by(*self.get_ordering()) + friends = self.profile_user.friends.all() # Sanitise trips to be privacy aware @@ -66,7 +128,7 @@ def get_queryset(self): return trips def get_ordering(self): - ordering = self.request.GET.get("sort", "") + ordering = self.request.GET.get("sort", "").lower() if ordering.replace("-", "") in self.allowed_ordering: return ordering, "pk" @@ -75,20 +137,13 @@ def get_ordering(self): def get_context_data(self, **kwargs): context = super().get_context_data() context["profile_user"] = self.profile_user - context["page_title"] = self.get_page_title() - context["mutual_friends"] = self.profile_user.mutual_friends(self.request.user) context["show_cavers"] = self.profile_user.show_cavers_on_trip_list - if self.request.user not in self.profile_user.friends.all(): - if self.profile_user.allow_friend_username: - context["can_add_friend"] = True - - if self.profile_user.public_statistics: - context["stats"] = statistics.yearly( - self.profile_user.trips.exclude(type=Trip.SURFACE) - ) - - if not self.profile_user.is_viewable_by(self.request.user): - context["private_profile"] = True + context["htmx_url"] = reverse( + "log:profile_trips_table", args=[self.profile_user.username] + ) + context["ordering"] = self.get_ordering()[0] + context["allow_sort"] = self.allow_sort + context["query"] = self.query # This code provides the current GET parameters as a context variable # so that when a pagination link is clicked, the GET parameters are @@ -104,60 +159,3 @@ def get_page_title(self): return self.profile_user.page_title else: return f"{self.profile_user.name}'s trips" - - -@method_decorator( - ratelimit(key="user_or_ip", rate="1000/h", method=ratelimit.UNSAFE), name="dispatch" -) -class HTMXTripListSearchView(View): - def __init__(self): - super().__init__() - self.profile_user = None - - def setup(self, *args, **kwargs): - """Assign self.profile_user and perform permissions checks""" - super().setup(*args, **kwargs) - self.profile_user = get_object_or_404(User, username=self.kwargs["username"]) - if not self.profile_user.is_viewable_by(self.request.user): - raise PermissionDenied - - def post(self, request, *args, **kwargs): - """Return a list of trips matching the search query""" - query = request.POST.get("query", "") - if len(query) < 3: - return render( - request, - "logger/_htmx_trip_list_search.html", - {"trips": None, "query": query}, - ) - - trips = ( - Trip.objects.filter( - Q(user=self.profile_user) - & Q( - Q(cave_name__unaccent__icontains=query) - | Q(cave_entrance__unaccent__icontains=query) - | Q(cave_exit__unaccent__icontains=query) - | Q(cavers__name__unaccent__icontains=query) - | Q(clubs__unaccent__icontains=query) - | Q(expedition__unaccent__icontains=query) - ) - ) - .distinct("start", "pk") - .order_by("-start", "pk")[:20] - ) - - friends = self.profile_user.friends.all() - - # Sanitise trips to be privacy aware - if not self.profile_user == self.request.user: - sanitised_trips = [ - x for x in trips if x.is_viewable_by(self.request.user, friends) - ] - trips = sanitised_trips - - return render( - request, - "logger/_htmx_trip_list_search.html", - {"trips": trips, "query": query}, - ) diff --git a/app/templates/logger/profile.html b/app/templates/logger/profile.html index cb263afb..cf014f3d 100644 --- a/app/templates/logger/profile.html +++ b/app/templates/logger/profile.html @@ -1,6 +1,4 @@ {% extends "base_sidebar.html" %} -{% load static %} -{% load logger_tags %} {% load markdownify %} {% block title %}{{ page_title }}{% endblock %} @@ -19,21 +17,19 @@ {% block main %} {% if not private_profile %} - {% if trips|length > 10 %} + {% if user_has_trips %}
-
{% endif %} -
{% if profile_user.bio %}
{{ profile_user.bio|markdownify }} @@ -54,145 +50,10 @@
{% endif %} - {% if trips %} -
- - - - - - - - - - {% if not profile_user.disable_distance_statistics %} - - - - {% endif %} - - - - - - - - - {% for trip in trips %} - - - - - - - - {% if not profile_user.disable_distance_statistics %} - - - {% endif %} - - - - - - - {% if trip.cavers.all and show_cavers %} - - - - {% endif %} - {% endfor %} - -
- Date - - - - - - - - - - Cave - - - - - - - - - - Duration - - - - - - - - - - Up - - - - - - - - - - Down - - - - - - - - - - Trip type - - - - - - - - -
{{ trip.start|date:"j M y" }} - - {{ trip.cave_name }} - - - {% if trip.duration_str %} - {{ trip.duration_str }} - {% endif %} - {{ trip.vert_dist_up|distformat:request.units }}{{ trip.vert_dist_down|distformat:request.units }} - {{ trip.type }} - - {% if trip.comment_count > 0 %} -   - {% endif %} - {% if trip.photo_count > 0 %} -   - {% endif %} - {% if trip.cave_coordinates and user == profile_user %} -   - {% endif %} - {% if user == profile_user %} - - {% endif %} -
- with {{ trip.cavers.all|join:", " }} -
- - {% include "_paginate_bootstrap.html" %} -
+ {% if user_has_trips %} +
{% endif %} + {% else %}
diff --git a/app/templates/logger/profile_trips_table.html b/app/templates/logger/profile_trips_table.html new file mode 100644 index 00000000..6a7cf4ff --- /dev/null +++ b/app/templates/logger/profile_trips_table.html @@ -0,0 +1,200 @@ +{% load logger_tags %} + +
+ {% if trips or query %} +
+ + + + + + + + + + {% if not profile_user.disable_distance_statistics %} + + + + {% endif %} + + + + + + + {% for trip in trips %} + + + + + + + + {% if not profile_user.disable_distance_statistics %} + + + {% endif %} + + + + + {% if trip.cavers.all and show_cavers %} + + + + {% endif %} + {% endfor %} + {% if not trips %} + + + + {% endif %} + + +
+ Date + {% if allow_sort %} + + + + + + + + + {% endif %} + + Cave + {% if allow_sort %} + + + + + + + + + {% endif %} + + Duration + {% if allow_sort %} + + + + + + + + + {% endif %} + + Up + {% if allow_sort %} + + + + + + + + + {% endif %} + + Down + {% if allow_sort %} + + + + + + + + + {% endif %} + + Trip type + {% if allow_sort %} + + + + + + + + + {% endif %} +
{{ trip.start|date:"j M y" }} + + {{ trip.cave_name }} + + + {% if trip.duration_str %} + {{ trip.duration_str }} + {% endif %} + {{ trip.vert_dist_up|distformat:request.units }}{{ trip.vert_dist_down|distformat:request.units }} + {{ trip.type }} +
+ with {{ trip.cavers.all|join:", " }} +
+ {% if query %} + No trips found matching {{ query }}. Try searching for cave names, cavers, clubs or expeditions. + {% else %} + No trips found. Try searching for cave names, cavers, clubs or expeditions. + {% endif %} +
+ + {% if page_obj.has_other_pages %} + + {% endif %} +
+ {% endif %} +
+ +{% include "_clickable_elements.html" %}