feat: Implement initial schema and add various API, service, and management command enhancements across the application.

This commit is contained in:
pacnpal
2026-01-01 15:13:01 -05:00
parent c95f99ca10
commit b243b17af7
413 changed files with 11164 additions and 17433 deletions

View File

@@ -8,10 +8,17 @@ Reusable utility functions for testing FSM transitions:
- Toast notification verification utilities
"""
from __future__ import annotations
import json
from typing import Any
from typing import TYPE_CHECKING, Any
from django.contrib.auth import get_user_model
if TYPE_CHECKING:
from apps.moderation.models import EditSubmission, PhotoSubmission
from apps.parks.models import Park
from apps.rides.models import Ride
from django.contrib.contenttypes.models import ContentType
from django.db.models import Model
from django.http import HttpResponse
@@ -31,8 +38,8 @@ def create_test_submission(
submission_type: str = "EDIT",
changes: dict[str, Any] | None = None,
reason: str = "Test submission",
**kwargs
) -> "EditSubmission":
**kwargs,
) -> EditSubmission:
"""
Create a test EditSubmission with the given status.
@@ -54,8 +61,7 @@ def create_test_submission(
# Get or create user
if user is None:
user, _ = User.objects.get_or_create(
username="test_submitter",
defaults={"email": "test_submitter@example.com"}
username="test_submitter", defaults={"email": "test_submitter@example.com"}
)
user.set_password("testpass123")
user.save()
@@ -80,18 +86,13 @@ def create_test_submission(
changes=changes,
reason=reason,
status=status,
**kwargs
**kwargs,
)
return submission
def create_test_park(
status: str = "OPERATING",
name: str | None = None,
slug: str | None = None,
**kwargs
) -> "Park":
def create_test_park(status: str = "OPERATING", name: str | None = None, slug: str | None = None, **kwargs) -> Park:
"""
Create a test Park with the given status.
@@ -108,29 +109,22 @@ def create_test_park(
if name is None:
import random
name = f"Test Park {random.randint(1000, 9999)}"
if slug is None:
from django.utils.text import slugify
slug = slugify(name)
park = ParkFactory(
name=name,
slug=slug,
status=status,
**kwargs
)
park = ParkFactory(name=name, slug=slug, status=status, **kwargs)
return park
def create_test_ride(
status: str = "OPERATING",
name: str | None = None,
slug: str | None = None,
park: Model | None = None,
**kwargs
) -> "Ride":
status: str = "OPERATING", name: str | None = None, slug: str | None = None, park: Model | None = None, **kwargs
) -> Ride:
"""
Create a test Ride with the given status.
@@ -148,18 +142,15 @@ def create_test_ride(
if name is None:
import random
name = f"Test Ride {random.randint(1000, 9999)}"
if slug is None:
from django.utils.text import slugify
slug = slugify(name)
ride_kwargs = {
"name": name,
"slug": slug,
"status": status,
**kwargs
}
ride_kwargs = {"name": name, "slug": slug, "status": status, **kwargs}
if park is not None:
ride_kwargs["park"] = park
@@ -170,11 +161,8 @@ def create_test_ride(
def create_test_photo_submission(
status: str = "PENDING",
user: User | None = None,
park: Model | None = None,
**kwargs
) -> "PhotoSubmission":
status: str = "PENDING", user: User | None = None, park: Model | None = None, **kwargs
) -> PhotoSubmission:
"""
Create a test PhotoSubmission with the given status.
@@ -193,8 +181,7 @@ def create_test_photo_submission(
# Get or create user
if user is None:
user, _ = User.objects.get_or_create(
username="test_photo_submitter",
defaults={"email": "test_photo@example.com"}
username="test_photo_submitter", defaults={"email": "test_photo@example.com"}
)
user.set_password("testpass123")
user.save()
@@ -210,11 +197,12 @@ def create_test_photo_submission(
# Get a photo if available
try:
from django_cloudflareimages_toolkit.models import CloudflareImage
photo = CloudflareImage.objects.first()
if not photo:
raise ValueError("No CloudflareImage available for testing")
except ImportError:
raise ValueError("CloudflareImage not available")
raise ValueError("CloudflareImage not available") from None
submission = PhotoSubmission.objects.create(
user=user,
@@ -223,7 +211,7 @@ def create_test_photo_submission(
photo=photo,
caption="Test photo submission",
status=status,
**kwargs
**kwargs,
)
return submission
@@ -247,16 +235,11 @@ def assert_status_changed(obj: Model, expected_status: str) -> None:
"""
obj.refresh_from_db()
actual_status = getattr(obj, "status", None)
assert actual_status == expected_status, (
f"Expected status '{expected_status}', got '{actual_status}'"
)
assert actual_status == expected_status, f"Expected status '{expected_status}', got '{actual_status}'"
def assert_state_log_created(
obj: Model,
transition_name: str,
user: User | None = None,
expected_state: str | None = None
obj: Model, transition_name: str, user: User | None = None, expected_state: str | None = None
) -> None:
"""
Assert that a StateLog entry was created for a transition.
@@ -274,31 +257,20 @@ def assert_state_log_created(
content_type = ContentType.objects.get_for_model(obj)
logs = StateLog.objects.filter(
content_type=content_type,
object_id=obj.pk
).order_by('-timestamp')
logs = StateLog.objects.filter(content_type=content_type, object_id=obj.pk).order_by("-timestamp")
assert logs.exists(), "No StateLog entries found for object"
latest_log = logs.first()
if expected_state is not None:
assert latest_log.state == expected_state, (
f"Expected state '{expected_state}' in log, got '{latest_log.state}'"
)
assert latest_log.state == expected_state, f"Expected state '{expected_state}' in log, got '{latest_log.state}'"
if user is not None:
assert latest_log.by == user, (
f"Expected log by user '{user}', got '{latest_log.by}'"
)
assert latest_log.by == user, f"Expected log by user '{user}', got '{latest_log.by}'"
def assert_toast_triggered(
response: HttpResponse,
message: str | None = None,
toast_type: str = "success"
) -> None:
def assert_toast_triggered(response: HttpResponse, message: str | None = None, toast_type: str = "success") -> None:
"""
Assert that the response contains an HX-Trigger header with toast data.
@@ -316,14 +288,12 @@ def assert_toast_triggered(
assert "showToast" in trigger_data, "HX-Trigger missing showToast event"
toast_data = trigger_data["showToast"]
assert toast_data.get("type") == toast_type, (
f"Expected toast type '{toast_type}', got '{toast_data.get('type')}'"
)
assert toast_data.get("type") == toast_type, f"Expected toast type '{toast_type}', got '{toast_data.get('type')}'"
if message is not None:
assert message in toast_data.get("message", ""), (
f"Expected '{message}' in toast message, got '{toast_data.get('message')}'"
)
assert message in toast_data.get(
"message", ""
), f"Expected '{message}' in toast message, got '{toast_data.get('message')}'"
def assert_no_status_change(obj: Model, original_status: str) -> None:
@@ -339,9 +309,9 @@ def assert_no_status_change(obj: Model, original_status: str) -> None:
"""
obj.refresh_from_db()
actual_status = getattr(obj, "status", None)
assert actual_status == original_status, (
f"Status should not have changed from '{original_status}', but is now '{actual_status}'"
)
assert (
actual_status == original_status
), f"Status should not have changed from '{original_status}', but is now '{actual_status}'"
# =============================================================================
@@ -349,11 +319,7 @@ def assert_no_status_change(obj: Model, original_status: str) -> None:
# =============================================================================
def wait_for_htmx_swap(
page,
target_selector: str,
timeout: int = 5000
) -> None:
def wait_for_htmx_swap(page, target_selector: str, timeout: int = 5000) -> None:
"""
Wait for an HTMX swap to complete on a target element.
@@ -370,14 +336,12 @@ def wait_for_htmx_swap(
return el && !el.classList.contains('htmx-request');
}}
""",
timeout=timeout
timeout=timeout,
)
def verify_transition_buttons_visible(
page,
transitions: list[str],
container_selector: str = "[data-status-actions]"
page, transitions: list[str], container_selector: str = "[data-status-actions]"
) -> dict[str, bool]:
"""
Verify which transition buttons are visible on the page.
@@ -447,11 +411,7 @@ def wait_for_toast(page, toast_selector: str = "[data-toast]", timeout: int = 50
return toast
def wait_for_toast_dismiss(
page,
toast_selector: str = "[data-toast]",
timeout: int = 10000
) -> None:
def wait_for_toast_dismiss(page, toast_selector: str = "[data-toast]", timeout: int = 10000) -> None:
"""
Wait for a toast notification to be dismissed.
@@ -473,6 +433,7 @@ def click_and_confirm(page, button_locator, accept: bool = True) -> None:
button_locator: The button locator to click
accept: Whether to accept (True) or dismiss (False) the dialog
"""
def handle_dialog(dialog):
if accept:
dialog.accept()
@@ -500,11 +461,7 @@ def make_htmx_post(client, url: str, data: dict | None = None) -> HttpResponse:
Returns:
HttpResponse from the request
"""
return client.post(
url,
data=data or {},
HTTP_HX_REQUEST="true"
)
return client.post(url, data=data or {}, HTTP_HX_REQUEST="true")
def make_htmx_get(client, url: str) -> HttpResponse:
@@ -518,19 +475,11 @@ def make_htmx_get(client, url: str) -> HttpResponse:
Returns:
HttpResponse from the request
"""
return client.get(
url,
HTTP_HX_REQUEST="true"
)
return client.get(url, HTTP_HX_REQUEST="true")
def get_fsm_transition_url(
app_label: str,
model_name: str,
pk: int,
transition_name: str,
use_slug: bool = False,
slug: str | None = None
app_label: str, model_name: str, pk: int, transition_name: str, use_slug: bool = False, slug: str | None = None
) -> str:
"""
Generate the URL for an FSM transition.
@@ -553,20 +502,10 @@ def get_fsm_transition_url(
raise ValueError("slug is required when use_slug is True")
return reverse(
"core:fsm_transition_by_slug",
kwargs={
"app_label": app_label,
"model_name": model_name,
"slug": slug,
"transition_name": transition_name
}
kwargs={"app_label": app_label, "model_name": model_name, "slug": slug, "transition_name": transition_name},
)
else:
return reverse(
"core:fsm_transition",
kwargs={
"app_label": app_label,
"model_name": model_name,
"pk": pk,
"transition_name": transition_name
}
kwargs={"app_label": app_label, "model_name": model_name, "pk": pk, "transition_name": transition_name},
)