This commit is contained in:
ponzischeme89
2026-04-18 07:23:55 +12:00
parent f210020772
commit 6d44e05de4
396 changed files with 75296 additions and 0 deletions
View File
+85
View File
@@ -0,0 +1,85 @@
from pydantic import BaseModel, Field
from typing import Optional, Dict, Any, List
from datetime import datetime
import uuid
class EventCreate(BaseModel):
event_type: str = Field(..., max_length=64)
page: str = Field(..., max_length=255)
element: Optional[str] = Field(None, max_length=255)
metadata: Optional[Dict[str, Any]] = None
session_id: Optional[str] = Field(None, max_length=64)
class EventResponse(BaseModel):
id: uuid.UUID
event_type: str
page: str
element: Optional[str]
session_id: str
ip_partial: Optional[str]
browser: Optional[str]
os_name: Optional[str]
country: Optional[str]
city: Optional[str]
created_at: datetime
model_config = {"from_attributes": True}
class DailyStat(BaseModel):
date: str
count: int
class TopItem(BaseModel):
label: str
count: int
class AnalyticsSummary(BaseModel):
total_events_today: int
total_events_yesterday: int
page_views_today: int
unique_sessions_today: int
unique_sessions_total: int
total_events_all_time: int
events_by_type: List[TopItem]
top_pages: List[TopItem]
top_elements: List[TopItem]
top_journeys: List[TopItem]
top_browsers: List[TopItem]
top_os: List[TopItem]
top_countries: List[TopItem]
events_last_7_days: List[DailyStat]
recent_events: List[EventResponse]
class BookingActivityStat(BaseModel):
date: str
booked: int
cancellations: int
class BookingForwardLoadStat(BaseModel):
date: str
total: int
am: int
pm: int
class BookingCustomerVolume(BaseModel):
label: str
count: int
class BookingOperationsSummary(BaseModel):
active_bookings_total: int
forward_load_total: int
booked_last_30_days: int
cancellations_last_30_days: int
high_volume_customer_count: int
forward_load_next_14_days: List[BookingForwardLoadStat]
activity_last_30_days: List[BookingActivityStat]
top_high_volume_customers: List[BookingCustomerVolume]
+37
View File
@@ -0,0 +1,37 @@
import uuid
from datetime import datetime
from typing import Optional
from pydantic import BaseModel
class AuditLogResponse(BaseModel):
id: uuid.UUID
timestamp: datetime
member_id: Optional[uuid.UUID]
member_email: Optional[str]
action_type: str
area: str
description: str
status: str
booking_id: Optional[uuid.UUID]
error_message: Optional[str]
error_detail: Optional[str]
ip_address: Optional[str]
user_agent: Optional[str]
extra: Optional[dict]
model_config = {"from_attributes": True}
class AuditLogPage(BaseModel):
items: list[AuditLogResponse]
total: int
page: int
page_size: int
total_pages: int
class PageVisitSchema(BaseModel):
path: str
title: Optional[str] = None
+28
View File
@@ -0,0 +1,28 @@
import uuid
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, EmailStr, ConfigDict
class LoginRequest(BaseModel):
email: str
password: str
class TokenResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
class RefreshRequest(BaseModel):
refresh_token: str
class UserResponse(BaseModel):
id: uuid.UUID
email: str
is_active: bool
created_at: datetime
model_config = ConfigDict(from_attributes=True)
+57
View File
@@ -0,0 +1,57 @@
import uuid
from datetime import datetime
from typing import Any, Optional
from pydantic import BaseModel, Field
class ContactLeadCreate(BaseModel):
name: str = Field(min_length=1, max_length=255)
email: str
phone: Optional[str] = Field(default=None, max_length=50)
service: Optional[str] = Field(default=None, max_length=255)
services: list[str] = Field(default_factory=list)
petName: Optional[str] = Field(default=None, max_length=100)
petBreed: Optional[str] = Field(default=None, max_length=100)
location: Optional[str] = Field(default=None, max_length=100)
serviceAreaStatus: Optional[str] = Field(default=None, max_length=32)
message: Optional[str] = Field(default=None, max_length=5000)
source: str = Field(default="contact_form", max_length=50)
class ContactLeadResponse(BaseModel):
id: uuid.UUID
full_name: str
email: str
phone: Optional[str]
requested_services: Optional[str]
pet_name: Optional[str]
pet_breed: Optional[str]
suburb: Optional[str]
service_area_status: Optional[str]
message: Optional[str]
source: str
status: str
notes: Optional[str]
invited_at: Optional[datetime]
invited_member_id: Optional[uuid.UUID]
metadata_json: Optional[dict[str, Any]]
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class ContactLeadUpdate(BaseModel):
status: Optional[str] = Field(default=None, max_length=32)
notes: Optional[str] = Field(default=None, max_length=5000)
class ContactLeadInviteRequest(BaseModel):
send_email: bool = True
class ContactLeadInviteResponse(BaseModel):
lead: ContactLeadResponse
member_id: uuid.UUID
member_status: str
+154
View File
@@ -0,0 +1,154 @@
from datetime import datetime
from decimal import Decimal
from typing import Any
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field, field_validator
KEY_PATTERN = r"^[a-z0-9_]{3,64}$"
SESSION_PATTERN = r"^[A-Za-z0-9_-]{8,128}$"
def _validate_metadata(metadata: dict[str, Any] | None) -> dict[str, Any] | None:
if metadata is None:
return None
if len(metadata) > 20:
raise ValueError("metadata must contain at most 20 keys")
clean: dict[str, Any] = {}
for key, value in metadata.items():
if not isinstance(key, str) or len(key) > 48:
raise ValueError("metadata keys must be strings up to 48 characters")
if isinstance(value, (str, int, float, bool)) or value is None:
clean[key] = value
continue
raise ValueError("metadata values must be scalar JSON types")
return clean
class ExperimentVariantDefinition(BaseModel):
variant_key: str = Field(..., pattern=KEY_PATTERN)
label: str = Field(..., min_length=1, max_length=120)
allocation: int = Field(..., ge=0, le=100)
is_control: bool
class ExperimentDefinitionResponse(BaseModel):
experiment_key: str = Field(..., pattern=KEY_PATTERN)
cookie_name: str = Field(..., min_length=3, max_length=96)
name: str
description: str | None = None
enabled: bool
eligible_routes: list[str]
variants: list[ExperimentVariantDefinition]
class ExperimentDefinitionUpdate(BaseModel):
cookie_name: str = Field(..., min_length=3, max_length=96)
name: str = Field(..., min_length=1, max_length=120)
description: str | None = Field(default=None, max_length=512)
enabled: bool
eligible_routes: list[str] = Field(default_factory=list, min_length=1)
variants: list[ExperimentVariantDefinition] = Field(..., min_length=2)
@field_validator("cookie_name")
@classmethod
def validate_cookie_name(cls, value: str) -> str:
if not value.startswith("exp_"):
raise ValueError("cookie_name must start with 'exp_'")
return value
@field_validator("eligible_routes")
@classmethod
def validate_routes(cls, value: list[str]) -> list[str]:
normalized: list[str] = []
for route in value:
if not route.startswith("/"):
raise ValueError("eligible routes must start with '/'")
normalized.append(route.rstrip("/") or "/")
return normalized
@field_validator("variants")
@classmethod
def validate_variants(cls, value: list[ExperimentVariantDefinition]) -> list[ExperimentVariantDefinition]:
if sum(1 for item in value if item.is_control) != 1:
raise ValueError("exactly one control variant is required")
if sum(item.allocation for item in value) <= 0:
raise ValueError("variant allocation total must be greater than zero")
return value
class ExperimentEventBase(BaseModel):
experiment_key: str = Field(..., pattern=KEY_PATTERN)
variant_key: str = Field(..., pattern=KEY_PATTERN)
session_id: str = Field(..., pattern=SESSION_PATTERN)
user_id: str | None = Field(None, max_length=64)
path: str = Field(..., min_length=1, max_length=255)
timestamp: datetime
metadata: dict[str, Any] | None = None
@field_validator("path")
@classmethod
def validate_path(cls, value: str) -> str:
if not value.startswith("/"):
raise ValueError("path must start with '/'")
return value
@field_validator("metadata")
@classmethod
def validate_metadata(cls, value: dict[str, Any] | None) -> dict[str, Any] | None:
return _validate_metadata(value)
class ExperimentImpressionCreate(ExperimentEventBase):
event_name: str = Field(default="impression", pattern=r"^impression$")
class ExperimentEventCreate(ExperimentEventBase):
event_name: str = Field(..., pattern=r"^(cta_click|form_start|form_submit)$")
class ExperimentConversionCreate(ExperimentEventBase):
event_name: str = Field(default="conversion", pattern=r"^conversion$")
conversion_value: Decimal | None = Field(default=None, max_digits=12, decimal_places=2)
class ExperimentEventResponse(BaseModel):
id: UUID
experiment_key: str
variant_key: str
session_id: str
user_id: str | None = None
path: str
event_type: str
conversion_value: Decimal | None = None
metadata: dict[str, Any] | None = None
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class ExperimentVariantResult(BaseModel):
variant_key: str
impressions: int
cta_clicks: int
form_starts: int
form_submits: int
conversions: int
unique_sessions: int
conversion_rate: float
conversion_value_total: float
class ExperimentResult(BaseModel):
experiment_key: str
generated_at: datetime
variants: list[ExperimentVariantResult]
class ExperimentIngestResponse(BaseModel):
ok: bool
accepted: bool
+370
View File
@@ -0,0 +1,370 @@
import uuid
from datetime import datetime
from typing import Optional, Any
from pydantic import BaseModel
# ── Magic link ─────────────────────────────────────────────────────────────────
class MagicLinkVerifySchema(BaseModel):
token: str
# ── Claim ──────────────────────────────────────────────────────────────────────
class ClaimRequestSchema(BaseModel):
email: str
class ClaimCompleteSchema(BaseModel):
email: str
code: str
password: str
class MemberClaimVerifyCodeSchema(BaseModel):
code: str
password: str
# ── Auth ───────────────────────────────────────────────────────────────────────
class MemberLoginSchema(BaseModel):
email: str
password: str
class MemberLoginVerifySchema(BaseModel):
email: str
code: str
class MemberTokenResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
class MemberRefreshSchema(BaseModel):
refresh_token: str
class MemberLogoutSchema(BaseModel):
refresh_token: Optional[str] = None
# ── Profile ────────────────────────────────────────────────────────────────────
class MemberProfileResponse(BaseModel):
id: uuid.UUID
email: str
first_name: str
last_name: str
phone: Optional[str]
address: Optional[str]
emergency_contact: Optional[str]
notifications_enabled: bool
is_claimed: bool
member_status: str
activated_at: Optional[datetime]
created_at: datetime
model_config = {"from_attributes": True}
class MemberProfileUpdate(BaseModel):
first_name: Optional[str] = None
last_name: Optional[str] = None
phone: Optional[str] = None
address: Optional[str] = None
emergency_contact: Optional[str] = None
notifications_enabled: Optional[bool] = None
class MemberOnboardingResponse(BaseModel):
id: uuid.UUID
email: str
first_name: str
last_name: str
phone: Optional[str]
address: Optional[str]
emergency_contact: Optional[str]
notifications_enabled: bool
onboarding_data: Optional[Any]
is_claimed: bool
member_status: str
claimed_at: Optional[datetime]
onboarding_completed_at: Optional[datetime]
contract_signed_at: Optional[datetime]
contract_signer_name: Optional[str]
contract_version: Optional[str]
activated_at: Optional[datetime]
created_at: datetime
model_config = {"from_attributes": True}
class MemberOnboardingUpdate(BaseModel):
first_name: Optional[str] = None
last_name: Optional[str] = None
phone: Optional[str] = None
address: Optional[str] = None
emergency_contact: Optional[str] = None
onboarding_data: Optional[Any] = None
complete_onboarding: bool = False
class ContractSignSchema(BaseModel):
signer_name: str
agreed: bool
contract_version: Optional[str] = None
# ── Walks ──────────────────────────────────────────────────────────────────────
class WalkResponse(BaseModel):
id: uuid.UUID
service_type: str
walked_at: datetime
duration_minutes: int
notes: Optional[str]
recorded_by: Optional[str]
created_at: datetime
model_config = {"from_attributes": True}
# ── Bookings ───────────────────────────────────────────────────────────────────
class BookingCreate(BaseModel):
service_type: str
requested_day: Optional[str] = None
requested_date: Optional[datetime] = None
requested_timeslot: Optional[str] = None
notes: Optional[str] = None
class BookingResponse(BaseModel):
id: uuid.UUID
service_type: str
requested_date: Optional[datetime]
status: str
notes: Optional[str]
admin_notes: Optional[str]
created_at: datetime
model_config = {"from_attributes": True}
class BookingSlotAvailabilityResponse(BaseModel):
slot: str
label: str
booked: int
capacity: int
remaining: int
is_available: bool
class BookingAvailabilityDayResponse(BaseModel):
date: str
label: str
slots: list[BookingSlotAvailabilityResponse]
class BookingAvailabilityResponse(BaseModel):
requested_date: str
selected: BookingAvailabilityDayResponse
alternatives: list[BookingAvailabilityDayResponse]
# ── Messages ───────────────────────────────────────────────────────────────────
class MessageResponse(BaseModel):
id: uuid.UUID
subject: str
body: str
sent_by: Optional[str]
read_at: Optional[datetime]
created_at: datetime
direction: str = "inbound"
reply_to_id: Optional[uuid.UUID] = None
model_config = {"from_attributes": True}
class MemberReplySchema(BaseModel):
body: str
# ── Admin: Create Member ───────────────────────────────────────────────────────
class AdminCreateMember(BaseModel):
email: str
first_name: str
last_name: str
phone: Optional[str] = None
address: Optional[str] = None
emergency_contact: Optional[str] = None
onboarding_data: Optional[Any] = None
service_pricing_overrides: Optional[Any] = None
force_two_factor: Optional[bool] = None
class AdminMemberResponse(BaseModel):
id: uuid.UUID
email: str
first_name: str
last_name: str
phone: Optional[str]
address: Optional[str]
emergency_contact: Optional[str]
notifications_enabled: bool
onboarding_data: Optional[Any]
is_claimed: bool
is_active: bool
member_status: str
claimed_at: Optional[datetime]
onboarding_completed_at: Optional[datetime]
contract_signed_at: Optional[datetime]
contract_signer_name: Optional[str]
contract_version: Optional[str]
activated_at: Optional[datetime]
service_pricing_overrides: Optional[Any]
force_two_factor: Optional[bool]
created_at: datetime
model_config = {"from_attributes": True}
class AdminMemberUpdate(BaseModel):
first_name: Optional[str] = None
last_name: Optional[str] = None
phone: Optional[str] = None
address: Optional[str] = None
emergency_contact: Optional[str] = None
notifications_enabled: Optional[bool] = None
onboarding_data: Optional[Any] = None
is_active: Optional[bool] = None
member_status: Optional[str] = None
service_pricing_overrides: Optional[Any] = None
force_two_factor: Optional[bool] = None
class AdminMemberToggleAction(BaseModel):
enabled: bool
class AdminBookingResponse(BaseModel):
id: uuid.UUID
member_id: uuid.UUID
service_type: str
requested_date: Optional[datetime]
status: str
notes: Optional[str]
admin_notes: Optional[str]
created_at: datetime
# Joined fields
member_first_name: Optional[str] = None
member_last_name: Optional[str] = None
member_email: Optional[str] = None
member_dog_name: Optional[str] = None
member_dog_breed: Optional[str] = None
model_config = {"from_attributes": True}
class AdminBookingCreate(BaseModel):
member_id: uuid.UUID
service_type: str
requested_date: Optional[datetime] = None
status: str = "confirmed"
notes: Optional[str] = None
admin_notes: Optional[str] = None
class AdminBookingUpdate(BaseModel):
requested_date: Optional[datetime] = None
status: Optional[str] = None # pending | confirmed | cancelled | completed
notes: Optional[str] = None
admin_notes: Optional[str] = None
# ── Admin: Record Walk ─────────────────────────────────────────────────────────
class AdminRecordWalk(BaseModel):
member_id: uuid.UUID
walked_at: datetime
service_type: str
duration_minutes: int = 60
notes: Optional[str] = None
# ── Admin: Send Message ────────────────────────────────────────────────────────
class AdminSendMessage(BaseModel):
member_id: uuid.UUID
subject: str
body: str
class AdminNotificationSettingsResponse(BaseModel):
automatic_member_notifications_enabled: bool
nz_public_holiday_notifications_enabled: bool
invoice_reminder_notifications_enabled: bool
invoice_day_of_week: int
class AdminNotificationSettingsUpdate(BaseModel):
automatic_member_notifications_enabled: Optional[bool] = None
nz_public_holiday_notifications_enabled: Optional[bool] = None
invoice_reminder_notifications_enabled: Optional[bool] = None
invoice_day_of_week: Optional[int] = None
class AdminNotificationRunResponse(BaseModel):
automatic_member_notifications_enabled: bool
public_holiday_messages_sent: int
invoice_reminders_sent: int
class AdminNotificationFeedItemResponse(BaseModel):
id: str
type: str
title: str
description: str
created_at: datetime
href: str
class AdminNotificationsResponse(BaseModel):
items: list[AdminNotificationFeedItemResponse]
total: int
settings: AdminNotificationSettingsResponse
class AdminMessageHistoryResponse(BaseModel):
id: uuid.UUID
member_id: uuid.UUID
member_name: str
member_email: str
subject: str
body: str
sent_by: Optional[str]
created_at: datetime
read_at: Optional[datetime]
# ── Contract ───────────────────────────────────────────────────────────────────
class ContractResponse(BaseModel):
onboarding_data: Optional[Any]
member_name: str
email: str
member_status: str
contract_signed_at: Optional[datetime]
contract_signer_name: Optional[str]
contract_version: Optional[str]
activated_at: Optional[datetime]
joined_at: datetime
model_config = {"from_attributes": True}
+36
View File
@@ -0,0 +1,36 @@
import uuid
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, ConfigDict
class PageBase(BaseModel):
title: str
slug: str
body: str = ""
meta_title: Optional[str] = None
meta_description: Optional[str] = None
og_image_url: Optional[str] = None
published: bool = False
class PageCreate(PageBase):
pass
class PageUpdate(BaseModel):
title: Optional[str] = None
slug: Optional[str] = None
body: Optional[str] = None
meta_title: Optional[str] = None
meta_description: Optional[str] = None
og_image_url: Optional[str] = None
published: Optional[bool] = None
class PageResponse(PageBase):
id: uuid.UUID
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
+46
View File
@@ -0,0 +1,46 @@
import uuid
from datetime import datetime
from typing import Optional, List
from pydantic import BaseModel, ConfigDict
class PostBase(BaseModel):
title: str
slug: str
excerpt: Optional[str] = None
body: str = ""
author: Optional[str] = None
featured_image_url: Optional[str] = None
tags: List[str] = []
published: bool = False
class PostCreate(PostBase):
pass
class PostUpdate(BaseModel):
title: Optional[str] = None
slug: Optional[str] = None
excerpt: Optional[str] = None
body: Optional[str] = None
author: Optional[str] = None
featured_image_url: Optional[str] = None
tags: Optional[List[str]] = None
published: Optional[bool] = None
class PostResponse(PostBase):
id: uuid.UUID
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
class PaginatedPostsResponse(BaseModel):
items: List[PostResponse]
total: int
page: int
per_page: int
total_pages: int
+91
View File
@@ -0,0 +1,91 @@
import uuid
from datetime import datetime
from typing import Optional, Dict, Any
from pydantic import BaseModel, ConfigDict, Field
from app.services.pricing import default_service_pricing
class SiteSettingsBase(BaseModel):
site_name: str = ""
tagline: Optional[str] = None
logo_url: Optional[str] = None
footer_text: Optional[str] = None
social_links: Dict[str, Any] = {}
automatic_member_notifications_enabled: bool = True
nz_public_holiday_notifications_enabled: bool = True
invoice_reminder_notifications_enabled: bool = True
invoice_day_of_week: int = 1
bookings_enabled: bool = True
walks_enabled: bool = True
messages_enabled: bool = True
two_factor_enabled: bool = True
audit_history_enabled: bool = True
experiments_enabled: bool = True
class SiteSettingsUpdate(BaseModel):
site_name: Optional[str] = None
tagline: Optional[str] = None
logo_url: Optional[str] = None
footer_text: Optional[str] = None
social_links: Optional[Dict[str, Any]] = None
automatic_member_notifications_enabled: Optional[bool] = None
nz_public_holiday_notifications_enabled: Optional[bool] = None
invoice_reminder_notifications_enabled: Optional[bool] = None
invoice_day_of_week: Optional[int] = None
bookings_enabled: Optional[bool] = None
walks_enabled: Optional[bool] = None
messages_enabled: Optional[bool] = None
two_factor_enabled: Optional[bool] = None
audit_history_enabled: Optional[bool] = None
experiments_enabled: Optional[bool] = None
class FeatureSettingsBase(BaseModel):
bookings_enabled: bool = True
walks_enabled: bool = True
messages_enabled: bool = True
two_factor_enabled: bool = True
audit_history_enabled: bool = True
experiments_enabled: bool = True
class FeatureSettingsUpdate(BaseModel):
bookings_enabled: Optional[bool] = None
walks_enabled: Optional[bool] = None
messages_enabled: Optional[bool] = None
two_factor_enabled: Optional[bool] = None
audit_history_enabled: Optional[bool] = None
experiments_enabled: Optional[bool] = None
class FeatureSettingsResponse(FeatureSettingsBase):
pass
class ServicePricingSettingsResponse(BaseModel):
service_pricing: Dict[str, Any] = Field(default_factory=default_service_pricing)
class ServicePricingSettingsUpdate(BaseModel):
service_pricing: Dict[str, Any]
class PlannerWeatherDay(BaseModel):
code: int
max: int
min: int
class PlannerWeatherResponse(BaseModel):
fetched_at: datetime
weather: Dict[str, PlannerWeatherDay]
class SiteSettingsResponse(SiteSettingsBase):
id: uuid.UUID
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)