Skip to content

Commit

Permalink
Abstract cavers into a model (#61) (#206)
Browse files Browse the repository at this point in the history
* Migrate cavers field to ManyToManyField

* Add Caver model and CRUD views; update tests

* Add URLs to cavers on statistics page

* Add page load tests for Caver model

* Add tests for caver links on profile

* Add tests for caver detail and rename views

* Add tests for caver merge

* Change button styling

* Add tests for linking accounts
  • Loading branch information
anorthall authored Nov 2, 2023
1 parent 166588c commit 586ea09
Show file tree
Hide file tree
Showing 37 changed files with 1,211 additions and 87 deletions.
8 changes: 7 additions & 1 deletion app/import/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand All @@ -80,7 +87,6 @@ class Meta:
"privacy",
"clubs",
"expedition",
"cavers",
"horizontal_dist",
"vert_dist_down",
"vert_dist_up",
Expand Down
2 changes: 1 addition & 1 deletion app/import/tests/test_csvs/valid_trips.csv
Original file line number Diff line number Diff line change
@@ -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,,,,,
3 changes: 2 additions & 1 deletion app/import/tests/test_import.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
12 changes: 12 additions & 0 deletions app/import/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down
16 changes: 13 additions & 3 deletions app/logger/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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",
Expand Down Expand Up @@ -108,10 +119,9 @@ class TripAdmin(ModelAdmin):
},
),
(
"Attendees",
"Organisations",
{
"fields": (
"cavers",
"clubs",
"expedition",
),
Expand Down
28 changes: 19 additions & 9 deletions app/logger/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from factory.django import DjangoModelFactory
from faker import Faker

from .models import Trip
from .models import Caver, Trip

fake = Faker()

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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(".", "")
Expand Down
109 changes: 108 additions & 1 deletion app/logger/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@

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
from django.utils import timezone
from users.models import CavingUser

from .mixins import CleanCaveLocationMixin, DistanceUnitFormMixin
from .models import Trip, TripPhoto
from .models import Caver, Trip, TripPhoto

User = CavingUser

Expand Down Expand Up @@ -113,6 +114,7 @@ class Meta:
"hx-indicator": "",
}
),
"cavers": autocomplete.ModelSelect2Multiple("log:caver_autocomplete"),
}

def __init__(self, user, *args, **kwargs):
Expand Down Expand Up @@ -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
61 changes: 61 additions & 0 deletions app/logger/migrations/0034_caver.py
Original file line number Diff line number Diff line change
@@ -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,
),
),
],
),
]
21 changes: 21 additions & 0 deletions app/logger/migrations/0035_trip_cavers_new.py
Original file line number Diff line number Diff line change
@@ -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",
),
),
]
Loading

0 comments on commit 586ea09

Please sign in to comment.