diff --git a/cs_app/main/views.py b/cs_app/main/views.py index 5df52ec..8301d37 100644 --- a/cs_app/main/views.py +++ b/cs_app/main/views.py @@ -1,7 +1,4 @@ -""" -This module contains views and helper functions for rendering and managing -the main view, about page, contact form, and user profiles. -""" +"""Views and helper functions for the main Django application.""" import datetime import json @@ -63,6 +60,46 @@ UPLOAD_SUBDIR = "uploaded_files" SI_FLOW_UNITS = frozenset({"CMS", "LPS", "MLD"}) US_FLOW_UNITS = frozenset({"CFS", "GPM", "MGD"}) +UPLOAD_SESSION_KEYS = ( + SIM_FORM_STATE_SESSION_KEY, + TS_FORM_STATE_SESSION_KEY, + "_subcatchment_ids", + "_subcatchment_ids_file", +) +SIM_SESSION_DEFAULTS = { + "show_download_button": False, + "chart_config": None, + "results_columns": [], + "results_data": [], + "feature_name": "", + "output_file_name": None, + "download_token": None, +} +TS_SESSION_DEFAULTS = { + "ts_chart_config": None, + "ts_time_to_peak": None, + "ts_runoff_volume": None, + "ts_show_results": False, + "output_file_name": None, + "download_token": None, +} +SESSION_VARIABLES_TO_CLEAR = ( + "show_download_button", + "chart_config", + "results_columns", + "results_data", + "feature_name", + "output_file_name", + "ts_chart_config", + "ts_time_to_peak", + "ts_runoff_volume", + "ts_show_results", + "ts_output_file_name", + SIM_RESULT_TOKEN_SESSION_KEY, + TS_RESULT_TOKEN_SESSION_KEY, + SIM_FORM_STATE_SESSION_KEY, + TS_FORM_STATE_SESSION_KEY, +) class ResultPayloadTooLargeError(ValueError): @@ -74,12 +111,7 @@ class InputValidationError(ValueError): def ajax_login_required(view_func): - """ - Decorator that checks authentication for AJAX requests. - - For AJAX requests, returns a 401 JSON response with a login URL. - For regular requests, redirects to the login page. - """ + """Like ``@login_required`` but returns 401 JSON for AJAX requests.""" @wraps(view_func) def wrapper(request, *args, **kwargs): @@ -98,7 +130,7 @@ def wrapper(request, *args, **kwargs): def _load_chart_json(filename: str, x_key: str, y_key: str) -> list[dict]: - """Load and validate chart data JSON structure.""" + """Load JSON from ``data/`` dir and validate it has numeric x/y keys.""" data_dir = os.path.join(settings.BASE_DIR, "data") path = os.path.join(data_dir, filename) with open(path, encoding="utf-8") as file: @@ -228,6 +260,74 @@ def _safe_remove_file(path: str | None) -> None: os.remove(path) +def _file_too_large_message() -> str: + return f"File too large. Maximum size is {MAX_UPLOAD_SIZE // (1024 * 1024)} MB." + + +def _timestamp_suffix() -> str: + return datetime.datetime.now().strftime("%Y%m%d_%H%M%S") + + +def _default_uploaded_file_path(request: HttpRequest) -> str: + return request.session.get( + "uploaded_file_path", os.path.abspath("catchment_simulation/example.inp") + ) + + +def _clear_upload_session_state(request: HttpRequest) -> None: + for key in UPLOAD_SESSION_KEYS: + request.session.pop(key, None) + + +def _set_uploaded_file_for_session(request: HttpRequest, file_path: str) -> None: + old_path = request.session.get("uploaded_file_path") + if old_path and old_path != file_path: + _safe_remove_file(old_path) + request.session["uploaded_file_path"] = file_path + _clear_upload_session_state(request) + + +def _store_result_payload_for_user( + request: HttpRequest, + *, + scope: str, + session_key: str, + payload: dict, +) -> None: + old_token = request.session.get(session_key) + token = _store_cached_result(scope, request.user.id, payload) + _delete_cached_result(scope, request.user.id, old_token) + request.session[session_key] = token + + +def _get_download_payload( + request: HttpRequest, + *, + scope: str, + redirect_name: str, + missing_message: str, +) -> tuple[dict | None, HttpResponseRedirect | None]: + token = request.POST.get("token") + if not _is_valid_result_token(token): + messages.error(request, "Invalid download token.") + return None, redirect(redirect_name) + + payload = _load_cached_result(scope, request.user.id, token) + if not payload: + messages.error(request, missing_message) + return None, redirect(redirect_name) + + return payload, None + + +def _default_simulation_session_data() -> dict: + return dict(SIM_SESSION_DEFAULTS) + + +def _default_timeseries_session_data() -> dict: + return dict(TS_SESSION_DEFAULTS) + + def _coerce_input_validation_error(error: Exception) -> InputValidationError | None: """Normalize known user-facing errors to InputValidationError.""" if isinstance(error, InputValidationError): @@ -272,54 +372,16 @@ def _format_input_error_message(error: InputValidationError) -> str: def main_view(request: HttpRequest) -> HttpResponse: - """ - Render the main view with interactive plots. - - Parameters - ---------- - request : HttpRequest - The incoming HTTP request. - - Returns - ------- - HttpResponse - The HTTP response with the rendered main view template. - """ + """Landing page with static slope/area/width charts.""" context = {"chart_data": _load_static_chart_data()} return render(request, "main/main_view.html", context) def about(request: HttpRequest) -> HttpResponse: - """ - Render the about page. - - Parameters - ---------- - request : HttpRequest - The incoming HTTP request. - - Returns - ------- - HttpResponse - The HTTP response with the rendered about page template. - """ return render(request, "main/about.html") def contact(request: HttpRequest) -> HttpResponse: - """ - Render the contact form and handle form submission. - - Parameters - ---------- - request : HttpRequest - The incoming HTTP request. - - Returns - ------- - HttpResponse - The HTTP response with the rendered contact form template. - """ if request.method == "POST": form = ContactForm(data=request.POST) if form.is_valid(): @@ -331,46 +393,41 @@ def contact(request: HttpRequest) -> HttpResponse: return render(request, "main/contact.html", {"form": form}) +def _build_user_profile_form(user, *, data: object | None = None) -> UserProfileForm: + form_kwargs = {} + if data is not None: + form_kwargs["data"] = data + + try: + return UserProfileForm(instance=user.userprofile, **form_kwargs) + except AttributeError: + if data is None: + logger.debug("User %s has no profile, creating empty form", user.id) + else: + logger.debug("User %s has no profile, creating new form for POST", user.id) + return UserProfileForm(initial={"user": user, "bio": ""}, **form_kwargs) + + +def _set_profile_form_read_only(form: UserProfileForm) -> None: + for field in form.fields.values(): + field.disabled = True + form.helper.inputs = [] + + def user_profile(request: HttpRequest, user_id: int) -> HttpResponse: - """ - Render the user profile page and handle form submission. - - Parameters - ---------- - request : HttpRequest - The incoming HTTP request. - user_id : int - The user ID for the profile. - - Returns - ------- - HttpResponse - The HTTP response with the rendered user profile template. - """ + """Display user profile; owners can edit, others get read-only view.""" user = get_object_or_404(get_user_model(), id=user_id) if request.method == "POST": if request.user != user: return HttpResponseForbidden("You are not allowed to edit this profile.") - try: - profile = user.userprofile - form = UserProfileForm(request.POST, instance=profile) - except AttributeError: - logger.debug(f"User {user_id} has no profile, creating new form for POST") - form = UserProfileForm(request.POST, initial={"user": user, "bio": ""}) + form = _build_user_profile_form(user, data=request.POST) if form.is_valid(): form.save() return HttpResponseRedirect(reverse("userprofile", args=[user_id])) else: - try: - profile = user.userprofile - form = UserProfileForm(instance=profile) - except AttributeError: - logger.debug(f"User {user_id} has no profile, creating empty form") - form = UserProfileForm(initial={"user": user, "bio": ""}) + form = _build_user_profile_form(user) if request.user != user: - for field in form.fields: - form.fields[field].disabled = True - form.helper.inputs = [] + _set_profile_form_read_only(form) return render(request, "main/userprofile.html", {"form": form}) @@ -395,12 +452,7 @@ def _sanitize_filename(filename: str) -> str: def _validate_inp_file_content(file_content: bytes) -> bool: - """ - Validate that the file content appears to be a valid SWMM .inp file. - - SWMM .inp files typically start with section headers like [TITLE], [OPTIONS], etc. - Handles BOM markers and various line endings (CRLF/LF). - """ + """Check in-memory bytes for SWMM section headers. Handles BOM and mixed line endings.""" try: if file_content.startswith(b"\xef\xbb\xbf"): file_content = file_content[3:] @@ -501,22 +553,7 @@ def file_complete(self, file_size: int) -> None: @require_POST @ajax_login_required def upload(request: HttpRequest) -> JsonResponse: - """ - Upload a .inp file to the server. - - Requires user authentication. For AJAX requests (like Dropzone.js), - returns a 401 JSON response if not authenticated. - - Parameters - ---------- - request : HttpRequest - The incoming HTTP request. - - Returns - ------- - JsonResponse - JSON response containing a success message if the file was uploaded successfully, or an error message if not. - """ + """Accept a user-uploaded .inp file (via Dropzone.js) after size and content validation.""" raw_content_length = request.META.get("CONTENT_LENGTH") if raw_content_length in ("",): return JsonResponse({"error": "Invalid Content-Length header."}, status=400) @@ -528,10 +565,7 @@ def upload(request: HttpRequest) -> JsonResponse: return JsonResponse({"error": "Invalid Content-Length header."}, status=400) if content_length > MAX_UPLOAD_BODY_SIZE: - return JsonResponse( - {"error": (f"File too large. Maximum size is {MAX_UPLOAD_SIZE // (1024 * 1024)} MB.")}, - status=413, - ) + return JsonResponse({"error": _file_too_large_message()}, status=413) request.upload_handlers = [ BodySizeLimitUploadHandler(request, MAX_UPLOAD_BODY_SIZE), @@ -542,10 +576,7 @@ def upload(request: HttpRequest) -> JsonResponse: except MultiPartParserError: return JsonResponse({"error": "Malformed multipart request."}, status=400) if getattr(request, "_upload_body_too_large", False): - return JsonResponse( - {"error": (f"File too large. Maximum size is {MAX_UPLOAD_SIZE // (1024 * 1024)} MB.")}, - status=413, - ) + return JsonResponse({"error": _file_too_large_message()}, status=413) if "file" not in files: return JsonResponse({"error": "No file provided."}, status=400) @@ -562,14 +593,11 @@ def upload(request: HttpRequest) -> JsonResponse: # Check file size if uploaded_file.size > MAX_UPLOAD_SIZE: - return JsonResponse( - {"error": f"File too large. Maximum size is {MAX_UPLOAD_SIZE // (1024 * 1024)} MB."}, - status=413, - ) + return JsonResponse({"error": _file_too_large_message()}, status=413) # Validate file content if not _validate_inp_file_stream(uploaded_file): - logger.warning(f"Invalid .inp file content uploaded: {uploaded_file.name}") + logger.warning("Invalid .inp file content uploaded: %s", uploaded_file.name) return JsonResponse( { "error": "Invalid file content. The file does not appear to be a valid SWMM .inp file." @@ -582,11 +610,6 @@ def upload(request: HttpRequest) -> JsonResponse: user_dir = _user_upload_dir(request.user.id) file_path = os.path.join(user_dir, safe_filename + file_extension) - # Remove previous uploaded file from disk - old_path = request.session.get("uploaded_file_path") - if old_path and old_path != file_path: - _safe_remove_file(old_path) - # Ensure upload directory exists os.makedirs(user_dir, exist_ok=True) @@ -594,13 +617,8 @@ def upload(request: HttpRequest) -> JsonResponse: for chunk in uploaded_file.chunks(): destination.write(chunk) - request.session["uploaded_file_path"] = file_path - request.session.pop(SIM_FORM_STATE_SESSION_KEY, None) - request.session.pop(TS_FORM_STATE_SESSION_KEY, None) - # Invalidate cached subcatchment IDs so they are re-read from new file - request.session.pop("_subcatchment_ids", None) - request.session.pop("_subcatchment_ids_file", None) - logger.info(f"File uploaded successfully: {file_path}") + _set_uploaded_file_for_session(request, file_path) + logger.info("File uploaded successfully: %s", file_path) return JsonResponse({"message": "File was sent."}) @@ -633,15 +651,7 @@ def upload_sample(request: HttpRequest) -> JsonResponse: logger.exception("Failed to copy sample INP file for user %s", request.user.id) return JsonResponse({"error": "Failed to load sample file."}, status=500) - old_path = request.session.get("uploaded_file_path") - if old_path and old_path != file_path: - _safe_remove_file(old_path) - - request.session["uploaded_file_path"] = file_path - request.session.pop(SIM_FORM_STATE_SESSION_KEY, None) - request.session.pop(TS_FORM_STATE_SESSION_KEY, None) - request.session.pop("_subcatchment_ids", None) - request.session.pop("_subcatchment_ids_file", None) + _set_uploaded_file_for_session(request, file_path) try: sample_size = os.path.getsize(file_path) except OSError: @@ -659,20 +669,7 @@ def upload_sample(request: HttpRequest) -> JsonResponse: @require_GET @ajax_login_required def upload_status(request: HttpRequest) -> JsonResponse: - """ - Return the current uploaded file status from the session. - - Parameters - ---------- - request : HttpRequest - The incoming HTTP request. - - Returns - ------- - JsonResponse - JSON with ``has_file``, ``filename``, and ``size`` when a file is - present, or ``has_file: false`` otherwise. - """ + """Return ``{has_file, filename, size}`` for the current session upload.""" file_path = request.session.get("uploaded_file_path") if file_path: try: @@ -692,42 +689,19 @@ def upload_status(request: HttpRequest) -> JsonResponse: @ajax_login_required def upload_clear(request: HttpRequest) -> JsonResponse: - """ - Clear the uploaded file from the session and disk. - - Parameters - ---------- - request : HttpRequest - The incoming HTTP request. - - Returns - ------- - JsonResponse - JSON response confirming the upload was cleared. - """ + """Remove uploaded file from disk and clear related session state.""" if request.method != "POST": return JsonResponse({"error": "Method not allowed."}, status=405) file_path = request.session.pop("uploaded_file_path", None) _safe_remove_file(file_path) - - request.session.pop(SIM_FORM_STATE_SESSION_KEY, None) - request.session.pop(TS_FORM_STATE_SESSION_KEY, None) - # Clear cached subcatchment IDs - request.session.pop("_subcatchment_ids", None) - request.session.pop("_subcatchment_ids_file", None) + _clear_upload_session_state(request) return JsonResponse({"message": "Upload cleared."}) def _get_subcatchment_ids(request: HttpRequest) -> list[str]: - """ - Return subcatchment IDs from the uploaded INP file, using session cache. - - The result is cached in the session under ``_subcatchment_ids`` and - ``_subcatchment_ids_file``. The cache is invalidated when the uploaded - file path changes. - """ + """Return subcatchment IDs from the uploaded INP, cached per session/file path.""" file_path = request.session.get("uploaded_file_path") cached_file = request.session.get("_subcatchment_ids_file") if file_path and file_path == cached_file: @@ -753,12 +727,7 @@ def _get_subcatchment_ids(request: HttpRequest) -> list[str]: def _get_catchment_choices(request: HttpRequest) -> list[tuple[str, str]]: - """ - Extract subcatchment IDs from the uploaded INP file stored in the session. - - Returns a list of (id, id) tuples suitable for a Select widget, or a - placeholder when no file is available. - """ + """Build ``(value, label)`` choices for the catchment ``