diff --git a/app/caves/__init__.py b/app/caves/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/caves/admin.py b/app/caves/admin.py new file mode 100644 index 00000000..f4c81d38 --- /dev/null +++ b/app/caves/admin.py @@ -0,0 +1,53 @@ +from django.contrib import admin +from unfold.admin import ModelAdmin, TabularInline + +from .models import CaveEntrance, CaveSystem + + +class CaveEntranceInline(TabularInline): + model = CaveEntrance + fk_name = "system" + fields = ("name", "region", "country", "coordinates") + readonly_fields = ("coordinates",) + + +@admin.register(CaveSystem) +class CaveSystemAdmin(ModelAdmin): + inlines = [CaveEntranceInline] + search_fields = ( + "name", + "user__username", + "user__name", + "user__email", + ) + search_help_text = "Search by system name, or author name, email or username." + readonly_fields = ("added", "updated", "uuid") + list_display = ( + "user", + "name", + "added", + "updated", + ) + list_display_links = ("name",) + list_filter = ("added", "updated") + ordering = ("-added",) + autocomplete_fields = ("user",) + fieldsets = ( + ( + "Cave system", + { + "fields": ("name",), + }, + ), + ( + "Internal data", + { + "fields": ( + "user", + "uuid", + "added", + "updated", + ), + }, + ), + ) diff --git a/app/caves/apps.py b/app/caves/apps.py new file mode 100644 index 00000000..261b8e40 --- /dev/null +++ b/app/caves/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class CavesConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "caves" diff --git a/app/caves/migrations/0001_initial.py b/app/caves/migrations/0001_initial.py new file mode 100644 index 00000000..74da8515 --- /dev/null +++ b/app/caves/migrations/0001_initial.py @@ -0,0 +1,114 @@ +# Generated by Django 4.2.3 on 2023-07-29 21:17 + +import uuid + +import django.contrib.gis.db.models.fields +import django.db.models.deletion +import django_countries.fields +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="CaveSystem", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=100)), + ( + "uuid", + models.UUIDField( + default=uuid.uuid4, + editable=False, + unique=True, + verbose_name="UUID", + ), + ), + ("added", models.DateTimeField(auto_now_add=True)), + ("updated", models.DateTimeField(auto_now=True)), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + migrations.CreateModel( + name="CaveEntrance", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=255)), + ( + "region", + models.CharField( + blank=True, + help_text="The state or region in which the cave is located.", + max_length=100, + ), + ), + ("country", django_countries.fields.CountryField(max_length=2)), + ( + "location", + models.CharField( + blank=True, + help_text="Enter a decimal latitude and longitude, address, or place name. Cave locations are not visible to other users.", + max_length=100, + ), + ), + ( + "coordinates", + django.contrib.gis.db.models.fields.PointField( + blank=True, + geography=True, + help_text="The coordinates of the cave in WGS84 format.", + null=True, + srid=4326, + ), + ), + ( + "uuid", + models.UUIDField( + default=uuid.uuid4, + editable=False, + unique=True, + verbose_name="UUID", + ), + ), + ("added", models.DateTimeField(auto_now_add=True)), + ("updated", models.DateTimeField(auto_now=True)), + ( + "system", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="entrances", + to="caves.cavesystem", + ), + ), + ], + ), + ] diff --git a/app/caves/migrations/__init__.py b/app/caves/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/caves/models.py b/app/caves/models.py new file mode 100644 index 00000000..9f0089ae --- /dev/null +++ b/app/caves/models.py @@ -0,0 +1,60 @@ +import uuid as uuid + +from django.contrib.gis.db import models +from django_countries.fields import CountryField + + +class CaveSystemManager(models.Manager): + def by(self, user): + return self.filter(user=user) + + +class CaveSystem(models.Model): + name = models.CharField(max_length=100) + uuid = models.UUIDField("UUID", default=uuid.uuid4, editable=False, unique=True) + added = models.DateTimeField(auto_now_add=True) + updated = models.DateTimeField(auto_now=True) + user = models.ForeignKey("users.CavingUser", on_delete=models.CASCADE) + objects = CaveSystemManager() + + def __str__(self): + return self.name + + +class CaveEntrance(models.Model): + name = models.CharField(max_length=255) + system = models.ForeignKey( + CaveSystem, on_delete=models.CASCADE, related_name="entrances" + ) + region = models.CharField( + max_length=100, + blank=True, + help_text="The state or region in which the cave is located.", + ) + country = CountryField() + location = models.CharField( + max_length=100, + blank=True, + help_text=( + "Enter a decimal latitude and longitude, " + "address, or place name. " + "Cave locations are not visible to other users." + ), + ) + coordinates = models.PointField( + blank=True, + null=True, + geography=True, + help_text="The coordinates of the cave in WGS84 format.", + ) + + uuid = models.UUIDField("UUID", default=uuid.uuid4, editable=False, unique=True) + added = models.DateTimeField(auto_now_add=True) + updated = models.DateTimeField(auto_now=True) + + def __str__(self): + return f"{self.name} ({self.system.name})" + + @property + def user(self): + return self.system.user diff --git a/app/caves/tests/__init__.py b/app/caves/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/caves/urls.py b/app/caves/urls.py new file mode 100644 index 00000000..222653b4 --- /dev/null +++ b/app/caves/urls.py @@ -0,0 +1,4 @@ +from django.urls import path # noqa F401 + +app_name = "caves" +urlpatterns = [] diff --git a/app/caves/views.py b/app/caves/views.py new file mode 100644 index 00000000..6edc3131 --- /dev/null +++ b/app/caves/views.py @@ -0,0 +1 @@ +from django.shortcuts import render # noqa F401 diff --git a/app/logger/migrations/0027_trip_entrance_trip_system.py b/app/logger/migrations/0027_trip_entrance_trip_system.py new file mode 100644 index 00000000..48cf3d05 --- /dev/null +++ b/app/logger/migrations/0027_trip_entrance_trip_system.py @@ -0,0 +1,34 @@ +# Generated by Django 4.2.3 on 2023-07-29 21:17 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("caves", "0001_initial"), + ("logger", "0026_alter_trip_cave_location"), + ] + + operations = [ + migrations.AddField( + model_name="trip", + name="entrance", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="trips", + to="caves.caveentrance", + ), + ), + migrations.AddField( + model_name="trip", + name="system", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="trips", + to="caves.cavesystem", + ), + ), + ] diff --git a/app/logger/migrations/0028_rename_entrance_trip_entered_by_trip_exited_by.py b/app/logger/migrations/0028_rename_entrance_trip_entered_by_trip_exited_by.py new file mode 100644 index 00000000..5faf4110 --- /dev/null +++ b/app/logger/migrations/0028_rename_entrance_trip_entered_by_trip_exited_by.py @@ -0,0 +1,29 @@ +# Generated by Django 4.2.3 on 2023-07-29 21:25 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("caves", "0001_initial"), + ("logger", "0027_trip_entrance_trip_system"), + ] + + operations = [ + migrations.RenameField( + model_name="trip", + old_name="entrance", + new_name="entered_by", + ), + migrations.AddField( + model_name="trip", + name="exited_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="exits", + to="caves.caveentrance", + ), + ), + ] diff --git a/app/logger/migrations/0029_create_cave_systems_from_trips.py b/app/logger/migrations/0029_create_cave_systems_from_trips.py new file mode 100644 index 00000000..bdc4e06d --- /dev/null +++ b/app/logger/migrations/0029_create_cave_systems_from_trips.py @@ -0,0 +1,190 @@ +# Generated by Django 4.2.3 on 2023-07-29 21:25 +from django.db import migrations +from django_countries import countries + + +def create_cave_systems_from_trips(apps, _schema_editor): + """Create cave systems and entrances from existing trips.""" + print("\n\nCreating cave systems from trips...") + trip = apps.get_model("logger", "Trip") + cave_system = apps.get_model("caves", "CaveSystem") + cave_entrance = apps.get_model("caves", "CaveEntrance") + + trips = trip.objects.all() + total = len(trips) + for i, t in enumerate(trips): + if i % 1000 == 0: + print(f"Processing trip {i} of {total}") + process_trip(t, cave_system, cave_entrance) + + +def process_trip(t, cave_system, cave_entrance): + """Try to find a system/entrance for the given trip, or create one if not found.""" + match = match_trip_to_system(t, cave_system, cave_entrance) + if all(match): + system, entered_by, exited_by = match + else: + system, entered_by, exited_by = create_new_system(t, cave_system, cave_entrance) + + assert all([system, entered_by, exited_by]) + t.entered_by = entered_by + t.exited_by = exited_by + t.system = system + t.save() + + +def match_trip_to_system(t, cave_system, cave_entrance): + """Try to match the given trip to an existing system.""" + name = t.cave_name.lower().strip() + systems = cave_system.objects.filter(user=t.user, name__iexact=name) + if not systems: + return None, None, None + + matched_systems = [] + for candidate in systems: + poor_match = False + for ent in candidate.entrances.all(): + if t.cave_country and ent.country: + if t.cave_country.lower().strip() != ent.country.name.lower().strip(): + poor_match = True + if t.cave_region and ent.region: + if t.cave_region.lower().strip() != ent.region.lower().strip(): + poor_match = True + if t.cave_coordinates and ent.coordinates: + distance_in_km = t.cave_coordinates.distance(ent.coordinates) * 100 + if distance_in_km > 10: # could be a different entrance + poor_match = True + + if not poor_match: + matched_systems.append(candidate) + + if not matched_systems: + return None, None, None + + assert len(matched_systems) == 1 + system = matched_systems[0] + if t.cave_entrance and t.cave_exit: + entered_by, exited_by = None, None + for ent in system.entrances: + if ent.name.lower().strip() == t.cave_entrance.lower().strip(): + entered_by = ent + if ent.name.lower().strip() == t.cave_exit.lower().strip(): + exited_by = ent + + if not entered_by: + entered_by = cave_entrance.objects.create( + system=system, + name=t.cave_entrance, + region=t.cave_region, + location=t.cave_location, + coordinates=t.cave_coordinates, + country=match_country(t.cave_country), + ) + + if not exited_by: + exited_by = cave_entrance.objects.create( + system=system, + name=t.cave_exit, + region=t.cave_region, + location=t.cave_location, + coordinates=t.cave_coordinates, + country=match_country(t.cave_country), + ) + + assert all([entered_by, exited_by]) + return system, entered_by, exited_by + else: + name = t.cave_entrance or t.cave_exit or t.cave_name + assert name is not None + + try: + ent = system.entrances.get(name__iexact=name) + except cave_entrance.DoesNotExist: + ent = None + + if not ent: + ent = cave_entrance.objects.create( + system=system, + name=name, + region=t.cave_region, + location=t.cave_location, + coordinates=t.cave_coordinates, + country=match_country(t.cave_country), + ) + return system, ent, ent + + +def create_new_system(t, cave_system, cave_entrance): + # print( + # f"Creating new cave system '{t.cave_name}' " + # f"for {t.user.name}'s trip '{t.cave_name}' " + # f"via '{t.cave_entrance}' and '{t.cave_exit}'" + # ) + system = cave_system.objects.create( + name=t.cave_name, + user=t.user, + ) + + if t.cave_entrance and t.cave_exit: + # Create two entrances + entrance = cave_entrance.objects.create( + system=system, + name=t.cave_entrance, + region=t.cave_region, + location=t.cave_location, + coordinates=t.cave_coordinates, + country=match_country(t.cave_country), + ) + cave_exit = cave_entrance.objects.create( + system=system, + name=t.cave_exit, + region=t.cave_region, + location=t.cave_location, + coordinates=t.cave_coordinates, + country=match_country(t.cave_country), + ) + # print( + # f"Created entrances '{entrance.name}' " + # f"and '{cave_exit.name}' for '{system.name}'" + # ) + entered_by, exited_by = entrance, cave_exit + else: + name = t.cave_entrance or t.cave_exit or t.cave_name + assert name is not None + entrance = cave_entrance.objects.create( + system=system, + name=name, + region=t.cave_region, + location=t.cave_location, + coordinates=t.cave_coordinates, + country=match_country(t.cave_country), + ) + # print(f"Created entrance '{entrance.name}' for '{system.name}'") + entered_by, exited_by = entrance, entrance + return system, entered_by, exited_by + + +def match_country(country: str) -> str: + """Try to match the given country to a valid country code.""" + country = country.lower().strip() + for code, name in countries: + name = name.lower().strip() + if name == country: + return code + elif code == country: + return code + elif "USA" == country: + return "US" + elif "united states" == country: + return "US" + raise ValueError(f"Could not match country '{country}'") + + +class Migration(migrations.Migration): + dependencies = [ + ("logger", "0028_rename_entrance_trip_entered_by_trip_exited_by"), + ] + + operations = [ + migrations.RunPython(create_cave_systems_from_trips), + ] diff --git a/app/logger/models/trip.py b/app/logger/models/trip.py index 2ade7316..2b16f667 100644 --- a/app/logger/models/trip.py +++ b/app/logger/models/trip.py @@ -1,6 +1,7 @@ import uuid import humanize +from caves.models import CaveEntrance, CaveSystem from distancefield import D, DistanceField, DistanceUnitField from django.conf import settings from django.contrib.gis.db import models @@ -61,6 +62,18 @@ class Trip(models.Model): ] # Cave details + # New + system = models.ForeignKey( + CaveSystem, on_delete=models.CASCADE, related_name="trips", null=True + ) + entered_by = models.ForeignKey( + CaveEntrance, on_delete=models.CASCADE, related_name="trips", null=True + ) + exited_by = models.ForeignKey( + CaveEntrance, on_delete=models.CASCADE, related_name="exits", null=True + ) + + # Old cave_name = models.CharField( max_length=100, help_text="The name of the cave or cave system entered.", diff --git a/config/django/settings/base.py b/config/django/settings/base.py index e5fad9b4..6c7f1ecc 100644 --- a/config/django/settings/base.py +++ b/config/django/settings/base.py @@ -96,6 +96,7 @@ def env(name, default=None, force_type: Any = str): "core.apps.CoreConfig", "users.apps.UsersConfig", "logger.apps.LoggerConfig", + "caves.apps.CavesConfig", "staff.apps.StaffConfig", "stats.apps.StatsConfig", "import.apps.ImportConfig", diff --git a/config/django/urls.py b/config/django/urls.py index 803fb780..454f3c0e 100644 --- a/config/django/urls.py +++ b/config/django/urls.py @@ -5,6 +5,7 @@ urlpatterns = [ path("", include("core.urls")), path("", include("logger.urls")), + path("caves/", include("caves.urls")), path("map/", include("maps.urls")), path("comments/", include("comments.urls")), path("statistics/", include("stats.urls")),