diff --git a/app/import/forms.py b/app/import/forms.py index 1da19994..c71f8553 100644 --- a/app/import/forms.py +++ b/app/import/forms.py @@ -66,6 +66,13 @@ def clean_file(self) -> list[dict]: class TripImportForm(BaseTripForm): + cavers = forms.CharField( + max_length=255, + label="Cavers", + help_text="A comma separated list of cavers.", + required=False, + ) + class Meta: model = Trip fields = [ @@ -80,7 +87,6 @@ class Meta: "privacy", "clubs", "expedition", - "cavers", "horizontal_dist", "vert_dist_down", "vert_dist_up", diff --git a/app/import/tests/test_csvs/valid_trips.csv b/app/import/tests/test_csvs/valid_trips.csv index b83738af..eff969a8 100644 --- a/app/import/tests/test_csvs/valid_trips.csv +++ b/app/import/tests/test_csvs/valid_trips.csv @@ -1,4 +1,4 @@ Cave name,Cave entrance,Cave exit,Cave region,Cave country,Start date/time,End date/time,Type,Privacy,Clubs,Expedition,Cavers,Horizontal dist,Vertical dist down,Vertical dist up,Surveyed dist,Resurveyed dist,Aid dist,Notes -Trip 1, Lancaster Hole, County Pot, Yorkshire Dales, England,2022-06-17 12:00,2022-06-17 18:00,Sport,Default,NYMCC,,Andrew Northall,500m,25m,25m,,,,, +Trip 1, Lancaster Hole, County Pot, Yorkshire Dales, England,2022-06-17 12:00,2022-06-17 18:00,Sport,Default,NYMCC,,"Andrew Northall, John Smith",500m,25m,25m,,,,, Trip 2, Lancaster Hole, County Pot, Yorkshire Dales, England,2022-06-20 4pm,2022-06-20 8pm,Sport,,NYMCC,,Andrew Northall,500m,25m,25m,,,,, Trip 3, Ireby Fell Cavern, Ireby Fell Cavern, Yorkshire Dales, England,2022-06-20 4pm,2022-06-20 8pm,Sport,,NYMCC,,Andrew Northall,500m,25m,25m,,,,, diff --git a/app/import/tests/test_import.py b/app/import/tests/test_import.py index 730d4b2a..276de1ec 100644 --- a/app/import/tests/test_import.py +++ b/app/import/tests/test_import.py @@ -174,7 +174,8 @@ def test_save_with_valid_data(self): self.assertEqual( trip.end, datetime(2022, 6, 17, 17, 0, 0, tzinfo=ZoneInfo("UTC")) ) - self.assertEqual(trip.cavers, "Andrew Northall") + self.assertEqual(trip.cavers.all()[0].name, "Andrew Northall") + self.assertEqual(trip.cavers.all()[1].name, "John Smith") trip = Trip.objects.get(cave_name="Trip 2") self.assertEqual(trip.privacy, Trip.DEFAULT) diff --git a/app/import/views.py b/app/import/views.py index 5b16e9f4..ba066e40 100644 --- a/app/import/views.py +++ b/app/import/views.py @@ -7,6 +7,7 @@ from django.utils.safestring import SafeString from django.views.generic import FormView, View from django_ratelimit.decorators import ratelimit +from logger.models import Caver from . import services from .forms import ImportUploadForm, TripImportFormset, TripImportFormsetHelper @@ -76,8 +77,19 @@ def post(self, request, *args, **kwargs): trip = form.save(commit=False) trip.user = request.user trip.save() + trip.followers.add(request.user) + cavers = form.cleaned_data["cavers"] + cavers = cavers.split(",") + for caver in cavers: + caver = caver.strip() + if caver: + caver_obj, _ = Caver.objects.get_or_create( + name=caver, user=request.user + ) + trip.cavers.add(caver_obj) + # noinspection PyUnboundLocalVariable messages.success(request, f"Successfully imported {count} trips!") log_user_action(request.user, f"imported {count} trips from a CSV file") diff --git a/app/logger/admin.py b/app/logger/admin.py index b5be0c2b..2649810f 100644 --- a/app/logger/admin.py +++ b/app/logger/admin.py @@ -14,6 +14,17 @@ class TripAdminForm(DistanceUnitFormMixin, ModelForm): pass +class CaverInline(TabularInline): + model = Trip.cavers.through + extra = 0 + + def has_add_permission(self, request, obj=None): + return False + + def has_change_permission(self, request, obj=None): + return False + + class TripPhotoInline(TabularInline): model = TripPhoto fk_name = "trip" @@ -29,7 +40,7 @@ def has_add_permission(self, request, obj=None): @admin.register(Trip) class TripAdmin(ModelAdmin): form = TripAdminForm - inlines = [TripPhotoInline] + inlines = [CaverInline, TripPhotoInline] search_fields = ( "cave_name", "cave_entrance", @@ -108,10 +119,9 @@ class TripAdmin(ModelAdmin): }, ), ( - "Attendees", + "Organisations", { "fields": ( - "cavers", "clubs", "expedition", ), diff --git a/app/logger/factories.py b/app/logger/factories.py index 84ed6ab1..9140716f 100644 --- a/app/logger/factories.py +++ b/app/logger/factories.py @@ -7,7 +7,7 @@ from factory.django import DjangoModelFactory from faker import Faker -from .models import Trip +from .models import Caver, Trip fake = Faker() @@ -21,13 +21,6 @@ MAX_SHORT_TRIP_LENGTH_IN_MINUTES = MAX_SHORT_TRIP_LENGTH_IN_HOURS * 60 -def _get_caver_list(): - names_list = [] - for _ in range(random.randgen.randint(0, 5)): - names_list.append(fake.name()) - return ", ".join(names_list) - - def _generate_distance(min, max, chance_of_none=0.2): if (chance_of_none * 100) > random.randgen.randint(1, 100): return None @@ -166,6 +159,13 @@ def generate_club(): return f"{city} {middle} {suffix}" +class CaverFactory(DjangoModelFactory): + class Meta: + model = Caver + + name = factory.Faker("name") + + class TripFactory(DjangoModelFactory): class Meta: model = Trip @@ -205,7 +205,6 @@ class Meta: start_date=datetime.now() - timedelta(days=365 * 5), ) clubs = factory.LazyFunction(generate_club) - cavers = factory.LazyFunction(_get_caver_list) expedition = factory.LazyFunction(_generate_expedition) privacy = factory.Faker( "random_element", @@ -257,6 +256,17 @@ def vert_dist_down(self): return self.vert_dist_up + @factory.post_generation + def add_cavers(self, create, extracted, **kwargs): + if not create or not extracted: + return + + num_cavers = random.randgen.randint(1, 10) + cavers = CaverFactory.create_batch(num_cavers, user=self.user) + + for caver in cavers: + self.cavers.add(caver) + @classmethod def _adjust_kwargs(cls, **kwargs): kwargs["cave_name"] = kwargs["cave_name"].replace(".", "") diff --git a/app/logger/forms.py b/app/logger/forms.py index bc3bf11d..a464b28c 100644 --- a/app/logger/forms.py +++ b/app/logger/forms.py @@ -2,6 +2,7 @@ from crispy_forms.helper import FormHelper from crispy_forms.layout import HTML, Div, Field, Fieldset, Layout, Submit +from dal import autocomplete from django import forms from django.core.exceptions import ValidationError from django.urls import reverse_lazy @@ -9,7 +10,7 @@ from users.models import CavingUser from .mixins import CleanCaveLocationMixin, DistanceUnitFormMixin -from .models import Trip, TripPhoto +from .models import Caver, Trip, TripPhoto User = CavingUser @@ -113,6 +114,7 @@ class Meta: "hx-indicator": "", } ), + "cavers": autocomplete.ModelSelect2Multiple("log:caver_autocomplete"), } def __init__(self, user, *args, **kwargs): @@ -361,3 +363,108 @@ def clean_user(self): except User.DoesNotExist: raise ValidationError("Username not found.") return user + + +class LinkCaverForm(forms.Form): + account = forms.ChoiceField( + label="Account to link", + required=True, + ) + + def __init__(self, *args, **kwargs): + self.user = kwargs.pop("user") + super().__init__(*args, **kwargs) + self.fields["account"].choices = self._get_account_choices() + self.helper = FormHelper() + self.helper.form_method = "post" + self.helper.add_input(Submit("submit", "Save linked account")) + + def _get_account_choices(self): + choices = [] + already_linked = [] + + cavers = Caver.objects.filter(user=self.user) + for caver in cavers: + if caver.linked_account: + already_linked.append(caver.linked_account) + + for friend in self.user.friends.all(): + if friend not in already_linked: + choices.append( + [friend.username, f"{friend.name} -- @{friend.username}"] + ) + + return choices + + def clean_account(self): + account = self.cleaned_data.get("account") + if not account: + raise ValidationError("Please select an account.") + + try: + account = User.objects.get(username=account) + except User.DoesNotExist: + raise ValidationError("Account not found.") + + if account == self.user: + raise ValidationError("You cannot link your own account.") + + if account not in self.user.friends.all(): + raise ValidationError("You can only link accounts of your friends.") + + return account + + +class RenameCaverForm(forms.Form): + name = forms.CharField( + label="New name", + required=True, + help_text="The new name of this caver as it will appear on your trips.", + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.helper = FormHelper() + + def clean_name(self): + name = self.cleaned_data.get("name").strip() + if len(name) < 3: + raise ValidationError("Please enter at least three characters.") + return name + + +class MergeCaverForm(forms.Form): + caver = forms.ChoiceField( + label="Record to merge", + required=True, + widget=forms.Select, + ) + + def __init__(self, *args, **kwargs): + self.user = kwargs.pop("user") + super().__init__(*args, **kwargs) + self.fields["caver"].choices = self._get_caver_choices() + self.helper = FormHelper() + self.helper.form_method = "post" + self.helper.add_input(Submit("submit", "Merge caver")) + + def _get_caver_choices(self): + choices = [] + + cavers = Caver.objects.filter(user=self.user).order_by("name") + for caver in cavers: + choices.append([caver.uuid, f"{caver.name}"]) + + return choices + + def clean_caver(self): + caver = self.cleaned_data.get("caver") + if not caver: + raise ValidationError("Please select a caver record to merge.") + + try: + caver = Caver.objects.get(uuid=caver, user=self.user) + except Caver.DoesNotExist: + raise ValidationError("Caver record not found.") + + return caver diff --git a/app/logger/migrations/0034_caver.py b/app/logger/migrations/0034_caver.py new file mode 100644 index 00000000..28a9bd86 --- /dev/null +++ b/app/logger/migrations/0034_caver.py @@ -0,0 +1,61 @@ +# Generated by Django 4.2.6 on 2023-10-31 17:00 + +import uuid + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("logger", "0033_make_trip_owner_follow_their_trips"), + ] + + operations = [ + migrations.CreateModel( + name="Caver", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=40)), + ( + "added", + models.DateTimeField( + auto_now_add=True, verbose_name="caver added on" + ), + ), + ( + "updated", + models.DateTimeField( + auto_now=True, verbose_name="caver last updated" + ), + ), + ( + "uuid", + models.UUIDField( + default=uuid.uuid4, + editable=False, + help_text="A unique identifier for this caver.", + unique=True, + verbose_name="UUID", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + ] diff --git a/app/logger/migrations/0035_trip_cavers_new.py b/app/logger/migrations/0035_trip_cavers_new.py new file mode 100644 index 00000000..67af97bb --- /dev/null +++ b/app/logger/migrations/0035_trip_cavers_new.py @@ -0,0 +1,21 @@ +# Generated by Django 4.2.6 on 2023-10-31 17:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("logger", "0034_caver"), + ] + + operations = [ + migrations.AddField( + model_name="trip", + name="cavers_new", + field=models.ManyToManyField( + blank=True, + help_text="A list of cavers that were on this trip.", + to="logger.caver", + ), + ), + ] diff --git a/app/logger/migrations/0036_old_cavers_to_new_cavers.py b/app/logger/migrations/0036_old_cavers_to_new_cavers.py new file mode 100644 index 00000000..6dda2611 --- /dev/null +++ b/app/logger/migrations/0036_old_cavers_to_new_cavers.py @@ -0,0 +1,36 @@ +# Generated by Django 4.2.6 on 2023-10-31 17:03 + +from django.db import migrations + + +def migrate_old_cavers_to_new_cavers(apps, schema_editor): + """Migrate old, comma separated text field to new ManyToMany field""" + Trip = apps.get_model("logger", "Trip") + Caver = apps.get_model("logger", "Caver") + + for trip in Trip.objects.all(): + if not trip.cavers: + continue + + caver_names = trip.cavers.split(",") + for caver_name in caver_names: + caver_name = caver_name.strip() + if not caver_name: + continue + + try: + caver = Caver.objects.get(name__iexact=caver_name, user=trip.user) + except Caver.DoesNotExist: + caver = Caver.objects.create(name=caver_name, user=trip.user) + + trip.cavers_new.add(caver) + + +class Migration(migrations.Migration): + dependencies = [ + ("logger", "0035_trip_cavers_new"), + ] + + operations = [ + migrations.RunPython(migrate_old_cavers_to_new_cavers), + ] diff --git a/app/logger/migrations/0037_rename_caver_fields.py b/app/logger/migrations/0037_rename_caver_fields.py new file mode 100644 index 00000000..f478b214 --- /dev/null +++ b/app/logger/migrations/0037_rename_caver_fields.py @@ -0,0 +1,22 @@ +# Generated by Django 4.2.6 on 2023-10-31 17:19 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("logger", "0036_old_cavers_to_new_cavers"), + ] + + operations = [ + migrations.RenameField( + model_name="trip", + old_name="cavers", + new_name="cavers_old", + ), + migrations.RenameField( + model_name="trip", + old_name="cavers_new", + new_name="cavers", + ), + ] diff --git a/app/logger/migrations/0038_caver_linked_account.py b/app/logger/migrations/0038_caver_linked_account.py new file mode 100644 index 00000000..4c746ae2 --- /dev/null +++ b/app/logger/migrations/0038_caver_linked_account.py @@ -0,0 +1,25 @@ +# Generated by Django 4.2.6 on 2023-11-01 09:00 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("logger", "0037_rename_caver_fields"), + ] + + operations = [ + migrations.AddField( + model_name="caver", + name="linked_account", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="linked_cavers", + to=settings.AUTH_USER_MODEL, + ), + ), + ] diff --git a/app/logger/models/trip.py b/app/logger/models/trip.py index bc807d7c..1a01ded6 100644 --- a/app/logger/models/trip.py +++ b/app/logger/models/trip.py @@ -5,6 +5,7 @@ from django.conf import settings from django.contrib.gis.db import models from django.core.exceptions import ValidationError +from django.db.models import Sum from django.urls import reverse from tinymce.models import HTMLField @@ -15,6 +16,54 @@ ) +class Caver(models.Model): + """A caver that was on a trip""" + + name = models.CharField(max_length=40) + added = models.DateTimeField("caver added on", auto_now_add=True) + updated = models.DateTimeField("caver last updated", auto_now=True) + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + linked_account = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + related_name="linked_cavers", + ) + uuid = models.UUIDField( + verbose_name="UUID", + default=uuid.uuid4, + editable=False, + unique=True, + help_text="A unique identifier for this caver.", + ) + + def __str__(self): + return self.name + + def total_trip_duration(self): + return self.trip_set.aggregate(Sum("duration"))["duration__sum"] + + def total_trip_duration_str(self): + td = self.total_trip_duration() + if td: + return humanize.precisedelta( + td, minimum_unit="minutes", suppress=["months", "years"] + ) + else: + return None + + def save(self, *args, **kwargs): + self.name = self.name.strip() + + if self.linked_account not in self.user.friends.all(): + self.linked_account = None + + super().save(*args, **kwargs) + + def get_absolute_url(self): + return reverse("log:caver_detail", args=[self.uuid]) + + # noinspection PyUnresolvedReferences class Trip(models.Model): """Caving trip model.""" @@ -119,7 +168,7 @@ class Trip(models.Model): choices=TRIP_TYPES, default=SPORT, ) - cavers = models.CharField( + cavers_old = models.CharField( max_length=250, blank=True, help_text=( @@ -127,6 +176,11 @@ class Trip(models.Model): "Avoid adding yourself to this list." ), ) + cavers = models.ManyToManyField( + Caver, + blank=True, + help_text="A list of cavers that were on this trip.", + ) clubs = models.CharField( max_length=100, blank=True, diff --git a/app/logger/search.py b/app/logger/search.py index 29e387b1..fbfe3e6b 100644 --- a/app/logger/search.py +++ b/app/logger/search.py @@ -48,7 +48,7 @@ def trip_search(*, terms, for_user, search_user=None, type=None, fields=None) -> def _build_search_field_queries(terms, fields, for_user) -> Q: queries = Q() if "cavers" in fields or not fields: - queries |= Q(cavers__unaccent__icontains=terms) + queries |= Q(cavers__name__unaccent__icontains=terms) if "cave_name" in fields or not fields: queries |= Q(cave_name__unaccent__icontains=terms) if "cave_entrance" in fields or not fields: diff --git a/app/logger/tests/test_cavers.py b/app/logger/tests/test_cavers.py new file mode 100644 index 00000000..b96ccb6c --- /dev/null +++ b/app/logger/tests/test_cavers.py @@ -0,0 +1,284 @@ +import logging +from datetime import datetime as dt + +from django.contrib.auth import get_user_model +from django.test import TestCase, tag +from django.urls import reverse +from logger.models import Caver, Trip + +User = get_user_model() + + +@tag("logger", "caver", "fast") +class CaverModelTests(TestCase): + def setUp(self): + """Reduce log level to avoid 404 error""" + logger = logging.getLogger("django.request") + self.previous_level = logger.getEffectiveLevel() + logger.setLevel(logging.ERROR) + + # Test user to enable trip creation + self.user = User.objects.create_user( + email="test@caves.app", + username="testuser", + password="password", + name="Test User", + ) + self.user.is_active = True + self.user.save() + + self.user2 = User.objects.create_user( + email="test2@caves.app", + username="testuser2", + password="password", + name="Test User 2", + ) + self.user2.is_active = True + self.user2.save() + + self.trip = Trip.objects.create( + user=self.user, + cave_name="Test Trip", + start=dt.fromisoformat("2010-01-01T12:00:00+00:00"), + end=dt.fromisoformat("2010-01-01T14:00:00+00:00"), + ) + + self.trip2 = Trip.objects.create( + user=self.user, + cave_name="Test Trip 2", + start=dt.fromisoformat("2010-01-01T12:00:00+00:00"), + end=dt.fromisoformat("2010-01-01T14:00:00+00:00"), + ) + + self.caver = Caver.objects.create(name="Test Caver", user=self.user) + self.caver2 = Caver.objects.create(name="Test Caver 2", user=self.user) + + self.trip.cavers.add(self.caver) + self.trip2.cavers.add(self.caver2) + + def test_caver_model_str(self): + """Test that the Caver model returns the correct string representation""" + self.assertEqual(str(self.caver), "Test Caver") + + def test_caver_model_get_absolute_url(self): + """Test that the Caver model returns the correct absolute URL""" + self.assertEqual( + self.caver.get_absolute_url(), f"/log/cavers/{self.caver.uuid}/" + ) + + def test_caver_detail_view(self): + """Test that the Caver detail view returns a 200""" + self.client.force_login(self.user) + response = self.client.get(self.caver.get_absolute_url()) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Test Caver") + self.assertContains(response, "Test Trip") + self.assertContains(response, "Link caves.app account") + + @tag("privacy") + def test_caver_detail_view_as_invalid_user(self): + """Test that the Caver detail view returns a 403 for an invalid user""" + self.client.force_login(self.user2) + response = self.client.get(self.caver.get_absolute_url()) + self.assertEqual(response.status_code, 404) + + @tag("privacy") + def test_caver_detail_view_as_anonymous_user(self): + """Test that the Caver detail view returns a 403 for an anonymous user""" + response = self.client.get(self.caver.get_absolute_url(), follow=False) + self.assertEqual(response.status_code, 302) + + def test_caver_rename_view(self): + """Test that the Caver rename view returns a 200""" + self.client.force_login(self.user) + response = self.client.post( + reverse("log:caver_rename", args=[self.caver.uuid]), + data={"name": "Rename Test Caver"}, + follow=True, + ) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Rename Test Caver") + + self.caver.refresh_from_db() + self.assertEqual(self.caver.name, "Rename Test Caver") + + @tag("privacy") + def test_caver_rename_view_as_invalid_user(self): + """Test that the Caver rename view returns a 404 for an invalid user""" + self.client.force_login(self.user2) + response = self.client.post( + reverse("log:caver_rename", args=[self.caver.uuid]), + data={"name": "Rename Test Caver"}, + ) + + self.assertEqual(response.status_code, 404) + self.assertEqual(self.caver.name, "Test Caver") + + @tag("privacy") + def test_caver_rename_view_as_anonymous_user(self): + """Test that the Caver rename view returns a 302 for an anonymous user""" + response = self.client.post( + reverse("log:caver_rename", args=[self.caver.uuid]), + data={"name": "Rename Test Caver"}, + ) + + self.assertEqual(response.status_code, 302) + self.assertEqual(self.caver.name, "Test Caver") + + def test_caver_rename_view_with_invalid_data(self): + """Test that the Caver rename view returns a 200 with invalid data""" + self.client.force_login(self.user) + response = self.client.post( + reverse("log:caver_rename", args=[self.caver.uuid]), + data={"name": "a"}, + follow=True, + ) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Please enter at least three characters.") + self.assertEqual(self.caver.name, "Test Caver") + + def test_caver_merge_view(self): + """Test that the Caver merge view returns a 200""" + self.client.force_login(self.user) + self.assertEqual(self.caver.trip_set.count(), 1) + self.assertEqual(self.caver2.trip_set.count(), 1) + + response = self.client.post( + reverse("log:caver_merge", args=[self.caver.uuid]), + data={"caver": self.caver2.uuid}, + follow=True, + ) + + self.assertEqual(response.status_code, 200) + self.caver.refresh_from_db() + self.assertEqual(self.caver.trip_set.count(), 2) + self.assertEqual(self.caver.name, "Test Caver") + + with self.assertRaises(Caver.DoesNotExist): + self.caver2.refresh_from_db() + + @tag("privacy") + def test_caver_merge_view_as_invalid_user(self): + """Test that the Caver merge view returns a 404 for an invalid user""" + self.client.force_login(self.user2) + response = self.client.post( + reverse("log:caver_merge", args=[self.caver.uuid]), + data={"caver": self.caver2.uuid}, + ) + + self.assertEqual(response.status_code, 404) + self.assertEqual(self.caver.name, "Test Caver") + self.assertEqual(self.caver2.name, "Test Caver 2") + + @tag("privacy") + def test_caver_merge_view_as_anonymous_user(self): + """Test that the Caver merge view returns a 302 for an anonymous user""" + response = self.client.post( + reverse("log:caver_merge", args=[self.caver.uuid]), + data={"caver": self.caver2.uuid}, + ) + + self.assertEqual(response.status_code, 302) + self.assertEqual(self.caver.name, "Test Caver") + self.assertEqual(self.caver2.name, "Test Caver 2") + + def test_caver_merge_view_with_invalid_data(self): + """Test that the Caver merge view does not merge with invalid data""" + self.client.force_login(self.user) + response = self.client.post( + reverse("log:caver_merge", args=[self.caver.uuid]), + data={"caver": "invalid-uuid"}, + follow=True, + ) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Select a valid choice.") + self.assertEqual(self.caver.name, "Test Caver") + self.assertEqual(self.caver2.name, "Test Caver 2") + + def test_caver_link_and_unlink_views(self): + """Test that the Caver link and unlink views""" + self.client.force_login(self.user) + self.assertEqual(self.caver.linked_account, None) + + self.user.friends.add(self.user2) + self.user2.friends.add(self.user) + + response = self.client.post( + reverse("log:caver_link", args=[self.caver.uuid]), + data={"account": self.user2.username}, + follow=True, + ) + + self.assertEqual(response.status_code, 200) + self.caver.refresh_from_db() + self.assertEqual(self.caver.linked_account, self.user2) + + response = self.client.post( + reverse("log:caver_unlink", args=[self.caver.uuid]), + follow=True, + ) + + self.assertEqual(response.status_code, 200) + self.caver.refresh_from_db() + self.assertEqual(self.caver.linked_account, None) + + @tag("privacy") + def test_caver_link_and_unlink_views_as_invalid_user(self): + """Test that the Caver link and unlink views return a 404 for an invalid user""" + self.client.force_login(self.user2) + response = self.client.post( + reverse("log:caver_link", args=[self.caver.uuid]), + data={"account": self.user2.username}, + ) + + self.assertEqual(response.status_code, 404) + self.assertEqual(self.caver.linked_account, None) + + response = self.client.post( + reverse("log:caver_unlink", args=[self.caver.uuid]), + ) + + self.assertEqual(response.status_code, 404) + self.assertEqual(self.caver.linked_account, None) + + def test_caver_link_message_appears_on_caver_detail_view(self): + """Test that the Caver link message appears on the Caver detail view""" + self.client.force_login(self.user) + self.assertEqual(self.caver.linked_account, None) + + self.user.friends.add(self.user2) + self.user2.friends.add(self.user) + + response = self.client.get(self.caver.get_absolute_url()) + self.assertEqual(response.status_code, 200) + self.assertNotContains( + response, "This caver record is linked to the caves.app account of" + ) + + response = self.client.post( + reverse("log:caver_link", args=[self.caver.uuid]), + data={"account": self.user2.username}, + follow=True, + ) + + self.assertEqual(response.status_code, 200) + self.assertContains( + response, "The caver record for Test Caver has been linked to @testuser2." + ) + self.caver.refresh_from_db() + self.assertEqual(self.caver.linked_account, self.user2) + + response = self.client.get(self.caver.get_absolute_url()) + self.assertEqual(response.status_code, 200) + self.assertContains(response, self.user2.get_absolute_url()) + self.assertContains( + response, "This caver record is linked to the caves.app account of" + ) + + def tearDown(self): + """Reset the log level back to normal""" + logger = logging.getLogger("django.request") + logger.setLevel(self.previous_level) diff --git a/app/logger/tests/test_pages_load.py b/app/logger/tests/test_pages_load.py index f83ff3d1..11d00d98 100644 --- a/app/logger/tests/test_pages_load.py +++ b/app/logger/tests/test_pages_load.py @@ -2,7 +2,7 @@ from django.test import Client, TestCase, tag from django.urls import reverse from django.utils import timezone -from logger.models import Trip +from logger.models import Caver, Trip User = get_user_model() @@ -146,3 +146,24 @@ def test_search_page_loads(self): self.client.force_login(self.user) response = self.client.get(reverse("log:search")) self.assertEqual(response.status_code, 200) + + def test_caver_list_page_loads(self): + """Test that the caver list page loads""" + self.client.force_login(self.user) + response = self.client.get(reverse("log:caver_list")) + self.assertEqual(response.status_code, 200) + + def test_caver_list_page_loads_with_cavers(self): + """Test that the caver list page loads with cavers""" + self.client.force_login(self.user) + for i in range(250): + Caver.objects.create(name=f"Test Caver {i}", user=self.user) + response = self.client.get(reverse("log:caver_list")) + self.assertEqual(response.status_code, 200) + + def test_caver_detail_page_loads(self): + """Test that the caver detail page loads""" + self.client.force_login(self.user) + caver = Caver.objects.create(name="Test Caver", user=self.user) + response = self.client.get(caver.get_absolute_url()) + self.assertEqual(response.status_code, 200) diff --git a/app/logger/tests/test_search.py b/app/logger/tests/test_search.py index ca77f5d7..15d6d9bb 100644 --- a/app/logger/tests/test_search.py +++ b/app/logger/tests/test_search.py @@ -144,7 +144,7 @@ def test_private_trips_do_not_appear_in_search_results(self): test_finder = str(uuid.uuid4()) test_identifier = str(uuid.uuid4()) trip = TripFactory( - user=self.user, cave_name=test_finder, cavers=test_identifier + user=self.user, cave_name=test_finder, cave_entrance=test_identifier ) trip.privacy = Trip.PUBLIC trip.save() @@ -181,9 +181,7 @@ def test_private_trips_appear_in_results_when_searching_own_trips(self): test_finder = str(uuid.uuid4()) test_identifier = str(uuid.uuid4()) - trip = TripFactory( - user=self.user, cave_name=test_finder, cavers=test_identifier - ) + trip = TripFactory(user=self.user, cave_name=test_finder, notes=test_identifier) trip.privacy = Trip.PRIVATE trip.save() @@ -265,8 +263,8 @@ def test_search_result_pagination(self): for i in range(11): TripFactory( user=self.user, - cavers="Testing Pagination", cave_name=f"Pagination Test {i}", + cave_entrance="Testing Pagination", ) # Check that the first page contains 10 trips diff --git a/app/logger/tests/test_trips.py b/app/logger/tests/test_trips.py index 53bb091c..5ccb2cb6 100644 --- a/app/logger/tests/test_trips.py +++ b/app/logger/tests/test_trips.py @@ -370,7 +370,6 @@ def test_trip_creation_form(self): self.assertContains(response, "Cave name") self.assertContains(response, "Region") self.assertContains(response, "Country") - self.assertContains(response, "Cavers") self.assertContains(response, "Trip notes") self.assertContains(response, "Create trip") @@ -381,7 +380,6 @@ def test_trip_creation_form(self): "cave_region": "Test Region", "cave_country": "Test Country", "type": Trip.SPORT, - "cavers": "Test Cavers", "start": tz.now(), "end": tz.now() + td(days=1), "privacy": Trip.DEFAULT, @@ -395,7 +393,6 @@ def test_trip_creation_form(self): self.assertEqual(trip.cave_region, "Test Region") self.assertEqual(trip.cave_country, "Test Country") self.assertEqual(trip.type, Trip.SPORT) - self.assertEqual(trip.cavers, "Test Cavers") self.assertEqual(trip.notes, "Test Notes") self.assertEqual(trip.privacy, Trip.DEFAULT) @@ -436,7 +433,6 @@ def test_trip_creation_form_with_invalid_data(self): "cave_region": "Test Region", "cave_country": "Test Country", "type": Trip.SPORT, - "cavers": "Test Cavers", "privacy": Trip.DEFAULT, "notes": "Test Notes", "start": same_time, @@ -452,7 +448,6 @@ def test_trip_creation_form_with_invalid_data(self): "cave_region": "Test Region", "cave_country": "Test Country", "type": Trip.SPORT, - "cavers": "Test Cavers", "privacy": Trip.DEFAULT, "notes": "Test Notes", "start": tz.now() + td(days=8), @@ -470,7 +465,6 @@ def test_trip_creation_form_with_invalid_data(self): "cave_region": "Test Region", "cave_country": "Test Country", "type": Trip.SPORT, - "cavers": "Test Cavers", "privacy": Trip.DEFAULT, "notes": "Test Notes", "start": tz.now(), @@ -509,7 +503,6 @@ def test_trip_update_form(self): self.assertContains(response, "Cave name") self.assertContains(response, "Region") self.assertContains(response, "Country") - self.assertContains(response, "Cavers") self.assertContains(response, "Trip notes") self.assertContains(response, "Update trip") self.assertContains(response, self.trip.cave_name) @@ -521,7 +514,6 @@ def test_trip_update_form(self): "cave_region": "Test Region", "cave_country": "Test Country", "type": Trip.SPORT, - "cavers": "Test Cavers", "start": tz.now(), "end": tz.now() + td(days=1), "privacy": Trip.DEFAULT, @@ -535,7 +527,6 @@ def test_trip_update_form(self): self.assertEqual(trip.cave_region, "Test Region") self.assertEqual(trip.cave_country, "Test Country") self.assertEqual(trip.type, Trip.SPORT) - self.assertEqual(trip.cavers, "Test Cavers") self.assertEqual(trip.notes, "Test Notes") self.assertEqual(trip.privacy, Trip.DEFAULT) self.assertEqual(trip.user, self.user) diff --git a/app/logger/tests/test_user_profile.py b/app/logger/tests/test_user_profile.py index 58fffc80..b8be1431 100644 --- a/app/logger/tests/test_user_profile.py +++ b/app/logger/tests/test_user_profile.py @@ -6,7 +6,7 @@ from django.utils.timezone import timedelta as td from ..factories import TripFactory -from ..models import Trip +from ..models import Caver, Trip from ..views.userprofile import UserProfile as UserProfileView User = get_user_model() @@ -372,3 +372,25 @@ def test_cave_location_privacy(self): self.assertEqual(response.status_code, 200) self.assertContains(response, "1.00000, 1.00000") self.assertContains(response, "Cave location") + + def test_caver_links_shown_when_viewed_by_owner(self): + """Test that caver links are shown when viewed by the owner""" + self.client.force_login(self.user) + trip = TripFactory(user=self.user, privacy=Trip.PUBLIC) + caver = Caver.objects.create(name="Test Caver", user=self.user) + trip.cavers.add(caver) + + response = self.client.get(trip.get_absolute_url()) + self.assertEqual(response.status_code, 200) + self.assertContains(response, caver.get_absolute_url()) + + def test_caver_links_not_shown_when_viewed_by_other_users(self): + """Test that caver links are not shown when viewed by other users""" + self.client.force_login(self.user2) + trip = TripFactory(user=self.user, privacy=Trip.PUBLIC) + caver = Caver.objects.create(name="Test Caver", user=self.user) + trip.cavers.add(caver) + + response = self.client.get(trip.get_absolute_url()) + self.assertEqual(response.status_code, 200) + self.assertNotContains(response, caver.get_absolute_url()) diff --git a/app/logger/urls.py b/app/logger/urls.py index fbd8899a..db6443dc 100644 --- a/app/logger/urls.py +++ b/app/logger/urls.py @@ -53,6 +53,32 @@ views.TripPhotosUpdate.as_view(), name="trip_photos_update", ), + path("log/cavers/", views.CaverList.as_view(), name="caver_list"), + path("log/cavers//", views.CaverDetail.as_view(), name="caver_detail"), + path( + "log/cavers//delete/", + views.CaverDelete.as_view(), + name="caver_delete", + ), + path( + "log/cavers//rename/", + views.CaverRename.as_view(), + name="caver_rename", + ), + path("log/cavers//link/", views.CaverLink.as_view(), name="caver_link"), + path( + "log/cavers//unlink/", + views.CaverUnlink.as_view(), + name="caver_unlink", + ), + path( + "log/cavers//merge/", views.CaverMerge.as_view(), name="caver_merge" + ), + path( + "log/cavers/autocomplete", + views.CaverAutocomplete.as_view(create_field="name", validate_create=True), + name="caver_autocomplete", + ), path( "report//", views.TripReportRedirect.as_view(), name="report_detail" ), diff --git a/app/logger/views/__init__.py b/app/logger/views/__init__.py index b489b95a..5fcd8b1c 100644 --- a/app/logger/views/__init__.py +++ b/app/logger/views/__init__.py @@ -1,3 +1,4 @@ +from .cavers import * # noqa: F403 from .feed import * # noqa: F403 from .search import * # noqa: F403 from .tripphotos import * # noqa: F403 diff --git a/app/logger/views/cavers.py b/app/logger/views/cavers.py new file mode 100644 index 00000000..bdc7873b --- /dev/null +++ b/app/logger/views/cavers.py @@ -0,0 +1,159 @@ +from dal import autocomplete +from django.contrib import messages +from django.contrib.auth.mixins import LoginRequiredMixin +from django.core.exceptions import PermissionDenied +from django.db.models import Count, Max +from django.shortcuts import get_object_or_404, redirect +from django.views import View +from django.views.generic import DetailView, ListView + +from ..forms import LinkCaverForm, MergeCaverForm, RenameCaverForm +from ..models import Caver, Trip + + +class CaverAutocomplete(LoginRequiredMixin, autocomplete.Select2QuerySetView): + def get_queryset(self): + qs = Caver.objects.filter(user=self.request.user) + + if self.q: + qs = qs.filter(name__istartswith=self.q) + + return qs + + def create_object(self, text): + return self.get_queryset().get_or_create(name=text, user=self.request.user)[0] + + +class CaverList(LoginRequiredMixin, ListView): + model = Caver + template_name = "logger/caver_list.html" + context_object_name = "cavers" + paginate_by = 50 + + def get_queryset(self): + return ( + Caver.objects.filter(user=self.request.user) + .order_by("name") + .annotate(trip_count=Count("trip", distinct=True)) + .annotate(last_trip_date=Max("trip__start")) + ) + + +class CaverDetail(LoginRequiredMixin, DetailView): + model = Caver + template_name = "logger/caver_detail.html" + context_object_name = "caver" + slug_field = "uuid" + slug_url_kwarg = "uuid" + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["trips"] = Trip.objects.filter(cavers=self.object).order_by("-start") + context["trip_count"] = context["trips"].count() + context["link_caver_form"] = LinkCaverForm(user=self.request.user) + context["rename_caver_form"] = RenameCaverForm() + context["merge_caver_form"] = MergeCaverForm(user=self.request.user) + return context + + def get_queryset(self): + return Caver.objects.filter(user=self.request.user) + + +class CaverDelete(LoginRequiredMixin, View): + def post(self, request, *args, **kwargs): + caver = get_object_or_404(Caver, uuid=kwargs["uuid"], user=request.user) + caver.delete() + + messages.success( + request, f"The caver record for {caver.name} has been deleted." + ) + return redirect("log:caver_list") + + +class CaverRename(LoginRequiredMixin, View): + def post(self, request, *args, **kwargs): + caver = get_object_or_404(Caver, uuid=kwargs["uuid"], user=request.user) + form = RenameCaverForm(request.POST) + + if form.is_valid(): + new_name = form.cleaned_data["name"] + caver.name = new_name + caver.save() + messages.success( + request, f"The caver record for {caver.name} has been updated." + ) + else: + messages.error( + request, + form.errors["name"][0], + ) + + return redirect(caver.get_absolute_url()) + + +class CaverLink(LoginRequiredMixin, View): + def post(self, request, *args, **kwargs): + caver = get_object_or_404(Caver, uuid=kwargs["uuid"], user=request.user) + form = LinkCaverForm(request.POST, user=request.user) + + if form.is_valid(): + caver.linked_account = form.cleaned_data["account"] + caver.save() + messages.success( + request, + ( + f"The caver record for {caver.name} has been linked to " + f"@{caver.linked_account.username}." + ), + ) + else: + messages.error( + request, + form.errors["account"][0], + ) + + return redirect(caver.get_absolute_url()) + + +class CaverUnlink(LoginRequiredMixin, View): + def post(self, request, *args, **kwargs): + caver = get_object_or_404(Caver, uuid=kwargs["uuid"], user=request.user) + caver.linked_account = None + caver.save() + messages.success( + request, + f"The caver record for {caver.name} has been unlinked.", + ) + return redirect(caver.get_absolute_url()) + + +class CaverMerge(LoginRequiredMixin, View): + def post(self, request, *args, **kwargs): + caver = get_object_or_404(Caver, uuid=kwargs["uuid"], user=request.user) + form = MergeCaverForm(request.POST, user=request.user) + + if form.is_valid(): + merge_caver = form.cleaned_data["caver"] + if not merge_caver.user == request.user: + raise PermissionDenied + + for trip in merge_caver.trip_set.all(): + trip.cavers.add(caver) + trip.save() + + merge_caver.delete() + + messages.success( + request, + ( + f"The caver record for {caver.name} has been merged with " + f"{merge_caver.name}." + ), + ) + else: + messages.error( + request, + form.errors["caver"][0], + ) + + return redirect(caver.get_absolute_url()) diff --git a/app/logger/views/trips.py b/app/logger/views/trips.py index b94795da..7ed1168f 100644 --- a/app/logger/views/trips.py +++ b/app/logger/views/trips.py @@ -60,6 +60,7 @@ def form_valid(self, form): trip.cave_coordinates = Point(lng, lat) trip.save() + form.save_m2m() log_trip_action(self.request.user, self.object, "updated") return redirect(trip.get_absolute_url()) @@ -152,6 +153,7 @@ def form_valid(self, form): trip.cave_coordinates = Point(lng, lat) trip.save() + form.save_m2m() trip.followers.add(self.request.user) log_trip_action(self.request.user, trip, "added") diff --git a/app/logger/views/userprofile.py b/app/logger/views/userprofile.py index f11f950b..ba179290 100644 --- a/app/logger/views/userprofile.py +++ b/app/logger/views/userprofile.py @@ -137,7 +137,7 @@ def post(self, request, *args, **kwargs): Q(cave_name__unaccent__icontains=query) | Q(cave_entrance__unaccent__icontains=query) | Q(cave_exit__unaccent__icontains=query) - | Q(cavers__unaccent__icontains=query) + | Q(cavers__name__unaccent__icontains=query) | Q(clubs__unaccent__icontains=query) | Q(expedition__unaccent__icontains=query) ) diff --git a/app/stats/statistics/metrics.py b/app/stats/statistics/metrics.py index 40061e78..8242b130 100644 --- a/app/stats/statistics/metrics.py +++ b/app/stats/statistics/metrics.py @@ -50,15 +50,4 @@ def unique_regions(queryset): def unique_cavers(queryset): - caver_values = queryset.values_list("cavers", flat=True) - if not caver_values: - return 0 - - cavers = set() - for cavers_list in caver_values: - for caver in cavers_list.split(","): - caver = caver.strip().lower() - if caver: - cavers.add(caver) - - return len(cavers) + return len(queryset.values_list("cavers", flat=True)) diff --git a/app/stats/statistics/most_common.py b/app/stats/statistics/most_common.py index c530a595..6f38a798 100644 --- a/app/stats/statistics/most_common.py +++ b/app/stats/statistics/most_common.py @@ -1,13 +1,14 @@ import humanize from attrs import Factory, define, frozen -from django.db.models import Count -from django.utils import timezone +from django.db.models import Count, Sum +from logger.models import Caver @frozen class MostCommonRow: metric: str value: str + url: str @define @@ -17,8 +18,8 @@ class MostCommonStatistics: value_name: str rows: list = Factory(list) - def add_row(self, metric, value): - self.rows.append(MostCommonRow(metric=metric, value=value)) + def add_row(self, metric, value, url=None): + self.rows.append(MostCommonRow(metric=metric, value=value, url=url)) def _sort_comma_separated_list(qs, value, limit=10): @@ -95,34 +96,46 @@ def most_common_from_csv(*, queryset, field, title, metric_name, value_name, lim return stats -def most_common_cavers_by_time(queryset, limit): +def most_common_cavers_by_trips(queryset, limit): stats = MostCommonStatistics( - title="Most common cavers by time", + title="Most common cavers by trips", metric_name="Caver", - value_name="Time", + value_name="Trips", + ) + + cavers = ( + Caver.objects.filter(trip__in=queryset) + .annotate(trip_count=Count("trip")) + .order_by("-trip_count")[0:limit] ) - cavers = {} - for trip in queryset: - if not trip.cavers: - continue + for caver in cavers: + stats.add_row(caver.name, caver.trip_count, caver.get_absolute_url()) - for caver in trip.cavers.split(","): - caver = caver.strip() - if not caver: # pragma: no cover - continue + return stats - if caver not in cavers: - cavers[caver] = timezone.timedelta() - cavers[caver] += trip.duration if trip.duration else timezone.timedelta() +def most_common_cavers_by_time(queryset, limit): + stats = MostCommonStatistics( + title="Most common cavers by time", + metric_name="Caver", + value_name="Time", + ) - results = sorted(cavers.items(), key=lambda x: x[1], reverse=True)[0:limit] + cavers = ( + Caver.objects.filter(trip__in=queryset) + .annotate(duration=Sum("trip__duration")) + .exclude(duration__isnull=True) + .order_by("-duration")[0:limit] + ) - for row in results: + for caver in cavers: stats.add_row( - row[0], - humanize.precisedelta(row[1], minimum_unit="minutes", format="%.0f"), + caver.name, + humanize.precisedelta( + caver.duration, minimum_unit="minutes", format="%.0f" + ), + caver.get_absolute_url(), ) return stats @@ -130,14 +143,7 @@ def most_common_cavers_by_time(queryset, limit): def most_common(queryset, limit=10): stats = [ - most_common_from_csv( - queryset=queryset, - field="cavers", - title="Most common cavers by trips", - metric_name="Caver", - value_name="Trips", - limit=limit, - ), + most_common_cavers_by_trips(queryset=queryset, limit=limit), most_common_cavers_by_time(queryset, limit), most_common_caves(queryset, limit), most_common_from_csv( diff --git a/app/templates/logger/_htmx_trip_list_search.html b/app/templates/logger/_htmx_trip_list_search.html index 1660df67..6f1acca2 100644 --- a/app/templates/logger/_htmx_trip_list_search.html +++ b/app/templates/logger/_htmx_trip_list_search.html @@ -16,7 +16,7 @@
{{ trip.cave_name }} - {{ trip.cavers }} + {{ trip.cavers.all|join:", " }}
diff --git a/app/templates/logger/_sidebar_trips.html b/app/templates/logger/_sidebar_trips.html index 21eb354b..f310a0f7 100644 --- a/app/templates/logger/_sidebar_trips.html +++ b/app/templates/logger/_sidebar_trips.html @@ -14,8 +14,8 @@ {% comment %} Authenticated user links {% endcomment %} Trips - - Recent + + Cavers diff --git a/app/templates/logger/_trip_data_blocks.html b/app/templates/logger/_trip_data_blocks.html index 2d8b68ba..4fb0344d 100644 --- a/app/templates/logger/_trip_data_blocks.html +++ b/app/templates/logger/_trip_data_blocks.html @@ -1,11 +1,17 @@ {% load users_tags %} {% load logger_tags %} -{% if trip.cavers %} +{% if trip.cavers.all %}
Cavers
- {{ trip.cavers }} + {% if user == trip.user %} + {% for caver in trip.cavers.all %} +
{{ caver.name }}{% if forloop.counter < trip.cavers.all|length %},{% endif %} + {% endfor %} + {% else %} + {{ trip.cavers.all|join:", " }} + {% endif %}
{% endif %} diff --git a/app/templates/logger/caver_detail.html b/app/templates/logger/caver_detail.html new file mode 100644 index 00000000..a48a655b --- /dev/null +++ b/app/templates/logger/caver_detail.html @@ -0,0 +1,195 @@ +{% extends "base_trips.html" %} +{% load logger_tags %} +{% load crispy_forms_tags %} +{% load humanize %} +{% load users_tags %} + +{% block title %}{{ caver.name }}{% endblock %} +{% block description %}A list of trips you've been on with {{ caver.name }}{% endblock %} +{% block display_title_container %}{% endblock %} + +{% block main %} +
+

{{ caver.name }}

+
+ + {% if trips %} +

+ You've caved with {{ caver.name }} {% if trip_count == 1 %}once{% else %}{{ trip_count|apnumber }} time{% endif %}{{ trip_count|pluralize }}{% if caver.total_trip_duration_str %} for a total of {{ caver.total_trip_duration_str }} underground.{% else %}.{% endif %} +

+ + {% if caver.linked_account %} +

+ This caver record is linked to the caves.app account of {% user caver.linked_account %}. +

+ {% endif %} + +
+ + + + {% if caver.linked_account %} +
+ {% csrf_token %} + +
+ {% else %} + + {% endif %} + + +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+
Trips with {{ caver.name }}
+
+ +
+ + + + + + + + + + {% for trip in trips %} + + + + + + {% endfor %} + +
DateCave
{{ trip.start|date }}{{ trip.cave_name }} + + View + +
+
+ {% else %} +

You haven't been on any trips with {{ caver.name }}.

+ {% endif %} +{% endblock %} diff --git a/app/templates/logger/caver_list.html b/app/templates/logger/caver_list.html new file mode 100644 index 00000000..85923956 --- /dev/null +++ b/app/templates/logger/caver_list.html @@ -0,0 +1,49 @@ +{% extends "base_trips.html" %} +{% load logger_tags %} +{% load humanize %} + +{% block title %}Caver list{% endblock %} +{% block description %}A list of cavers that you've been on trips with{% endblock %} +{% block display_title_container %}{% endblock %} + +{% block main %} +
+

Caver list

+
+ + {% if cavers %} +
+ + + + + + + + + + + + + {% for caver in cavers %} + + + + + + + + {% endfor %} + +
NameTripsLast tripTotal duration
{{ caver.name }}{{ caver.trip_count }}{{ caver.last_trip_date|date }}{{ caver.total_trip_duration_str|default_if_none:"" }} + + View + +
+ + {% include "_paginate_bootstrap.html" with page=cavers %} +
+ {% else %} +

You've not added any cavers yet!

+ {% endif %} +{% endblock %} diff --git a/app/templates/logger/profile.html b/app/templates/logger/profile.html index 966841ec..cb263afb 100644 --- a/app/templates/logger/profile.html +++ b/app/templates/logger/profile.html @@ -179,10 +179,10 @@ - {% if trip.cavers and show_cavers %} + {% if trip.cavers.all and show_cavers %} - with {{ trip.cavers }} + with {{ trip.cavers.all|join:", " }} {% endif %} diff --git a/app/templates/logger/search.html b/app/templates/logger/search.html index 0ee85371..1350eda3 100644 --- a/app/templates/logger/search.html +++ b/app/templates/logger/search.html @@ -58,9 +58,9 @@

{{ trip.expedition }} {% endif %} - {% if trip.cavers %} + {% if trip.cavers.all %}
  • - {{ trip.cavers }} + {{ trip.cavers.all|join:", " }}
  • {% endif %} {% if trip.notes %} diff --git a/app/templates/stats/index.html b/app/templates/stats/index.html index a34a1f39..7fd47b3b 100644 --- a/app/templates/stats/index.html +++ b/app/templates/stats/index.html @@ -113,7 +113,13 @@

    {{ stat.title }}
    {% for row in stat.rows %} {{ forloop.counter }} - {{ row.metric }} + + {% if row.url %} + {{ row.metric }} + {% else %} + {{ row.metric }} + {% endif %} + {{ row.value }} {% endfor %} diff --git a/config/django/settings/base.py b/config/django/settings/base.py index bc27425c..9782ba8c 100644 --- a/config/django/settings/base.py +++ b/config/django/settings/base.py @@ -102,6 +102,8 @@ def env(name, default=None, force_type: Any = str): "export.apps.ExportConfig", "comments.apps.CommentsConfig", "maps.apps.MapsConfig", + "dal", + "dal_select2", "django_countries", "tinymce", "active_link", diff --git a/config/requirements/development.txt b/config/requirements/development.txt index e177f61a..f69dafc5 100644 --- a/config/requirements/development.txt +++ b/config/requirements/development.txt @@ -5,6 +5,7 @@ coverage==7.3.* crispy-bootstrap5==0.7 Django==4.2.6 django-active-link==0.1.8 +django-autocomplete-light==3.9.7 django-countries==7.5.1 django-crispy-forms==2.1 django-debug-toolbar==4.2.0 diff --git a/config/requirements/production.txt b/config/requirements/production.txt index 8ac10b1f..ce5e6f6a 100644 --- a/config/requirements/production.txt +++ b/config/requirements/production.txt @@ -4,6 +4,7 @@ chardet==5.2.0 crispy-bootstrap5==0.7 Django==4.2.6 django-active-link==0.1.8 +django-autocomplete-light==3.9.7 django-countries==7.5.1 django-crispy-forms==2.1 django-debug-toolbar==4.2.0