2
0
mirror of https://expo.survex.com/repositories/troggle/.git synced 2026-05-13 16:07:00 +01:00
Files
troggle/core/views/new_hole.py
T

609 lines
26 KiB
Python

import re
import subprocess
from pathlib import Path
from django import forms
from django.core.exceptions import ValidationError
from django.utils.safestring import mark_safe
from django.conf import settings
from troggle.core.models.caves import Cave, CaveAndEntrance, Entrance, GetCaveLookup
from troggle.parsers.people import who_is_this
# from troggle.core.views.editor_helpers import HTMLarea
from troggle.core.utils import (
get_cookie_max_age,
WriteAndCommitError,
add_commit,
current_expo,
get_editor,
git_commit,
git_string,
sanitize_name,
is_identified_user,
write_and_commit,
)
# TO DO check if wallet already exists and if so put a blue label against it & check names are same people
# assuming this is a new wallet for now - so add a check for this?
from troggle.core.position_utils import which_area # file-type import, not module type.
class NewHoleForm(forms.Form):
"""The validation on this form is a bit of a beast, sorry.
"""
discovery_date = forms.DateField(label="Trip date", widget=forms.DateInput(attrs={'type': 'date'}), required=True)
# Identification
cave_id = forms.CharField(label="New Cave Identifier for internal identifiers. Cannot easily be changed.",
widget=forms.TextInput(attrs={'placeholder':
'e.g. 2035-ZB-03 '}),
max_length=50, required=True)
tag_text = forms.CharField(label="Exact text on tag if placed, or seen in-place",
widget=forms.TextInput(attrs={'placeholder': 'e.g. 29 CUCC 35'}),
max_length=50, required=False)
# Naming
proposed_name = forms.CharField(label="Proposed Cave Name, can easily be changed later",
max_length=90, required=True)
# Discovery
discoverers = forms.CharField(label="Discoverers / Investigators today",
widget=forms.TextInput(attrs={'placeholder': 'e.g. Dour, Animal, Becka'}),
max_length=255, required=True)
surface_wallet = forms.CharField(label="Old wallet used to find the entrance (if any)",
widget=forms.TextInput(attrs={'placeholder': 'e.g. 2005 # 63'}),
max_length=100, required=False)
survey_wallet = forms.CharField(label="New Wallet for all this data (must match the year of the trip)",
widget=forms.TextInput(attrs={'placeholder': 'e.g. 2029 # 88'}),
required=True)
# GPS Data
gps_owner = forms.CharField(label="GPS: Whose device?",
widget=forms.TextInput(attrs={'placeholder': 'e.g. Becka'}),
max_length=100, required=True)
gps_lat = forms.FloatField(
label="GPS Latitude",
widget=forms.TextInput(attrs={'placeholder': 'e.g. 47.6964483 N'}), required=True
)
gps_long = forms.FloatField(
label="GPS Longitude",
widget=forms.TextInput(attrs={'placeholder': 'e.g. 13.8160500 E'}), required=True
)
gps_time = forms.TimeField(
label="Time of GPS reading",
widget=forms.TimeInput(attrs={'type': 'time'})
)
gps_screenshot = forms.BooleanField(label="Screenshot taken of GPSTest while GPS device in situ?", required=False)
gps_photo = forms.BooleanField(label="Photo taken of GPS device in situ with view of entrance?", required=False)
# Navigation
dist_to_ent = forms.FloatField(label="Distance from GPS to entrance (m)",
widget=forms.TextInput(attrs={'placeholder': 'e.g. 11.5'})
)
bear_to_ent = forms.FloatField(label="Compass bearing from GPS to entrance (degrees)",
widget=forms.TextInput(attrs={'placeholder': 'e.g. 217'})
)
# Status & Surveys
is_explored = forms.BooleanField(label="Exploration complete?", required=False)
ug_survey_done = forms.BooleanField(label="Survex data recorded?", required=False)
# Media: Entrance Photo (Replaced dropdown with checkboxes)
photo_ent_no = forms.BooleanField(label="Entrance photos ?", required=False)
photo_ent_who = forms.CharField(label="Who has photos of entrance, tag and GPS?", required=False)
# Entrance description and approach
entrance_description = forms.CharField(
label="Entrance description",
required=True,
widget=forms.Textarea(attrs={
"height": "80%", "rows": 2,
"placeholder": "horizontal slot at foot level in 3m high NE-facing cliff"}),
)
approach = forms.CharField(
label="Approach",
max_length=100,
required=True,
widget=forms.Textarea(attrs={
"height": "80%", "rows": 2,
"placeholder": "from top camp, go NE round the side of Augst Eck, in some trees"}),
)
identified_login = forms.BooleanField(required=False,widget=forms.CheckboxInput(attrs={"onclick":"return false"})) # makes it readonly
who_are_you = forms.CharField(strip=True,
label="Who are you ? (You do not need to have been on this trip)",
required=True,
widget=forms.TextInput(
attrs={"size": 100, "placeholder": "You are entering data, who are you ? e.g. 'Becka' or 'Animal <mta@gasthof.expo>'",
"style": "vertical-align: text-top;"}
)
)
# VALIDATIONS
# Django's validation logic will automatically trigger these.
# Trigger: When form.is_valid() is called in your new_hole view, Django automatically looks
# for methods named clean_<fieldname>.
# Using self.cleaned_data.get('fieldname') is a safer habit than direct square brackets.
# If a field fails basic validation (e.g., someone typed letters into a numeric field),
# it won't exist in cleaned_data. .get() returns None
def _validate_caver_list(self, field_name, year, raw_data):
"""
Helper to split comma-separated names and validate them against who_is_this.
Returns a list of validated person_ids.
"""
if not raw_data:
self._add_caver_error(field_name, "No one", year)
return []
# Handle both single names and comma-separated lists
names = [n.strip() for n in raw_data.split(',') if n.strip()]
validated_ids = []
for name in names:
try:
person_id = who_is_this(year, name)
if person_id:
validated_ids.append(person_id)
else:
self._add_caver_error(field_name, name, year)
except (ValueError, IndexError) as e:
self._add_caver_error(field_name, name, year)
return validated_ids
def _add_caver_error(self, field_name, name, year):
"""Standardized HTML error reporter"""
error_html = mark_safe(
f"'{name}' is not a recognized caver for the year {year}. "
f"See <a href='/aliases/{year}'>aliases list</a>"
)
self.add_error(field_name, error_html)
def clean(self):
# Unlike clean_<fieldname>, which validates one field at a time, the general clean() method
# allows you to compare multiple fields against each other.
# Always call the parent clean() first to get cleaned_data dictionary
cleaned_data = super().clean()
trip_date = cleaned_data.get("discovery_date")
wallet_id = cleaned_data.get("survey_wallet")
cave_id = cleaned_data.get("cave_id")
if cave_id and trip_date:
try:
clean_cave = "".join(cave_id.split()) # removes whitespace
cave_year = int(clean_cave[:4])
trip_year = trip_date.year
if cave_year != trip_year:
self.add_error('cave_id',
f"Year mismatch: Cave identifier year ({cave_year}) does not match Trip date year ({trip_year}).")
except (ValueError, IndexError):
# Individual field cleaners (regex) will handle malformed wallet strings
pass
if wallet_id and trip_date:
try:
clean_wallet = "".join(wallet_id.split()) # should already be cleaned
wallet_year = int(clean_wallet[:4])
trip_year = trip_date.year
if wallet_year != trip_year:
self.add_error('survey_wallet',
f"Year mismatch: Wallet year ({wallet_year}) does not match Trip date year ({trip_year}).")
except (ValueError, IndexError):
# Individual field cleaners (regex) will handle malformed wallet strings
pass
# 2. Extract the year from survey_wallet (YYYY#NN)
# We only proceed if wallet_id passed its own validation earlier,
# which removes whitespace and checks the year
if wallet_id and "#" in wallet_id:
year = int(clean_wallet[:4])
intrepids = self._validate_caver_list(
'discoverers', year, cleaned_data.get("discoverers"))
if cleaned_data.get("photo_ent_no") or cleaned_data.get("gps_photo"):
cameramen = self._validate_caver_list(
'photo_ent_who', year, cleaned_data.get("photo_ent_who"))
gps_user = self._validate_caver_list(
'gps_owner', year, cleaned_data.get("gps_owner"))
# can now store these lists in cleaned_data if we want
# cleaned_data['intrepid_ids'] = intrepids
# cleaned_data['cameramen_ids'] = cameramen
gps_lat = cleaned_data.get("gps_lat")
gps_long = cleaned_data.get("gps_long")
valid_area, area = which_area(gps_lat, gps_long)
if not valid_area:
self.add_error('gps_lat', "Not in Area 1626 or 1623")
self.add_error('gps_long', "Not in Area 1626 or 1623")
else:
if Cave.objects.filter(unofficial_number=cave_id, areacode=area).exists():
slug = f"{area}-{cave_id}"
error_html = mark_safe(
f"This Cave already exists, pick another identifier, or edit it here: "
f"<a href='/{area}/{cave_id}/{area}-{cave_id}_cave_edit/'>{slug}</a>"
)
self.add_error('cave_id', error_html)
# Entrance Photo Logic
photo_ent_on_camera = cleaned_data.get("photo_ent_on_camera")
gps_screenshot = cleaned_data.get("gps_screenshot")
photo_ent_who = cleaned_data.get("photo_ent_who")
if photo_ent_on_camera and not photo_ent_who:
# This attaches the error specifically to the 'who' field
self.add_error('photo_ent_who', "Please specify who has the entrance photo.")
if gps_screenshot and not photo_ent_who:
# This attaches the error specifically to the 'who' field
self.add_error('photo_ent_who', "Please specify who has the photo of the GPS device.")
# Tag Photo Logic (Applying the same logic for consistency)
photo_tag_on_camera = cleaned_data.get("photo_tag_on_camera")
photo_tag_who = cleaned_data.get("photo_tag_who")
if photo_tag_on_camera and not photo_tag_who:
self.add_error('photo_tag_who', "Please specify who has the tag photo.")
return cleaned_data
def clean_cave_id(self):
data = self.cleaned_data.get('cave_id')
# 1. Remove whitespace and force uppercase for consistency
clean_text = "".join(data.split()).upper()
# 2. Regex check: 4 digits, hyphen, 1-4 letters, hyphen, 2 digits
# \d{4} = year, [A-Z]{1,4} = 1 to 4 letters, \d{2} = number
match = re.match(r'^(\d{4})-([A-Z]{1,4})-(\d{2})$', clean_text)
if not match:
raise forms.ValidationError(
"Tag ID must be in format 'YYYY-A[AAA]-NN' (e.g., 2035-AA-99). "
"The middle can be 1 to 4 letters."
)
# 3. Validate year range from the first capture group
year = int(match.group(1))
if year < 1976 or year > 2099:
raise forms.ValidationError(
f"The year {year} must be between 1976 and 2099."
)
return clean_text
def clean_gps_lat(self):
data = self.cleaned_data['gps_lat']
min_val = 47.5
max_val = 47.9
if data < min_val or data > max_val:
raise ValidationError(
f"A valid latitude is between {min_val} and {max_val}")
return data
def clean_gps_long(self):
data = self.cleaned_data['gps_long']
placeholder_val = 13.8160500
min_val = 13.6
max_val = 14.0
if data < min_val or data > max_val:
raise ValidationError(
f"A valid longitude is between {min_val} and {max_val}")
return data
def clean_dist_to_ent(self):
dist = self.cleaned_data.get('dist_to_ent')
if dist is None:
raise forms.ValidationError("Distance cannot empty. Please enter a value between 0 and 20.")
else:
if dist < 0 or dist > 20:
raise forms.ValidationError("Distance must be between 0 and 20 meters.")
return dist
def clean_bear_to_ent(self):
bearing = self.cleaned_data.get('bear_to_ent')
if bearing is None:
raise forms.ValidationError("Bearing cannot empty. Please enter a value between 0 and 360.")
else:
if bearing < 0 or bearing > 360:
raise forms.ValidationError("Bearing must be between 0 and 360 degrees.")
return bearing
def clean_survey_wallet(self):
data = self.cleaned_data.get('survey_wallet')
if not data:
return data # Skip if empty (assumes required=False)
# 1. Remove all whitespace, catches tabs etc (\t), (\n)
clean_text = "".join(data.split())
# 2. Check general format using Regex (4 digits, #, 2 digits)
# ^\d{4}#\d{2}$ ensures the entire string matches exactly
if not re.match(r'^\d{4}#\d{2}$', clean_text):
raise forms.ValidationError(
"Survey Wallet must be in the format 'YYYY#NN' (e.g., 2019#99)."
)
# 3. Validate the year range (1976 to 2099)
year = int(clean_text[:4])
if year < 1976 or year > 2099:
raise forms.ValidationError(
f"The year {year} must be between 1976 and 2099."
)
# Return the whitespace-stripped version to be saved
return clean_text
from textwrap import dedent
from django.shortcuts import render, redirect
from django.contrib import messages
from .auth import login_required_if_public
from troggle.parsers.caves import make_cave
@login_required_if_public
def new_hole(request):
identified_login = is_identified_user(request.user)
editor = get_editor(request)
areatext = None
if request.method == 'POST':
form = NewHoleForm(request.POST, request.FILES)
lat = float(form.data['gps_lat'])
long = float(form.data['gps_long'])
valid_area, area = which_area(lat, long)
if valid_area:
areatext = f"in {area}"
else:
areatext = "Not in 1623 or 1626"
if form.is_valid():
editor = form.cleaned_data["who_are_you"]
editor = git_string(editor)
if valid_area:
process_new_hole(form, area)
messages.success(request, "New prospect save data successfully saved.")
success_url = "/walletedit/" + form.cleaned_data.get('survey_wallet').replace("#",":")
return redirect(success_url)
else:
# not in 1623 or 1626
messages.failure(request, "Not in 1623 or 1626. Fix this.")
return render(request, 'new_hole.html', {'form': form,
"identified_login": identified_login,
"areatext": areatext})
# GET
else:
form = NewHoleForm(initial={"identified_login": identified_login, "who_are_you":editor})
if identified_login:
# disable editing the git id string as we get it from the logged-on user data
form.fields["who_are_you"].widget.attrs["readonly"]="readonly"
return render(request, 'new_hole.html', {'form': form,
"identified_login": identified_login,
"areatext": areatext})
def get_auto_file():
auto_gps_file = settings.SURVEX_DATA / "fixedpts/gps/auto.svx"
if not auto_gps_file.exists():
auto_gps_file.parent.mkdir(parents=True, exist_ok=True)
with open(auto_gps_file, "w") as auto:
auto.write(dedent(""" ; Auto-created GPS fixes from 2026 for new caves in BOTH 1623 and 1626
*begin
*cs out UTM33
*cs LONG-LAT
*end
"""))
with open(auto_gps_file, "r") as auto:
old_content = auto.readlines()
content_list = [line for line in old_content if "*end" not in line]
content = "".join(content_list)
print(content)
return auto_gps_file, content
def process_new_hole(form, area):
"""
🚧 under construction.
✅Detect if logged-in & set who_am_i using the cookie system, make read-only if logged in
✅ Create a fixed point *fix record by inserting into :loser:/fixedpts/gps/auto.svx
✅ Do a git commit (loser) of the new GPS position
✅Create a new Cave description file
⚡Create an associated new Entrance description file with GPS location using *fix
✅Update the database with this new Cave
⚡Update the database with this new Entrance
⚡Do a git commit (expoweb) of the new Cave and Entrance description files
⚡+ Link page to go to
Entrance (upload approach & photos, already done Other Station location),
Wallet (auto-set related Cave & People)
GPSlog upload
Logbook entry for the trip (date, people)
Cave (if cave description, but probably not)
Survex new (needs new creation page) (date, people, cave id)
+ list of "happened this day" at the bottom, as for other pages.
⚡Update the database "locations" with the new *fix (is this even possible 🧩 without reset?)
writes:
*fix 1623.g2025-bz-06 47.6964481 13.816103 0
we do not put "reference" in the *fix because we know it is used in the Entrance we are creating
❌BUT we DO put reference in now because we haven't written the Entrance bit of the code yet!
"""
editor = git_string(form.cleaned_data["who_are_you"])
fix_id = f"{area}.g{form.cleaned_data.get("cave_id").lower()}"
_newfix(form, area, editor, fix_id)
cave = _makecave(form, area)
_newent(form, area, editor, fix_id, cave) # yes, make the Entrance first
_savecave(form, area, editor, cave)
_newwallet(form, area, editor)
return
def _newfix(form, area, editor, fix_id):
auto_gps_file, content = get_auto_file()
fix_line = f"*fix {fix_id} reference {form.cleaned_data.get("gps_lat")} {form.cleaned_data.get("gps_long")} 0\n"
content += f"\n; {form.cleaned_data.get("discovery_date")} wallet: {form.cleaned_data.get("survey_wallet")} \n"
content += fix_line
content += f"*entrance {fix_id}\n"
content +=f"\n*end\n"
files = [(auto_gps_file, content, "utf8")]
try:
write_and_commit(files, f"Online *fix {fix_id} ", editor)
except PermissionError:
message = f"CANNOT save this file.\nPERMISSIONS incorrectly set on server for this file {auto_gps_file}. Ask a nerd to fix this."
return render(request, "errors/generic.html", {"message": message})
except WriteAndCommitError as e:
message = f"CANNOT git on server for this file {auto_gps_file}.\n{e}\nEdits may not be committed.\nAsk a nerd to fix this."
return render(request, "errors/generic.html", {"message": e.message})
except subprocess.SubprocessError as e:
message = f"CANNOT update server for this file {auto_gps_file}.\n{e}\nEdits may not be committed.\nAsk a nerd to fix this."
return render(request, "errors/generic.html", {"message": message})
except:
raise
return
def _newent(form, areacode, editor, fix_id, cave):
"""All a bit over-complicated by the existance of teh combined Entrance & Letter Form and the
CaveAndEntrance class
Ideally we want to use the validity checks defined on the Form objects to check that this data is valid.
But really this should be done within the form we have just processed which the user has just filled in.
So we won't use the EntranceForm or the EntranceLetterForm, but just duplicate the checks above instead.
In the parser, the Entrance is created first, then the Cave. But when doing NewCave, the Cave is created
first, then the Entrance. So this code is derived from a bit of both.
"""
slug = f"{areacode}-{form.cleaned_data.get("cave_id")}" # no letter suffix a,b, or c..
imgpath = Path(areacode) / form.cleaned_data.get("cave_id")
ent = Entrance.objects.create( # creates object
slug=slug,
filename = slug + ".html",
findability="S", # Coordinates
marking="U", # Unmarked
approach= form.cleaned_data.get("approach"),
entrance_description=form.cleaned_data.get("entrance_description"),
# location_description=location_description[0],
lastvisit=form.cleaned_data.get("discovery_date"),
other_station=fix_id,
)
# link ent to cave
ce =CaveAndEntrance.objects.create(
cave=cave, entranceletter="", entrance=ent
)
#
# Add in saving Entrance to database and then .html file to filesystem and git
#
ent.save() # saves into db
try:
ent_file = ent.file_output()
write_and_commit([ent_file], f"Creating new Entrance {ent}", editor)
# leave other exceptions unhandled so that they bubble up to user interface
except PermissionError:
message = f"CANNOT save this file.\nPERMISSIONS incorrectly set on server for this file {ent.filename}. Ask a nerd to fix this."
return render(request, "errors/generic.html", {"message": message})
except WriteAndCommitError as e:
message = f"CANNOT git on server for this file {ent.filename}.\n{e}\nEdits may not be committed.\nAsk a nerd to fix this."
return render(request, "errors/generic.html", {"message": e.message})
except subprocess.SubprocessError as e:
message = f"CANNOT update server for this file {ent.filename}.\n{e}\nEdits may not be committed.\nAsk a nerd to fix this."
return render(request, "errors/generic.html", {"message": message})
except:
raise
return
def _guess_survex_file(areacode, id):
survex_file = f"caves-{areacode}/{id}/{id}.svx"
if Path(settings.SURVEX_DATA, survex_file).is_file():
return survex_file
else:
survex_file = f"caves-{areacode}/{id}.svx"
if Path(settings.SURVEX_DATA, survex_file).is_file():
return survex_file
return ""
def _makecave(form, areacode):
cave_id = form.cleaned_data.get("cave_id")
slug = f"{areacode}-{cave_id}"
cave = make_cave(slug)
# Add in default text for various fields here, and links to the two Wallets.
description = f"Created from New Cave Datasheet on {form.cleaned_data.get("discovery_date")} "
f"details in wallet: {form.cleaned_data.get("survey_wallet")} "
if form.cleaned_data.get("surface_wallet"):
description += f"<br />discovered using old wallet: {form.cleaned_data.get("surface_wallet")} "
default_note = "Created from New Cave Datasheet. "
wallet = form.cleaned_data.get("survey_wallet")
wallet_url = f"/walletedit/{wallet.replace('#',':')}"
references = f"Wallet <a href='{wallet_url}'>{wallet}</a>"
# TO-DO Need to detect if the existence of the survex was ticked but the file was not found,
# but that is probably normal: people will mostly record the Cave first and then do the survex data.
cave = Cave(
official_name=form.cleaned_data.get("proposed_name"),
underground_description=description,
unofficial_number=cave_id,
survex_file=_guess_survex_file(areacode, cave_id), # possible that they did the survex file already?
url=f"{areacode}/{cave_id}/{cave_id}.html",
notes=default_note,
areacode=areacode,
fully_explored=form.cleaned_data.get("is_explored"),
unexplored= not form.cleaned_data.get("is_explored"),
references=references,
)
cave.save()
return cave
def _savecave(form, areacode, editor, cave):
cave.save()
# need a CaveForm f we do it this way, which is a ModelForm.
# form.save_m2m() # this does the many-to-many relationship saving between caves and entrances
# can we do this manually?
try:
cave_file = cave.file_output()
write_and_commit([cave_file], f"Creating new cave {cave}", editor)
# leave other exceptions unhandled so that they bubble up to user interface
except PermissionError:
message = f"CANNOT save this file.\nPERMISSIONS incorrectly set on server for this file {cave.filename}. Ask a nerd to fix this."
return render(request, "errors/generic.html", {"message": message})
except WriteAndCommitError as e:
message = f"CANNOT git on server for this file {cave.filename}.\n{e}\nEdits may not be committed.\nAsk a nerd to fix this."
return render(request, "errors/generic.html", {"message": e.message})
except subprocess.SubprocessError as e:
message = f"CANNOT update server for this file {cave.filename}.\n{e}\nEdits may not be committed.\nAsk a nerd to fix this."
return render(request, "errors/generic.html", {"message": message})
except:
raise
return
def _newwallet(form, areacode, editor):
return