diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..52240b1e8 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +node_modules +dist/ +.env +__pycache__/ +.venv/ +*.pyc diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000..4a7ea3036 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.md] +trim_trailing_whitespace = false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..9e05d3a56 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,70 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +jobs: + frontend: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 9 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + - run: pnpm install --frozen-lockfile + - name: Lint + run: pnpm lint + - name: Build (tsc type-check + vite build — the merge gate) + run: pnpm build + - name: Security audit + run: pnpm audit --audit-level=high + + api: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: user + POSTGRES_PASSWORD: pass + POSTGRES_DB: venue404 + ports: + - 5432:5432 + options: >- + --health-cmd="pg_isready -U user -d venue404" + --health-interval=10s + --health-timeout=5s + --health-retries=5 + env: + DATABASE_URL: postgresql://user:pass@localhost:5432/venue404 + SUPABASE_URL: https://example.supabase.co + SUPABASE_JWT_SECRET: test-secret + SUPABASE_SERVICE_ROLE_KEY: test-service-role + STRIPE_SECRET_KEY: sk_test_dummy + STRIPE_WEBHOOK_SECRET: whsec_dummy + defaults: + run: + working-directory: apps/api + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v4 + with: + enable-cache: true + cache-dependency-glob: "apps/api/pyproject.toml" + python-version: "3.12" + - name: Install dependencies + run: uv pip install -e ".[dev]" + - name: Lint + run: ruff check . + - name: Migrate + run: alembic upgrade head + - name: Tests + run: pytest -q + - name: Security audit + run: uvx pip-audit diff --git a/.github/workflows/jobs.yml b/.github/workflows/jobs.yml new file mode 100644 index 000000000..f10e237c7 --- /dev/null +++ b/.github/workflows/jobs.yml @@ -0,0 +1,45 @@ +name: Background Jobs + +# Triggers the API's background jobs at their original cadences by calling the +# token-guarded POST /api/internal/run-jobs endpoint. The in-process scheduler +# stays off (ENABLE_JOBS=false) so the free Render service can sleep. +# +# hourly (0 * * * *) -> hold_expiry, payment_reminders +# 6-hourly (0 */6 * * *) -> request_expiry, overdue_flag, overdue_autocancel +# daily (0 0 * * *) -> completion +on: + schedule: + - cron: "0 * * * *" + - cron: "0 */6 * * *" + - cron: "0 0 * * *" + workflow_dispatch: + inputs: + jobs: + description: "Comma-separated job names to run" + required: true + default: "hold_expiry,payment_reminders,request_expiry,overdue_flag,overdue_autocancel,completion,search_indexer" + +jobs: + run: + runs-on: ubuntu-latest + steps: + - name: Select job set + id: select + run: | + case "${{ github.event.schedule }}" in + "0 * * * *") JOBS="hold_expiry,payment_reminders,search_indexer" ;; + "0 */6 * * *") JOBS="request_expiry,overdue_flag,overdue_autocancel" ;; + "0 0 * * *") JOBS="completion" ;; + *) JOBS="${{ github.event.inputs.jobs }}" ;; + esac + echo "jobs=$JOBS" >> "$GITHUB_OUTPUT" + echo "Running jobs: $JOBS" + + - name: Warm up (ride out free-tier cold start) + run: curl -fsS --max-time 90 --retry 5 --retry-all-errors --retry-delay 12 "${{ secrets.API_BASE_URL }}/health" + + - name: Trigger jobs + run: | + curl -fsS --max-time 300 -X POST \ + -H "X-Job-Token: ${{ secrets.JOB_RUNNER_TOKEN }}" \ + "${{ secrets.API_BASE_URL }}/api/internal/run-jobs?jobs=${{ steps.select.outputs.jobs }}" diff --git a/.github/workflows/keepalive.yml b/.github/workflows/keepalive.yml new file mode 100644 index 000000000..2ce49d0ba --- /dev/null +++ b/.github/workflows/keepalive.yml @@ -0,0 +1,19 @@ +name: Keepalive + +# Pings the API /health every 13 minutes so Render's free web service does not +# spin down (it sleeps after ~15 min idle). GitHub Actions cron is best-effort. +# +# NOTE: on a PRIVATE repo this consumes ~3,300 Actions minutes/month and will +# exceed the 2,000-min free budget — use a free external pinger (UptimeRobot / +# cron-job.org) instead. On a PUBLIC repo, Actions minutes are free. +on: + schedule: + - cron: "*/13 * * * *" + workflow_dispatch: + +jobs: + ping: + runs-on: ubuntu-latest + steps: + - name: Ping /health + run: curl -fsS --max-time 60 --retry 3 --retry-delay 10 "${{ secrets.API_BASE_URL }}/health" diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..3bf5e0f4b --- /dev/null +++ b/.gitignore @@ -0,0 +1,44 @@ +# ---- Dependencies ---- +node_modules/ +.venv/ +*.egg-info/ + +# ---- Build output ---- +dist/ +build/ +*.tsbuildinfo + +# ---- Environment secrets ---- +.env +.env.local +.env.*.local + +# ---- Python runtime ---- +__pycache__/ +*.py[cod] +*.pyo + +# ---- Test & coverage ---- +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +htmlcov/ +.coverage +coverage.xml + +# ---- Editor & OS ---- +.vscode/ +.idea/ +*.swp +.DS_Store +Thumbs.db + +# ---- Misc ---- +prd.txt +auth-prd.md +.claude/ +claude.md +/prds + +.akhil +self/ \ No newline at end of file diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 000000000..238d4d936 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,7 @@ +{ + "semi": false, + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "es5", + "printWidth": 100 +} diff --git a/apps/admin-panel/.env.example b/apps/admin-panel/.env.example new file mode 100644 index 000000000..f940594e8 --- /dev/null +++ b/apps/admin-panel/.env.example @@ -0,0 +1,7 @@ +# Supabase (browser client - anon key only) +VITE_SUPABASE_URL=https://your-project-ref.supabase.co +VITE_SUPABASE_ANON_KEY=your-supabase-anon-key + +# FastAPI backend +VITE_API_BASE_URL=http://localhost:8000 +VITE_STRIPE_PUBLISHABLE_KEY= \ No newline at end of file diff --git a/apps/admin-panel/index.html b/apps/admin-panel/index.html new file mode 100644 index 000000000..af41e4f9b --- /dev/null +++ b/apps/admin-panel/index.html @@ -0,0 +1,16 @@ + + + + + + + + Venue404 | Admin + + + +
+ + + + \ No newline at end of file diff --git a/apps/admin-panel/package.json b/apps/admin-panel/package.json new file mode 100644 index 000000000..fc3c4cdf5 --- /dev/null +++ b/apps/admin-panel/package.json @@ -0,0 +1,33 @@ +{ + "name": "@venue404/admin-panel", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite --port 5399", + "build": "tsc && vite build", + "lint": "eslint src", + "preview": "vite preview" + }, + "dependencies": { + "@tailwindcss/vite": "^4.3.0", + "@tanstack/react-query": "^5.101.0", + "@venue404/api-client": "workspace:*", + "@venue404/ui": "workspace:*", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^1.17.0", + "react": "^18.3.0", + "react-dom": "^18.3.0", + "react-router-dom": "^6.23.0", + "recharts": "^3.8.1", + "tailwind-merge": "^3.6.0", + "tailwindcss": "^4.3.0" + }, + "devDependencies": { + "@types/react": "^18.3.0", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.0", + "vite": "^5.3.0" + } +} \ No newline at end of file diff --git a/apps/admin-panel/src/App.tsx b/apps/admin-panel/src/App.tsx new file mode 100644 index 000000000..4e4b9ecb7 --- /dev/null +++ b/apps/admin-panel/src/App.tsx @@ -0,0 +1,6 @@ +import { RouterProvider } from 'react-router-dom' +import { router } from './routes' + +export default function App() { + return +} diff --git a/apps/admin-panel/src/components/AdminLayout.tsx b/apps/admin-panel/src/components/AdminLayout.tsx new file mode 100644 index 000000000..db2f9dc26 --- /dev/null +++ b/apps/admin-panel/src/components/AdminLayout.tsx @@ -0,0 +1,56 @@ +import { useNavigate, useLocation } from 'react-router-dom' +import { useAuth } from '../lib/AuthContext' +import { AppShell, Logo, type NavItemConfig } from '@venue404/ui' +import { + LayoutDashboard, Building2, Users, UserCheck, + CalendarDays, ClipboardList, Settings, Sparkles, LayoutGrid, Search, +} from 'lucide-react' + +const NAV: NavItemConfig[] = [ + { label: 'Dashboard', href: '/dashboard', icon: }, + { label: 'Venue Approvals', href: '/venues/pending', icon: }, + { label: 'Users', href: '/users', icon: }, + { label: 'Venue Owners', href: '/owners', icon: }, + { label: 'Amenities', href: '/amenities', icon: }, + { label: 'Categories', href: '/categories', icon: }, + { label: 'Bookings', href: '/bookings', icon: }, + { label: 'Deep Research', href: '/deep-research-insights', icon: }, + { label: 'Audit Log', href: '/audit-log', icon: }, + { label: 'Settings', href: '/settings', icon: }, +] + +type AdminLayoutProps = { + pageTitle: string + pageSubtitle?: string + children: React.ReactNode +} + +export function AdminLayout({ pageTitle, pageSubtitle, children }: AdminLayoutProps) { + const { user, signOut } = useAuth() + const navigate = useNavigate() + const location = useLocation() + + async function handleSignOut() { + await signOut() + navigate('/login', { replace: true }) + } + + return ( + } + user={user ? { + name: user.profile.full_name ?? user.email ?? 'Admin', + email: user.email ?? '', + role: 'Super Admin', + } : undefined} + onSignOut={handleSignOut} + > + {children} + + ) +} diff --git a/apps/admin-panel/src/components/GrowthChart.tsx b/apps/admin-panel/src/components/GrowthChart.tsx new file mode 100644 index 000000000..26841ae65 --- /dev/null +++ b/apps/admin-panel/src/components/GrowthChart.tsx @@ -0,0 +1,184 @@ +import { useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import { + ResponsiveContainer, + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + Legend, +} from 'recharts' +import { RefreshCw } from 'lucide-react' +import { createClient, adminGrowthEndpoints } from '@venue404/api-client' +import type { GrowthPeriod, GrowthStats } from '@venue404/api-client' +import { SectionHeader } from '@venue404/ui' + +const api = adminGrowthEndpoints(createClient()) + +const SERIES = [ + { key: 'users', label: 'Users', color: '#6366f1' }, + { key: 'owners', label: 'Owners', color: '#f59e0b' }, + { key: 'venues', label: 'Venues', color: '#10b981' }, + { key: 'bookings', label: 'Bookings', color: '#ef4444' }, +] as const + +const PERIODS: { label: string; value: GrowthPeriod }[] = [ + { label: '7D', value: '7d' }, + { label: '30D', value: '30d' }, + { label: '3M', value: '3m' }, + { label: '6M', value: '6m' }, + { label: '12M', value: '12m' }, +] + +function fmt(n: number): string { + if (n >= 1000) return `${(n / 1000).toFixed(1)}k` + return String(n) +} + +function TotalPill({ label, value, color }: { label: string; value: number; color: string }) { + return ( +
+ + {label} + + {value.toLocaleString('en-IN')} + +
+ ) +} + +function ChartBody({ data }: { data: GrowthStats }) { + const chartData = data.labels.map((label, i) => ({ + label, + users: data.users[i], + owners: data.owners[i], + venues: data.venues[i], + bookings: data.bookings[i], + })) + + return ( +
+ {/* Totals strip */} +
+ {SERIES.map((s) => ( + + ))} +
+ + {/* Chart */} +
+ + + + + + [ + typeof value === 'number' ? value.toLocaleString('en-IN') : value, + typeof name === 'string' ? name.charAt(0).toUpperCase() + name.slice(1) : name, + ]} + /> + ( + + {value.charAt(0).toUpperCase() + value.slice(1)} + + )} + /> + {SERIES.map((s) => ( + + ))} + + +
+
+ ) +} + +export function GrowthChart() { + const [period, setPeriod] = useState('30d') + + const { data, isLoading } = useQuery({ + queryKey: ['admin', 'growth-stats', period], + queryFn: () => api.getGrowthStats(period), + }) + + const periodDesc = period.endsWith('d') + ? `Last ${period.slice(0, -1)} days — daily` + : `Last ${period.slice(0, -1)} months — monthly` + + return ( +
+ {/* Header */} +
+ + {PERIODS.map((p) => ( + + ))} +
+ } + /> +
+ + {/* Body */} +
+ {isLoading || !data ? ( +
+ +
+ ) : ( + + )} +
+ + ) +} diff --git a/apps/admin-panel/src/components/ProtectedRoute.tsx b/apps/admin-panel/src/components/ProtectedRoute.tsx new file mode 100644 index 000000000..5786e9787 --- /dev/null +++ b/apps/admin-panel/src/components/ProtectedRoute.tsx @@ -0,0 +1,17 @@ +import { Navigate } from 'react-router-dom' +import { useAuth } from '../lib/AuthContext' +import { LoadingScreen } from '@venue404/ui' + +export function ProtectedRoute({ children }: { children: React.ReactNode }) { + const { user, loading } = useAuth() + + if (loading) return + + if (!user) return + + if (!user.roles.includes('super_admin')) { + return + } + + return <>{children} +} diff --git a/apps/admin-panel/src/lib/AuthContext.tsx b/apps/admin-panel/src/lib/AuthContext.tsx new file mode 100644 index 000000000..dbdabf941 --- /dev/null +++ b/apps/admin-panel/src/lib/AuthContext.tsx @@ -0,0 +1,80 @@ +import { createContext, useContext, useEffect, useState } from 'react' +import { + createClient, + onAuthStateChange, + signInWithEmail, + signUpWithEmail, + signOut as supabaseSignOut, + type SignInInput, + type SignUpInput, +} from '@venue404/api-client' +import { authEndpoints, type AuthUser } from '@venue404/api-client' + +type AuthState = { + user: AuthUser | null + loading: boolean + signIn: (input: SignInInput) => Promise + signUp: (input: SignUpInput) => Promise + signOut: () => Promise +} + +const AuthContext = createContext(null) + +export function AuthProvider({ children }: { children: React.ReactNode }) { + const [user, setUser] = useState(null) + const [loading, setLoading] = useState(true) + + async function loadUser() { + try { + const client = createClient() + const authUser = await authEndpoints(client).me() + setUser(authUser) + } catch { + setUser(null) + } + } + + useEffect(() => { + const subscription = onAuthStateChange(async (event, session) => { + if (event === 'SIGNED_OUT') { + setUser(null) + setLoading(false) + return + } + + if ((event === 'INITIAL_SESSION' || event === 'SIGNED_IN') && session) { + await loadUser() + setLoading(false) + return + } + + setLoading(false) + }) + + return () => subscription.unsubscribe() + }, []) + + async function signIn(input: SignInInput) { + await signInWithEmail(input) + } + + async function signUp(input: SignUpInput) { + await signUpWithEmail(input) + } + + async function signOut() { + await supabaseSignOut() + } + + return ( + + {children} + + ) +} + +export function useAuth(): AuthState { + const ctx = useContext(AuthContext) + if (!ctx) throw new Error('useAuth must be used inside AuthProvider') + return ctx +} diff --git a/apps/admin-panel/src/lib/queryClient.ts b/apps/admin-panel/src/lib/queryClient.ts new file mode 100644 index 000000000..76ba5efaf --- /dev/null +++ b/apps/admin-panel/src/lib/queryClient.ts @@ -0,0 +1,10 @@ +import { QueryClient } from '@tanstack/react-query' + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 0, + retry: 1, + }, + }, +}) diff --git a/apps/admin-panel/src/main.tsx b/apps/admin-panel/src/main.tsx new file mode 100644 index 000000000..19597d856 --- /dev/null +++ b/apps/admin-panel/src/main.tsx @@ -0,0 +1,17 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import { QueryClientProvider } from '@tanstack/react-query' +import App from './App' +import { AuthProvider } from './lib/AuthContext' +import { queryClient } from './lib/queryClient' +import './styles/index.css' + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + + + +) diff --git a/apps/admin-panel/src/pages/Amenities.tsx b/apps/admin-panel/src/pages/Amenities.tsx new file mode 100644 index 000000000..5409f0e1e --- /dev/null +++ b/apps/admin-panel/src/pages/Amenities.tsx @@ -0,0 +1,528 @@ +import { useState, useEffect, useRef } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { + Sparkles, Plus, Pencil, Trash2, Search, + AlertTriangle, CheckCircle2, +} from 'lucide-react' +import { createClient } from '@venue404/api-client' +import { adminAmenityEndpoints } from '@venue404/api-client' +import type { AdminAmenity } from '@venue404/api-client' +import { AdminLayout } from '../components/AdminLayout' +import { + MetricCard, StatusBadge, SectionHeader, + EmptyState, LoadingScreen, ErrorState, Button, Modal, +} from '@venue404/ui' + +const api = adminAmenityEndpoints(createClient()) + +const DEBOUNCE_MS = 350 + +export default function Amenities() { + const qc = useQueryClient() + + const [searchInput, setSearchInput] = useState('') + const [search, setSearch] = useState('') + const [showArchived, setShowArchived] = useState(false) + + // Create modal + const [createOpen, setCreateOpen] = useState(false) + const [createName, setCreateName] = useState('') + const [createIcon, setCreateIcon] = useState('') + + // Edit modal + const [editTarget, setEditTarget] = useState(null) + const [editName, setEditName] = useState('') + const [editIcon, setEditIcon] = useState('') + + // Delete modal + const [deleteTarget, setDeleteTarget] = useState(null) + + const debounceRef = useRef | null>(null) + function handleSearchChange(value: string) { + setSearchInput(value) + if (debounceRef.current) clearTimeout(debounceRef.current) + debounceRef.current = setTimeout(() => setSearch(value), DEBOUNCE_MS) + } + useEffect(() => () => { if (debounceRef.current) clearTimeout(debounceRef.current) }, []) + + // ── Query ─────────────────────────────────────────────────────────────────── + + const { data, isLoading, error } = useQuery({ + queryKey: ['admin', 'amenities'], + queryFn: () => api.listAmenities({ include_deleted: true }), + }) + + const allAmenities = data?.items ?? [] + + // ── Mutations ─────────────────────────────────────────────────────────────── + + const createMutation = useMutation({ + mutationFn: (input: { name: string; icon: string | null }) => + api.createAmenity(input), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['admin', 'amenities'] }) + closeCreate() + }, + }) + + const editMutation = useMutation({ + mutationFn: ({ id, name, icon }: { id: string; name: string; icon: string | null }) => + api.updateAmenity(id, { name, icon }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['admin', 'amenities'] }) + closeEdit() + }, + }) + + const deleteMutation = useMutation({ + mutationFn: (id: string) => api.deleteAmenity(id), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['admin', 'amenities'] }) + closeDelete() + }, + }) + + // ── Client-side filtering ─────────────────────────────────────────────────── + + const filtered = allAmenities + .filter((a) => showArchived || !a.deleted_at) + .filter((a) => !search || a.name.toLowerCase().includes(search.toLowerCase())) + + const stats = { + total: allAmenities.filter((a) => !a.deleted_at).length, + inUse: allAmenities.filter((a) => !a.deleted_at && a.active_venue_count > 0).length, + archived: allAmenities.filter((a) => !!a.deleted_at).length, + } + + // ── Modal helpers ─────────────────────────────────────────────────────────── + + function openCreate() { + setCreateName('') + setCreateIcon('') + createMutation.reset() + setCreateOpen(true) + } + + function closeCreate() { + setCreateOpen(false) + createMutation.reset() + } + + function openEdit(a: AdminAmenity) { + setEditTarget(a) + setEditName(a.name) + setEditIcon(a.icon ?? '') + editMutation.reset() + } + + function closeEdit() { + setEditTarget(null) + editMutation.reset() + } + + function closeDelete() { + setDeleteTarget(null) + deleteMutation.reset() + } + + // ── Handlers ──────────────────────────────────────────────────────────────── + + function handleCreate() { + if (!createName.trim()) return + createMutation.mutate({ name: createName.trim(), icon: createIcon.trim() || null }) + } + + function handleEdit() { + if (!editTarget || !editName.trim()) return + editMutation.mutate({ id: editTarget.id, name: editName.trim(), icon: editIcon.trim() || null }) + } + + function handleDelete() { + if (!deleteTarget) return + deleteMutation.mutate(deleteTarget.id) + } + + const hasFilters = !!(search || showArchived) + + return ( + + + {/* Metric strip */} +
+
+ } + accent="brand" + /> +
+
+ } + accent="emerald" + /> +
+
+ } + accent="amber" + /> +
+
+ + {/* Table card */} +
+ + {/* Card header */} +
+ +
+ + {/* Content states */} + {isLoading && ( +
+ +
+ )} + + {!isLoading && error && ( +
+ qc.invalidateQueries({ queryKey: ['admin', 'amenities'] })}> + Retry + + } + /> +
+ )} + + {!isLoading && !error && filtered.length === 0 && ( +
+ } + title="No amenities found" + description={ + search + ? 'Try adjusting the search term.' + : 'Add your first amenity to get started.' + } + /> +
+ )} + + {!isLoading && !error && filtered.length > 0 && ( +
+ + + + + + + + + + + + {filtered.map((amenity) => ( + + + + + + + + ))} + +
AmenityVenues using itStatusCreatedActions
+
+ {amenity.icon ? ( + + {amenity.icon} + + ) : ( + + + )} + {amenity.name} +
+
+ {amenity.active_venue_count > 0 ? ( + + {amenity.active_venue_count} {amenity.active_venue_count === 1 ? 'venue' : 'venues'} + + ) : ( + None + )} + + {amenity.deleted_at ? ( + + ) : ( + + )} + + {new Date(amenity.created_at).toLocaleDateString('en-IN', { + year: 'numeric', month: 'short', day: 'numeric', + })} + + {amenity.deleted_at ? null : ( +
+ + +
+ )} +
+
+ )} +
+ + {/* Create Modal */} + +
+
+
+
+

New amenity

+

+ This amenity will immediately be available for venue owners to add to their listings. +

+ +
+
+ + { setCreateName(e.target.value); createMutation.reset() }} + autoFocus + /> +
+
+ + setCreateIcon(e.target.value)} + /> +
+
+ + {createMutation.error && ( +

+ {createMutation.error instanceof Error ? createMutation.error.message : 'Failed to create amenity'} +

+ )} + +
+ + +
+
+
+
+ + {/* Edit Modal */} + +
+
+
+
+

Edit amenity

+

+ Changes apply immediately across all venues using this amenity. +

+ +
+
+ + { setEditName(e.target.value); editMutation.reset() }} + autoFocus + /> +
+
+ + setEditIcon(e.target.value)} + /> +
+
+ + {editMutation.error && ( +

+ {editMutation.error instanceof Error ? editMutation.error.message : 'Failed to update amenity'} +

+ )} + +
+ + +
+
+
+
+ + {/* Delete Modal */} + +
+
+
+
+

Archive amenity

+

+ {deleteTarget?.name} will be hidden from venue owners and can no longer be assigned to new listings. +

+ + {/* Active venue warning */} + {deleteTarget && deleteTarget.active_venue_count > 0 && ( +
+
+ )} + + {deleteMutation.error && ( +
+ {deleteMutation.error instanceof Error ? deleteMutation.error.message : 'Failed to archive amenity'} +
+ )} + +
+ + +
+
+
+
+ +
+ ) +} diff --git a/apps/admin-panel/src/pages/AuditLog.tsx b/apps/admin-panel/src/pages/AuditLog.tsx new file mode 100644 index 000000000..d68fc440f --- /dev/null +++ b/apps/admin-panel/src/pages/AuditLog.tsx @@ -0,0 +1,300 @@ +import { useState } from 'react' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { + CheckCircle2, XCircle, ShieldOff, + Sparkles, User, Building2, CalendarDays, ClipboardList, +} from 'lucide-react' +import { createClient, adminActionEndpoints } from '@venue404/api-client' +import type { AdminAction } from '@venue404/api-client' +import { AdminLayout } from '../components/AdminLayout' +import { + SectionHeader, EmptyState, LoadingScreen, ErrorState, Button, +} from '@venue404/ui' + +const api = adminActionEndpoints(createClient()) + +type TargetTab = 'user' | 'venue' | 'amenity' | '' + +const TABS: { label: string; value: TargetTab; icon: React.ReactNode }[] = [ + { label: 'All', value: '', icon: }, + { label: 'Users', value: 'user', icon: }, + { label: 'Venues', value: 'venue', icon: }, + { label: 'Amenities', value: 'amenity', icon: }, +] + +// ── Action display metadata ─────────────────────────────────────────────────── + +type ActionMeta = { + label: string + icon: React.ReactNode + color: string + bg: string +} + +function getActionMeta(type: string): ActionMeta { + if (type.endsWith('approved') || type.endsWith('reactivated')) + return { label: fmtType(type), icon: , color: 'text-emerald-700', bg: 'bg-emerald-50' } + if (type.endsWith('rejected') || type.endsWith('deleted')) + return { label: fmtType(type), icon: , color: 'text-red-600', bg: 'bg-red-50' } + if (type.endsWith('suspended')) + return { label: fmtType(type), icon: , color: 'text-orange-600', bg: 'bg-orange-50' } + if (type.includes('created') || type.includes('updated')) + return { label: fmtType(type), icon: , color: 'text-brand', bg: 'bg-brand-light' } + return { label: fmtType(type), icon: , color: 'text-zinc-600', bg: 'bg-zinc-100' } +} + +function fmtType(type: string): string { + return type.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()) +} + +function fmtTargetType(t: string): string { + if (t === 'user') return 'User' + if (t === 'venue') return 'Venue' + if (t === 'amenity') return 'Amenity' + if (t === 'booking') return 'Booking' + return t.charAt(0).toUpperCase() + t.slice(1) +} + +function timeAgo(iso: string): string { + const diff = Date.now() - new Date(iso).getTime() + const mins = Math.floor(diff / 60_000) + if (mins < 1) return 'just now' + if (mins < 60) return `${mins}m ago` + const hrs = Math.floor(mins / 60) + if (hrs < 24) return `${hrs}h ago` + const days = Math.floor(hrs / 24) + if (days < 7) return `${days}d ago` + return new Date(iso).toLocaleDateString('en-IN', { year: 'numeric', month: 'short', day: 'numeric' }) +} + +function fmtDate(iso: string): string { + return new Date(iso).toLocaleString('en-IN', { + year: 'numeric', month: 'short', day: 'numeric', + hour: '2-digit', minute: '2-digit', + }) +} + +// ── Component ───────────────────────────────────────────────────────────────── + +const PAGE_SIZE = 25 + +export default function AuditLog() { + const qc = useQueryClient() + const [activeTab, setActiveTab] = useState('') + const [page, setPage] = useState(1) + + const { data, isLoading, error } = useQuery({ + queryKey: ['admin', 'audit-log', { page, target_type: activeTab }], + queryFn: () => api.listActions({ + page, + page_size: PAGE_SIZE, + target_type: activeTab || undefined, + }), + }) + + const total = data?.total ?? 0 + const totalPages = data?.total_pages ?? 1 + + return ( + +
+ + {/* Header + tabs */} +
+ + +
+ {TABS.map((tab) => { + const isActive = activeTab === tab.value + return ( + + ) + })} +
+
+ + {/* States */} + {isLoading && ( +
+ +
+ )} + + {!isLoading && error && ( +
+ qc.invalidateQueries({ queryKey: ['admin', 'audit-log'] })}> + Retry + + } + /> +
+ )} + + {!isLoading && !error && data?.items.length === 0 && ( +
+ } + title="No actions yet" + description={ + activeTab + ? `No admin actions recorded for ${fmtTargetType(activeTab).toLowerCase()}s.` + : 'Admin actions will appear here once recorded.' + } + /> +
+ )} + + {!isLoading && !error && data && data.items.length > 0 && ( + <> +
+ + + + + + + + + + + + {data.items.map((action) => ( + + ))} + +
ActionTargetAdminReasonTime
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+ + {((page - 1) * PAGE_SIZE) + 1}–{Math.min(page * PAGE_SIZE, total)} of {total.toLocaleString()} + +
+ + Page {page} of {totalPages} + +
+
+ )} + + )} +
+
+ ) +} + +// ── Row component ───────────────────────────────────────────────────────────── + +function ActionRow({ action }: { action: AdminAction }) { + const meta = getActionMeta(action.action_type) + return ( + + + {/* Action */} + + + {meta.icon} + {meta.label} + + + + {/* Target */} + +
+ +
+
+ {action.target_type} + {action.target_name && ( + ({action.target_name}) + )} +
+ + {action.target_id} + +
+
+ + + {/* Admin */} + + + {action.admin_name ?? } + + + + {/* Reason */} + + {action.reason ? ( + + {action.reason} + + ) : ( + No reason provided + )} + + + {/* Time */} + + + {timeAgo(action.created_at)} + + + + ) +} + +function TargetIcon({ type }: { type: string }) { + const cls = 'h-3.5 w-3.5 text-zinc-400' + if (type === 'user') return + if (type === 'venue') return + if (type === 'amenity') return + if (type === 'booking') return + return +} diff --git a/apps/admin-panel/src/pages/Bookings.tsx b/apps/admin-panel/src/pages/Bookings.tsx new file mode 100644 index 000000000..05686eeaf --- /dev/null +++ b/apps/admin-panel/src/pages/Bookings.tsx @@ -0,0 +1,471 @@ +import { useState, useEffect, useRef } from 'react' +import { createPortal } from 'react-dom' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { + CalendarDays, Building2, Search, + CheckCircle2, Clock, CalendarCheck, X, + Phone, Mail, CreditCard, +} from 'lucide-react' +import { createClient, adminBookingEndpoints } from '@venue404/api-client' +import type { AdminBookingSummary } from '@venue404/api-client' +import { AdminLayout } from '../components/AdminLayout' +import { + MetricCard, SectionHeader, StatusBadge, EmptyState, + LoadingScreen, ErrorState, Button, +} from '@venue404/ui' + +const api = adminBookingEndpoints(createClient()) + +const PAGE_SIZE = 25 +const DEBOUNCE_MS = 350 + +type TabValue = '' | 'requested' | 'confirmed' | 'completed' | 'cancelled' + +const TABS: { label: string; value: TabValue; statsKey?: 'requested' | 'confirmed' | 'completed' | 'cancelled' }[] = [ + { label: 'All', value: '' }, + { label: 'Requested', value: 'requested', statsKey: 'requested' }, + { label: 'Confirmed', value: 'confirmed', statsKey: 'confirmed' }, + { label: 'Completed', value: 'completed', statsKey: 'completed' }, + { label: 'Cancelled', value: 'cancelled', statsKey: 'cancelled' }, +] + +// ── Status helpers ───────────────────────────────────────────────────────────── + +function statusVariant(status: string): 'success' | 'warning' | 'danger' | 'pending' | 'neutral' { + if (status === 'confirmed') return 'success' + if (status === 'completed') return 'neutral' + if (status === 'owner_accepted') return 'warning' + if (status === 'requested') return 'pending' + return 'danger' +} + +function statusLabel(status: string): string { + if (status === 'owner_accepted') return 'Accepted' + if (status === 'conflict_cancelled') return 'Conflict Cancelled' + if (status === 'user_cancelled') return 'User Cancelled' + if (status === 'admin_cancelled') return 'Admin Cancelled' + if (status === 'owner_rejected') return 'Rejected' + if (status === 'hold_expired') return 'Hold Expired' + if (status === 'request_expired') return 'Request Expired' + if (status === 'balance_overdue_cancelled') return 'Balance Overdue' + return status.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()) +} + +function paymentVariant(ps: string): 'success' | 'warning' | 'danger' | 'neutral' { + if (ps === 'fully_paid') return 'success' + if (ps === 'advance_paid') return 'warning' + if (ps === 'refunded' || ps === 'partially_refunded') return 'neutral' + return 'danger' +} + +function paymentLabel(ps: string): string { + if (ps === 'unpaid') return 'Unpaid' + if (ps === 'advance_paid') return 'Advance Paid' + if (ps === 'fully_paid') return 'Fully Paid' + if (ps === 'refunded') return 'Refunded' + if (ps === 'partially_refunded') return 'Part Refunded' + return ps.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()) +} + +// ── Date helpers ─────────────────────────────────────────────────────────────── + +function fmtDate(iso: string): string { + if (!iso) return '—' + return new Date(iso).toLocaleDateString('en-IN', { year: 'numeric', month: 'short', day: 'numeric' }) +} + +function fmtDateTime(iso: string): string { + return new Date(iso).toLocaleString('en-IN', { + year: 'numeric', month: 'short', day: 'numeric', + hour: '2-digit', minute: '2-digit', + }) +} + +function timeAgo(iso: string): string { + const diff = Date.now() - new Date(iso).getTime() + const mins = Math.floor(diff / 60_000) + if (mins < 1) return 'just now' + if (mins < 60) return `${mins}m ago` + const hrs = Math.floor(mins / 60) + if (hrs < 24) return `${hrs}h ago` + const days = Math.floor(hrs / 24) + if (days < 7) return `${days}d ago` + return fmtDate(iso) +} + +// ── Component ───────────────────────────────────────────────────────────────── + +export default function Bookings() { + const qc = useQueryClient() + + const [activeTab, setActiveTab] = useState('') + const [searchInput, setSearchInput] = useState('') + const [search, setSearch] = useState('') + const [page, setPage] = useState(1) + const [detailBooking, setDetailBooking] = useState(null) + + const debounceRef = useRef | null>(null) + function handleSearchChange(value: string) { + setSearchInput(value) + if (debounceRef.current) clearTimeout(debounceRef.current) + debounceRef.current = setTimeout(() => { setSearch(value); setPage(1) }, DEBOUNCE_MS) + } + useEffect(() => () => { if (debounceRef.current) clearTimeout(debounceRef.current) }, []) + + const { data, isLoading, error } = useQuery({ + queryKey: ['admin', 'bookings', { page, status: activeTab, search }], + queryFn: () => api.listBookings({ + page, + page_size: PAGE_SIZE, + status: activeTab || undefined, + search: search.trim() || undefined, + }), + }) + + const items = data?.items ?? [] + const total = data?.total ?? 0 + const totalPages = data?.total_pages ?? 1 + const stats = data?.stats ?? null + const hasFilters = !!(searchInput || activeTab) + + const invalidateBookings = () => qc.invalidateQueries({ queryKey: ['admin', 'bookings'] }) + + return ( + + + {/* Metric strip */} +
+ {[ + { label: 'Total Bookings', value: stats?.total, accent: 'brand' as const, icon: , description: 'All time' }, + { label: 'Confirmed', value: stats?.confirmed, accent: 'emerald' as const, icon: , description: 'Payment received' }, + { label: 'Requested', value: stats?.requested, accent: 'amber' as const, icon: , description: 'Awaiting owner action' }, + { label: 'Completed', value: stats?.completed, accent: 'violet' as const, icon: , description: 'Successfully held' }, + ].map((m, i) => ( +
+ +
+ ))} +
+ + {/* Main card */} +
+ + {/* Header + tabs */} +
+ + + {/* Tabs */} +
+ {TABS.map((tab) => { + const isActive = activeTab === tab.value + const count = tab.statsKey ? stats?.[tab.statsKey] : undefined + return ( + + ) + })} +
+ + {/* Search */} +
+
+
+
+
+ + {/* Content states */} + {isLoading && ( +
+ +
+ )} + + {!isLoading && error && ( +
+ Retry} + /> +
+ )} + + {!isLoading && !error && items.length === 0 && ( +
+ } + title="No bookings found" + description={ + hasFilters + ? 'Try adjusting the search or filters.' + : 'Bookings will appear here once customers start making requests.' + } + /> +
+ )} + + {!isLoading && !error && items.length > 0 && ( + <> +
+ + + + + + + + + + + + + + {items.map((b) => ( + setDetailBooking(b)} /> + ))} + +
VenueCustomerEvent dateBooking statusPaymentGuestsRequested +
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+ + {((page - 1) * PAGE_SIZE) + 1}–{Math.min(page * PAGE_SIZE, total)} of {total.toLocaleString()} + +
+ + Page {page} of {totalPages} + +
+
+ )} + + )} +
+ + {/* Detail modal */} + {detailBooking && ( + setDetailBooking(null)} /> + )} + +
+ ) +} + +// ── Row ─────────────────────────────────────────────────────────────────────── + +function BookingRow({ booking: b, onViewDetails }: { booking: AdminBookingSummary; onViewDetails: () => void }) { + return ( + + +
+ + {b.venue_name} +
+ + +
+
{b.customer_name ?? '—'}
+ {b.customer_email && ( +
{b.customer_email}
+ )} +
+ + {fmtDate(b.event_date)} + + + + + + + {b.guest_count} + + + {timeAgo(b.created_at)} + + + + + + + ) +} + +// ── Detail modal ────────────────────────────────────────────────────────────── + +function ContactRow({ icon, value }: { icon: React.ReactNode; value: string | null }) { + if (!value) return null + return ( +
+ {icon} + {value} +
+ ) +} + +function PersonCard({ + role, name, email, phone, +}: { + role: string + name: string | null + email: string | null + phone: string | null +}) { + return ( +
+

{role}

+
+
+ {name?.[0]?.toUpperCase() ?? '?'} +
+
+

{name ?? '—'}

+
+
+
+ } value={email} /> + } value={phone} /> +
+
+ ) +} + +function BookingDetailModal({ booking: b, onClose }: { booking: AdminBookingSummary; onClose: () => void }) { + return createPortal( +
+
+ +
+ + {/* Header */} +
+
+

Booking details

+

{b.id}

+
+ +
+ +
+ + {/* Venue */} +
+

Venue

+
+ + {b.venue_name} +
+
+ + {/* People */} +
+ + +
+ + {/* Booking info grid */} +
+
+

Event date

+

{fmtDate(b.event_date)}

+
+
+

Guests

+

{b.guest_count}

+
+
+

Booking status

+ +
+
+

Payment

+
+ + +
+
+
+ + {/* Footer */} +

+ Requested {fmtDateTime(b.created_at)} +

+ +
+
+
, + document.body, + ) +} diff --git a/apps/admin-panel/src/pages/Categories.tsx b/apps/admin-panel/src/pages/Categories.tsx new file mode 100644 index 000000000..6103846b4 --- /dev/null +++ b/apps/admin-panel/src/pages/Categories.tsx @@ -0,0 +1,430 @@ +import { useState, useRef } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { + LayoutGrid, Plus, Pencil, Trash2, Image, X, + AlertTriangle, CheckCircle2, +} from 'lucide-react' +import { createClient } from '@venue404/api-client' +import { adminCategoryEndpoints } from '@venue404/api-client' +import type { AdminCategory } from '@venue404/api-client' +import { AdminLayout } from '../components/AdminLayout' +import { + MetricCard, StatusBadge, SectionHeader, + EmptyState, LoadingScreen, ErrorState, Button, Modal, +} from '@venue404/ui' + +const api = adminCategoryEndpoints(createClient()) + +export default function Categories() { + const qc = useQueryClient() + + const [showArchived, setShowArchived] = useState(false) + + // Create modal + const [createOpen, setCreateOpen] = useState(false) + const [createSlug, setCreateSlug] = useState('') + const [createLabel, setCreateLabel] = useState('') + const [createIcon, setCreateIcon] = useState('') + const [createSortOrder, setCreateSortOrder] = useState('0') + + // Edit modal + const [editTarget, setEditTarget] = useState(null) + const [editLabel, setEditLabel] = useState('') + const [editIcon, setEditIcon] = useState('') + const [editSortOrder, setEditSortOrder] = useState('0') + const [editIsActive, setEditIsActive] = useState(true) + + // Delete modal + const [deleteTarget, setDeleteTarget] = useState(null) + + // Banner upload + const [bannerTarget, setBannerTarget] = useState(null) + const bannerInputRef = useRef(null) + + const { data, isLoading, error } = useQuery({ + queryKey: ['admin', 'categories'], + queryFn: () => api.listCategories({ include_deleted: true }), + }) + + const allCategories = data?.items ?? [] + + const filtered = allCategories.filter((c) => showArchived || !c.deleted_at) + + const stats = { + total: allCategories.filter((c) => !c.deleted_at).length, + active: allCategories.filter((c) => !c.deleted_at && c.is_active).length, + archived: allCategories.filter((c) => !!c.deleted_at).length, + } + + // ── Mutations ─────────────────────────────────────────────────────────────── + + const createMutation = useMutation({ + mutationFn: (input: { slug: string; label: string; icon: string | null; sort_order: number }) => + api.createCategory(input), + onSuccess: () => { qc.invalidateQueries({ queryKey: ['admin', 'categories'] }); closeCreate() }, + }) + + const editMutation = useMutation({ + mutationFn: ({ id, ...body }: { id: string; label: string; icon: string | null; sort_order: number; is_active: boolean }) => + api.updateCategory(id, body), + onSuccess: () => { qc.invalidateQueries({ queryKey: ['admin', 'categories'] }); closeEdit() }, + }) + + const bannerMutation = useMutation({ + mutationFn: ({ id, file }: { id: string; file: File }) => { + const fd = new FormData() + fd.append('file', file) + return api.uploadCategoryBanner(id, fd) + }, + onSuccess: () => { qc.invalidateQueries({ queryKey: ['admin', 'categories'] }); setBannerTarget(null) }, + }) + + const deleteBannerMutation = useMutation({ + mutationFn: (id: string) => api.deleteCategoryBanner(id), + onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'categories'] }), + }) + + const deleteMutation = useMutation({ + mutationFn: (id: string) => api.deleteCategory(id), + onSuccess: () => { qc.invalidateQueries({ queryKey: ['admin', 'categories'] }); closeDelete() }, + }) + + // ── Modal helpers ─────────────────────────────────────────────────────────── + + function openCreate() { + setCreateSlug(''); setCreateLabel(''); setCreateIcon(''); setCreateSortOrder('0') + createMutation.reset(); setCreateOpen(true) + } + function closeCreate() { setCreateOpen(false); createMutation.reset() } + + function openEdit(c: AdminCategory) { + setEditTarget(c); setEditLabel(c.label); setEditIcon(c.icon ?? '') + setEditSortOrder(String(c.sort_order)); setEditIsActive(c.is_active) + editMutation.reset() + } + function closeEdit() { setEditTarget(null); editMutation.reset() } + + function closeDelete() { setDeleteTarget(null); deleteMutation.reset() } + + // ── Handlers ──────────────────────────────────────────────────────────────── + + function handleCreate() { + if (!createSlug.trim() || !createLabel.trim()) return + createMutation.mutate({ + slug: createSlug.trim(), + label: createLabel.trim(), + icon: createIcon.trim() || null, + sort_order: parseInt(createSortOrder, 10) || 0, + }) + } + + function handleEdit() { + if (!editTarget || !editLabel.trim()) return + editMutation.mutate({ + id: editTarget.id, + label: editLabel.trim(), + icon: editIcon.trim() || null, + sort_order: parseInt(editSortOrder, 10) || 0, + is_active: editIsActive, + }) + } + + function handleBannerFile(e: React.ChangeEvent) { + const file = e.target.files?.[0] + if (!file || !bannerTarget) return + bannerMutation.mutate({ id: bannerTarget.id, file }) + } + + return ( + + + {/* Metric strip */} +
+
+ } accent="brand" /> +
+
+ } accent="emerald" /> +
+
+ } accent="amber" /> +
+
+ + {/* Table card */} +
+
+ + New category + + } + /> +
+ +
+
+ + {isLoading &&
} + + {!isLoading && error && ( +
+ qc.invalidateQueries({ queryKey: ['admin', 'categories'] })}>Retry} /> +
+ )} + + {!isLoading && !error && filtered.length === 0 && ( +
+ } title="No categories found" + description="Add your first category to get started." /> +
+ )} + + {!isLoading && !error && filtered.length > 0 && ( +
+ + + + + + + + + + + + + + {filtered.map((cat) => ( + + + + + + + + + + ))} + +
CategorySlugBannerVenuesStatusOrderActions
+
+ {cat.icon && ( + {cat.icon} + )} + {cat.label} +
+
{cat.slug} + {cat.banner_image ? ( +
+ {cat.label} + {!cat.deleted_at && ( + + )} +
+ ) : ( + !cat.deleted_at ? ( + + ) : ( + None + ) + )} +
+ {cat.venue_count > 0 ? ( + + {cat.venue_count} {cat.venue_count === 1 ? 'venue' : 'venues'} + + ) : ( + None + )} + + {cat.deleted_at ? ( + + ) : cat.is_active ? ( + + ) : ( + + )} + {cat.sort_order} + {!cat.deleted_at && ( +
+ + +
+ )} +
+
+ )} +
+ + {/* Hidden banner file input */} + + + {/* Create Modal */} + +
+
+
+ +
+

New category

+

Slug is permanent and used in URLs (lowercase, underscores only).

+
+
+ + { setCreateSlug(e.target.value); createMutation.reset() }} autoFocus /> +
+
+ + { setCreateLabel(e.target.value); createMutation.reset() }} /> +
+
+ + setCreateIcon(e.target.value)} /> +
+
+ + setCreateSortOrder(e.target.value)} /> +
+
+ {createMutation.error && ( +

+ {createMutation.error instanceof Error ? createMutation.error.message : 'Failed to create category'} +

+ )} +
+ + +
+
+
+
+ + {/* Edit Modal */} + +
+
+
+ +
+

Edit category

+

Slug cannot be changed after creation.

+
+
+ + +
+
+ + { setEditLabel(e.target.value); editMutation.reset() }} autoFocus /> +
+
+ + setEditIcon(e.target.value)} /> +
+
+ + setEditSortOrder(e.target.value)} /> +
+ +
+ {editMutation.error && ( +

+ {editMutation.error instanceof Error ? editMutation.error.message : 'Failed to update category'} +

+ )} +
+ + +
+
+
+
+ + {/* Archive Modal */} + +
+
+
+ +
+

Archive category

+

+ {deleteTarget?.label} will be hidden from venue owners. +

+ {deleteTarget && deleteTarget.venue_count > 0 && ( +
+ +

+ {deleteTarget.venue_count} {deleteTarget.venue_count === 1 ? 'venue uses' : 'venues use'} this category.{' '} + Existing venues are unaffected but this category won't appear for new venues. +

+
+ )} + {deleteMutation.error && ( +
+ {deleteMutation.error instanceof Error ? deleteMutation.error.message : 'Failed to archive category'} +
+ )} +
+ + +
+
+
+
+ +
+ ) +} diff --git a/apps/admin-panel/src/pages/ComingSoon.tsx b/apps/admin-panel/src/pages/ComingSoon.tsx new file mode 100644 index 000000000..bf0047adb --- /dev/null +++ b/apps/admin-panel/src/pages/ComingSoon.tsx @@ -0,0 +1,22 @@ +import { EmptyState } from '@venue404/ui' +import { Construction } from 'lucide-react' +import { AdminLayout } from '../components/AdminLayout' + +type ComingSoonProps = { + title: string + description: string +} + +export default function ComingSoon({ title, description }: ComingSoonProps) { + return ( + +
+ } + title={title} + description={description} + /> +
+
+ ) +} diff --git a/apps/admin-panel/src/pages/Dashboard.tsx b/apps/admin-panel/src/pages/Dashboard.tsx new file mode 100644 index 000000000..868de58b9 --- /dev/null +++ b/apps/admin-panel/src/pages/Dashboard.tsx @@ -0,0 +1,237 @@ +import { useQuery } from '@tanstack/react-query' +import { useAuth } from '../lib/AuthContext' +import { + MetricCard, ActivityItem, + SectionHeader, StatusBadge, EmptyState, + type DashboardMetric, +} from '@venue404/ui' +import { + Building2, UserCheck, + CalendarDays, ClipboardList, + CheckCircle2, XCircle, Clock, + RefreshCw, +} from 'lucide-react' +import { useNavigate } from 'react-router-dom' +import { AdminLayout } from '../components/AdminLayout' +import { createClient, ApiError } from '@venue404/api-client' +import { adminActionEndpoints, adminUserEndpoints, adminBookingEndpoints, adminVenueEndpoints } from '@venue404/api-client' +import { GrowthChart } from '../components/GrowthChart' + +const actionsApi = adminActionEndpoints(createClient()) +const usersApi = adminUserEndpoints(createClient()) +const bookingsApi = adminBookingEndpoints(createClient()) +const venuesApi = adminVenueEndpoints(createClient()) + +const METRIC_TEMPLATES: DashboardMetric[] = [ + { + label: 'Pending Approvals', + value: '—', + description: 'Venues awaiting approval', + icon: , + accent: 'amber', + }, + { + label: 'Active Bookings', + value: '—', + description: 'Confirmed this month', + icon: , + accent: 'brand', + }, + { + label: 'Venue Owners', + value: '—', + description: 'Registered on platform', + icon: , + accent: 'emerald', + }, + { + label: 'Open Actions', + value: '—', + description: 'Total admin actions logged', + icon: , + accent: 'violet', + }, +] + +const today = new Date().toLocaleDateString('en-IN', { + weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', +}) + +function actionLabel(type: string): string { + return type.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()) +} + +function actionIcon(type: string) { + if (type.endsWith('approved') || type.endsWith('reactivated') || type.endsWith('completed')) + return + if (type.endsWith('rejected') || type.endsWith('suspended') || type.endsWith('deleted')) + return + return +} + +function actionBadge(type: string) { + if (type.includes('suspend')) return + if (type.includes('reactivat')) return + if (type.includes('approved')) return + if (type.includes('rejected')) return + return null +} + +function timeAgo(iso: string): string { + const diff = Date.now() - new Date(iso).getTime() + const mins = Math.floor(diff / 60_000) + if (mins < 1) return 'just now' + if (mins < 60) return `${mins}m ago` + const hrs = Math.floor(mins / 60) + if (hrs < 24) return `${hrs}h ago` + return `${Math.floor(hrs / 24)}d ago` +} + +function suppressAuthErrors(e: unknown) { + if (e instanceof ApiError && (e.status === 401 || e.status === 403)) throw e + return null +} + +export default function Dashboard() { + const { user } = useAuth() + const navigate = useNavigate() + + const { data: actionsData, isLoading: actionsLoading } = useQuery({ + queryKey: ['admin', 'dashboard', 'actions'], + queryFn: () => actionsApi.listActions({ limit: 4 }).catch(suppressAuthErrors), + }) + + const { data: ownerStats } = useQuery({ + queryKey: ['admin', 'dashboard', 'owner-stats'], + queryFn: () => usersApi.getOwnerStats().catch(suppressAuthErrors), + }) + + const { data: bookingStats } = useQuery({ + queryKey: ['admin', 'dashboard', 'booking-stats'], + queryFn: () => bookingsApi.getStats().catch(suppressAuthErrors), + }) + + const { data: venueStats } = useQuery({ + queryKey: ['admin', 'dashboard', 'venue-stats'], + queryFn: () => venuesApi.getVenueStats().catch(suppressAuthErrors), + }) + + + const recentActions = actionsData?.items ?? [] + const actionsTotal = actionsData?.total ?? null + + const metrics = METRIC_TEMPLATES.map((m) => { + if (m.label === 'Pending Approvals') return { ...m, value: venueStats ? String(venueStats.pending_approval) : '—' } + if (m.label === 'Active Bookings') return { ...m, value: bookingStats ? String(bookingStats.confirmed) : '—' } + if (m.label === 'Venue Owners') return { ...m, value: ownerStats ? String(ownerStats.total) : '—' } + if (m.label === 'Open Actions') return { ...m, value: actionsTotal !== null ? String(actionsTotal) : '—' } + return m + }) + + const firstName = user?.profile.full_name?.split(' ')[0] ?? null + + return ( + + {/* Welcome */} +
+

+ {firstName ? `Good to see you, ${firstName}` : 'Welcome back'} +

+

+ Here is what needs your attention today. +

+
+ + {/* Metrics */} +
+ {metrics.map((m, i) => ( +
+ +
+ ))} +
+ + {/* Content grid */} +
+ + {/* Platform growth chart */} +
+ +
+ + {/* Recent audit actions */} +
+
+ navigate('/audit-log')} + className="press text-xs font-medium text-brand transition-colors hover:text-brand" + > + Full log + + } + /> +
+ + {actionsLoading && ( +
+ +
+ )} + + {!actionsLoading && recentActions.length === 0 && ( +
+ } + title="No actions yet" + description="Admin actions will appear here." + /> +
+ )} + + {!actionsLoading && recentActions.length > 0 && ( +
    + {recentActions.map((a) => ( +
  • + +
  • + ))} +
+ )} +
+
+ + {/* Quick actions */} +
+ +
+ {[ + { label: 'Review pending venues', href: '/venues/pending', icon: }, + { label: 'Manage users', href: '/users', icon: }, + { label: 'Open audit log', href: '/audit-log', icon: }, + { label: 'Active bookings', href: '/bookings', icon: }, + ].map((a) => ( + + ))} +
+
+
+ ) +} diff --git a/apps/admin-panel/src/pages/DeepResearchInsights.tsx b/apps/admin-panel/src/pages/DeepResearchInsights.tsx new file mode 100644 index 000000000..0f200c574 --- /dev/null +++ b/apps/admin-panel/src/pages/DeepResearchInsights.tsx @@ -0,0 +1,384 @@ +import { useState, useEffect, useRef } from 'react' +import { createPortal } from 'react-dom' +import { useQuery } from '@tanstack/react-query' +import { + BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, +} from 'recharts' +import { + Sparkles, Search, TrendingUp, Target, X, MapPin, Users, Wallet, CalendarDays, Tag, +} from 'lucide-react' +import { createClient, adminDeepResearchEndpoints } from '@venue404/api-client' +import type { DeepResearchQuerySummary, DeepResearchQueryDetail } from '@venue404/api-client' +import { AdminLayout } from '../components/AdminLayout' +import { + MetricCard, SectionHeader, EmptyState, LoadingScreen, ErrorState, Button, +} from '@venue404/ui' + +const api = adminDeepResearchEndpoints(createClient()) +const PAGE_SIZE = 20 +const DEBOUNCE_MS = 350 + +type QueryBreakdown = { + intent?: string + city?: string | null + venue_type?: string | null + capacity?: number | null + budget_hint?: string | null + date_hint?: string | null + required_amenities?: string[] + special_requirements?: string[] +} + +function pct(value: number | null | undefined) { + if (value == null) return '—' + return `${Math.round(Math.max(0, Math.min(1, value)) * 100)}%` +} + +function fmtDateTime(iso: string): string { + return new Date(iso).toLocaleString('en-IN', { + year: 'numeric', month: 'short', day: 'numeric', + hour: '2-digit', minute: '2-digit', + }) +} + +function timeAgo(iso: string): string { + const diff = Date.now() - new Date(iso).getTime() + const mins = Math.floor(diff / 60_000) + if (mins < 1) return 'just now' + if (mins < 60) return `${mins}m ago` + const hrs = Math.floor(mins / 60) + if (hrs < 24) return `${hrs}h ago` + const days = Math.floor(hrs / 24) + return `${days}d ago` +} + +export default function DeepResearchInsights() { + const [searchInput, setSearchInput] = useState('') + const [search, setSearch] = useState('') + const [page, setPage] = useState(1) + const [detailId, setDetailId] = useState(null) + + const debounceRef = useRef | null>(null) + function handleSearchChange(value: string) { + setSearchInput(value) + if (debounceRef.current) clearTimeout(debounceRef.current) + debounceRef.current = setTimeout(() => { setSearch(value); setPage(1) }, DEBOUNCE_MS) + } + useEffect(() => () => { if (debounceRef.current) clearTimeout(debounceRef.current) }, []) + + const statsQuery = useQuery({ + queryKey: ['admin', 'deep-research', 'stats'], + queryFn: () => api.getStats(30), + }) + + const listQuery = useQuery({ + queryKey: ['admin', 'deep-research', 'queries', { page, search }], + queryFn: () => api.listQueries({ page, page_size: PAGE_SIZE, search: search.trim() || undefined }), + }) + + const stats = statsQuery.data + const chartData = stats + ? stats.labels.map((label, i) => ({ label, count: stats.query_counts[i] })) + : [] + + const items = listQuery.data?.items ?? [] + const total = listQuery.data?.total ?? 0 + const totalPages = Math.ceil(total / PAGE_SIZE) || 1 + + return ( + + {/* Metric strip */} +
+ {[ + { label: 'Total queries', value: stats?.total_queries, icon: , accent: 'brand' as const, description: 'All time' }, + { label: 'Avg results / query', value: stats ? stats.avg_result_count.toFixed(1) : undefined, icon: , accent: 'emerald' as const, description: 'Internal catalog matches' }, + { label: 'Avg match score', value: stats ? pct(stats.avg_match_score_overall) : undefined, icon: , accent: 'violet' as const, description: 'Blended relevance, all-time' }, + { label: 'Last 30 days', value: stats?.query_counts.reduce((a, b) => a + b, 0), icon: , accent: 'amber' as const, description: 'Queries this period' }, + ].map((m) => ( + + ))} +
+ + {/* Chart */} +
+
+ +
+
+ {statsQuery.isLoading || !stats ? ( +
Loading…
+ ) : ( + + + + + + + + + + )} +
+
+ + {/* Recent queries table */} +
+
+ +
+
+
+ + {listQuery.isLoading && ( +
+ )} + + {!listQuery.isLoading && listQuery.error && ( +
+ listQuery.refetch()}>Retry} + /> +
+ )} + + {!listQuery.isLoading && !listQuery.error && items.length === 0 && ( +
+ } + title="No queries found" + description={search ? 'Try a different search term.' : 'Deep Research queries will appear here once users start searching.'} + /> +
+ )} + + {!listQuery.isLoading && !listQuery.error && items.length > 0 && ( + <> +
+ + + + + + + + + + + + {items.map((q) => ( + setDetailId(q.id)} /> + ))} + +
QueryCityResultsMatch scoreWhen +
+
+ + {totalPages > 1 && ( +
+ {((page - 1) * PAGE_SIZE) + 1}–{Math.min(page * PAGE_SIZE, total)} of {total.toLocaleString()} +
+ + Page {page} of {totalPages} + +
+
+ )} + + )} +
+ + {detailId && setDetailId(null)} />} +
+ ) +} + +function QueryRow({ query: q, onViewDetails }: { query: DeepResearchQuerySummary; onViewDetails: () => void }) { + return ( + + + {q.query_text} + + {q.city_filter ?? '—'} + {q.result_count} + {pct(q.avg_match_score)} + + {timeAgo(q.created_at)} + + + + + + ) +} + +function DetailField({ icon: Icon, label, value }: { icon: typeof Sparkles; label: string; value: string | null | undefined }) { + return ( +
+ + + +
+

{label}

+

{value ?? '—'}

+
+
+ ) +} + +function QueryDetailModal({ queryId, onClose }: { queryId: string; onClose: () => void }) { + const detailQuery = useQuery({ + queryKey: ['admin', 'deep-research', 'query', queryId], + queryFn: () => api.getQuery(queryId), + }) + + const detail: DeepResearchQueryDetail | undefined = detailQuery.data + const breakdown = (detail?.understanding_json ?? null) as QueryBreakdown | null + + return createPortal( +
+
+ +
+
+
+

Query details

+ {detail &&

{fmtDateTime(detail.created_at)}

} +
+ +
+ +
+ {detailQuery.isLoading &&

Loading…

} + + {detail && ( + <> +
+

Raw query

+

{detail.query_text}

+
+ + {breakdown && ( +
+

Model breakdown

+
+ + + + + + +
+ {(breakdown.required_amenities?.length ?? 0) > 0 && ( +
+ {breakdown.required_amenities!.map((tag) => ( + + {tag.replace(/_/g, ' ')} + + ))} +
+ )} + {(breakdown.special_requirements?.length ?? 0) > 0 && ( +
    + {breakdown.special_requirements!.map((req) => ( +
  • + + {req} +
  • + ))} +
+ )} +
+ )} + +
+
+

Results found

+

{detail.result_count}

+
+
+

Avg match score

+

{pct(detail.avg_match_score)}

+
+
+ + {detail.top_results_json && detail.top_results_json.length > 0 && ( +
+

Top results

+
+ {detail.top_results_json.map((r) => ( +
+ {r.name} +
+ {r.match_source && ( + + {r.match_source} + + )} + {pct(r.match_score)} +
+
+ ))} +
+
+ )} + + )} +
+
+
, + document.body, + ) +} diff --git a/apps/admin-panel/src/pages/Forbidden.tsx b/apps/admin-panel/src/pages/Forbidden.tsx new file mode 100644 index 000000000..9568aa967 --- /dev/null +++ b/apps/admin-panel/src/pages/Forbidden.tsx @@ -0,0 +1,36 @@ +import { useNavigate } from 'react-router-dom' +import { ForbiddenState } from '@venue404/ui' +import { useAuth } from '../lib/AuthContext' + +export default function Forbidden() { + const navigate = useNavigate() + const { signOut } = useAuth() + + async function handleSignOut() { + await signOut() + navigate('/login', { replace: true }) + } + + return ( + + + +
+ } + /> + ) +} diff --git a/apps/admin-panel/src/pages/Login.tsx b/apps/admin-panel/src/pages/Login.tsx new file mode 100644 index 000000000..f26350fa4 --- /dev/null +++ b/apps/admin-panel/src/pages/Login.tsx @@ -0,0 +1,134 @@ +import { useState, useEffect } from 'react' +import { useNavigate } from 'react-router-dom' +import { useAuth } from '../lib/AuthContext' +import { AuthLayout, AuthCard, AuthStatusPanel, Logo } from '@venue404/ui' +import { ShieldCheck, Building2, Users, BookOpen, ClipboardList } from 'lucide-react' + +const FEATURES = [ + { label: 'Venue Approvals', icon: }, + { label: 'User Management', icon: }, + { label: 'Booking Monitor', icon: }, + { label: 'Audit Trail', icon: }, +] + +export default function Login() { + const { user, loading, signIn } = useAuth() + const navigate = useNavigate() + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [error, setError] = useState(null) + const [submitting, setSubmitting] = useState(false) + + useEffect(() => { + if (!loading && user) navigate('/login/success', { replace: true }) + }, [user, loading, navigate]) + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + setError(null) + setSubmitting(true) + try { + await signIn({ email, password }) + } catch (err: unknown) { + setError(err instanceof Error ? err.message : 'Sign in failed. Check your credentials.') + } finally { + setSubmitting(false) + } + } + + if (loading) return null + + return ( + Not an admin? Contact your system administrator. + } + > +
+ +
+ +
+
+ + setEmail(e.target.value)} + placeholder="you@venue404.com" + disabled={submitting} + className="block w-full rounded-lg border border-zinc-300 bg-white px-3.5 py-2.5 text-sm text-zinc-900 placeholder-zinc-400 shadow-sm outline-none transition-[border-color,box-shadow] duration-150 focus:border-brand focus:ring-3 focus:ring-brand-secondary/15 disabled:opacity-50" + /> +
+ +
+ + setPassword(e.target.value)} + placeholder="••••••••••" + disabled={submitting} + className="block w-full rounded-lg border border-zinc-300 bg-white px-3.5 py-2.5 text-sm text-zinc-900 placeholder-zinc-400 shadow-sm outline-none transition-[border-color,box-shadow] duration-150 focus:border-brand focus:ring-3 focus:ring-brand-secondary/15 disabled:opacity-50" + /> +
+ + {error && ( +
+ + {error} +
+ )} + + +
+ +
+
+ + } + right={ + + } + /> + ) +} diff --git a/apps/admin-panel/src/pages/LoginSuccess.tsx b/apps/admin-panel/src/pages/LoginSuccess.tsx new file mode 100644 index 000000000..c8f9b0a9c --- /dev/null +++ b/apps/admin-panel/src/pages/LoginSuccess.tsx @@ -0,0 +1,47 @@ +import { useEffect, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { createClient, authEndpoints } from '@venue404/api-client' +import { LoadingScreen, ErrorState } from '@venue404/ui' + +export default function LoginSuccess() { + const navigate = useNavigate() + const [error, setError] = useState(null) + + useEffect(() => { + async function verify() { + try { + const client = createClient() + const user = await authEndpoints(client).me() + + if (user.roles.includes('super_admin')) { + navigate('/dashboard', { replace: true }) + } else { + navigate('/403', { replace: true }) + } + } catch { + setError('Session verification failed. Please sign in again.') + } + } + + verify() + }, [navigate]) + + if (error) { + return ( + navigate('/login', { replace: true })} + className="rounded-lg bg-brand px-4 py-2 text-sm font-medium text-white hover:bg-brand-hover" + > + Back to Login + + } + /> + ) + } + + return +} diff --git a/apps/admin-panel/src/pages/NotFound.tsx b/apps/admin-panel/src/pages/NotFound.tsx new file mode 100644 index 000000000..a059cee5b --- /dev/null +++ b/apps/admin-panel/src/pages/NotFound.tsx @@ -0,0 +1,29 @@ +import { useNavigate } from 'react-router-dom' +import { NotFoundState } from '@venue404/ui' + +export default function NotFound() { + const navigate = useNavigate() + + return ( + + + +
+ } + /> + ) +} diff --git a/apps/admin-panel/src/pages/Users.tsx b/apps/admin-panel/src/pages/Users.tsx new file mode 100644 index 000000000..55d781df8 --- /dev/null +++ b/apps/admin-panel/src/pages/Users.tsx @@ -0,0 +1,605 @@ +import { useState, useEffect, useRef } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { ShieldOff, ShieldCheck, Users as UsersIcon, Search, UserCheck, UserX, Clock, CheckCircle2, XCircle } from 'lucide-react' +import { createClient } from '@venue404/api-client' +import { adminUserEndpoints } from '@venue404/api-client' +import type { AdminUserSummary, AdminUserStatus, AdminUserRole } from '@venue404/api-client' +import { AdminLayout } from '../components/AdminLayout' +import { + MetricCard, StatusBadge, SectionHeader, EmptyState, + LoadingScreen, ErrorState, Button, Modal, +} from '@venue404/ui' + +const api = adminUserEndpoints(createClient()) + +const DEBOUNCE_MS = 350 + +function statusVariant(s: AdminUserStatus): 'success' | 'danger' | 'pending' | 'neutral' { + if (s === 'active') return 'success' + if (s === 'suspended') return 'danger' + if (s === 'pending') return 'pending' + return 'neutral' +} + +function roleVariant(r: string): 'info' | 'pending' | 'neutral' { + if (r === 'super_admin') return 'info' + if (r === 'venue_owner') return 'pending' + return 'neutral' +} + +function roleLabel(r: string) { + return r.replace(/_/g, ' ') +} + +function initials(u: AdminUserSummary): string { + if (u.full_name) { + const parts = u.full_name.trim().split(/\s+/) + return parts.length >= 2 + ? (parts[0][0] + parts[parts.length - 1][0]).toUpperCase() + : parts[0].slice(0, 2).toUpperCase() + } + return u.email?.[0]?.toUpperCase() ?? '?' +} + +const AVATAR_COLORS = [ + 'bg-brand-light-strong text-brand', + 'bg-violet-100 text-violet-700', + 'bg-amber-100 text-amber-700', + 'bg-emerald-100 text-emerald-700', + 'bg-rose-100 text-rose-700', + 'bg-teal-100 text-teal-700', +] + +function avatarColor(id: string): string { + let hash = 0 + for (const ch of id) hash = (hash * 31 + ch.charCodeAt(0)) >>> 0 + return AVATAR_COLORS[hash % AVATAR_COLORS.length] +} + +export default function Users() { + const qc = useQueryClient() + + const [searchInput, setSearchInput] = useState('') + const [search, setSearch] = useState('') + const [statusFilter, setStatusFilter] = useState('') + const [roleFilter, setRoleFilter] = useState('') + const [page, setPage] = useState(1) + + const [suspendTarget, setSuspendTarget] = useState(null) + const [suspendReason, setSuspendReason] = useState('') + + const [reactivateTarget, setReactivateTarget] = useState(null) + const [approveTarget, setApproveTarget] = useState(null) + const [rejectTarget, setRejectTarget] = useState(null) + const [rejectReason, setRejectReason] = useState('') + + const debounceRef = useRef | null>(null) + function handleSearchChange(value: string) { + setSearchInput(value) + if (debounceRef.current) clearTimeout(debounceRef.current) + debounceRef.current = setTimeout(() => { setSearch(value); setPage(1) }, DEBOUNCE_MS) + } + useEffect(() => () => { if (debounceRef.current) clearTimeout(debounceRef.current) }, []) + + // ── Query ─────────────────────────────────────────────────────────────────── + + const { data: response, isLoading, error } = useQuery({ + queryKey: ['admin', 'users', { page, search, status: statusFilter, role: roleFilter }], + queryFn: () => api.listUsers({ + page, + page_size: 20, + search: search.trim() || undefined, + status: statusFilter || undefined, + role: roleFilter || undefined, + }), + }) + + const invalidateUsers = () => qc.invalidateQueries({ queryKey: ['admin', 'users'] }) + + // ── Mutations ─────────────────────────────────────────────────────────────── + + const suspendMutation = useMutation({ + mutationFn: ({ id, reason }: { id: string; reason: string }) => + api.suspendUser(id, { reason }), + onSuccess: () => { invalidateUsers(); closeSuspendModal() }, + }) + + const reactivateMutation = useMutation({ + mutationFn: (id: string) => api.reactivateUser(id), + onSuccess: () => { invalidateUsers(); closeReactivateModal() }, + }) + + const approveMutation = useMutation({ + mutationFn: (id: string) => api.approveOwner(id), + onSuccess: () => { invalidateUsers(); closeApproveModal() }, + }) + + const rejectMutation = useMutation({ + mutationFn: ({ id, reason }: { id: string; reason?: string }) => + api.rejectOwner(id, { reason }), + onSuccess: () => { invalidateUsers(); closeRejectModal() }, + }) + + // ── Modal helpers ─────────────────────────────────────────────────────────── + + function closeSuspendModal() { + setSuspendTarget(null) + setSuspendReason('') + suspendMutation.reset() + } + + function closeReactivateModal() { + setReactivateTarget(null) + reactivateMutation.reset() + } + + function closeApproveModal() { + setApproveTarget(null) + approveMutation.reset() + } + + function closeRejectModal() { + setRejectTarget(null) + setRejectReason('') + rejectMutation.reset() + } + + // ── Handlers ──────────────────────────────────────────────────────────────── + + function handleSuspend() { + if (!suspendTarget || !suspendReason.trim()) return + suspendMutation.mutate({ id: suspendTarget.id, reason: suspendReason.trim() }) + } + + function handleReactivate() { + if (!reactivateTarget) return + reactivateMutation.mutate(reactivateTarget.id) + } + + function handleApprove() { + if (!approveTarget) return + approveMutation.mutate(approveTarget.id) + } + + function handleReject() { + if (!rejectTarget) return + rejectMutation.mutate({ id: rejectTarget.id, reason: rejectReason.trim() || undefined }) + } + + const stats = response?.stats + const pageSize = response?.page_size ?? 20 + const hasFilters = !!(searchInput || statusFilter || roleFilter) + + return ( + + + {/* Metric strip */} +
+
+ } + accent="brand" + /> +
+
+ } + accent="emerald" + /> +
+
+ } + accent="amber" + /> +
+
+ } + accent="rose" + /> +
+
+ + {/* Table card */} +
+ + {/* Card header: title + filters */} +
+ + +
+ {/* Search */} +
+
+ + {/* Status filter */} +
+ +
+ + {/* Role filter */} +
+ +
+
+
+ + {/* Content states */} + {isLoading && ( +
+ +
+ )} + + {!isLoading && error && ( +
+ Retry} + /> +
+ )} + + {!isLoading && !error && response && response.items.length === 0 && ( +
+ } + title="No users found" + description={ + hasFilters + ? 'Try adjusting the search or filters.' + : 'No users have registered yet.' + } + /> +
+ )} + + {!isLoading && !error && response && response.items.length > 0 && ( + <> +
+ + + + + + + + + + + + {response.items.map((user) => ( + + + + + + + + ))} + +
UserRolesStatusJoinedActions
+
+ + {initials(user)} + +
+
{user.full_name ?? '—'}
+
{user.email ?? '—'}
+
+
+
+
+ {user.roles.map((r) => ( + + ))} +
+
+ + + {new Date(user.created_at).toLocaleDateString('en-IN', { + year: 'numeric', month: 'short', day: 'numeric', + })} + + {user.is_super_admin ? ( + + ) : user.status === 'pending' && user.roles.includes('venue_owner') ? ( +
+ + +
+ ) : user.status === 'active' ? ( + + ) : user.status === 'suspended' || user.status === 'rejected' ? ( + + ) : null} +
+
+ + {/* Pagination */} + {response.total_pages > 1 && ( +
+ + {((page - 1) * pageSize) + 1}–{Math.min(page * pageSize, response.total)} of {response.total} + +
+ + Page {page} of {response.total_pages} + +
+
+ )} + + )} +
+ + {/* Suspend Modal */} + +
+
+
+
+

Suspend account

+

+ + {suspendTarget?.full_name ?? suspendTarget?.email ?? 'This user'} + {' '} + will immediately lose platform access and cannot log in until reactivated. +

+ +
+ + { setSuspendReason(e.target.value); suspendMutation.reset() }} + autoFocus + /> + {suspendMutation.error && ( +

+ {suspendMutation.error instanceof Error ? suspendMutation.error.message : 'Failed to suspend user'} +

+ )} +
+ +
+ + +
+
+
+
+ + {/* Reactivate Modal */} + +
+
+
+
+

Reactivate account

+

+ + {reactivateTarget?.full_name ?? reactivateTarget?.email ?? 'This user'} + {' '} + will immediately regain full platform access. +

+ {reactivateMutation.error && ( +
+ {reactivateMutation.error instanceof Error ? reactivateMutation.error.message : 'Failed to reactivate user'} +
+ )} +
+ + +
+
+
+
+ + {/* Approve Modal */} + +
+
+
+
+

Approve venue owner

+

+ + {approveTarget?.full_name ?? approveTarget?.email ?? 'This user'} + {' '} + will be granted full venue owner access immediately. +

+ {approveMutation.error && ( +
+ {approveMutation.error instanceof Error ? approveMutation.error.message : 'Failed to approve owner'} +
+ )} +
+ + +
+
+
+
+ + {/* Reject Modal */} + +
+
+
+
+

Reject venue owner

+

+ + {rejectTarget?.full_name ?? rejectTarget?.email ?? 'This user'} + {' '} + will be notified their application was not approved. They can re-apply later. +

+
+ + { setRejectReason(e.target.value); rejectMutation.reset() }} + autoFocus + /> + {rejectMutation.error && ( +

+ {rejectMutation.error instanceof Error ? rejectMutation.error.message : 'Failed to reject owner'} +

+ )} +
+
+ + +
+
+
+
+ +
+ ) +} diff --git a/apps/admin-panel/src/pages/VenueApprovals.tsx b/apps/admin-panel/src/pages/VenueApprovals.tsx new file mode 100644 index 000000000..d4b9a72f4 --- /dev/null +++ b/apps/admin-panel/src/pages/VenueApprovals.tsx @@ -0,0 +1,710 @@ +import { useState, useEffect, useRef } from 'react' +import { createPortal } from 'react-dom' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { + Building2, MapPin, Clock, Users, IndianRupee, + CheckCircle2, XCircle, ShieldOff, ShieldCheck, + ImageOff, Search, +} from 'lucide-react' +import { createClient, adminVenueEndpoints } from '@venue404/api-client' +import type { AdminVenueItem, AdminVenueStats } from '@venue404/api-client' +import { AdminLayout } from '../components/AdminLayout' +import { + MetricCard, SectionHeader, StatusBadge, EmptyState, + LoadingScreen, ErrorState, Button, Modal, +} from '@venue404/ui' + +const api = adminVenueEndpoints(createClient()) + +type TabStatus = AdminVenueItem['status'] | '' + +const TABS: { label: string; value: TabStatus; statsKey?: keyof AdminVenueStats }[] = [ + { label: 'All', value: '' }, + { label: 'Pending Approval', value: 'pending_approval', statsKey: 'pending_approval' }, + { label: 'Approved', value: 'approved', statsKey: 'approved' }, + { label: 'Rejected', value: 'rejected', statsKey: 'rejected' }, + { label: 'Suspended', value: 'suspended', statsKey: 'suspended' }, +] + +function statusVariant(s: AdminVenueItem['status']): 'success' | 'danger' | 'pending' | 'warning' | 'neutral' { + if (s === 'approved') return 'success' + if (s === 'suspended') return 'danger' + if (s === 'pending_approval') return 'pending' + if (s === 'rejected') return 'warning' + return 'neutral' +} + +function statusLabel(s: AdminVenueItem['status']): string { + if (s === 'pending_approval') return 'Pending Approval' + return s.charAt(0).toUpperCase() + s.slice(1) +} + + +const PAGE_SIZE = 10 + +export default function VenueApprovals() { + const qc = useQueryClient() + + const [activeTab, setActiveTab] = useState('pending_approval') + const [page, setPage] = useState(1) + const [searchInput, setSearchInput] = useState('') + const [search, setSearch] = useState('') + + // Modal targets — no loading/error state needed, mutations own that + const [approveTarget, setApproveTarget] = useState(null) + const [rejectTarget, setRejectTarget] = useState(null) + const [rejectReason, setRejectReason] = useState('') + const [suspendTarget, setSuspendTarget] = useState(null) + const [suspendReason, setSuspendReason] = useState('') + const [reactivateTarget, setReactivateTarget] = useState(null) + + const debounceRef = useRef | null>(null) + function handleSearchChange(value: string) { + setSearchInput(value) + if (debounceRef.current) clearTimeout(debounceRef.current) + debounceRef.current = setTimeout(() => { setSearch(value); setPage(1) }, 350) + } + useEffect(() => () => { if (debounceRef.current) clearTimeout(debounceRef.current) }, []) + + // ── Query ─────────────────────────────────────────────────────────────────── + + const { data, isLoading, error } = useQuery({ + queryKey: ['admin', 'venues', { page, status: activeTab, search }], + queryFn: () => api.listVenues({ + page, + page_size: PAGE_SIZE, + status: activeTab || undefined, + search: search.trim() || undefined, + }), + }) + + const items = data?.items ?? [] + const total = data?.total ?? 0 + const totalPages = data?.total_pages ?? 1 + const stats = data?.stats ?? null + + const invalidateVenues = () => qc.invalidateQueries({ queryKey: ['admin', 'venues'] }) + + // ── Mutations ─────────────────────────────────────────────────────────────── + + const approveMutation = useMutation({ + mutationFn: (id: string) => api.approveVenue(id), + onSuccess: () => { invalidateVenues(); closeApprove() }, + }) + + const rejectMutation = useMutation({ + mutationFn: ({ id, reason }: { id: string; reason?: string }) => + api.rejectVenue(id, { reason }), + onSuccess: () => { invalidateVenues(); closeReject() }, + }) + + const suspendMutation = useMutation({ + mutationFn: ({ id, reason }: { id: string; reason: string }) => + api.suspendVenue(id, { reason }), + onSuccess: () => { invalidateVenues(); closeSuspend() }, + }) + + const reactivateMutation = useMutation({ + mutationFn: (id: string) => api.reactivateVenue(id), + onSuccess: () => { invalidateVenues(); closeReactivate() }, + }) + + // ── Modal helpers ─────────────────────────────────────────────────────────── + + function closeApprove() { setApproveTarget(null); approveMutation.reset() } + function closeReject() { setRejectTarget(null); setRejectReason(''); rejectMutation.reset() } + function closeSuspend() { setSuspendTarget(null); setSuspendReason(''); suspendMutation.reset() } + function closeReactivate() { setReactivateTarget(null); reactivateMutation.reset() } + + // ── Handlers ──────────────────────────────────────────────────────────────── + + function handleApprove() { + if (!approveTarget) return + approveMutation.mutate(approveTarget.id) + } + + function handleReject() { + if (!rejectTarget) return + rejectMutation.mutate({ id: rejectTarget.id, reason: rejectReason.trim() || undefined }) + } + + function handleSuspend() { + if (!suspendTarget || !suspendReason.trim()) return + suspendMutation.mutate({ id: suspendTarget.id, reason: suspendReason.trim() }) + } + + function handleReactivate() { + if (!reactivateTarget) return + reactivateMutation.mutate(reactivateTarget.id) + } + + return ( + + + {/* Stats strip */} +
+ {[ + { label: 'Pending Approval', value: stats?.pending_approval, accent: 'amber' as const, icon: }, + { label: 'Approved', value: stats?.approved, accent: 'emerald' as const, icon: }, + { label: 'Suspended', value: stats?.suspended, accent: 'rose' as const, icon: }, + { label: 'Total Venues', value: stats?.total, accent: 'brand' as const, icon: }, + ].map((m, i) => ( +
+ +
+ ))} +
+ + {/* Main card */} +
+ + {/* Header + tabs */} +
+ +
+ {TABS.map((tab) => { + const isActive = activeTab === tab.value + const count = tab.statsKey ? stats?.[tab.statsKey] : undefined + return ( + + ) + })} +
+ + {/* Search */} +
+
+
+
+
+ + {/* Content */} + {isLoading && ( +
+ +
+ )} + + {!isLoading && error && ( +
+ Retry} + /> +
+ )} + + {!isLoading && !error && items.length === 0 && ( +
+ } + title={activeTab === 'pending_approval' ? 'No pending venues' : 'No venues found'} + description={ + activeTab === 'pending_approval' + ? 'All venue submissions have been reviewed.' + : 'No venues match this filter.' + } + /> +
+ )} + + {!isLoading && !error && items.length > 0 && ( +
+ {items.map((venue) => ( + setApproveTarget(venue)} + onReject={() => setRejectTarget(venue)} + onSuspend={() => setSuspendTarget(venue)} + onReactivate={() => setReactivateTarget(venue)} + /> + ))} +
+ )} + + {/* Pagination */} + {!isLoading && totalPages > 1 && ( +
+ + {((page - 1) * PAGE_SIZE) + 1}–{Math.min(page * PAGE_SIZE, total)} of {total} + +
+ + Page {page} of {totalPages} + +
+
+ )} +
+ + {/* ── Approve Modal ─────────────────────────────────────────────────────── */} + +
+
+
+ +
+

Approve venue

+

+ {approveTarget?.name} will be + listed publicly and visible to customers immediately. +

+ {approveMutation.error && ( +
+ {approveMutation.error instanceof Error ? approveMutation.error.message : 'Failed to approve venue'} +
+ )} +
+ + +
+
+
+
+ + {/* ── Reject Modal ──────────────────────────────────────────────────────── */} + +
+
+
+ +
+

Reject venue

+

+ {rejectTarget?.name} will be + marked rejected. The owner can resubmit after making changes. +

+
+ + { setRejectReason(e.target.value); rejectMutation.reset() }} + autoFocus + /> + {rejectMutation.error && ( +

+ {rejectMutation.error instanceof Error ? rejectMutation.error.message : 'Failed to reject venue'} +

+ )} +
+
+ + +
+
+
+
+ + {/* ── Suspend Modal ─────────────────────────────────────────────────────── */} + +
+
+
+ +
+

Suspend venue

+

+ {suspendTarget?.name} will be + hidden from customers immediately. This action cannot proceed if the venue has + active bookings. +

+

+ Venues with confirmed or accepted bookings cannot be suspended. +

+
+ + { setSuspendReason(e.target.value); suspendMutation.reset() }} + autoFocus + /> + {suspendMutation.error && ( +

+ {suspendMutation.error instanceof Error ? suspendMutation.error.message : 'Failed to suspend venue'} +

+ )} +
+
+ + +
+
+
+
+ + {/* ── Reactivate Modal ──────────────────────────────────────────────────── */} + +
+
+
+ +
+

Reactivate venue

+

+ {reactivateTarget?.name} will be + restored to approved status and visible to customers again. +

+ {reactivateMutation.error && ( +
+ {reactivateMutation.error instanceof Error ? reactivateMutation.error.message : 'Failed to reactivate venue'} +
+ )} +
+ + +
+
+
+
+ +
+ ) +} + +// ── Venue Card ─────────────────────────────────────────────────────────────── + +type VenueCardProps = { + venue: AdminVenueItem + onApprove: () => void + onReject: () => void + onSuspend: () => void + onReactivate: () => void +} + +function formatTime(t: string): string { + const [h, m] = t.split(':').map(Number) + const ampm = h >= 12 ? 'PM' : 'AM' + const hr = h % 12 || 12 + return `${hr}:${String(m).padStart(2, '0')} ${ampm}` +} + +function pricingLabel(v: AdminVenueItem): string { + const fmt = (paise: number) => `₹${(paise / 100).toLocaleString('en-IN')}` + if (v.pricing_mode === 'flat' && v.starting_price_paise != null) return `${fmt(v.starting_price_paise)} flat` + if (v.pricing_mode === 'hourly' && v.hourly_rate_paise != null) return `${fmt(v.hourly_rate_paise)}/hr` + if (v.pricing_mode === 'mixed') + return [ + v.starting_price_paise != null ? fmt(v.starting_price_paise) : null, + v.hourly_rate_paise != null ? `${fmt(v.hourly_rate_paise)}/hr` : null, + ].filter(Boolean).join(' + ') + return '—' +} + +function VenueDetailsModal({ venue, onClose }: { venue: AdminVenueItem; onClose: () => void }) { + return createPortal( +
+ {/* Backdrop */} +
+ + {/* Sheet */} +
+ + {/* Hero image */} +
+ {venue.cover_photo_url ? ( + {venue.name} + ) : ( +
+ +
+ )} + {/* Gradient overlay */} +
+ + {/* Close button */} + + + {/* Title over image */} +
+

{venue.name}

+

+ + {venue.address_line1}, {venue.city}, {venue.state} +

+
+
+ +
+ + {/* Owner */} +
+
+ {venue.owner.full_name?.[0]?.toUpperCase() ?? '?'} +
+
+

{venue.owner.full_name ?? '—'}

+

{venue.owner.email ?? '—'}

+
+ Owner +
+ + {/* Description */} + {venue.description && ( +

{venue.description}

+ )} + + {/* Stats row */} +
+ {[ + { label: 'Capacity', value: venue.min_capacity ? `${venue.min_capacity}–${venue.max_capacity}` : `Up to ${venue.max_capacity}`, icon: }, + { label: 'Hours', value: `${formatTime(venue.open_time)} – ${formatTime(venue.close_time)}`, icon: }, + { label: 'Pricing', value: pricingLabel(venue), icon: }, + ].map(({ label, value, icon }) => ( +
+
{icon}{label}
+

{value}

+
+ ))} +
+ + {/* Financial */} +
+
+

Advance Required

+

{venue.advance_pct}%

+
+
+

Platform Commission

+

{venue.platform_commission_pct}%

+
+
+ + {/* Amenities */} + {venue.amenities.length > 0 && ( +
+

Amenities

+
+ {venue.amenities.map((a) => ( + {a} + ))} +
+
+ )} + + {/* Footer */} +

+ Submitted {new Date(venue.created_at).toLocaleDateString('en-IN', { year: 'numeric', month: 'long', day: 'numeric' })} +

+ +
+
+
, + document.body + ) +} + +function VenueCard({ venue, onApprove, onReject, onSuspend, onReactivate }: VenueCardProps) { + const [imgError, setImgError] = useState(false) + const [showDetails, setShowDetails] = useState(false) + + useEffect(() => { setImgError(false) }, [venue.id]) + + return ( + <> + {showDetails && setShowDetails(false)} />} +
+ + {/* Cover photo */} +
+ {venue.cover_photo_url && !imgError ? ( + {venue.name} setImgError(true)} + /> + ) : ( +
+ +
+ )} +
+ +
+ {venue.status === 'approved' && !venue.is_active && ( +
+ +
+ )} +
+ + {/* Body */} +
+ + {/* Title + owner */} +
+
+

{venue.name}

+

+ + {venue.city}, {venue.state} +

+
+
+

{venue.owner.full_name ?? '—'}

+

{venue.owner.email ?? '—'}

+
+
+ + {/* Actions */} +
+ {venue.status === 'pending_approval' && ( + <> + + + + )} + + {venue.status === 'approved' && ( + + )} + + {(venue.status === 'suspended' || venue.status === 'rejected') && ( + + )} + + +
+
+
+ + ) +} + diff --git a/apps/admin-panel/src/pages/VenueOwners.tsx b/apps/admin-panel/src/pages/VenueOwners.tsx new file mode 100644 index 000000000..51f84a80a --- /dev/null +++ b/apps/admin-panel/src/pages/VenueOwners.tsx @@ -0,0 +1,624 @@ +import { useState, useEffect, useCallback, useRef } from 'react' +import { + ShieldOff, ShieldCheck, UserCheck, Search, + Clock, CheckCircle2, XCircle, Users as UsersIcon, + PhoneCall, BadgeCheck, +} from 'lucide-react' +import { createClient, adminUserEndpoints } from '@venue404/api-client' +import type { AdminUserSummary, AdminUserListResponse, AdminUserStatus, OwnerStats } from '@venue404/api-client' +import { AdminLayout } from '../components/AdminLayout' +import { + MetricCard, StatusBadge, SectionHeader, EmptyState, + LoadingScreen, ErrorState, Button, Modal, +} from '@venue404/ui' + +const api = adminUserEndpoints(createClient()) + +const DEBOUNCE_MS = 350 + +type TabStatus = 'pending' | 'active' | 'rejected' | 'suspended' | '' + +const TABS: { label: string; value: TabStatus }[] = [ + { label: 'All', value: '' }, + { label: 'Pending', value: 'pending' }, + { label: 'Active', value: 'active' }, + { label: 'Rejected', value: 'rejected' }, + { label: 'Suspended', value: 'suspended' }, +] + +function statusVariant(s: AdminUserStatus): 'success' | 'danger' | 'pending' | 'neutral' { + if (s === 'active') return 'success' + if (s === 'suspended') return 'danger' + if (s === 'pending') return 'pending' + return 'neutral' +} + +function initials(u: AdminUserSummary): string { + if (u.full_name) { + const parts = u.full_name.trim().split(/\s+/) + return parts.length >= 2 + ? (parts[0][0] + parts[parts.length - 1][0]).toUpperCase() + : parts[0].slice(0, 2).toUpperCase() + } + return u.email?.[0]?.toUpperCase() ?? '?' +} + +const AVATAR_COLORS = [ + 'bg-brand-light-strong text-brand', + 'bg-violet-100 text-violet-700', + 'bg-amber-100 text-amber-700', + 'bg-emerald-100 text-emerald-700', + 'bg-rose-100 text-rose-700', + 'bg-teal-100 text-teal-700', +] + +function avatarColor(id: string): string { + let hash = 0 + for (const ch of id) hash = (hash * 31 + ch.charCodeAt(0)) >>> 0 + return AVATAR_COLORS[hash % AVATAR_COLORS.length] +} + +export default function VenueOwners() { + const [stats, setStats] = useState(null) + const [response, setResponse] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + const [searchInput, setSearchInput] = useState('') + const [search, setSearch] = useState('') + const [activeTab, setActiveTab] = useState('pending') + const [page, setPage] = useState(1) + + // Approve + const [approveTarget, setApproveTarget] = useState(null) + const [approveLoading, setApproveLoading] = useState(false) + const [approveError, setApproveError] = useState(null) + + // Reject + const [rejectTarget, setRejectTarget] = useState(null) + const [rejectReason, setRejectReason] = useState('') + const [rejectLoading, setRejectLoading] = useState(false) + const [rejectError, setRejectError] = useState(null) + + // Suspend + const [suspendTarget, setSuspendTarget] = useState(null) + const [suspendReason, setSuspendReason] = useState('') + const [suspendLoading, setSuspendLoading] = useState(false) + const [suspendError, setSuspendError] = useState(null) + + // Reactivate + const [reactivateTarget, setReactivateTarget] = useState(null) + const [reactivateLoading, setReactivateLoading] = useState(false) + const [reactivateError, setReactivateError] = useState(null) + + // Debounce + const debounceRef = useRef | null>(null) + function handleSearchChange(value: string) { + setSearchInput(value) + if (debounceRef.current) clearTimeout(debounceRef.current) + debounceRef.current = setTimeout(() => { + setSearch(value) + setPage(1) + }, DEBOUNCE_MS) + } + useEffect(() => () => { if (debounceRef.current) clearTimeout(debounceRef.current) }, []) + + // Load stats once on mount + useEffect(() => { + api.getOwnerStats().then(setStats).catch(() => {}) + }, []) + + const fetchOwners = useCallback(async () => { + setLoading(true) + setError(null) + try { + const data = await api.listUsers({ + page, + page_size: 20, + search: search.trim() || undefined, + status: activeTab || undefined, + role: 'venue_owner', + }) + setResponse(data) + } catch (e: unknown) { + setError(e instanceof Error ? e.message : 'Failed to load venue owners') + } finally { + setLoading(false) + } + }, [page, search, activeTab]) + + useEffect(() => { fetchOwners() }, [fetchOwners]) + + function refreshAll() { + fetchOwners() + api.getOwnerStats().then(setStats).catch(() => {}) + } + + // --- Modal helpers --- + function closeApprove() { setApproveTarget(null); setApproveError(null); setApproveLoading(false) } + function closeReject() { setRejectTarget(null); setRejectReason(''); setRejectError(null); setRejectLoading(false) } + function closeSuspend() { setSuspendTarget(null); setSuspendReason(''); setSuspendError(null); setSuspendLoading(false) } + function closeReactivate() { setReactivateTarget(null); setReactivateError(null); setReactivateLoading(false) } + + async function handleApprove() { + if (!approveTarget) return + setApproveLoading(true); setApproveError(null) + try { + await api.approveOwner(approveTarget.id) + closeApprove(); refreshAll() + } catch (e: unknown) { + setApproveError(e instanceof Error ? e.message : 'Failed to approve owner') + } finally { setApproveLoading(false) } + } + + async function handleReject() { + if (!rejectTarget) return + setRejectLoading(true); setRejectError(null) + try { + await api.rejectOwner(rejectTarget.id, { reason: rejectReason.trim() || undefined }) + closeReject(); refreshAll() + } catch (e: unknown) { + setRejectError(e instanceof Error ? e.message : 'Failed to reject application') + } finally { setRejectLoading(false) } + } + + async function handleSuspend() { + if (!suspendTarget) return + if (!suspendReason.trim()) { setSuspendError('A reason is required'); return } + setSuspendLoading(true); setSuspendError(null) + try { + await api.suspendUser(suspendTarget.id, { reason: suspendReason.trim() }) + closeSuspend(); refreshAll() + } catch (e: unknown) { + setSuspendError(e instanceof Error ? e.message : 'Failed to suspend account') + } finally { setSuspendLoading(false) } + } + + async function handleReactivate() { + if (!reactivateTarget) return + setReactivateLoading(true); setReactivateError(null) + try { + await api.reactivateUser(reactivateTarget.id) + closeReactivate(); refreshAll() + } catch (e: unknown) { + setReactivateError(e instanceof Error ? e.message : 'Failed to reactivate account') + } finally { setReactivateLoading(false) } + } + + const pageSize = response?.page_size ?? 20 + const hasFilters = !!(searchInput) + + return ( + + + {/* Metric strip */} +
+
+ } + accent="brand" + /> +
+
+ } + accent="amber" + /> +
+
+ } + accent="emerald" + /> +
+
+ } + accent="rose" + /> +
+
+ + {/* Table card */} +
+ + {/* Header */} +
+ + + {/* Status tabs */} +
+ {TABS.map((tab) => { + const isActive = activeTab === tab.value + const isPending = tab.value === 'pending' + return ( + + ) + })} +
+ + {/* Search row */} +
+
+
+
+
+ + {/* Content states */} + {loading && ( +
+ +
+ )} + + {!loading && error && ( +
+ Retry} + /> +
+ )} + + {!loading && !error && response && response.items.length === 0 && ( +
+ } + title={activeTab === 'pending' ? 'No pending applications' : 'No venue owners found'} + description={ + activeTab === 'pending' + ? 'All applications have been reviewed.' + : hasFilters + ? 'Try adjusting the search.' + : 'No venue owners have registered yet.' + } + /> +
+ )} + + {!loading && !error && response && response.items.length > 0 && ( + <> +
+ + + + + + + + + + + + {response.items.map((user) => ( + + + {/* Owner */} + + + {/* Phone */} + + + {/* Status */} + + + {/* Applied date */} + + + {/* Actions */} + + + ))} + +
OwnerPhoneStatusAppliedActions
+
+ + {initials(user)} + +
+
{user.full_name ?? '—'}
+
{user.email ?? '—'}
+
+
+
+ {user.phone ? ( + + + ) : ( + + )} + + + + {new Date(user.created_at).toLocaleDateString('en-IN', { + year: 'numeric', month: 'short', day: 'numeric', + })} + + {user.is_super_admin ? ( + + ) : user.status === 'pending' ? ( +
+ + +
+ ) : user.status === 'active' ? ( + + ) : user.status === 'suspended' || user.status === 'rejected' ? ( + + ) : null} +
+
+ + {/* Pagination */} + {response.total_pages > 1 && ( +
+ + {((page - 1) * pageSize) + 1}–{Math.min(page * pageSize, response.total)} of {response.total} + +
+ + Page {page} of {response.total_pages} + +
+
+ )} + + )} +
+ + {/* Approve Modal */} + +
+
+
+
+

Approve venue owner

+

+ + {approveTarget?.full_name ?? approveTarget?.email ?? 'This applicant'} + {' '} + will be granted full venue owner access and can start listing venues immediately. +

+ {approveError && ( +
+ {approveError} +
+ )} +
+ + +
+
+
+
+ + {/* Reject Modal */} + +
+
+
+
+

Reject application

+

+ + {rejectTarget?.full_name ?? rejectTarget?.email ?? 'This applicant'} + {' '} + will be notified their application was not approved. They can re-apply after reviewing the feedback. +

+
+ + { setRejectReason(e.target.value); setRejectError(null) }} + autoFocus + /> + {rejectError && ( +

{rejectError}

+ )} +
+
+ + +
+
+
+
+ + {/* Suspend Modal */} + +
+
+
+
+

Suspend account

+

+ + {suspendTarget?.full_name ?? suspendTarget?.email ?? 'This owner'} + {' '} + will immediately lose platform access. Their active venues will be hidden until the account is reactivated. +

+
+ + { setSuspendReason(e.target.value); setSuspendError(null) }} + autoFocus + /> + {suspendError && ( +

{suspendError}

+ )} +
+
+ + +
+
+
+
+ + {/* Reactivate Modal */} + +
+
+
+
+

Reactivate account

+

+ + {reactivateTarget?.full_name ?? reactivateTarget?.email ?? 'This owner'} + {' '} + will regain full venue owner access immediately. +

+ {reactivateError && ( +
+ {reactivateError} +
+ )} +
+ + +
+
+
+
+ +
+ ) +} diff --git a/apps/admin-panel/src/routes.tsx b/apps/admin-panel/src/routes.tsx new file mode 100644 index 000000000..bcb24a255 --- /dev/null +++ b/apps/admin-panel/src/routes.tsx @@ -0,0 +1,98 @@ +import { createBrowserRouter, Navigate } from 'react-router-dom' +import { ProtectedRoute } from './components/ProtectedRoute' +import Dashboard from './pages/Dashboard' +import Login from './pages/Login' +import LoginSuccess from './pages/LoginSuccess' +import Forbidden from './pages/Forbidden' +import NotFound from './pages/NotFound' +import ComingSoon from './pages/ComingSoon' +import Bookings from './pages/Bookings' +import Users from './pages/Users' +import VenueOwners from './pages/VenueOwners' +import VenueApprovals from './pages/VenueApprovals' +import Amenities from './pages/Amenities' +import Categories from './pages/Categories' +import AuditLog from './pages/AuditLog' +import DeepResearchInsights from './pages/DeepResearchInsights' + +export const router = createBrowserRouter([ + // Auth + { path: '/login', element: }, + { path: '/login/success', element: }, + { path: '/403', element: }, + + // Root → dashboard + { path: '/', element: }, + + // Protected routes + { + path: '/dashboard', + element: , + }, + { + path: '/venues/pending', + element: , + }, + { + path: '/users', + element: ( + + + + ), + }, + { + path: '/owners', + element: ( + + + + ), + }, + { + path: '/amenities', + element: ( + + + + ), + }, + { + path: '/categories', + element: ( + + + + ), + }, + { + path: '/bookings', + element: ( + + + + ), + }, + { + path: '/audit-log', + element: , + }, + { + path: '/deep-research-insights', + element: , + }, + { + path: '/settings', + element: ( + + + + ), + }, + + // Catch-all → 404 + { path: '*', element: }, +]) diff --git a/apps/admin-panel/src/styles/index.css b/apps/admin-panel/src/styles/index.css new file mode 100644 index 000000000..b80916285 --- /dev/null +++ b/apps/admin-panel/src/styles/index.css @@ -0,0 +1,3 @@ +@import "tailwindcss"; +@import "@venue404/ui/theme.css"; +@source "../../../../packages/ui/src"; diff --git a/apps/admin-panel/src/vite-env.d.ts b/apps/admin-panel/src/vite-env.d.ts new file mode 100644 index 000000000..1c5248e7c --- /dev/null +++ b/apps/admin-panel/src/vite-env.d.ts @@ -0,0 +1,24 @@ +// Ambient declarations for static asset imports (Vite resolves these at build). +// Note: we intentionally do NOT `/// ` here — +// it redeclares import.meta.env and conflicts with @venue404/api-client's own +// ImportMeta.env declaration (TS2717). +declare module '*.png' { + const src: string + export default src +} +declare module '*.jpg' { + const src: string + export default src +} +declare module '*.jpeg' { + const src: string + export default src +} +declare module '*.svg' { + const src: string + export default src +} +declare module '*.webp' { + const src: string + export default src +} diff --git a/apps/admin-panel/tsconfig.json b/apps/admin-panel/tsconfig.json new file mode 100644 index 000000000..351ec2e37 --- /dev/null +++ b/apps/admin-panel/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "jsx": "react-jsx", + "baseUrl": "." + }, + "include": ["src"] +} diff --git a/apps/admin-panel/vercel.json b/apps/admin-panel/vercel.json new file mode 100644 index 000000000..88dccfdcd --- /dev/null +++ b/apps/admin-panel/vercel.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "framework": "vite", + "buildCommand": "pnpm build", + "outputDirectory": "dist" +} diff --git a/apps/admin-panel/vite.config.ts b/apps/admin-panel/vite.config.ts new file mode 100644 index 000000000..82cbf6cae --- /dev/null +++ b/apps/admin-panel/vite.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' +import { brandingPlugin } from '../../packages/assets/vite-branding' + +export default defineConfig({ + plugins: [brandingPlugin(), tailwindcss(), react()], + server: { + port: 5399, + proxy: { + '/api': 'http://localhost:8000', + }, + }, +}) diff --git a/apps/api/.env.example b/apps/api/.env.example new file mode 100644 index 000000000..903339442 --- /dev/null +++ b/apps/api/.env.example @@ -0,0 +1,71 @@ +# Database (Supabase direct connection - use this for Alembic migrations) +# Find in: Supabase Dashboard -> Project Settings -> Database -> Connection string -> URI +DATABASE_URL=postgresql://postgres.qcctiopmoscndyyitzpg:[YOUR-PASSWORD]@aws-1-ap-southeast-1.pooler.supabase.com:5432/postgres + +# Supabase +SUPABASE_URL=https://your-project-ref.supabase.co +SUPABASE_JWT_SECRET=your-supabase-jwt-secret +SUPABASE_SERVICE_ROLE_KEY=your-supabase-service-role-key + +# Stripe +STRIPE_SECRET_KEY= +STRIPE_WEBHOOK_SECRET= +STRIPE_CURRENCY=inr + +# Email - Resend is primary, SMTP is the fallback transport +RESEND_API_KEY= +EMAIL_FROM=Venue404 +SMTP_HOST= +SMTP_PORT=587 +SMTP_USER= +SMTP_PASSWORD= + +# Deep links inside notification emails +FRONTEND_BASE_URL=http://localhost:5173 + +# Comma-separated list of allowed browser origins for CORS +CORS_ORIGINS=http://localhost:5397,http://localhost:5398,http://localhost:5399 + +# Booking economics (percent of venue price) +TOKEN_ADVANCE_PCT=20 +PLATFORM_FEE_PCT=5 + +# Background jobs - enable on exactly ONE process/instance +ENABLE_JOBS=false + +# Shared secret guarding the machine-to-machine job-runner endpoint. +# Empty disables the endpoint (returns 503). Set to a long random value in prod. +JOB_RUNNER_TOKEN= + +# Super Admin +SUPER_ADMIN_NAME= +SUPER_ADMIN_EMAIL= +SUPER_ADMIN_PASSWORD= + +# Cloudinary +CLOUDINARY_CLOUD_NAME= +CLOUDINARY_API_KEY= +CLOUDINARY_API_SECRET= + +# Upstash Redis - fast delivery channel for search index jobs. +# If unset, the worker falls back to polling search_index_jobs directly. +UPSTASH_REDIS_URL= +UPSTASH_REDIS_TOKEN= +UPSTASH_SEARCH_QUEUE_KEY=search_index_jobs + +# Jina AI - used to generate venue embeddings for semantic search +JINA_API_KEY= +JINA_EMBEDDING_MODEL=jina-embeddings-v3 +EMBEDDING_DIMENSIONS=1024 + +# Groq API Key for LLM/SLM calls +GROQ_API_KEY= +GROQ_MODEL=llama-3.1-8b-instant +GROQ_BASE_URL=https://api.groq.com/openai/v1 + +# Google Places API Key for external venue search +GOOGLE_PLACES_API_KEY= + +LOG_LEVEL=INFO + +search_min_vector_similarity= \ No newline at end of file diff --git a/apps/api/.env.test b/apps/api/.env.test new file mode 100644 index 000000000..e94d8d400 --- /dev/null +++ b/apps/api/.env.test @@ -0,0 +1,6 @@ +DATABASE_URL=postgresql://user:pass@localhost:5432/venue404_test +SUPABASE_URL=https://example.supabase.co +SUPABASE_JWT_SECRET=test-secret +SUPABASE_SERVICE_ROLE_KEY=test-service-role +STRIPE_SECRET_KEY=sk_test_dummy +STRIPE_WEBHOOK_SECRET=whsec_dummy diff --git a/apps/api/.python-version b/apps/api/.python-version new file mode 100644 index 000000000..e4fba2183 --- /dev/null +++ b/apps/api/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile new file mode 100644 index 000000000..547373ae5 --- /dev/null +++ b/apps/api/Dockerfile @@ -0,0 +1,11 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY pyproject.toml . +RUN pip install --no-cache-dir uv && uv pip install --system -e . + +COPY . . + +# Shell form so $PORT (injected by Render) expands; defaults to 8000 locally. +CMD ["sh", "-c", "uvicorn app.main:app --reload --host 0.0.0.0 --port ${PORT:-8000}"] diff --git a/apps/api/alembic.ini b/apps/api/alembic.ini new file mode 100644 index 000000000..581cac9a7 --- /dev/null +++ b/apps/api/alembic.ini @@ -0,0 +1,38 @@ +[alembic] +script_location = alembic +prepend_sys_path = . +sqlalchemy.url = placeholder_overridden_by_env_py + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/apps/api/alembic/env.py b/apps/api/alembic/env.py new file mode 100644 index 000000000..d105703c6 --- /dev/null +++ b/apps/api/alembic/env.py @@ -0,0 +1,59 @@ +from logging.config import fileConfig +from sqlalchemy import engine_from_config, pool +from alembic import context +from app.core.config import settings +from app.core.database import Base + +# import all models so Base.metadata is populated for autogenerate +import app.modules.profile.models # noqa: F401 +import app.modules.admin.models # noqa: F401 +import app.modules.venue.models # noqa: F401 +import app.modules.booking.models # noqa: F401 +import app.modules.payment.models # noqa: F401 +import app.modules.notification.models # noqa: F401 +import app.modules.search.models # noqa: F401 +import app.modules.deep_research.models # noqa: F401 + +config = context.config + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + + +def get_url() -> str: + url = settings.database_url + # Alembic needs a sync driver — strip +asyncpg if present + return url.replace("postgresql+asyncpg://", "postgresql://") + + +def run_migrations_offline() -> None: + context.configure( + url=get_url(), + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + configuration = config.get_section(config.config_ini_section, {}) + configuration["sqlalchemy.url"] = get_url() + connectable = engine_from_config( + configuration, + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/apps/api/alembic/script.py.mako b/apps/api/alembic/script.py.mako new file mode 100644 index 000000000..4940cfda6 --- /dev/null +++ b/apps/api/alembic/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/apps/api/alembic/versions/.gitkeep b/apps/api/alembic/versions/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/apps/api/alembic/versions/0001_auth_schema.py b/apps/api/alembic/versions/0001_auth_schema.py new file mode 100644 index 000000000..a75ad1419 --- /dev/null +++ b/apps/api/alembic/versions/0001_auth_schema.py @@ -0,0 +1,123 @@ +"""auth schema - profiles, user_roles, admin_actions + +Revision ID: 0001 +Revises: +Create Date: 2026-06-02 + +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import UUID, ENUM as PgEnum + +revision: str = "0001" +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # create enums via raw SQL — DO block silently skips if already exists + op.execute(""" + DO $$ BEGIN + CREATE TYPE profile_status AS ENUM ('active', 'suspended'); + EXCEPTION WHEN duplicate_object THEN NULL; + END $$; + """) + op.execute(""" + DO $$ BEGIN + CREATE TYPE user_role AS ENUM ('customer', 'venue_owner', 'super_admin'); + EXCEPTION WHEN duplicate_object THEN NULL; + END $$; + """) + + # profiles — id mirrors auth.users.id (Supabase manages auth.users) + op.create_table( + "profiles", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column("full_name", sa.String, nullable=True), + sa.Column("phone", sa.String, nullable=True), + sa.Column("avatar_url", sa.String, nullable=True), + sa.Column( + "status", + PgEnum("active", "suspended", name="profile_status", create_type=False), + nullable=False, + server_default="active", + ), + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + sa.ForeignKeyConstraint( + ["id"], + ["auth.users.id"], + name="fk_profiles_auth_users", + ondelete="CASCADE", + ), + ) + + # user_roles + op.create_table( + "user_roles", + sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")), + sa.Column("user_id", UUID(as_uuid=True), nullable=False), + sa.Column( + "role", + PgEnum("customer", "venue_owner", "super_admin", name="user_role", create_type=False), + nullable=False, + ), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + sa.ForeignKeyConstraint( + ["user_id"], + ["profiles.id"], + name="fk_user_roles_profile", + ondelete="CASCADE", + ), + sa.UniqueConstraint("user_id", "role", name="uq_user_roles_user_role"), + ) + + # admin_actions — append-only audit log + op.create_table( + "admin_actions", + sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")), + sa.Column("admin_id", UUID(as_uuid=True), nullable=False), + sa.Column("action_type", sa.String, nullable=False), + sa.Column("target_type", sa.String, nullable=False), + sa.Column("target_id", UUID(as_uuid=True), nullable=False), + sa.Column("reason", sa.Text, nullable=True), + sa.Column("metadata", sa.JSON, nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + sa.ForeignKeyConstraint( + ["admin_id"], + ["profiles.id"], + name="fk_admin_actions_profile", + ondelete="RESTRICT", + ), + ) + + +def downgrade() -> None: + op.drop_table("admin_actions") + op.drop_table("user_roles") + op.drop_table("profiles") + sa.Enum(name="user_role").drop(op.get_bind(), checkfirst=True) + sa.Enum(name="profile_status").drop(op.get_bind(), checkfirst=True) diff --git a/apps/api/alembic/versions/0002_signup_trigger.py b/apps/api/alembic/versions/0002_signup_trigger.py new file mode 100644 index 000000000..62fe12c63 --- /dev/null +++ b/apps/api/alembic/versions/0002_signup_trigger.py @@ -0,0 +1,59 @@ +"""signup trigger - auto-create profile and customer role on auth.users insert + +Revision ID: 0002 +Revises: 0001 +Create Date: 2026-06-02 + +""" +from typing import Sequence, Union +from alembic import op + +revision: str = "0002" +down_revision: Union[str, None] = "0001" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute(""" + CREATE OR REPLACE FUNCTION public.handle_new_user() + RETURNS trigger + LANGUAGE plpgsql + SECURITY DEFINER SET search_path = public + AS $$ + BEGIN + INSERT INTO public.profiles (id, full_name, phone, status, created_at, updated_at) + VALUES ( + NEW.id, + NEW.raw_user_meta_data ->> 'full_name', + NEW.raw_user_meta_data ->> 'phone', + 'active', + now(), + now() + ) + ON CONFLICT (id) DO NOTHING; + + INSERT INTO public.user_roles (id, user_id, role, created_at) + VALUES ( + gen_random_uuid(), + NEW.id, + 'customer', + now() + ) + ON CONFLICT (user_id, role) DO NOTHING; + + RETURN NEW; + END; + $$; + """) + + op.execute(""" + CREATE OR REPLACE TRIGGER on_auth_user_created + AFTER INSERT ON auth.users + FOR EACH ROW EXECUTE FUNCTION public.handle_new_user(); + """) + + +def downgrade() -> None: + op.execute("DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users;") + op.execute("DROP FUNCTION IF EXISTS public.handle_new_user();") diff --git a/apps/api/alembic/versions/0003_profile_email.py b/apps/api/alembic/versions/0003_profile_email.py new file mode 100644 index 000000000..5a90bd736 --- /dev/null +++ b/apps/api/alembic/versions/0003_profile_email.py @@ -0,0 +1,25 @@ +"""profiles: add email column + +Revision ID: 0003 +Revises: 0002 +Create Date: 2026-06-04 + +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa + +revision: str = "0003" +down_revision: Union[str, None] = "0002" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("profiles", sa.Column("email", sa.String, nullable=True)) + op.create_index("ix_profiles_email", "profiles", ["email"], unique=False) + + +def downgrade() -> None: + op.drop_index("ix_profiles_email", table_name="profiles") + op.drop_column("profiles", "email") diff --git a/apps/api/alembic/versions/0004_venue_schema.py b/apps/api/alembic/versions/0004_venue_schema.py new file mode 100644 index 000000000..fa219f92e --- /dev/null +++ b/apps/api/alembic/versions/0004_venue_schema.py @@ -0,0 +1,216 @@ +"""feat(venue): add venue domain models + +Revision ID: 9170dab48e33 +Revises: 0003 +Create Date: 2026-06-05 15:14:00.136309 + +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision: str = '9170dab48e33' +down_revision: Union[str, None] = '0003' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('amenities', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('name', sa.Text(), nullable=False), + sa.Column('icon', sa.Text(), nullable=True), + sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name') + ) + op.create_table('venues', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('owner_id', sa.UUID(), nullable=False), + sa.Column('name', sa.Text(), nullable=False), + sa.Column('slug', sa.Text(), nullable=True), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('venue_type', sa.Text(), nullable=False), + sa.Column('address_line1', sa.Text(), nullable=False), + sa.Column('address_line2', sa.Text(), nullable=True), + sa.Column('city', sa.Text(), nullable=False), + sa.Column('state', sa.Text(), nullable=False), + sa.Column('country', sa.Text(), server_default=sa.text("'India'"), nullable=False), + sa.Column('postal_code', sa.Text(), nullable=True), + sa.Column('latitude', sa.Numeric(precision=10, scale=7), nullable=True), + sa.Column('longitude', sa.Numeric(precision=10, scale=7), nullable=True), + sa.Column('timezone', sa.Text(), server_default=sa.text("'Asia/Kolkata'"), nullable=False), + sa.Column('min_capacity', sa.Integer(), nullable=True), + sa.Column('max_capacity', sa.Integer(), nullable=False), + sa.Column('open_time', sa.Time(), nullable=False), + sa.Column('close_time', sa.Time(), nullable=False), + sa.Column('spans_next_day', sa.Boolean(), server_default='false', nullable=False), + sa.Column('allowed_booking_types', postgresql.ARRAY(sa.Text()), server_default=sa.text("ARRAY['full_day','time_slot']"), nullable=False), + sa.Column('min_booking_duration_minutes', sa.Integer(), server_default='60', nullable=False), + sa.Column('max_booking_duration_minutes', sa.Integer(), server_default='1440', nullable=False), + sa.Column('slot_interval_minutes', sa.Integer(), server_default='30', nullable=False), + sa.Column('pre_buffer_minutes', sa.Integer(), server_default='0', nullable=False), + sa.Column('post_buffer_minutes', sa.Integer(), server_default='0', nullable=False), + sa.Column('pricing_mode', sa.Text(), server_default=sa.text("'flat'"), nullable=False), + sa.Column('base_price_paise', sa.BigInteger(), nullable=True), + sa.Column('hourly_rate_paise', sa.BigInteger(), nullable=True), + sa.Column('platform_commission_pct', sa.Numeric(precision=5, scale=2), server_default='10.00', nullable=False), + sa.Column('advance_pct', sa.Numeric(precision=5, scale=2), server_default='30.00', nullable=False), + sa.Column('balance_due_days_before_event', sa.Integer(), server_default='7', nullable=False), + sa.Column('owner_action_window_hours', sa.Integer(), server_default='48', nullable=False), + sa.Column('overdue_advance_refund_pct', sa.Numeric(precision=5, scale=2), server_default='0.00', nullable=False), + sa.Column('status', sa.Enum('draft', 'pending_approval', 'approved', 'rejected', 'suspended', name='venue_status'), server_default=sa.text("'draft'"), nullable=False), + sa.Column('is_active', sa.Boolean(), server_default='true', nullable=False), + sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.CheckConstraint("pricing_mode IN ('flat', 'hourly')", name='ck_venues_pricing_mode'), + sa.CheckConstraint('advance_pct > 0 AND advance_pct <= 100', name='ck_venues_advance_pct'), + sa.CheckConstraint('balance_due_days_before_event > 0', name='ck_venues_balance_days'), + sa.CheckConstraint('base_price_paise >= 0', name='ck_venues_base_price'), + sa.CheckConstraint('hourly_rate_paise >= 0', name='ck_venues_hourly_rate'), + sa.CheckConstraint('max_booking_duration_minutes > 0', name='ck_venues_max_duration'), + sa.CheckConstraint('max_capacity > 0', name='ck_venues_max_capacity'), + sa.CheckConstraint('min_booking_duration_minutes <= max_booking_duration_minutes', name='ck_venues_duration_range'), + sa.CheckConstraint('min_booking_duration_minutes > 0', name='ck_venues_min_duration'), + sa.CheckConstraint('min_capacity > 0', name='ck_venues_min_capacity'), + sa.CheckConstraint('min_capacity IS NULL OR min_capacity <= max_capacity', name='ck_venues_capacity'), + sa.CheckConstraint('overdue_advance_refund_pct BETWEEN 0 AND 100', name='ck_venues_overdue_refund_pct'), + sa.CheckConstraint('owner_action_window_hours BETWEEN 24 AND 72', name='ck_venues_action_window'), + sa.CheckConstraint('platform_commission_pct >= 0 AND platform_commission_pct <= 100', name='ck_venues_commission'), + sa.CheckConstraint('post_buffer_minutes >= 0', name='ck_venues_post_buffer'), + sa.CheckConstraint('pre_buffer_minutes >= 0', name='ck_venues_pre_buffer'), + sa.CheckConstraint('slot_interval_minutes > 0', name='ck_venues_slot_interval'), + sa.ForeignKeyConstraint(['owner_id'], ['profiles.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('slug') + ) + op.create_index('idx_venues_search', 'venues', ['city', 'venue_type', 'status', 'is_active'], unique=False, postgresql_where=sa.text('deleted_at IS NULL')) + op.create_table('venue_amenities', + sa.Column('venue_id', sa.UUID(), nullable=False), + sa.Column('amenity_id', sa.UUID(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['amenity_id'], ['amenities.id'], ), + sa.ForeignKeyConstraint(['venue_id'], ['venues.id'], ), + sa.PrimaryKeyConstraint('venue_id', 'amenity_id') + ) + op.create_table('venue_availability', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('venue_id', sa.UUID(), nullable=False), + sa.Column('day_of_week', sa.Integer(), nullable=False), + sa.Column('is_available', sa.Boolean(), server_default='true', nullable=False), + sa.Column('opens_at', sa.Time(), nullable=True), + sa.Column('closes_at', sa.Time(), nullable=True), + sa.Column('spans_next_day', sa.Boolean(), server_default='false', nullable=False), + sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.CheckConstraint('day_of_week BETWEEN 0 AND 6', name='ck_venue_availability_day'), + sa.CheckConstraint('is_available = false OR (opens_at IS NOT NULL AND closes_at IS NOT NULL)', name='ck_venue_availability_times'), + sa.ForeignKeyConstraint(['venue_id'], ['venues.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index('venue_availability_unique_day', 'venue_availability', ['venue_id', 'day_of_week'], unique=True, postgresql_where=sa.text('deleted_at IS NULL')) + op.create_table('venue_blocked_dates', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('venue_id', sa.UUID(), nullable=False), + sa.Column('starts_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('ends_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('reason', sa.Text(), nullable=True), + sa.Column('blocked_by', sa.UUID(), nullable=False), + sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.CheckConstraint('ends_at > starts_at', name='ck_venue_blocked_dates_order'), + sa.ForeignKeyConstraint(['blocked_by'], ['profiles.id'], ), + sa.ForeignKeyConstraint(['venue_id'], ['venues.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index('idx_venue_blocked_dates_venue', 'venue_blocked_dates', ['venue_id'], unique=False, postgresql_where=sa.text('deleted_at IS NULL')) + op.create_table('venue_cancellation_policies', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('venue_id', sa.UUID(), nullable=False), + sa.Column('tier_1_hours', sa.Integer(), nullable=True), + sa.Column('tier_1_refund_pct', sa.Numeric(precision=5, scale=2), nullable=True), + sa.Column('tier_2_hours', sa.Integer(), nullable=True), + sa.Column('tier_2_refund_pct', sa.Numeric(precision=5, scale=2), nullable=True), + sa.Column('tier_3_hours', sa.Integer(), nullable=True), + sa.Column('tier_3_refund_pct', sa.Numeric(precision=5, scale=2), nullable=True), + sa.Column('no_show_refund_pct', sa.Numeric(precision=5, scale=2), server_default='0', nullable=False), + sa.Column('platform_fee_refundable', sa.Boolean(), server_default='false', nullable=False), + sa.Column('notes', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.CheckConstraint('(tier_1_hours IS NULL OR tier_2_hours IS NULL OR tier_1_hours > tier_2_hours) AND (tier_2_hours IS NULL OR tier_3_hours IS NULL OR tier_2_hours > tier_3_hours)', name='tiers_descending'), + sa.CheckConstraint('(tier_1_hours IS NULL) = (tier_1_refund_pct IS NULL)', name='tier_1_paired'), + sa.CheckConstraint('(tier_2_hours IS NULL) = (tier_2_refund_pct IS NULL)', name='tier_2_paired'), + sa.CheckConstraint('(tier_3_hours IS NULL) = (tier_3_refund_pct IS NULL)', name='tier_3_paired'), + sa.CheckConstraint('no_show_refund_pct BETWEEN 0 AND 100', name='ck_vcp_no_show_pct'), + sa.CheckConstraint('tier_1_hours > 0', name='ck_vcp_tier_1_hours'), + sa.CheckConstraint('tier_1_refund_pct BETWEEN 0 AND 100', name='ck_vcp_tier_1_pct'), + sa.CheckConstraint('tier_2_hours > 0', name='ck_vcp_tier_2_hours'), + sa.CheckConstraint('tier_2_refund_pct BETWEEN 0 AND 100', name='ck_vcp_tier_2_pct'), + sa.CheckConstraint('tier_3_hours > 0', name='ck_vcp_tier_3_hours'), + sa.CheckConstraint('tier_3_refund_pct BETWEEN 0 AND 100', name='ck_vcp_tier_3_pct'), + sa.ForeignKeyConstraint(['venue_id'], ['venues.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('venue_id') + ) + op.create_table('venue_photos', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('venue_id', sa.UUID(), nullable=False), + sa.Column('image_url', sa.Text(), nullable=False), + sa.Column('sort_order', sa.Integer(), server_default='0', nullable=False), + sa.Column('is_cover', sa.Boolean(), server_default='false', nullable=False), + sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['venue_id'], ['venues.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index('venue_photos_one_cover', 'venue_photos', ['venue_id'], unique=True, postgresql_where=sa.text('is_cover = true AND deleted_at IS NULL')) + + + op.execute(""" + CREATE OR REPLACE FUNCTION set_updated_at() + RETURNS TRIGGER AS $$ + BEGIN NEW.updated_at = now(); RETURN NEW; END; + $$ LANGUAGE plpgsql; + """) + + op.execute(""" + CREATE TRIGGER trg_venues_updated_at + BEFORE UPDATE ON venues + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + """) + + op.execute(""" + CREATE TRIGGER trg_venue_availability_updated_at + BEFORE UPDATE ON venue_availability + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + """) + + op.execute(""" + CREATE TRIGGER trg_venue_cancellation_policies_updated_at + BEFORE UPDATE ON venue_cancellation_policies + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + """) + + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index('venue_photos_one_cover', table_name='venue_photos', postgresql_where=sa.text('is_cover = true AND deleted_at IS NULL')) + op.drop_table('venue_photos') + op.drop_table('venue_cancellation_policies') + op.drop_index('idx_venue_blocked_dates_venue', table_name='venue_blocked_dates', postgresql_where=sa.text('deleted_at IS NULL')) + op.drop_table('venue_blocked_dates') + op.drop_index('venue_availability_unique_day', table_name='venue_availability', postgresql_where=sa.text('deleted_at IS NULL')) + op.drop_table('venue_availability') + op.drop_table('venue_amenities') + op.drop_index('idx_venues_search', table_name='venues', postgresql_where=sa.text('deleted_at IS NULL')) + op.drop_table('venues') + op.drop_table('amenities') + # ### end Alembic commands ### diff --git a/apps/api/alembic/versions/0005_add_mixed_pricing_mode_to_venues.py b/apps/api/alembic/versions/0005_add_mixed_pricing_mode_to_venues.py new file mode 100644 index 000000000..c934a47e8 --- /dev/null +++ b/apps/api/alembic/versions/0005_add_mixed_pricing_mode_to_venues.py @@ -0,0 +1,28 @@ +"""add mixed pricing mode to venues + +Revision ID: 1ce66f7c8bf4 +Revises: 9170dab48e33 +Create Date: 2026-06-06 08:33:44.531549 + +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision: str = '1ce66f7c8bf4' +down_revision: Union[str, None] = '9170dab48e33' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + + op.drop_constraint('ck_venues_pricing_mode', 'venues', type_='check') + op.create_check_constraint('ck_venues_pricing_mode', 'venues', "pricing_mode IN ('flat', 'hourly', 'mixed')") + + +def downgrade() -> None: + + op.drop_constraint('ck_venues_pricing_mode', 'venues', type_='check') + op.create_check_constraint('ck_venues_pricing_mode', 'venues', "pricing_mode IN ('flat', 'hourly')") diff --git a/apps/api/alembic/versions/0005_booking_schema.py b/apps/api/alembic/versions/0005_booking_schema.py new file mode 100644 index 000000000..c6098aaff --- /dev/null +++ b/apps/api/alembic/versions/0005_booking_schema.py @@ -0,0 +1,40 @@ +"""booking_schema + +Revision ID: 0c70837a672c +Revises: 9170dab48e33 +Create Date: 2026-06-06 12:09:27.541886 + +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa + + +revision: str = '0c70837a672c' +down_revision: Union[str, None] = '9170dab48e33' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('bookings', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('venue_id', sa.UUID(), nullable=False), + sa.Column('user_id', sa.UUID(), nullable=False), + sa.Column('booking_type', sa.Enum('full_day', 'time_slot', name='booking_type'), nullable=False), + sa.Column('status', sa.Enum('requested', 'owner_accepted', 'confirmed', 'completed', 'hold_expired', 'request_expired', 'conflict_cancelled', 'user_cancelled', 'admin_cancelled', 'owner_rejected', 'balance_overdue_cancelled', name='booking_status'), nullable=False), + sa.Column('payment_status', sa.Enum('unpaid', 'advance_paid', 'fully_paid', 'refunded', 'partially_refunded', name='payment_status'), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['profiles.id'], ), + sa.ForeignKeyConstraint(['venue_id'], ['venues.id'], ), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('bookings') + # ### end Alembic commands ### diff --git a/apps/api/alembic/versions/0005_owner_status.py b/apps/api/alembic/versions/0005_owner_status.py new file mode 100644 index 000000000..d51e4ca18 --- /dev/null +++ b/apps/api/alembic/versions/0005_owner_status.py @@ -0,0 +1,92 @@ +"""owner status - add pending/rejected to profile_status enum, update signup trigger for is_owner + +Revision ID: 0005 +Revises: 0004 +Create Date: 2026-06-06 + +""" +from typing import Sequence, Union +from alembic import op + +revision: str = "0005" +down_revision: Union[str, None] = "9170dab48e33" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ALTER TYPE ADD VALUE cannot run inside a transaction in PostgreSQL + op.execute("COMMIT") + op.execute("ALTER TYPE profile_status ADD VALUE IF NOT EXISTS 'pending'") + op.execute("ALTER TYPE profile_status ADD VALUE IF NOT EXISTS 'rejected'") + + # Update signup trigger: if is_owner=true in metadata, assign venue_owner role + # and set status=pending; otherwise behave as before (customer + active) + op.execute(""" + CREATE OR REPLACE FUNCTION public.handle_new_user() + RETURNS trigger + LANGUAGE plpgsql + SECURITY DEFINER SET search_path = public + AS $$ + DECLARE + v_is_owner boolean; + BEGIN + v_is_owner := (NEW.raw_user_meta_data ->> 'is_owner')::boolean; + + INSERT INTO public.profiles (id, full_name, phone, status, created_at, updated_at) + VALUES ( + NEW.id, + NEW.raw_user_meta_data ->> 'full_name', + NEW.raw_user_meta_data ->> 'phone', + CASE WHEN v_is_owner THEN 'pending' ELSE 'active' END, + now(), + now() + ) + ON CONFLICT (id) DO NOTHING; + + INSERT INTO public.user_roles (id, user_id, role, created_at) + VALUES (gen_random_uuid(), NEW.id, 'customer', now()) + ON CONFLICT (user_id, role) DO NOTHING; + + IF v_is_owner THEN + INSERT INTO public.user_roles (id, user_id, role, created_at) + VALUES (gen_random_uuid(), NEW.id, 'venue_owner', now()) + ON CONFLICT (user_id, role) DO NOTHING; + END IF; + + RETURN NEW; + END; + $$; + """) + + +def downgrade() -> None: + # Restore original trigger (no is_owner handling) + op.execute(""" + CREATE OR REPLACE FUNCTION public.handle_new_user() + RETURNS trigger + LANGUAGE plpgsql + SECURITY DEFINER SET search_path = public + AS $$ + BEGIN + INSERT INTO public.profiles (id, full_name, phone, status, created_at, updated_at) + VALUES ( + NEW.id, + NEW.raw_user_meta_data ->> 'full_name', + NEW.raw_user_meta_data ->> 'phone', + 'active', + now(), + now() + ) + ON CONFLICT (id) DO NOTHING; + + INSERT INTO public.user_roles (id, user_id, role, created_at) + VALUES (gen_random_uuid(), NEW.id, 'customer', now()) + ON CONFLICT (user_id, role) DO NOTHING; + + RETURN NEW; + END; + $$; + """) + # Note: removing enum values in PostgreSQL requires a full type rebuild; + # downgrade leaves 'pending' and 'rejected' in the enum to avoid data loss. diff --git a/apps/api/alembic/versions/0006_expand_booking_schema.py b/apps/api/alembic/versions/0006_expand_booking_schema.py new file mode 100644 index 000000000..1c11a5dc9 --- /dev/null +++ b/apps/api/alembic/versions/0006_expand_booking_schema.py @@ -0,0 +1,338 @@ +"""expand_booking_schema + +Revision ID: 1679aa81b5b3 +Revises: 0c70837a672c +Create Date: 2026-06-06 13:10:37.968362 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision: str = "1679aa81b5b3" +down_revision: Union[str, None] = "0c70837a672c" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + booking_status_enum = postgresql.ENUM( + "requested", + "owner_accepted", + "confirmed", + "completed", + "hold_expired", + "request_expired", + "conflict_cancelled", + "user_cancelled", + "admin_cancelled", + "owner_rejected", + "balance_overdue_cancelled", + name="booking_status", + create_type=False, + ) + op.create_table( + "booking_slots", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("booking_id", sa.UUID(), nullable=False), + sa.Column("venue_id", sa.UUID(), nullable=False), + sa.Column("starts_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("ends_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("effective_starts_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("effective_ends_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("is_blocking", sa.Boolean(), nullable=False), + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.CheckConstraint( + "effective_ends_at >= ends_at", + name="ck_booking_slots_effective_end", + ), + sa.CheckConstraint( + "effective_starts_at <= starts_at", + name="ck_booking_slots_effective_start", + ), + sa.CheckConstraint( + "ends_at > starts_at", + name="ck_booking_slots_time_order", + ), + sa.ForeignKeyConstraint(["booking_id"], ["bookings.id"]), + sa.ForeignKeyConstraint(["venue_id"], ["venues.id"]), + sa.PrimaryKeyConstraint("id"), + ) + + op.create_table( + "booking_status_history", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("booking_id", sa.UUID(), nullable=False), + sa.Column( + "old_status", + booking_status_enum, + nullable=True, + ), + sa.Column( + "new_status", + booking_status_enum, + nullable=False, + ), + sa.Column("changed_by", sa.UUID(), nullable=True), + sa.Column("reason", sa.Text(), nullable=True), + sa.Column( + "metadata", + postgresql.JSONB(astext_type=sa.Text()), + nullable=True, + ), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.ForeignKeyConstraint(["booking_id"], ["bookings.id"]), + sa.ForeignKeyConstraint(["changed_by"], ["profiles.id"]), + sa.PrimaryKeyConstraint("id"), + ) + + op.add_column( + "bookings", + sa.Column("event_type", sa.Text(), nullable=True), + ) + + op.add_column( + "bookings", + sa.Column( + "guest_count", + sa.Integer(), + nullable=False, + server_default="1", + ), + ) + + op.add_column("bookings", sa.Column("user_notes", sa.Text(), nullable=True)) + op.add_column("bookings", sa.Column("owner_notes", sa.Text(), nullable=True)) + + op.add_column( + "bookings", + sa.Column( + "requested_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + ) + + op.add_column( + "bookings", + sa.Column("owner_responded_at", sa.DateTime(timezone=True), nullable=True), + ) + + op.add_column( + "bookings", + sa.Column("hold_expires_at", sa.DateTime(timezone=True), nullable=True), + ) + + op.add_column( + "bookings", + sa.Column("confirmed_at", sa.DateTime(timezone=True), nullable=True), + ) + + op.add_column( + "bookings", + sa.Column("balance_due_date", sa.Date(), nullable=True), + ) + + op.add_column( + "bookings", + sa.Column("balance_overdue_at", sa.DateTime(timezone=True), nullable=True), + ) + + op.add_column( + "bookings", + sa.Column("owner_action_deadline", sa.DateTime(timezone=True), nullable=True), + ) + + op.add_column( + "bookings", + sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True), + ) + + op.add_column( + "bookings", + sa.Column("cancelled_at", sa.DateTime(timezone=True), nullable=True), + ) + + op.add_column( + "bookings", + sa.Column("expired_at", sa.DateTime(timezone=True), nullable=True), + ) + + op.add_column( + "bookings", + sa.Column( + "deadline_extension_count", + sa.Integer(), + nullable=False, + server_default="0", + ), + ) + + op.add_column( + "bookings", + sa.Column("pricing_mode", sa.String(), nullable=True), + ) + + op.add_column( + "bookings", + sa.Column( + "quoted_price_paise", + sa.BigInteger(), + nullable=False, + server_default="0", + ), + ) + + op.add_column( + "bookings", + sa.Column( + "platform_commission_pct", + sa.Numeric(5, 2), + nullable=False, + server_default="0", + ), + ) + + op.add_column( + "bookings", + sa.Column( + "platform_fee_paise", + sa.BigInteger(), + nullable=False, + server_default="0", + ), + ) + + op.add_column( + "bookings", + sa.Column( + "owner_payout_paise", + sa.BigInteger(), + nullable=False, + server_default="0", + ), + ) + + op.add_column( + "bookings", + sa.Column( + "advance_pct", + sa.Numeric(5, 2), + nullable=False, + server_default="0", + ), + ) + + op.add_column( + "bookings", + sa.Column( + "advance_due_paise", + sa.BigInteger(), + nullable=False, + server_default="0", + ), + ) + + op.add_column( + "bookings", + sa.Column( + "balance_due_paise", + sa.BigInteger(), + nullable=False, + server_default="0", + ), + ) + + op.add_column( + "bookings", + sa.Column( + "overdue_advance_refund_pct", + sa.Numeric(5, 2), + nullable=False, + server_default="0", + ), + ) + + op.add_column( + "bookings", + sa.Column( + "amount_paid_paise", + sa.BigInteger(), + nullable=False, + server_default="0", + ), + ) + + op.add_column( + "bookings", + sa.Column( + "refund_amount_paise", + sa.BigInteger(), + nullable=False, + server_default="0", + ), + ) + + op.add_column( + "bookings", + sa.Column("stripe_advance_payment_intent_id", sa.Text(), nullable=True), + ) + + op.add_column( + "bookings", + sa.Column("stripe_balance_payment_intent_id", sa.Text(), nullable=True), + ) + + op.add_column( + "bookings", + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("bookings", "deleted_at") + op.drop_column("bookings", "stripe_balance_payment_intent_id") + op.drop_column("bookings", "stripe_advance_payment_intent_id") + op.drop_column("bookings", "refund_amount_paise") + op.drop_column("bookings", "amount_paid_paise") + op.drop_column("bookings", "overdue_advance_refund_pct") + op.drop_column("bookings", "balance_due_paise") + op.drop_column("bookings", "advance_due_paise") + op.drop_column("bookings", "advance_pct") + op.drop_column("bookings", "owner_payout_paise") + op.drop_column("bookings", "platform_fee_paise") + op.drop_column("bookings", "platform_commission_pct") + op.drop_column("bookings", "quoted_price_paise") + op.drop_column("bookings", "pricing_mode") + op.drop_column("bookings", "deadline_extension_count") + op.drop_column("bookings", "expired_at") + op.drop_column("bookings", "cancelled_at") + op.drop_column("bookings", "completed_at") + op.drop_column("bookings", "owner_action_deadline") + op.drop_column("bookings", "balance_overdue_at") + op.drop_column("bookings", "balance_due_date") + op.drop_column("bookings", "confirmed_at") + op.drop_column("bookings", "hold_expires_at") + op.drop_column("bookings", "owner_responded_at") + op.drop_column("bookings", "requested_at") + op.drop_column("bookings", "owner_notes") + op.drop_column("bookings", "user_notes") + op.drop_column("bookings", "guest_count") + op.drop_column("bookings", "event_type") + + op.drop_table("booking_status_history") + op.drop_table("booking_slots") diff --git a/apps/api/alembic/versions/0006_fix_owner_trigger.py b/apps/api/alembic/versions/0006_fix_owner_trigger.py new file mode 100644 index 000000000..18b92b002 --- /dev/null +++ b/apps/api/alembic/versions/0006_fix_owner_trigger.py @@ -0,0 +1,112 @@ +"""fix owner trigger - ensure enum values exist, revert trigger to simple form + +Revision ID: 0006 +Revises: 0005 +Create Date: 2026-06-06 + +ALTER TYPE ADD VALUE cannot run inside a transaction. We commit the current +transaction, do the DDL, then start a new one. enum values from 0005 may +already exist, so we check pg_enum first and only add what's missing. +Trigger is reverted to simple form — owner role/status is set by the +POST /api/auth/register-owner backend endpoint instead. +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa + +revision: str = "0006" +down_revision: Union[str, None] = "0005" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + bind = op.get_bind() + + # Check which values already exist — 0005 may have partially added them. + result = bind.execute(sa.text(""" + SELECT enumlabel FROM pg_enum e + JOIN pg_type t ON e.enumtypid = t.oid + WHERE t.typname = 'profile_status' + """)) + existing = {row[0] for row in result} + + needs_pending = "pending" not in existing + needs_rejected = "rejected" not in existing + + if needs_pending or needs_rejected: + # ALTER TYPE ADD VALUE must run outside a transaction. + # Commit the Alembic-managed transaction, do the DDL, restart it. + bind.execute(sa.text("COMMIT")) + if needs_pending: + bind.execute(sa.text("ALTER TYPE profile_status ADD VALUE 'pending'")) + if needs_rejected: + bind.execute(sa.text("ALTER TYPE profile_status ADD VALUE 'rejected'")) + bind.execute(sa.text("BEGIN")) + + # Revert trigger to simple form — owner logic moved to backend endpoint. + op.execute(""" + CREATE OR REPLACE FUNCTION public.handle_new_user() + RETURNS trigger + LANGUAGE plpgsql + SECURITY DEFINER SET search_path = public + AS $$ + BEGIN + INSERT INTO public.profiles (id, full_name, phone, status, created_at, updated_at) + VALUES ( + NEW.id, + NEW.raw_user_meta_data ->> 'full_name', + NEW.raw_user_meta_data ->> 'phone', + 'active', + now(), + now() + ) + ON CONFLICT (id) DO NOTHING; + + INSERT INTO public.user_roles (id, user_id, role, created_at) + VALUES (gen_random_uuid(), NEW.id, 'customer', now()) + ON CONFLICT (user_id, role) DO NOTHING; + + RETURN NEW; + END; + $$; + """) + + +def downgrade() -> None: + op.execute(""" + CREATE OR REPLACE FUNCTION public.handle_new_user() + RETURNS trigger + LANGUAGE plpgsql + SECURITY DEFINER SET search_path = public + AS $$ + DECLARE + v_is_owner boolean; + BEGIN + v_is_owner := (NEW.raw_user_meta_data ->> 'is_owner')::boolean; + + INSERT INTO public.profiles (id, full_name, phone, status, created_at, updated_at) + VALUES ( + NEW.id, + NEW.raw_user_meta_data ->> 'full_name', + NEW.raw_user_meta_data ->> 'phone', + CASE WHEN v_is_owner THEN 'pending' ELSE 'active' END, + now(), + now() + ) + ON CONFLICT (id) DO NOTHING; + + INSERT INTO public.user_roles (id, user_id, role, created_at) + VALUES (gen_random_uuid(), NEW.id, 'customer', now()) + ON CONFLICT (user_id, role) DO NOTHING; + + IF v_is_owner THEN + INSERT INTO public.user_roles (id, user_id, role, created_at) + VALUES (gen_random_uuid(), NEW.id, 'venue_owner', now()) + ON CONFLICT (user_id, role) DO NOTHING; + END IF; + + RETURN NEW; + END; + $$; + """) diff --git a/apps/api/alembic/versions/004b8919fa8e_remove_old_blocked_dates.py b/apps/api/alembic/versions/004b8919fa8e_remove_old_blocked_dates.py new file mode 100644 index 000000000..54e7441ff --- /dev/null +++ b/apps/api/alembic/versions/004b8919fa8e_remove_old_blocked_dates.py @@ -0,0 +1,32 @@ +"""remove_old_blocked_dates + +Revision ID: 004b8919fa8e +Revises: 1b6ee7242fff +Create Date: 2026-06-25 00:13:58.128310 + +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa + + +revision: str = '004b8919fa8e' +down_revision: Union[str, None] = '1b6ee7242fff' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.drop_table('blocked_dates') + + +def downgrade() -> None: + op.create_table( + 'blocked_dates', + sa.Column('id', sa.UUID(), primary_key=True), + sa.Column('venue_id', sa.UUID(), sa.ForeignKey('venues.id', ondelete='CASCADE'), nullable=False), + sa.Column('date', sa.Date(), nullable=False), + sa.Column('reason', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()) + ) diff --git a/apps/api/alembic/versions/014af2456033_merge_venue_pricing_and_owner_trigger_.py b/apps/api/alembic/versions/014af2456033_merge_venue_pricing_and_owner_trigger_.py new file mode 100644 index 000000000..02fc4595b --- /dev/null +++ b/apps/api/alembic/versions/014af2456033_merge_venue_pricing_and_owner_trigger_.py @@ -0,0 +1,24 @@ +"""merge venue pricing and owner trigger branches + +Revision ID: 014af2456033 +Revises: 0006, 1ce66f7c8bf4 +Create Date: 2026-06-07 16:05:52.804469 + +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa + + +revision: str = '014af2456033' +down_revision: Union[str, None] = ('0006', '1ce66f7c8bf4') +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass diff --git a/apps/api/alembic/versions/0b15a59e8154_add_guest_count_and_phone_to_lead_.py b/apps/api/alembic/versions/0b15a59e8154_add_guest_count_and_phone_to_lead_.py new file mode 100644 index 000000000..8721cf653 --- /dev/null +++ b/apps/api/alembic/versions/0b15a59e8154_add_guest_count_and_phone_to_lead_.py @@ -0,0 +1,30 @@ +"""Add guest_count and phone to lead_reservations + +Revision ID: 0b15a59e8154 +Revises: c00a79140491 +Create Date: 2026-07-06 13:35:36.380854 + +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision: str = '0b15a59e8154' +down_revision: Union[str, None] = 'c00a79140491' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('lead_reservations', sa.Column('guest_count', sa.Integer(), nullable=True)) + op.add_column('lead_reservations', sa.Column('phone', sa.Text(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('lead_reservations', 'phone') + op.drop_column('lead_reservations', 'guest_count') + # ### end Alembic commands ### diff --git a/apps/api/alembic/versions/108e5e92fd6e_notification_tables_added.py b/apps/api/alembic/versions/108e5e92fd6e_notification_tables_added.py new file mode 100644 index 000000000..b9472f3fa --- /dev/null +++ b/apps/api/alembic/versions/108e5e92fd6e_notification_tables_added.py @@ -0,0 +1,135 @@ +"""Notification tables added + +Revision ID: 108e5e92fd6e +Revises: bf6b28a6d1fd +Create Date: 2026-06-11 19:12:39.902906 + +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision: str = '108e5e92fd6e' +down_revision: Union[str, None] = 'bf6b28a6d1fd' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('stripe_events', + sa.Column('id', sa.String(), nullable=False), + sa.Column('type', sa.String(), nullable=False), + sa.Column('processed_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('processing_error', sa.Text(), nullable=True), + sa.Column('raw_payload', postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('blocked_dates', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('venue_id', sa.UUID(), nullable=False), + sa.Column('date', sa.Date(), nullable=False), + sa.Column('reason', sa.Text(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['venue_id'], ['venues.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('slots', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('venue_id', sa.UUID(), nullable=False), + sa.Column('date', sa.Date(), nullable=False), + sa.Column('is_available', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['venue_id'], ['venues.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('ledger_entries', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('booking_id', sa.UUID(), nullable=False), + sa.Column('venue_id', sa.UUID(), nullable=False), + sa.Column('owner_id', sa.UUID(), nullable=False), + sa.Column('user_id', sa.UUID(), nullable=False), + sa.Column('entry_type', sa.String(), nullable=False), + sa.Column('amount_paise', sa.BigInteger(), nullable=False), + sa.Column('direction', sa.String(), nullable=False), + sa.Column('stripe_pi_ref', sa.String(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['booking_id'], ['bookings.id'], ondelete='RESTRICT'), + sa.ForeignKeyConstraint(['owner_id'], ['profiles.id'], ondelete='RESTRICT'), + sa.ForeignKeyConstraint(['user_id'], ['profiles.id'], ondelete='RESTRICT'), + sa.ForeignKeyConstraint(['venue_id'], ['venues.id'], ondelete='RESTRICT'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('notifications', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('user_id', sa.UUID(), nullable=False), + sa.Column('booking_id', sa.UUID(), nullable=True), + sa.Column('type', sa.String(), nullable=False), + sa.Column('title', sa.String(), nullable=False), + sa.Column('body', sa.Text(), nullable=False), + sa.Column('read_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('sent_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['booking_id'], ['bookings.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['user_id'], ['profiles.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('payments', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('booking_id', sa.UUID(), nullable=False), + sa.Column('amount_paise', sa.BigInteger(), nullable=False), + sa.Column('currency', sa.String(), nullable=False), + sa.Column('status', sa.Enum('pending', 'succeeded', 'failed', 'refunded', name='payment_attempt_status'), nullable=False), + sa.Column('stripe_payment_intent_id', sa.String(), nullable=False), + sa.Column('stripe_client_secret', sa.String(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['booking_id'], ['bookings.id'], ondelete='RESTRICT'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('payout_requests', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('owner_id', sa.UUID(), nullable=False), + sa.Column('booking_id', sa.UUID(), nullable=False), + sa.Column('amount_paise', sa.BigInteger(), nullable=False), + sa.Column('status', sa.Enum('requested', 'processing', 'paid', 'failed', name='payout_status'), nullable=False), + sa.Column('stripe_transfer_id', sa.String(), nullable=True), + sa.Column('processed_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['booking_id'], ['bookings.id'], ondelete='RESTRICT'), + sa.ForeignKeyConstraint(['owner_id'], ['profiles.id'], ondelete='RESTRICT'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('refunds', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('payment_id', sa.UUID(), nullable=False), + sa.Column('booking_id', sa.UUID(), nullable=False), + sa.Column('amount_paise', sa.BigInteger(), nullable=False), + sa.Column('reason', sa.Text(), nullable=True), + sa.Column('status', sa.Enum('pending', 'succeeded', 'failed', name='refund_status'), nullable=False), + sa.Column('stripe_refund_id', sa.String(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['booking_id'], ['bookings.id'], ondelete='RESTRICT'), + sa.ForeignKeyConstraint(['payment_id'], ['payments.id'], ondelete='RESTRICT'), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('refunds') + op.drop_table('payout_requests') + op.drop_table('payments') + op.drop_table('notifications') + op.drop_table('ledger_entries') + op.drop_table('slots') + op.drop_table('blocked_dates') + op.drop_table('stripe_events') + # ### end Alembic commands ### diff --git a/apps/api/alembic/versions/134e235b06e3_add_payment_pending_booking_status.py b/apps/api/alembic/versions/134e235b06e3_add_payment_pending_booking_status.py new file mode 100644 index 000000000..c7cc3318f --- /dev/null +++ b/apps/api/alembic/versions/134e235b06e3_add_payment_pending_booking_status.py @@ -0,0 +1,27 @@ +"""add_payment_pending_booking_status + +Revision ID: 134e235b06e3 +Revises: ab7cfebcbee6 +Create Date: 2026-07-05 15:59:21.458993 + +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision: str = '134e235b06e3' +down_revision: Union[str, None] = 'ab7cfebcbee6' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade(): + op.execute(""" + ALTER TYPE booking_status + ADD VALUE IF NOT EXISTS 'payment_pending'; + """) + + +def downgrade(): + pass diff --git a/apps/api/alembic/versions/1b6ee7242fff_merge_heads.py b/apps/api/alembic/versions/1b6ee7242fff_merge_heads.py new file mode 100644 index 000000000..c7e0fb2cd --- /dev/null +++ b/apps/api/alembic/versions/1b6ee7242fff_merge_heads.py @@ -0,0 +1,24 @@ +"""merge_heads + +Revision ID: 1b6ee7242fff +Revises: 523613d60f75, da3ff47ffce8 +Create Date: 2026-06-25 00:13:34.563503 + +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa + + +revision: str = '1b6ee7242fff' +down_revision: Union[str, None] = ('523613d60f75', 'da3ff47ffce8') +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass diff --git a/apps/api/alembic/versions/23f89ef3a202_update_booking_status_history_.py b/apps/api/alembic/versions/23f89ef3a202_update_booking_status_history_.py new file mode 100644 index 000000000..2ca7c766c --- /dev/null +++ b/apps/api/alembic/versions/23f89ef3a202_update_booking_status_history_.py @@ -0,0 +1,40 @@ +"""update booking status history transition constraint + +Revision ID: 23f89ef3a202 +Revises: ec2964b424ff +Create Date: 2026-06-20 13:34:34.677352 + +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa + + +revision: str = '23f89ef3a202' +down_revision: Union[str, None] = 'ec2964b424ff' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.drop_constraint( + "ck_booking_status_history_transition", "booking_status_history", type_="check" + ) + + op.create_check_constraint( + "ck_booking_status_history_transition", + "booking_status_history", + """ + (old_status IS NULL) OR + (old_status = 'requested' AND new_status IN ('owner_accepted', 'owner_rejected', 'user_cancelled', 'conflict_cancelled', 'request_expired')) OR + (old_status = 'owner_accepted' AND new_status IN ('confirmed', 'hold_expired', 'user_cancelled')) OR + (old_status = 'confirmed' AND new_status IN ('completed', 'user_cancelled', 'admin_cancelled', 'balance_overdue_cancelled')) OR + (old_status = 'hold_expired' AND new_status = 'owner_accepted') + """, + ) + + +def downgrade() -> None: + op.drop_constraint( + "ck_booking_status_history_transition", "booking_status_history", type_="check" + ) diff --git a/apps/api/alembic/versions/4375235671ba_add_search_metadata_columns_to_venue_.py b/apps/api/alembic/versions/4375235671ba_add_search_metadata_columns_to_venue_.py new file mode 100644 index 000000000..d0ac4e5e0 --- /dev/null +++ b/apps/api/alembic/versions/4375235671ba_add_search_metadata_columns_to_venue_.py @@ -0,0 +1,79 @@ +"""add search metadata columns to venue_categories + +Revision ID: 4375235671ba +Revises: fed3afac41e8 +Create Date: 2026-07-02 09:36:32.341225 + +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa + + +revision: str = '4375235671ba' +down_revision: Union[str, None] = 'fed3afac41e8' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "venue_categories", + sa.Column("search_boost_group", sa.Text(), nullable=True), + ) + op.add_column( + "venue_categories", + sa.Column("search_keywords", sa.Text(), nullable=True), + ) + + # Backfill from the values that used to be hardcoded in + # category_intent.py / indexer.py / service.py, so behavior is + # identical immediately after migration — no silent regression in + # boost/keyword coverage. + op.execute( + """ + UPDATE venue_categories + SET search_boost_group = 'wedding_hall_banquet_hall' + WHERE slug IN ('wedding_hall', 'banquet_hall') + """ + ) + op.execute( + """ + UPDATE venue_categories + SET search_boost_group = 'event_space_rooftop_resort_lawn' + WHERE slug IN ('event_space', 'rooftop', 'resort', 'lawn') + """ + ) + + keyword_backfill = { + "wedding_hall": ( + "wedding marriage reception function banquet hall mandap sadya " + "kalyanam kalyana mandapam vivaham nikah shaadi shadi vivah " + "engagement muhurtham sangeet mehendi haldi baraat " + "wedding venue marriage hall wedding function hall" + ), + "banquet_hall": ( + "banquet hall wedding reception marriage function party hall " + "conference hall corporate event convention hall" + ), + "event_space": "event space party celebration function birthday anniversary get together", + "rooftop": "rooftop terrace open air rooftop party sundowner", + "club": "club nightclub party lounge discotheque dj night", + "resort": "resort destination wedding luxury staycation getaway resort wedding", + "lawn": "lawn garden outdoor open lawn poolside function lawn", + "auditorium": "auditorium theatre hall seminar convocation stage", + } + conn = op.get_bind() + for slug, keywords in keyword_backfill.items(): + conn.execute( + sa.text( + "UPDATE venue_categories SET search_keywords = :kw WHERE slug = :slug" + ), + {"kw": keywords, "slug": slug}, + ) + + +def downgrade() -> None: + op.drop_column("venue_categories", "search_keywords") + op.drop_column("venue_categories", "search_boost_group") + diff --git a/apps/api/alembic/versions/5134b9fb1f9e_deep_research_search_query_table.py b/apps/api/alembic/versions/5134b9fb1f9e_deep_research_search_query_table.py new file mode 100644 index 000000000..a8d200a40 --- /dev/null +++ b/apps/api/alembic/versions/5134b9fb1f9e_deep_research_search_query_table.py @@ -0,0 +1,36 @@ +"""deep research search query table + +Revision ID: 5134b9fb1f9e +Revises: 85bed236d64b +Create Date: 2026-07-05 13:41:00.515010 + +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa + + +revision: str = '5134b9fb1f9e' +down_revision: Union[str, None] = '85bed236d64b' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('deep_research_queries', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('user_id', sa.UUID(), nullable=False), + sa.Column('query_text', sa.Text(), nullable=False), + sa.Column('city_filter', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['profiles.id'], ), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('deep_research_queries') + # ### end Alembic commands ### diff --git a/apps/api/alembic/versions/523613d60f75_add_last_completed_step_to_venue.py b/apps/api/alembic/versions/523613d60f75_add_last_completed_step_to_venue.py new file mode 100644 index 000000000..a308bb640 --- /dev/null +++ b/apps/api/alembic/versions/523613d60f75_add_last_completed_step_to_venue.py @@ -0,0 +1,28 @@ +"""Add last_completed_step to Venue + +Revision ID: 523613d60f75 +Revises: 23f89ef3a202 +Create Date: 2026-06-21 20:36:07.394084 + +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa + + +revision: str = '523613d60f75' +down_revision: Union[str, None] = '23f89ef3a202' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('venues', sa.Column('last_completed_step', sa.Integer(), server_default='0', nullable=False)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('venues', 'last_completed_step') + # ### end Alembic commands ### diff --git a/apps/api/alembic/versions/6dadfa890c15_venue_category_table.py b/apps/api/alembic/versions/6dadfa890c15_venue_category_table.py new file mode 100644 index 000000000..ba1bdccb2 --- /dev/null +++ b/apps/api/alembic/versions/6dadfa890c15_venue_category_table.py @@ -0,0 +1,51 @@ +"""venue category table + +Revision ID: 6dadfa890c15 +Revises: 108e5e92fd6e +Create Date: 2026-06-18 22:07:44.130306 + +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa + + +revision: str = '6dadfa890c15' +down_revision: Union[str, None] = '108e5e92fd6e' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('venue_categories', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('slug', sa.Text(), nullable=False), + sa.Column('label', sa.Text(), nullable=False), + sa.Column('icon', sa.Text(), nullable=True), + sa.Column('banner_image', sa.Text(), nullable=True), + sa.Column('is_active', sa.Boolean(), server_default='true', nullable=False), + sa.Column('sort_order', sa.Integer(), server_default='0', nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_index('uq_venue_categories_slug_active', 'venue_categories', ['slug'], unique=True, postgresql_where=sa.text('deleted_at IS NULL')) + op.add_column('venues', sa.Column('category_id', sa.UUID(), nullable=True)) + op.drop_index(op.f('idx_venues_search'), table_name='venues', postgresql_where='(deleted_at IS NULL)') + op.create_index('idx_venues_search', 'venues', ['city', 'category_id', 'status', 'is_active'], unique=False, postgresql_where=sa.text('deleted_at IS NULL')) + op.create_foreign_key(None, 'venues', 'venue_categories', ['category_id'], ['id']) + op.drop_column('venues', 'venue_type') + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('venues', sa.Column('venue_type', sa.TEXT(), autoincrement=False, nullable=False)) + op.drop_constraint(None, 'venues', type_='foreignkey') + op.drop_index('idx_venues_search', table_name='venues', postgresql_where=sa.text('deleted_at IS NULL')) + op.create_index(op.f('idx_venues_search'), 'venues', ['city', 'venue_type', 'status', 'is_active'], unique=False, postgresql_where='(deleted_at IS NULL)') + op.drop_column('venues', 'category_id') + op.drop_index('uq_venue_categories_slug_active', table_name='venue_categories', postgresql_where=sa.text('deleted_at IS NULL')) + op.drop_table('venue_categories') + # ### end Alembic commands ### diff --git a/apps/api/alembic/versions/85bed236d64b_dynamic_pricing_model_change.py b/apps/api/alembic/versions/85bed236d64b_dynamic_pricing_model_change.py new file mode 100644 index 000000000..a07e48bb7 --- /dev/null +++ b/apps/api/alembic/versions/85bed236d64b_dynamic_pricing_model_change.py @@ -0,0 +1,66 @@ +"""dynamic pricing model change + +Revision ID: 85bed236d64b +Revises: 4375235671ba +Create Date: 2026-07-03 01:18:26.521190 + +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision: str = '85bed236d64b' +down_revision: Union[str, None] = '4375235671ba' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('venue_pricing_rules', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('venue_id', sa.UUID(), nullable=False), + sa.Column('name', sa.Text(), nullable=False), + sa.Column('days_of_week', postgresql.ARRAY(sa.Integer()), nullable=True), + sa.Column('start_date', sa.Date(), nullable=True), + sa.Column('end_date', sa.Date(), nullable=True), + sa.Column('start_time', sa.Time(), nullable=True), + sa.Column('end_time', sa.Time(), nullable=True), + sa.Column('adjustment_type', sa.Text(), nullable=False), + sa.Column('multiplier', sa.Numeric(precision=5, scale=2), nullable=True), + sa.Column('amount_paise', sa.BigInteger(), nullable=True), + sa.Column('applies_to', sa.Text(), server_default=sa.text("'both'"), nullable=False), + sa.Column('priority', sa.Integer(), server_default='0', nullable=False), + sa.Column('source', sa.Text(), server_default=sa.text("'owner'"), nullable=False), + sa.Column('is_active', sa.Boolean(), server_default='true', nullable=False), + sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.CheckConstraint("(adjustment_type = 'multiplier' AND multiplier IS NOT NULL AND multiplier > 0 AND amount_paise IS NULL) OR (adjustment_type = 'fixed_delta' AND amount_paise IS NOT NULL AND multiplier IS NULL) OR (adjustment_type = 'override' AND amount_paise IS NOT NULL AND amount_paise >= 0 AND multiplier IS NULL)", name='ck_venue_pricing_rules_adjustment_payload'), + sa.CheckConstraint("adjustment_type IN ('multiplier', 'fixed_delta', 'override')", name='ck_venue_pricing_rules_adjustment_type'), + sa.CheckConstraint("applies_to IN ('full_day', 'time_slot', 'both')", name='ck_venue_pricing_rules_applies_to'), + sa.CheckConstraint("source IN ('owner', 'system')", name='ck_venue_pricing_rules_source'), + sa.CheckConstraint('start_date IS NULL OR end_date IS NULL OR start_date <= end_date', name='ck_venue_pricing_rules_date_range'), + sa.ForeignKeyConstraint(['venue_id'], ['venues.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index('idx_venue_pricing_rules_priority', 'venue_pricing_rules', ['venue_id', 'priority'], unique=False, postgresql_where=sa.text('deleted_at IS NULL AND is_active = true')) + op.add_column('bookings', sa.Column('pricing_breakdown', postgresql.JSONB(astext_type=sa.Text()), nullable=True)) + op.add_column('venues', sa.Column('min_price_pct', sa.Numeric(precision=5, scale=2), server_default='50.00', nullable=False)) + op.add_column('venues', sa.Column('max_price_pct', sa.Numeric(precision=5, scale=2), server_default='200.00', nullable=False)) + op.add_column('venues', sa.Column('display_price_min_paise', sa.BigInteger(), nullable=True)) + op.add_column('venues', sa.Column('display_price_max_paise', sa.BigInteger(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('venues', 'display_price_max_paise') + op.drop_column('venues', 'display_price_min_paise') + op.drop_column('venues', 'max_price_pct') + op.drop_column('venues', 'min_price_pct') + op.drop_column('bookings', 'pricing_breakdown') + op.drop_index('idx_venue_pricing_rules_priority', table_name='venue_pricing_rules', postgresql_where=sa.text('deleted_at IS NULL AND is_active = true')) + op.drop_table('venue_pricing_rules') + # ### end Alembic commands ### diff --git a/apps/api/alembic/versions/867c2871ef67_remove_slots_table.py b/apps/api/alembic/versions/867c2871ef67_remove_slots_table.py new file mode 100644 index 000000000..bde9ddbd9 --- /dev/null +++ b/apps/api/alembic/versions/867c2871ef67_remove_slots_table.py @@ -0,0 +1,38 @@ +"""remove_slots_table + +Revision ID: 867c2871ef67 +Revises: c7ff94364ee8 +Create Date: 2026-07-05 15:25:33.028368 + +""" + +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision: str = '867c2871ef67' +down_revision: Union[str, None] = "c7ff94364ee8" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('slots') + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('slots', + sa.Column('id', sa.UUID(), autoincrement=False, nullable=False), + sa.Column('venue_id', sa.UUID(), autoincrement=False, nullable=False), + sa.Column('date', sa.DATE(), autoincrement=False, nullable=False), + sa.Column('is_available', sa.BOOLEAN(), autoincrement=False, nullable=False), + sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=False), + sa.Column('updated_at', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=False), + sa.ForeignKeyConstraint(['venue_id'], ['venues.id'], name=op.f('slots_venue_id_fkey'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('slots_pkey')) + ) + # ### end Alembic commands ### diff --git a/apps/api/alembic/versions/914d6f7a8b9c_add_booking_constraints_and_indices.py b/apps/api/alembic/versions/914d6f7a8b9c_add_booking_constraints_and_indices.py new file mode 100644 index 000000000..9c46f644e --- /dev/null +++ b/apps/api/alembic/versions/914d6f7a8b9c_add_booking_constraints_and_indices.py @@ -0,0 +1,67 @@ +"""add booking constraints and indices + +Revision ID: 914d6f7a8b9c +Revises: feaba28f7d2b +Create Date: 2026-06-09 09:20:00.000000 + +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = '914d6f7a8b9c' +down_revision: Union[str, None] = 'feaba28f7d2b' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # 1. Enable btree_gist extension + op.execute("CREATE EXTENSION IF NOT EXISTS btree_gist") + + # 2. Unique constraint on booking_slots(booking_id) + op.create_unique_constraint( + "uq_booking_slots_booking_id", + "booking_slots", + ["booking_id"] + ) + + # 3. Partial exclusion constraint booking_slots_no_overlap on booking_slots + op.execute( + """ + ALTER TABLE booking_slots + ADD CONSTRAINT booking_slots_no_overlap + EXCLUDE USING gist ( + venue_id WITH =, + tstzrange(effective_starts_at, effective_ends_at) WITH && + ) WHERE (is_blocking = true AND deleted_at IS NULL) + """ + ) + + # 4. Partial index idx_booking_slots_conflict_detection on booking_slots + op.create_index( + "idx_booking_slots_conflict_detection", + "booking_slots", + ["venue_id", "is_blocking", "deleted_at", "effective_starts_at", "effective_ends_at"], + unique=False, + postgresql_where=sa.text("is_blocking = true AND deleted_at IS NULL") + ) + + # 5. CHECK constraint ck_booking_status_history_transition on booking_status_history + op.create_check_constraint( + "ck_booking_status_history_transition", + "booking_status_history", + "old_status IS NULL OR " + "old_status = new_status OR " + "(old_status = 'requested' AND new_status IN ('owner_accepted', 'owner_rejected', 'request_expired', 'conflict_cancelled', 'admin_cancelled')) OR " + "(old_status = 'owner_accepted' AND new_status IN ('confirmed', 'hold_expired', 'user_cancelled', 'admin_cancelled')) OR " + "(old_status = 'confirmed' AND new_status IN ('completed', 'user_cancelled', 'admin_cancelled', 'balance_overdue_cancelled'))" + ) + + +def downgrade() -> None: + op.drop_constraint("ck_booking_status_history_transition", "booking_status_history", type_="check") + op.drop_index("idx_booking_slots_conflict_detection", table_name="booking_slots") + op.execute("ALTER TABLE booking_slots DROP CONSTRAINT booking_slots_no_overlap") + op.drop_constraint("uq_booking_slots_booking_id", "booking_slots", type_="unique") diff --git a/apps/api/alembic/versions/9530e5e46835_add_deep_research_phase_2.py b/apps/api/alembic/versions/9530e5e46835_add_deep_research_phase_2.py new file mode 100644 index 000000000..f717bf9f4 --- /dev/null +++ b/apps/api/alembic/versions/9530e5e46835_add_deep_research_phase_2.py @@ -0,0 +1,75 @@ +"""add_deep_research_phase_2 + +Revision ID: 9530e5e46835 +Revises: 5134b9fb1f9e +Create Date: 2026-07-05 19:15:09.813040 + +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision: str = '9530e5e46835' +down_revision: Union[str, None] = '5134b9fb1f9e' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('external_discovery_requests', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('query_id', sa.UUID(), nullable=False), + sa.Column('latitude', sa.Float(), nullable=False), + sa.Column('longitude', sa.Float(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['query_id'], ['deep_research_queries.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('query_id') + ) + op.create_table('external_venue_leads', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('discovered_via_query_id', sa.UUID(), nullable=False), + sa.Column('source', sa.Text(), nullable=False), + sa.Column('source_ref', sa.Text(), nullable=False), + sa.Column('name', sa.Text(), nullable=False), + sa.Column('city', sa.Text(), nullable=True), + sa.Column('category_guess', sa.Text(), nullable=True), + sa.Column('raw_contact_info', postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.Column('status', sa.Enum('DISCOVERED', 'CONTACTED', 'ONBOARDED', 'REJECTED', name='external_lead_status'), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['discovered_via_query_id'], ['deep_research_queries.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_external_venue_leads_source_ref'), 'external_venue_leads', ['source_ref'], unique=False) + + op.create_table('lead_reservations', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('lead_id', sa.UUID(), nullable=False), + sa.Column('user_id', sa.UUID(), nullable=False), + sa.Column('status', sa.Enum('REQUESTED', 'ADMIN_CONTACTED', 'OWNER_CONFIRMED', 'DECLINED', 'EXPIRED', name='lead_reservation_status'), nullable=False), + sa.Column('platform_fee_paise', sa.BigInteger(), nullable=False), + sa.Column('event_date', sa.Date(), nullable=True), + sa.Column('notes', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['lead_id'], ['external_venue_leads.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['user_id'], ['profiles.id'], ), + sa.PrimaryKeyConstraint('id') + ) + + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('lead_reservations') + op.drop_index(op.f('ix_external_venue_leads_source_ref'), table_name='external_venue_leads') + op.drop_table('external_venue_leads') + op.drop_table('external_discovery_requests') + + op.execute("DROP TYPE IF EXISTS external_lead_status") + op.execute("DROP TYPE IF EXISTS lead_reservation_status") + # ### end Alembic commands ### diff --git a/apps/api/alembic/versions/a2b3c4d5e6f7_add_formatted_address_to_external_leads.py b/apps/api/alembic/versions/a2b3c4d5e6f7_add_formatted_address_to_external_leads.py new file mode 100644 index 000000000..b5bc04114 --- /dev/null +++ b/apps/api/alembic/versions/a2b3c4d5e6f7_add_formatted_address_to_external_leads.py @@ -0,0 +1,24 @@ +"""add_formatted_address_to_external_leads + +Revision ID: a2b3c4d5e6f7 +Revises: 9530e5e46835 +Create Date: 2026-07-06 11:00:00.000000 + +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa + + +revision: str = 'a2b3c4d5e6f7' +down_revision: Union[str, None] = '9530e5e46835' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column('external_venue_leads', sa.Column('formatted_address', sa.Text(), nullable=True)) + + +def downgrade() -> None: + op.drop_column('external_venue_leads', 'formatted_address') diff --git a/apps/api/alembic/versions/ab7cfebcbee6_add_new_fields_bookings.py b/apps/api/alembic/versions/ab7cfebcbee6_add_new_fields_bookings.py new file mode 100644 index 000000000..baea4e7ca --- /dev/null +++ b/apps/api/alembic/versions/ab7cfebcbee6_add_new_fields_bookings.py @@ -0,0 +1,33 @@ +"""add_new_fields_bookings + +Revision ID: ab7cfebcbee6 +Revises: c4854b7f080d +Create Date: 2026-07-05 15:37:01.613230 + +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision: str = 'ab7cfebcbee6' +down_revision: Union[str, None] = 'c4854b7f080d' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('bookings', sa.Column('payment_expires_at', sa.DateTime(timezone=True), nullable=True)) + op.add_column('bookings', sa.Column('auto_confirmed_at', sa.DateTime(timezone=True), nullable=True)) + op.add_column('bookings', sa.Column('confirmed_by', sa.Text(), nullable=True)) + + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('bookings', 'confirmed_by') + op.drop_column('bookings', 'auto_confirmed_at') + op.drop_column('bookings', 'payment_expires_at') + # ### end Alembic commands ### diff --git a/apps/api/alembic/versions/adc8fe218fd7_search_index_table_venue_table_column_.py b/apps/api/alembic/versions/adc8fe218fd7_search_index_table_venue_table_column_.py new file mode 100644 index 000000000..e92011fd5 --- /dev/null +++ b/apps/api/alembic/versions/adc8fe218fd7_search_index_table_venue_table_column_.py @@ -0,0 +1,38 @@ +"""search index table + venue table column update + +Revision ID: adc8fe218fd7 +Revises: 004b8919fa8e +Create Date: 2026-06-30 21:45:29.148213 + +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql +import pgvector + +revision: str = 'adc8fe218fd7' +down_revision: Union[str, None] = '004b8919fa8e' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute('CREATE EXTENSION IF NOT EXISTS vector') + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('bookings', 'payment_mode') + op.add_column('venues', sa.Column('search_vector', postgresql.TSVECTOR(), nullable=True)) + op.add_column('venues', sa.Column('embedding', pgvector.sqlalchemy.vector.VECTOR(dim=1024), nullable=True)) + op.add_column('venues', sa.Column('embedding_updated_at', sa.DateTime(timezone=True), nullable=True)) + op.drop_column('venues', 'payment_mode') + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('venues', sa.Column('payment_mode', sa.TEXT(), server_default=sa.text("'advance_balance'::text"), autoincrement=False, nullable=False)) + op.drop_column('venues', 'embedding_updated_at') + op.drop_column('venues', 'embedding') + op.drop_column('venues', 'search_vector') + op.add_column('bookings', sa.Column('payment_mode', sa.VARCHAR(), server_default=sa.text("'advance_balance'::character varying"), autoincrement=False, nullable=False)) + # ### end Alembic commands ### diff --git a/apps/api/alembic/versions/b3c4d5e6f7a8_add_cover_photo_url_to_external_leads.py b/apps/api/alembic/versions/b3c4d5e6f7a8_add_cover_photo_url_to_external_leads.py new file mode 100644 index 000000000..abbdfc6dc --- /dev/null +++ b/apps/api/alembic/versions/b3c4d5e6f7a8_add_cover_photo_url_to_external_leads.py @@ -0,0 +1,25 @@ +"""add_cover_photo_url_to_external_leads + +Revision ID: b3c4d5e6f7a8 +Revises: a2b3c4d5e6f7 +Create Date: 2026-07-06 12:22:00.000000 + +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa + + +revision: str = 'b3c4d5e6f7a8' +down_revision: Union[str, None] = 'a2b3c4d5e6f7' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Cloudinary URL — null until the background job successfully uploads the photo. + op.add_column('external_venue_leads', sa.Column('cover_photo_url', sa.Text(), nullable=True)) + + +def downgrade() -> None: + op.drop_column('external_venue_leads', 'cover_photo_url') diff --git a/apps/api/alembic/versions/bf4731fd3ac6_update_booking_status_history_constraint.py b/apps/api/alembic/versions/bf4731fd3ac6_update_booking_status_history_constraint.py new file mode 100644 index 000000000..6d0a3a100 --- /dev/null +++ b/apps/api/alembic/versions/bf4731fd3ac6_update_booking_status_history_constraint.py @@ -0,0 +1,125 @@ +"""update_booking_status_history_constraint + +Revision ID: bf4731fd3ac6 +Revises: 134e235b06e3 +Create Date: 2026-07-05 16:06:07.393205 + +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision: str = 'bf4731fd3ac6' +down_revision: Union[str, None] = '134e235b06e3' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +CHECK_CONSTRAINT = """ +(old_status IS NULL) + +OR + +(old_status = 'requested' + AND new_status IN ( + 'owner_accepted', + 'owner_rejected', + 'request_expired', + 'conflict_cancelled', + 'admin_cancelled', + 'user_cancelled' + )) + +OR + +(old_status = 'payment_pending' + AND new_status IN ( + 'confirmed', + 'hold_expired', + 'user_cancelled', + 'admin_cancelled', + 'conflict_cancelled' + )) + +OR + +(old_status = 'owner_accepted' + AND new_status IN ( + 'confirmed', + 'hold_expired', + 'user_cancelled', + 'admin_cancelled' + )) + +OR + +(old_status = 'confirmed' + AND new_status IN ( + 'completed', + 'user_cancelled', + 'admin_cancelled', + 'balance_overdue_cancelled' + )) +""" + + +def upgrade() -> None: + op.drop_constraint( + "ck_booking_status_history_transition", + "booking_status_history", + type_="check", + ) + + op.create_check_constraint( + "ck_booking_status_history_transition", + "booking_status_history", + CHECK_CONSTRAINT, + ) + + +def downgrade() -> None: + op.drop_constraint( + "ck_booking_status_history_transition", + "booking_status_history", + type_="check", + ) + + op.create_check_constraint( + "ck_booking_status_history_transition", + "booking_status_history", + """ + (old_status IS NULL) + + OR + + (old_status = 'requested' + AND new_status IN ( + 'owner_accepted', + 'owner_rejected', + 'request_expired', + 'conflict_cancelled', + 'admin_cancelled' + )) + + OR + + (old_status = 'owner_accepted' + AND new_status IN ( + 'confirmed', + 'hold_expired', + 'user_cancelled', + 'admin_cancelled' + )) + + OR + + (old_status = 'confirmed' + AND new_status IN ( + 'completed', + 'user_cancelled', + 'admin_cancelled', + 'balance_overdue_cancelled' + )) + """, + ) diff --git a/apps/api/alembic/versions/bf6b28a6d1fd_amenity_name_uniqueness_partial_index_.py b/apps/api/alembic/versions/bf6b28a6d1fd_amenity_name_uniqueness_partial_index_.py new file mode 100644 index 000000000..317b03012 --- /dev/null +++ b/apps/api/alembic/versions/bf6b28a6d1fd_amenity_name_uniqueness_partial_index_.py @@ -0,0 +1,30 @@ +"""amenity name uniqueness: partial index excluding soft-deleted rows + +Revision ID: bf6b28a6d1fd +Revises: 914d6f7a8b9c +Create Date: 2026-06-09 18:47:52.686325 + +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa + + +revision: str = 'bf6b28a6d1fd' +down_revision: Union[str, None] = '914d6f7a8b9c' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(op.f('amenities_name_key'), 'amenities', type_='unique') + op.create_index('uq_amenities_name_active', 'amenities', ['name'], unique=True, postgresql_where=sa.text('deleted_at IS NULL')) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index('uq_amenities_name_active', table_name='amenities', postgresql_where=sa.text('deleted_at IS NULL')) + op.create_unique_constraint(op.f('amenities_name_key'), 'amenities', ['name'], postgresql_nulls_not_distinct=False) + # ### end Alembic commands ### diff --git a/apps/api/alembic/versions/c00a79140491_update_leadreservationstatus_enum.py b/apps/api/alembic/versions/c00a79140491_update_leadreservationstatus_enum.py new file mode 100644 index 000000000..a20ccfe4c --- /dev/null +++ b/apps/api/alembic/versions/c00a79140491_update_leadreservationstatus_enum.py @@ -0,0 +1,37 @@ +"""Update LeadReservationStatus enum + +Revision ID: c00a79140491 +Revises: d1e2f3a4b5c6 +Create Date: 2026-07-06 13:24:27.472285 + +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + + + +revision: str = 'c00a79140491' +down_revision: Union[str, None] = 'd1e2f3a4b5c6' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # 1. Rename existing enum + op.execute("ALTER TYPE lead_reservation_status RENAME TO lead_reservation_status_old") + + # 2. Create new enum + op.execute("CREATE TYPE lead_reservation_status AS ENUM('new', 'contacted', 'owner_interested', 'owner_invited', 'owner_onboarded', 'venue_draft_created', 'venue_pending_approval', 'venue_approved', 'booking_created', 'closed', 'cancelled', 'rejected')") + + # 3. Alter table to use varchar temporarily, map data, then cast to new enum + op.execute("ALTER TABLE lead_reservations ALTER COLUMN status TYPE VARCHAR USING status::text") + op.execute("UPDATE lead_reservations SET status = 'new' WHERE status = 'requested'") + op.execute("ALTER TABLE lead_reservations ALTER COLUMN status TYPE lead_reservation_status USING status::lead_reservation_status") + + # 4. Drop old enum + op.execute("DROP TYPE lead_reservation_status_old") + +def downgrade() -> None: + pass diff --git a/apps/api/alembic/versions/c4854b7f080d_add_booking_mode_venue.py b/apps/api/alembic/versions/c4854b7f080d_add_booking_mode_venue.py new file mode 100644 index 000000000..5c7f4c352 --- /dev/null +++ b/apps/api/alembic/versions/c4854b7f080d_add_booking_mode_venue.py @@ -0,0 +1,48 @@ +"""add_booking_mode_venue + +Revision ID: c4854b7f080d +Revises: 867c2871ef67 +Create Date: 2026-07-05 15:30:56.305238 + +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa + + +revision: str = 'c4854b7f080d' +down_revision: Union[str, None] = '867c2871ef67' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +booking_mode_enum = sa.Enum( + "MANUAL", + "INSTANT", + name="booking_mode", +) + + +def upgrade(): + booking_mode_enum.create(op.get_bind(), checkfirst=True) + + op.add_column( + "venues", + sa.Column( + "booking_mode", + booking_mode_enum, + nullable=False, + server_default="MANUAL", + ), + ) + + op.alter_column( + "venues", + "booking_mode", + server_default=None, + ) + + +def downgrade(): + op.drop_column("venues", "booking_mode") + booking_mode_enum.drop(op.get_bind(), checkfirst=True) diff --git a/apps/api/alembic/versions/c7ff94364ee8_deep_research_logs_field_update.py b/apps/api/alembic/versions/c7ff94364ee8_deep_research_logs_field_update.py new file mode 100644 index 000000000..d74473360 --- /dev/null +++ b/apps/api/alembic/versions/c7ff94364ee8_deep_research_logs_field_update.py @@ -0,0 +1,33 @@ +"""deep research logs field update + +Revision ID: c7ff94364ee8 +Revises: 9530e5e46835 +Create Date: 2026-07-06 00:41:16.979095 + +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision: str = 'c7ff94364ee8' +down_revision: Union[str, None] = '9530e5e46835' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column('deep_research_queries', sa.Column('understanding_json', postgresql.JSONB(astext_type=sa.Text()), nullable=True)) + op.add_column('deep_research_queries', sa.Column('result_count', sa.Integer(), server_default='0', nullable=False)) + op.add_column('deep_research_queries', sa.Column('avg_match_score', sa.Float(), nullable=True)) + op.add_column('deep_research_queries', sa.Column('top_results_json', postgresql.JSONB(astext_type=sa.Text()), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('deep_research_queries', 'top_results_json') + op.drop_column('deep_research_queries', 'avg_match_score') + op.drop_column('deep_research_queries', 'result_count') + op.drop_column('deep_research_queries', 'understanding_json') + # ### end Alembic commands ### diff --git a/apps/api/alembic/versions/d1e2f3a4b5c6_merge_deep_research_branches.py b/apps/api/alembic/versions/d1e2f3a4b5c6_merge_deep_research_branches.py new file mode 100644 index 000000000..8251b98e0 --- /dev/null +++ b/apps/api/alembic/versions/d1e2f3a4b5c6_merge_deep_research_branches.py @@ -0,0 +1,23 @@ +"""merge_deep_research_branches + +Revision ID: d1e2f3a4b5c6 +Revises: b3c4d5e6f7a8, c7ff94364ee8 +Create Date: 2026-07-06 12:54:00.000000 + +""" +from typing import Sequence, Union +from alembic import op + + +revision: str = 'd1e2f3a4b5c6' +down_revision: Union[str, Sequence[str], None] = ('b3c4d5e6f7a8', 'c7ff94364ee8') +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + pass # Merge only — no schema changes + + +def downgrade() -> None: + pass diff --git a/apps/api/alembic/versions/d5464ac6f73e_add_venue_likes_table_v2.py b/apps/api/alembic/versions/d5464ac6f73e_add_venue_likes_table_v2.py new file mode 100644 index 000000000..67a7ce3e5 --- /dev/null +++ b/apps/api/alembic/versions/d5464ac6f73e_add_venue_likes_table_v2.py @@ -0,0 +1,38 @@ +"""Add venue_likes table v2 + +Revision ID: d5464ac6f73e +Revises: 0b15a59e8154 +Create Date: 2026-07-06 14:39:36.384314 + +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision: str = 'd5464ac6f73e' +down_revision: Union[str, None] = '0b15a59e8154' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('venue_likes', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('user_id', sa.UUID(), nullable=False), + sa.Column('venue_id', sa.UUID(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['profiles.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['venue_id'], ['venues.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index('uq_venue_likes_user_venue', 'venue_likes', ['user_id', 'venue_id'], unique=True) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index('uq_venue_likes_user_venue', table_name='venue_likes') + op.drop_table('venue_likes') + # ### end Alembic commands ### diff --git a/apps/api/alembic/versions/da3ff47ffce8_bugs_and_balance_payment_refactor.py b/apps/api/alembic/versions/da3ff47ffce8_bugs_and_balance_payment_refactor.py new file mode 100644 index 000000000..f047599c6 --- /dev/null +++ b/apps/api/alembic/versions/da3ff47ffce8_bugs_and_balance_payment_refactor.py @@ -0,0 +1,32 @@ +"""Bugs and Balance payment refactor + +Revision ID: da3ff47ffce8 +Revises: ec2964b424ff +Create Date: 2026-06-21 10:00:05.418688 + +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa + + +revision: str = 'da3ff47ffce8' +down_revision: Union[str, None] = 'ec2964b424ff' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('bookings', sa.Column('payment_mode', sa.String(), server_default='advance_balance', nullable=False)) + op.add_column('payments', sa.Column('payment_type', sa.String(), server_default='advance', nullable=False)) + op.add_column('venues', sa.Column('payment_mode', sa.Text(), server_default='advance_balance', nullable=False)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('venues', 'payment_mode') + op.drop_column('payments', 'payment_type') + op.drop_column('bookings', 'payment_mode') + # ### end Alembic commands ### diff --git a/apps/api/alembic/versions/ec2964b424ff_venue_category_id_not_null.py b/apps/api/alembic/versions/ec2964b424ff_venue_category_id_not_null.py new file mode 100644 index 000000000..a9fd8a478 --- /dev/null +++ b/apps/api/alembic/versions/ec2964b424ff_venue_category_id_not_null.py @@ -0,0 +1,32 @@ +"""venue category_id not null + +Revision ID: ec2964b424ff +Revises: 6dadfa890c15 +Create Date: 2026-06-18 22:41:20.323502 + +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa + + +revision: str = 'ec2964b424ff' +down_revision: Union[str, None] = '6dadfa890c15' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('venues', 'category_id', + existing_type=sa.UUID(), + nullable=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('venues', 'category_id', + existing_type=sa.UUID(), + nullable=True) + # ### end Alembic commands ### diff --git a/apps/api/alembic/versions/feaba28f7d2b_merge_booking_and_venue_branches.py b/apps/api/alembic/versions/feaba28f7d2b_merge_booking_and_venue_branches.py new file mode 100644 index 000000000..2dee3dbc6 --- /dev/null +++ b/apps/api/alembic/versions/feaba28f7d2b_merge_booking_and_venue_branches.py @@ -0,0 +1,24 @@ +"""Merge booking and venue branches - no schema changes needed + +Revision ID: feaba28f7d2b +Revises: 1679aa81b5b3, 014af2456033 +Create Date: 2026-06-08 23:22:56.136502 + +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa + + +revision: str = 'feaba28f7d2b' +down_revision: Union[str, None] = ('1679aa81b5b3', '014af2456033') +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass diff --git a/apps/api/alembic/versions/fed3afac41e8_search_index_table.py b/apps/api/alembic/versions/fed3afac41e8_search_index_table.py new file mode 100644 index 000000000..921a8d2bd --- /dev/null +++ b/apps/api/alembic/versions/fed3afac41e8_search_index_table.py @@ -0,0 +1,40 @@ +"""search index table + +Revision ID: fed3afac41e8 +Revises: adc8fe218fd7 +Create Date: 2026-06-30 21:49:51.958140 + +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa + + +revision: str = 'fed3afac41e8' +down_revision: Union[str, None] = 'adc8fe218fd7' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('search_index_jobs', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('entity_type', sa.Text(), nullable=False), + sa.Column('entity_id', sa.UUID(), nullable=False), + sa.Column('operation', sa.Text(), nullable=False), + sa.Column('status', sa.Text(), server_default='pending', nullable=False), + sa.Column('retry_count', sa.Integer(), server_default='0', nullable=False), + sa.Column('error_message', sa.Text(), nullable=True), + sa.Column('started_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('search_index_jobs') + # ### end Alembic commands ### diff --git a/apps/api/app/__init__.py b/apps/api/app/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apps/api/app/core/config.py b/apps/api/app/core/config.py new file mode 100644 index 000000000..aa709361b --- /dev/null +++ b/apps/api/app/core/config.py @@ -0,0 +1,87 @@ +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + database_url: str + supabase_url: str + supabase_jwt_secret: str + supabase_service_role_key: str + stripe_secret_key: str = "" + stripe_webhook_secret: str = "" + stripe_currency: str = "inr" + + # Email — Resend is primary, SMTP is the fallback transport + resend_api_key: str = "" + email_from: str = "Venue404 " + smtp_host: str = "" + smtp_port: int = 587 + smtp_user: str = "" + smtp_password: str = "" + + # Used to build deep links inside notification emails + frontend_base_url: str = "https://venue404-user-web-git-main-venue123.vercel.app" + + # Comma-separated list of allowed browser origins for CORS. Defaults to the + # local dev ports; in production set this to the deployed Vercel app URLs. + cors_origins: str = ("https://venue404-owner-portal-git-main-venue123.vercel.app,https://venue404-user-web-git-main-venue123.vercel.app,https://venue404-admin-panel-git-main-venue123.vercel.app,http://localhost:5397,http://localhost:5398,http://localhost:5399" + ) + + # Shared secret guarding the machine-to-machine job-runner endpoint. Empty + # disables the endpoint (returns 503). Set to a long random value in prod. + job_runner_token: str = "" + + @property + def cors_origins_list(self) -> list[str]: + return [o.strip() for o in self.cors_origins.split(",") if o.strip()] + + # Booking economics (percent of venue price) + token_advance_pct: int = 20 + platform_fee_pct: int = 5 + + # Only the process that owns scheduling should flip this on + enable_jobs: bool = False + + super_admin_name: str = "" + super_admin_email: str = "" + super_admin_password: str = "" + cloudinary_cloud_name: str = "" + cloudinary_api_key: str = "" + cloudinary_api_secret: str = "" + + # Upstash Redis — used as a fast delivery channel for search index jobs. + # If unset, the worker falls back to polling search_index_jobs directly. + upstash_redis_url: str = "" + upstash_redis_token: str = "" + upstash_search_queue_key: str = "search_index_jobs" + + # Jina AI — used to generate venue embeddings for semantic search. + jina_api_key: str = "" + jina_embedding_model: str = "jina-embeddings-v3" + embedding_dimensions: int = 1024 + + search_min_vector_similarity: float = 0.15 + search_wedding_boost: float = 1.85 + search_event_boost: float = 1.40 + search_fts_weight: float = 0.3 + search_corporate_boost: float = 1.40 + search_vector_weight: float = 0.7 + search_diagnostics_enabled: bool = False + + search_normalizer_match_threshold: int = 85 + search_normalizer_min_token_len: int = 3 + + # Groq — used for Deep Research's query-understanding stage (OpenAI-compatible API). + groq_api_key: str = "" + groq_model: str = "llama-3.1-8b-instant" + groq_base_url: str = "https://api.groq.com/openai/v1" + + # Google Places API Key + google_places_api_key: str = "" + + log_level: str = "INFO" # DEBUG / INFO / WARNING / ERROR + + class Config: + env_file = ".env" + + +settings = Settings() diff --git a/apps/api/app/core/database.py b/apps/api/app/core/database.py new file mode 100644 index 000000000..dfd24cc74 --- /dev/null +++ b/apps/api/app/core/database.py @@ -0,0 +1,42 @@ +from contextlib import contextmanager +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, DeclarativeBase, Session +from app.core.config import settings + +engine = create_engine(settings.database_url) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + +class Base(DeclarativeBase): + pass + + +def get_db(): + """FastAPI request-scoped session dependency.""" + db = SessionLocal() + try: + yield db + db.commit() + except Exception: + db.rollback() + raise + finally: + db.close() + + +@contextmanager +def with_session() -> Session: + """Session for code that runs outside a request (jobs, webhooks). + + Commits on success, rolls back on exception, always closes. Use this for + transactional work like payment confirmation that needs explicit control. + """ + db = SessionLocal() + try: + yield db + db.commit() + except Exception: + db.rollback() + raise + finally: + db.close() diff --git a/apps/api/app/core/email.py b/apps/api/app/core/email.py new file mode 100644 index 000000000..a836e1444 --- /dev/null +++ b/apps/api/app/core/email.py @@ -0,0 +1,63 @@ +"""Email transport: Resend primary, SMTP fallback. + +`send_email` picks a transport at call time: + 1. RESEND_API_KEY set -> send via Resend + 2. else SMTP_HOST set -> send via SMTP + 3. else (dev, nothing set) -> log and no-op (never raises in dev) + +Returns True if an email was actually dispatched, False if it was a dev no-op. +""" +import logging +import smtplib +from email.message import EmailMessage + +from app.core.config import settings + +logger = logging.getLogger(__name__) + + +def send_email(to: str, subject: str, html: str) -> bool: + if settings.resend_api_key: + return _send_via_resend(to, subject, html) + if settings.smtp_host: + return _send_via_smtp(to, subject, html) + logger.warning( + "No email transport configured (RESEND_API_KEY / SMTP_HOST unset); " + "skipping email to %s: %r", + to, + subject, + ) + return False + + +def _send_via_resend(to: str, subject: str, html: str) -> bool: + import resend + + resend.api_key = settings.resend_api_key + resend.Emails.send( + { + "from": settings.email_from, + "to": [to], + "subject": subject, + "html": html, + } + ) + logger.info("Sent email via Resend to %s: %r", to, subject) + return True + + +def _send_via_smtp(to: str, subject: str, html: str) -> bool: + msg = EmailMessage() + msg["From"] = settings.email_from + msg["To"] = to + msg["Subject"] = subject + msg.set_content("This email requires an HTML-capable client.") + msg.add_alternative(html, subtype="html") + + with smtplib.SMTP(settings.smtp_host, settings.smtp_port) as server: + server.starttls() + if settings.smtp_user: + server.login(settings.smtp_user, settings.smtp_password) + server.send_message(msg) + logger.info("Sent email via SMTP to %s: %r", to, subject) + return True diff --git a/apps/api/app/core/exceptions.py b/apps/api/app/core/exceptions.py new file mode 100644 index 000000000..df9fb6aa1 --- /dev/null +++ b/apps/api/app/core/exceptions.py @@ -0,0 +1,26 @@ +from fastapi import HTTPException, status + + +class NotFoundError(HTTPException): + def __init__(self, detail: str = "Not found"): + super().__init__(status_code=status.HTTP_404_NOT_FOUND, detail=detail) + + +class UnauthorizedError(HTTPException): + def __init__(self, detail: str = "Unauthorized"): + super().__init__(status_code=status.HTTP_401_UNAUTHORIZED, detail=detail) + + +class ForbiddenError(HTTPException): + def __init__(self, detail: str = "Forbidden"): + super().__init__(status_code=status.HTTP_403_FORBIDDEN, detail=detail) + + +class ConflictError(HTTPException): + def __init__(self, detail: str = "Conflict"): + super().__init__(status_code=status.HTTP_409_CONFLICT, detail=detail) + + +class BadRequestError(HTTPException): + def __init__(self, detail: str = "Bad request"): + super().__init__(status_code=status.HTTP_400_BAD_REQUEST, detail=detail) diff --git a/apps/api/app/core/logging.py b/apps/api/app/core/logging.py new file mode 100644 index 000000000..1169efb56 --- /dev/null +++ b/apps/api/app/core/logging.py @@ -0,0 +1,35 @@ +import logging +import sys +from app.core.config import settings + + +def setup_logging() -> None: + """Configure root logger for console output (critical for Docker).""" + level = getattr(logging, settings.log_level.upper(), logging.INFO) + + root = logging.getLogger() + root.setLevel(level) + + # Console handler (stdout → captured by Docker) + handler = logging.StreamHandler(sys.stdout) + handler.setLevel(level) + + formatter = logging.Formatter( + fmt="%(asctime)s | %(levelname)-8s | %(name)s | %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + handler.setFormatter(formatter) + + # Avoid duplicate handlers + if not root.handlers: + root.addHandler(handler) + + # Quiet noisy libraries + logging.getLogger("uvicorn").setLevel(logging.INFO) + logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING) + logging.getLogger("stripe").setLevel(logging.WARNING) + logging.getLogger("httpx").setLevel(logging.WARNING) + + logging.getLogger(__name__).info( + "✅ Logging initialized at level: %s", settings.log_level + ) diff --git a/apps/api/app/core/middleware.py b/apps/api/app/core/middleware.py new file mode 100644 index 000000000..3384ba29c --- /dev/null +++ b/apps/api/app/core/middleware.py @@ -0,0 +1,14 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.core.config import settings + + +def register_middleware(app: FastAPI) -> None: + app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origins_list, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) diff --git a/apps/api/app/core/storage.py b/apps/api/app/core/storage.py new file mode 100644 index 000000000..c407f2520 --- /dev/null +++ b/apps/api/app/core/storage.py @@ -0,0 +1,60 @@ +import io +import cloudinary +import cloudinary.uploader +from fastapi import HTTPException, status +from PIL import Image +from app.core.config import settings + +_MAX_DIMENSION = 2048 +_JPEG_QUALITY = 85 + + +def compress_image(file_bytes: bytes) -> bytes: + img = Image.open(io.BytesIO(file_bytes)) + exif = img.info.get("exif", b"") + if img.mode in ("RGBA", "P"): + img = img.convert("RGB") + img.thumbnail((_MAX_DIMENSION, _MAX_DIMENSION), Image.LANCZOS) + out = io.BytesIO() + img.save(out, format="WEBP", quality=_JPEG_QUALITY, method=6, exif=exif) + return out.getvalue() + + +if settings.cloudinary_cloud_name and settings.cloudinary_api_key and settings.cloudinary_api_secret: + cloudinary.config( + cloud_name=settings.cloudinary_cloud_name, + api_key=settings.cloudinary_api_key, + api_secret=settings.cloudinary_api_secret, + secure=True, + ) + +def upload_image_to_cloudinary(file_bytes: bytes, folder: str = "venues") -> str: + + if not settings.cloudinary_cloud_name: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Cloudinary is not configured on the server." + ) + + try: + file_bytes = compress_image(file_bytes) + response = cloudinary.uploader.upload( + file_bytes, + folder=folder, + resource_type="image" + ) + return response.get("secure_url") + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Image upload failed: {str(e)}" + ) + +def delete_image_from_cloudinary(public_id: str): + + if not settings.cloudinary_cloud_name: + return + try: + cloudinary.uploader.destroy(public_id) + except Exception: + pass \ No newline at end of file diff --git a/apps/api/app/core/stripe_client.py b/apps/api/app/core/stripe_client.py new file mode 100644 index 000000000..6c6079824 --- /dev/null +++ b/apps/api/app/core/stripe_client.py @@ -0,0 +1,13 @@ +"""Single configured Stripe client. + +Importing `stripe` from here guarantees the API key is set once, and gives tests +a single place to monkeypatch the SDK. +""" +import stripe +from app.core.config import settings + +stripe.api_key = settings.stripe_secret_key + + +def get_stripe(): + return stripe diff --git a/apps/api/app/infrastructure/__init__.py b/apps/api/app/infrastructure/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apps/api/app/infrastructure/embeddings/jina.py b/apps/api/app/infrastructure/embeddings/jina.py new file mode 100644 index 000000000..95339b830 --- /dev/null +++ b/apps/api/app/infrastructure/embeddings/jina.py @@ -0,0 +1,147 @@ +"""Jina AI embeddings client. + +This module is the only place that knows about Jina's API URL, auth headers, +request/response shape, retry policy, and task-type strings. Callers (e.g. +app.modules.search.indexer) should only ever import embed_passage / +embed_query / embed_texts from here — they should never need to know this +is Jina, or what the HTTP contract looks like. +""" + +import logging +import time +from enum import Enum + +import httpx +import numpy as np + +from app.core.config import settings + +logger = logging.getLogger(__name__) + +_EMBEDDINGS_URL = "https://api.jina.ai/v1/embeddings" + +# Retry policy for transient failures (network errors, 5xx, 429). +_MAX_RETRIES = 3 +_RETRY_BACKOFF_SECONDS = [1, 3, 8] + +# Jina caps input array size per request; batch larger inputs to stay under it. +_MAX_BATCH_SIZE = 64 + + +class EmbeddingTask(str, Enum): + """Jina task-type strings. Passage = documents being indexed, query = search input.""" + + PASSAGE = "retrieval.passage" + QUERY = "retrieval.query" + + +def _normalize(vector: list[float]) -> list[float]: + """L2-normalize an embedding vector — critical for cosine/dot-product search quality.""" + arr = np.array(vector, dtype=np.float32) + norm = np.linalg.norm(arr) + if norm > 0: + arr = arr / norm + return arr.tolist() + + +def _is_retryable(exc: httpx.HTTPStatusError) -> bool: + status = exc.response.status_code + return status == 429 or status >= 500 + + +def _post_with_retries(payload: dict) -> dict: + if not settings.jina_api_key: + # An empty key produces a malformed `Authorization: Bearer ` header, + # which httpx rejects locally (never reaches the network) — retrying + # that a further 3 times with backoff just delays every caller for no + # chance of success. Fail immediately instead. + raise RuntimeError("JINA_API_KEY is not configured — cannot call Jina embeddings API") + + last_exc: Exception | None = None + for attempt in range(_MAX_RETRIES + 1): + try: + response = httpx.post( + _EMBEDDINGS_URL, + headers={ + "Authorization": f"Bearer {settings.jina_api_key}", + "Content-Type": "application/json", + }, + json=payload, + timeout=30.0, + ) + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as exc: + last_exc = exc + if not _is_retryable(exc): + # 4xx (bad request, bad key, etc.) — retrying won't help. + raise + except httpx.TransportError as exc: + last_exc = exc + + if attempt < _MAX_RETRIES: + delay = _RETRY_BACKOFF_SECONDS[attempt] + logger.warning( + "jina_embeddings: attempt %s/%s failed (%s), retrying in %ss", + attempt + 1, + _MAX_RETRIES + 1, + last_exc, + delay, + ) + time.sleep(delay) + + logger.error( + "jina_embeddings: all %s attempts failed (%s)", _MAX_RETRIES + 1, last_exc + ) + raise last_exc + + +def _chunk(items: list[str], size: int) -> list[list[str]]: + return [items[i : i + size] for i in range(0, len(items), size)] + + +def embed_texts( + texts: list[str], task: EmbeddingTask = EmbeddingTask.PASSAGE +) -> list[list[float]]: + """Embed a batch of texts, batching internally if needed. Returns L2-normalized + vectors in the same order as the input.""" + if not texts: + return [] + + all_embeddings: list[list[float]] = [] + for batch in _chunk(texts, _MAX_BATCH_SIZE): + data = _post_with_retries( + { + "model": settings.jina_embedding_model, + "input": batch, + "task": task.value, + } + ) + # Jina returns results tagged with their input index; sort defensively + # in case the API doesn't guarantee response order matches request order. + ordered = sorted(data["data"], key=lambda item: item.get("index", 0)) + all_embeddings.extend(_normalize(item["embedding"]) for item in ordered) + + return all_embeddings + + +def embed_text( + text_input: str, task: EmbeddingTask = EmbeddingTask.PASSAGE +) -> list[float]: + """Embed a single text; convenience wrapper around embed_texts.""" + return embed_texts([text_input], task=task)[0] + + +def embed_passage(text_input: str) -> list[float]: + """Embed a document being indexed (e.g. a venue's search document).""" + return embed_text(text_input, task=EmbeddingTask.PASSAGE) + + +def embed_passages(texts: list[str]) -> list[list[float]]: + """Embed a batch of documents being indexed.""" + return embed_texts(texts, task=EmbeddingTask.PASSAGE) + + +def embed_query(query: str) -> list[float]: + """Embed a user's search query.""" + return embed_text(query, task=EmbeddingTask.QUERY) diff --git a/apps/api/app/infrastructure/llm/__init__.py b/apps/api/app/infrastructure/llm/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apps/api/app/infrastructure/llm/groq.py b/apps/api/app/infrastructure/llm/groq.py new file mode 100644 index 000000000..c2d653286 --- /dev/null +++ b/apps/api/app/infrastructure/llm/groq.py @@ -0,0 +1,83 @@ +"""Groq chat-completions client (OpenAI-compatible API). + +This module is the only place that knows about Groq's base URL, auth header, +and request/response shape. Callers (e.g. +app.modules.deep_research.query_understanding) should only ever import +chat_completion from here — they should never need to know this is Groq, or +what the HTTP contract looks like. Mirrors the retry/backoff shape of +app.infrastructure.embeddings.jina. +""" + +import logging +import time + +import httpx + +from app.core.config import settings + +logger = logging.getLogger(__name__) + +_MAX_RETRIES = 2 +_RETRY_BACKOFF_SECONDS = [1, 3] + + +def _is_retryable(exc: httpx.HTTPStatusError) -> bool: + status = exc.response.status_code + return status == 429 or status >= 500 + + +def chat_completion( + messages: list[dict[str, str]], temperature: float = 0.0 +) -> str: + """Send a chat-completions request to Groq, returning the assistant + message content. Raises on non-retryable errors (bad key, bad request) + and after exhausting retries on transient failures.""" + if not settings.groq_api_key: + # An empty key produces a malformed `Authorization: Bearer ` header, + # which httpx rejects locally before ever reaching the network — + # retrying that is guaranteed to fail again. Fail fast instead. + raise RuntimeError("GROQ_API_KEY is not configured — cannot call Groq") + + payload = { + "model": settings.groq_model, + "messages": messages, + "temperature": temperature, + } + + last_exc: Exception | None = None + for attempt in range(_MAX_RETRIES + 1): + try: + response = httpx.post( + f"{settings.groq_base_url}/chat/completions", + headers={ + "Authorization": f"Bearer {settings.groq_api_key}", + "Content-Type": "application/json", + }, + json=payload, + timeout=30.0, + ) + response.raise_for_status() + data = response.json() + return data["choices"][0]["message"]["content"] + except httpx.HTTPStatusError as exc: + last_exc = exc + if not _is_retryable(exc): + raise + except httpx.TransportError as exc: + last_exc = exc + + if attempt < _MAX_RETRIES: + delay = _RETRY_BACKOFF_SECONDS[attempt] + logger.warning( + "groq_chat_completion: attempt %s/%s failed (%s), retrying in %ss", + attempt + 1, + _MAX_RETRIES + 1, + last_exc, + delay, + ) + time.sleep(delay) + + logger.error( + "groq_chat_completion: all %s attempts failed (%s)", _MAX_RETRIES + 1, last_exc + ) + raise last_exc diff --git a/apps/api/app/jobs/balance_overdue.py b/apps/api/app/jobs/balance_overdue.py new file mode 100644 index 000000000..77bcc66b2 --- /dev/null +++ b/apps/api/app/jobs/balance_overdue.py @@ -0,0 +1,97 @@ +import logging +from datetime import datetime, timezone, timedelta + +from app.core.database import with_session +from app.modules.booking.models import ( + Booking, BookingStatus, PaymentStatus, BookingStatusHistory, +) +from app.modules.venue.models import Venue +from app.modules.notification import service as notifications + +logger = logging.getLogger(__name__) + +DEFAULT_ACTION_WINDOW_HOURS = 48 +BATCH = 100 + + +def run_flag() -> int: + """Flag confirmed-but-balance-unpaid bookings whose balance due date has + passed, opening the owner-action window (extend / forfeit / goodwill). + """ + now = datetime.now(timezone.utc) + today = now.date() + flagged = 0 + with with_session() as db: + rows = ( + db.query(Booking) + .filter( + Booking.status == BookingStatus.confirmed, + Booking.payment_status == PaymentStatus.advance_paid, + Booking.balance_due_date.isnot(None), + Booking.balance_due_date < today, + Booking.balance_overdue_at.is_(None), + Booking.deleted_at.is_(None), + ) + .with_for_update(skip_locked=True) + .limit(BATCH) + .all() + ) + for b in rows: + venue = db.get(Venue, b.venue_id) + window = venue.owner_action_window_hours if venue else DEFAULT_ACTION_WINDOW_HOURS + b.balance_overdue_at = now + b.owner_action_deadline = now + timedelta(hours=window) + venue_name = venue.name if venue else "your venue" + notifications.notify(db, b.user_id, "balance_overdue", + context={"venue_name": venue_name}, booking_id=b.id) + if venue: + notifications.notify(db, venue.owner_id, "balance_overdue", + context={"venue_name": venue_name}, booking_id=b.id) + flagged += 1 + logger.info("balance_overdue_flag: flagged %d booking(s)", flagged) + return flagged + + +def run_autocancel() -> int: + """Auto-cancel bookings whose owner-action window expired without the owner + extending the deadline or otherwise acting on the overdue balance. + + The advance is NOT refunded here: the customer missed the balance deadline, + so the advance is forfeited (this is a system cancel for non-payment, not an + owner cancellation). Owner-initiated cancels still refund per their own paths. + """ + now = datetime.now(timezone.utc) + cancelled = 0 + with with_session() as db: + rows = ( + db.query(Booking) + .filter( + Booking.status == BookingStatus.confirmed, + Booking.payment_status == PaymentStatus.advance_paid, + Booking.balance_overdue_at.isnot(None), + Booking.owner_action_deadline.isnot(None), + Booking.owner_action_deadline < now, + Booking.deleted_at.is_(None), + ) + .with_for_update(skip_locked=True) + .limit(BATCH) + .all() + ) + for b in rows: + old = b.status + b.status = BookingStatus.balance_overdue_cancelled + b.cancelled_at = now + if b.slot: + b.slot.is_blocking = False + db.add(BookingStatusHistory( + booking_id=b.id, old_status=old, + new_status=BookingStatus.balance_overdue_cancelled, + reason="balance_overdue_autocancel_job", + )) + venue = db.get(Venue, b.venue_id) + venue_name = venue.name if venue else "your venue" + notifications.notify(db, b.user_id, "booking_canceled", + context={"venue_name": venue_name}, booking_id=b.id) + cancelled += 1 + logger.info("balance_overdue_autocancel: cancelled %d booking(s)", cancelled) + return cancelled diff --git a/apps/api/app/jobs/booking_completion.py b/apps/api/app/jobs/booking_completion.py new file mode 100644 index 000000000..5f24c0532 --- /dev/null +++ b/apps/api/app/jobs/booking_completion.py @@ -0,0 +1,52 @@ +import logging +from datetime import datetime, timezone + +from app.core.database import with_session +from app.modules.booking.models import ( + Booking, BookingSlot, BookingStatus, PaymentStatus, BookingStatusHistory, +) +from app.modules.venue.models import Venue +from app.modules.notification import service as notifications + +logger = logging.getLogger(__name__) + +BATCH = 100 + + +def run() -> int: + """Mark confirmed bookings completed once the event date has passed and no + payment is pending. (Dispute/cancellation workflow checks go here too.) + """ + now = datetime.now(timezone.utc) + completed = 0 + with with_session() as db: + rows = ( + db.query(Booking) + .join(BookingSlot, BookingSlot.booking_id == Booking.id) + .filter( + Booking.status == BookingStatus.confirmed, + Booking.payment_status == PaymentStatus.fully_paid, + Booking.deleted_at.is_(None), + BookingSlot.deleted_at.is_(None), + BookingSlot.effective_ends_at < now, + ) + .with_for_update(skip_locked=True) + .limit(BATCH) + .all() + ) + for b in rows: + b.status = BookingStatus.completed + b.completed_at = now + if b.slot: + b.slot.is_blocking = False + db.add(BookingStatusHistory( + booking_id=b.id, old_status=BookingStatus.confirmed, + new_status=BookingStatus.completed, reason="booking_completion_job", + )) + venue = db.get(Venue, b.venue_id) + venue_name = venue.name if venue else "your venue" + notifications.notify(db, b.user_id, "booking_completed", + context={"venue_name": venue_name}, booking_id=b.id) + completed += 1 + logger.info("booking_completion: completed %d booking(s)", completed) + return completed diff --git a/apps/api/app/jobs/hold_expiry.py b/apps/api/app/jobs/hold_expiry.py new file mode 100644 index 000000000..77b7e774d --- /dev/null +++ b/apps/api/app/jobs/hold_expiry.py @@ -0,0 +1,45 @@ +import logging +from datetime import datetime, timezone + +from app.core.database import with_session +from app.modules.booking.models import Booking, BookingStatus, PaymentStatus, BookingStatusHistory +from app.modules.venue.models import Venue +from app.modules.notification import service as notifications + +logger = logging.getLogger(__name__) + +BATCH = 100 + + +def run() -> int: + """Cancel bookings whose 24-hour advance payment window has expired.""" + now = datetime.now(timezone.utc) + with with_session() as db: + rows = ( + db.query(Booking) + .filter( + Booking.status == BookingStatus.owner_accepted, + Booking.hold_expires_at.isnot(None), + Booking.hold_expires_at < now, + Booking.deleted_at.is_(None), + ) + .with_for_update(skip_locked=True) + .limit(BATCH) + .all() + ) + for b in rows: + b.status = BookingStatus.hold_expired + b.payment_status = PaymentStatus.unpaid + b.expired_at = now + if b.slot: + b.slot.is_blocking = False + db.add(BookingStatusHistory( + booking_id=b.id, old_status=BookingStatus.owner_accepted, + new_status=BookingStatus.hold_expired, reason="hold_expiry_job", + )) + venue = db.get(Venue, b.venue_id) + venue_name = venue.name if venue else "your venue" + notifications.notify(db, b.user_id, "hold_expired", + context={"venue_name": venue_name}, booking_id=b.id) + logger.info("hold_expiry: expired %d booking(s)", len(rows)) + return len(rows) diff --git a/apps/api/app/jobs/payment_pending_expiry.py b/apps/api/app/jobs/payment_pending_expiry.py new file mode 100644 index 000000000..d7b2e8d76 --- /dev/null +++ b/apps/api/app/jobs/payment_pending_expiry.py @@ -0,0 +1,45 @@ +import logging +from datetime import datetime, timezone + +from app.core.database import with_session +from app.modules.booking.models import Booking, BookingStatus, PaymentStatus, BookingStatusHistory +from app.modules.venue.models import Venue +from app.modules.notification import service as notifications + +logger = logging.getLogger(__name__) + +BATCH = 100 + + +def run() -> int: + """Cancel bookings whose 15-minute payment pending window has expired.""" + now = datetime.now(timezone.utc) + with with_session() as db: + rows = ( + db.query(Booking) + .filter( + Booking.status == BookingStatus.payment_pending, + Booking.payment_expires_at.isnot(None), + Booking.payment_expires_at < now, + Booking.deleted_at.is_(None), + ) + .with_for_update(skip_locked=True) + .limit(BATCH) + .all() + ) + for b in rows: + b.status = BookingStatus.hold_expired + b.payment_status = PaymentStatus.unpaid + b.expired_at = now + if b.slot: + b.slot.is_blocking = False + db.add(BookingStatusHistory( + booking_id=b.id, old_status=BookingStatus.payment_pending, + new_status=BookingStatus.hold_expired, reason="payment_pending_expiry_job", + )) + venue = db.get(Venue, b.venue_id) + venue_name = venue.name if venue else "your venue" + notifications.notify(db, b.user_id, "hold_expired", + context={"venue_name": venue_name}, booking_id=b.id) + logger.info("payment_pending_expiry: expired %d booking(s)", len(rows)) + return len(rows) diff --git a/apps/api/app/jobs/payment_reminders.py b/apps/api/app/jobs/payment_reminders.py new file mode 100644 index 000000000..69caab2a7 --- /dev/null +++ b/apps/api/app/jobs/payment_reminders.py @@ -0,0 +1,58 @@ +import logging +from datetime import datetime, timezone, timedelta + +from app.core.database import with_session +from app.modules.booking.models import Booking, BookingStatus +from app.modules.notification.models import InAppNotification +from app.modules.venue.models import Venue +from app.modules.notification import service as notifications + +logger = logging.getLogger(__name__) + +# The advance hold is only 24h, so reminders are keyed to the hold expiry, not +# days-before-event. Remind once when the hold is within this window of expiring. +REMINDER_BEFORE = timedelta(hours=12) +BATCH = 100 + + +def run() -> int: + """Remind accepted-but-unpaid users to pay the token advance before their + 24-hour hold expires. Deduped via the in-app notification row so a user gets + at most one reminder per booking even if the job runs repeatedly. + """ + now = datetime.now(timezone.utc) + sent = 0 + with with_session() as db: + rows = ( + db.query(Booking) + .filter( + Booking.status == BookingStatus.owner_accepted, + Booking.hold_expires_at.isnot(None), + Booking.hold_expires_at > now, + Booking.hold_expires_at <= now + REMINDER_BEFORE, + Booking.deleted_at.is_(None), + ) + .with_for_update(skip_locked=True) + .limit(BATCH) + .all() + ) + for b in rows: + already = ( + db.query(InAppNotification) + .filter( + InAppNotification.booking_id == b.id, + InAppNotification.type == "payment_reminder", + ) + .first() + ) + if already: + continue + venue = db.get(Venue, b.venue_id) + venue_name = venue.name if venue else "your venue" + hours_left = max(0, int((b.hold_expires_at - now).total_seconds() // 3600)) + notifications.notify(db, b.user_id, "payment_reminder", + context={"venue_name": venue_name, "hours_left": hours_left}, + booking_id=b.id) + sent += 1 + logger.info("payment_reminders: sent %d reminder(s)", sent) + return sent diff --git a/apps/api/app/jobs/runner.py b/apps/api/app/jobs/runner.py new file mode 100644 index 000000000..7f46b77c3 --- /dev/null +++ b/apps/api/app/jobs/runner.py @@ -0,0 +1,52 @@ +"""Single source of truth for the background-job catalogue. + +The same canonical `app/jobs/*` functions are run three ways: + - in-process via APScheduler (`app/jobs/scheduler.py`), when ENABLE_JOBS=true + - from the CLI (`apps/api/run_job.py`) + - from the machine-to-machine endpoint (`POST /api/internal/run-jobs`) + +Each job manages its own session (commit on success, rollback on error) and +returns the number of rows it processed. +""" +import logging + +from app.jobs import ( + hold_expiry, + stale_requests, + booking_completion, + payment_reminders, + balance_overdue, + search_indexer, + payment_pending_expiry, +) + +logger = logging.getLogger(__name__) + +# name -> callable. Names are stable; the cron triggers reference them. +JOBS = { + "hold_expiry": hold_expiry.run, + "request_expiry": stale_requests.run, + "completion": booking_completion.run, + "payment_reminders": payment_reminders.run, + "overdue_flag": balance_overdue.run_flag, + "overdue_autocancel": balance_overdue.run_autocancel, + "search_indexer": search_indexer.run, + "payment_pending_expiry": payment_pending_expiry.run, +} + + +def run_job(job_name: str) -> int: + """Run one named job and return the count it processed. + + Raises KeyError if the name is unknown so callers can map it to a 400. + """ + job_func = JOBS[job_name] + logger.info("running job %s", job_name) + count = job_func() + logger.info("job %s processed %s row(s)", job_name, count) + return count + + +def run_all() -> dict[str, int]: + """Run every job once; return a {name: count} summary.""" + return {name: run_job(name) for name in JOBS} diff --git a/apps/api/app/jobs/scheduler.py b/apps/api/app/jobs/scheduler.py new file mode 100644 index 000000000..0e2ceb005 --- /dev/null +++ b/apps/api/app/jobs/scheduler.py @@ -0,0 +1,30 @@ +from apscheduler.schedulers.background import BackgroundScheduler +from app.jobs import ( + hold_expiry, + stale_requests, + payment_reminders, + booking_completion, + balance_overdue, + search_indexer, + payment_pending_expiry, +) + +scheduler = BackgroundScheduler() + + +def start(): + scheduler.add_job(payment_pending_expiry.run, "interval", minutes=1, id="payment_pending_expiry") + scheduler.add_job(hold_expiry.run, "interval", hours=1, id="hold_expiry") + scheduler.add_job(stale_requests.run, "interval", hours=6, id="stale_requests") + # Hourly so the 12h pre-hold-expiry reminder window is reliably caught. + scheduler.add_job(payment_reminders.run, "interval", hours=1, id="payment_reminders") + scheduler.add_job(booking_completion.run, "cron", hour=0, id="booking_completion") + scheduler.add_job(balance_overdue.run_flag, "interval", hours=6, id="balance_overdue_flag") + scheduler.add_job(balance_overdue.run_autocancel, "interval", hours=6, id="balance_overdue_autocancel") + scheduler.add_job(search_indexer.run, "interval", hours=1, id="search_indexer") + scheduler.start() + + +def shutdown(): + if scheduler.running: + scheduler.shutdown(wait=False) diff --git a/apps/api/app/jobs/search_indexer.py b/apps/api/app/jobs/search_indexer.py new file mode 100644 index 000000000..0cb89d72a --- /dev/null +++ b/apps/api/app/jobs/search_indexer.py @@ -0,0 +1,79 @@ +import logging + +from app.core.config import settings +from app.core.database import SessionLocal +from app.modules.search.indexer import process_job, retryable_job_ids + +logger = logging.getLogger(__name__) + + +def _dequeue_from_upstash(limit: int) -> list[str]: + if not settings.upstash_redis_url or not settings.upstash_redis_token: + return [] + + try: + from upstash_redis import Redis + + redis = Redis( + url=settings.upstash_redis_url, + token=settings.upstash_redis_token, + ) + + job_ids = [] + + for _ in range(limit): + job_id = redis.rpop(settings.upstash_search_queue_key) + if not job_id: + break + job_ids.append(job_id) + + return job_ids + + except Exception as exc: + logger.warning("Redis unavailable: %s", exc) + return [] + + +def run() -> int: + db = SessionLocal() + + try: + job_ids = _dequeue_from_upstash(limit=10) + source = "Redis" + + if not job_ids: + job_ids = retryable_job_ids(db, limit=10) + source = "Database" + + if not job_ids: + logger.info("Search indexer: no jobs") + return 0 + + logger.info( + "Search indexer: processing %d job(s) from %s", + len(job_ids), + source, + ) + + processed = 0 + + for job_id in job_ids: + try: + process_job(db, job_id) + processed += 1 + except Exception: + logger.exception( + "Failed processing search index job %s", + job_id, + ) + + logger.info( + "Search indexer: finished (%d/%d processed)", + processed, + len(job_ids), + ) + + return processed + + finally: + db.close() diff --git a/apps/api/app/jobs/stale_requests.py b/apps/api/app/jobs/stale_requests.py new file mode 100644 index 000000000..e09fda7d1 --- /dev/null +++ b/apps/api/app/jobs/stale_requests.py @@ -0,0 +1,45 @@ +import logging +from datetime import datetime, timezone, timedelta + +from app.core.database import with_session +from app.modules.booking.models import Booking, BookingStatus, BookingStatusHistory +from app.modules.venue.models import Venue +from app.modules.notification import service as notifications + +logger = logging.getLogger(__name__) + +STALE_AFTER = timedelta(days=7) +BATCH = 100 + + +def run() -> int: + """Auto-expire booking requests that have been pending (requested) for 7 days.""" + now = datetime.now(timezone.utc) + cutoff = now - STALE_AFTER + expired = 0 + with with_session() as db: + rows = ( + db.query(Booking) + .filter( + Booking.status == BookingStatus.requested, + Booking.requested_at < cutoff, + Booking.deleted_at.is_(None), + ) + .with_for_update(skip_locked=True) + .limit(BATCH) + .all() + ) + for b in rows: + b.status = BookingStatus.request_expired + b.expired_at = now + db.add(BookingStatusHistory( + booking_id=b.id, old_status=BookingStatus.requested, + new_status=BookingStatus.request_expired, reason="stale_requests_job", + )) + venue = db.get(Venue, b.venue_id) + venue_name = venue.name if venue else "the venue" + notifications.notify(db, b.user_id, "request_expired", + context={"venue_name": venue_name}, booking_id=b.id) + expired += 1 + logger.info("stale_requests: expired %d request(s)", expired) + return expired diff --git a/apps/api/app/main.py b/apps/api/app/main.py new file mode 100644 index 000000000..86bb37863 --- /dev/null +++ b/apps/api/app/main.py @@ -0,0 +1,74 @@ +import logging +from contextlib import asynccontextmanager +from fastapi import FastAPI +from app.core.config import settings +from app.core.database import SessionLocal +from app.core.logging import setup_logging +from app.core.middleware import register_middleware +from app.jobs import scheduler as job_scheduler +from app.modules.auth.routes import router as auth_router +from app.modules.profile.routes import router as profile_router +from app.modules.venue.routes import router as venue_router +from app.modules.search.routes import router as search_router +from app.modules.search import search_metadata_cache, query_normalizer +from app.modules.booking.routes import router as booking_router +from app.modules.availability.routes import router as availability_router +from app.modules.notification.routes import router as notification_router +from app.modules.admin.routes import router as admin_router +from app.modules.payment.routes import router as payment_router +from app.modules.internal.routes import router as internal_router +from app.modules.owner.routes import router as owner_router +from app.modules.deep_research.routes import router as deep_research_router +from app.modules.admin.service import seed_super_admin + + +logger = logging.getLogger(__name__) + + +def _load_search_metadata() -> None: + """Populate the search metadata cache (category boost groups, keyword + expansion, query-normalizer vocabulary) from the DB. Runs at startup so + none of it is hardcoded in the search module — see + app/modules/search/search_metadata_cache.py. + """ + with SessionLocal() as db: + search_metadata_cache.load_category_metadata(db) + vocabulary = search_metadata_cache.build_query_vocabulary(db) + query_normalizer.set_dynamic_vocabulary(vocabulary) + + +@asynccontextmanager +async def lifespan(_: FastAPI): + setup_logging() + seed_super_admin() + _load_search_metadata() + if settings.enable_jobs: + logger.info("ENABLE_JOBS=true — starting background scheduler") + job_scheduler.start() + else: + logger.info("ENABLE_JOBS=false — background scheduler not started") + yield + if settings.enable_jobs: + job_scheduler.shutdown() + + +app = FastAPI(title="Venue404 API", lifespan=lifespan) + +register_middleware(app) + +app.include_router(auth_router, prefix="/api/auth", tags=["auth"]) +app.include_router(profile_router, prefix="/api/profile", tags=["profile"]) +app.include_router(venue_router, prefix="/api/venues", tags=["venues"]) +app.include_router(search_router, prefix="/api/search", tags=["search"]) +app.include_router(booking_router, prefix="/api/bookings", tags=["bookings"]) +app.include_router(availability_router, prefix="/api/availability", tags=["availability"]) +app.include_router(notification_router, prefix="/api/notifications", tags=["notifications"]) +app.include_router(admin_router, prefix="/api/admin", tags=["admin"]) +app.include_router(payment_router, prefix="/api/payments", tags=["payments"]) +app.include_router(internal_router, prefix="/api/internal", tags=["internal"]) +app.include_router(owner_router, prefix="/api/owner", tags=["owner"]) +app.include_router(deep_research_router, prefix="/api/deep-research", tags=["deep-research"]) + +@app.get("/health", tags=["health"]) +async def health_check(): + return {"status": "ok"} diff --git a/apps/api/app/modules/admin/models.py b/apps/api/app/modules/admin/models.py new file mode 100644 index 000000000..746427999 --- /dev/null +++ b/apps/api/app/modules/admin/models.py @@ -0,0 +1,19 @@ +import uuid +from datetime import datetime +from sqlalchemy import String, DateTime, Text, ForeignKey, JSON, func +from sqlalchemy.orm import mapped_column, Mapped +from sqlalchemy.dialects.postgresql import UUID +from app.core.database import Base + + +class AdminAction(Base): + __tablename__ = "admin_actions" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + admin_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("profiles.id", ondelete="RESTRICT"), nullable=False) + action_type: Mapped[str] = mapped_column(String, nullable=False) + target_type: Mapped[str] = mapped_column(String, nullable=False) + target_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False) + reason: Mapped[str | None] = mapped_column(Text, nullable=True) + action_metadata: Mapped[dict | None] = mapped_column("metadata", JSON, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=func.now()) diff --git a/apps/api/app/modules/admin/routes.py b/apps/api/app/modules/admin/routes.py new file mode 100644 index 000000000..21b12947a --- /dev/null +++ b/apps/api/app/modules/admin/routes.py @@ -0,0 +1,359 @@ +from uuid import UUID +from fastapi import APIRouter, Depends, Query, UploadFile, File, HTTPException +from sqlalchemy.orm import Session + +from app.core.database import get_db +from app.modules.admin.schemas import ( + VenueActionRequest, + AdminVenueListResponse, + UserListResponse, + UserSummary, + SuspendUserRequest, + ReactivateUserRequest, + AdminActionListResponse, + OwnerApprovalRequest, + OwnerStatsResponse, + AmenityCreateRequest, + AmenityUpdateRequest, + AdminAmenityResponse, + AmenityListResponse, + AmenityDeleteResponse, + CategoryCreateRequest, + CategoryUpdateRequest, + AdminCategoryResponse, + CategoryListResponse, + CategoryDeleteResponse, + CategoryBannerResponse, + BookingStatsResponse, + AdminBookingListResponse, + VenueStatsResponse, + GrowthStatsResponse, + DeepResearchQueryDetail, + DeepResearchQueryListResponse, + DeepResearchStatsResponse, +) +from app.modules.auth.dependencies import require_admin, AuthContext +from app.modules.admin import service + +router = APIRouter() + + +@router.get("/venues/stats", response_model=VenueStatsResponse) +def get_venue_stats( + _: AuthContext = Depends(require_admin), + db: Session = Depends(get_db), +): + return service.get_venue_stats(db) + + +@router.get("/venues", response_model=AdminVenueListResponse) +def list_venues( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + status: str | None = Query(None, pattern="^(draft|pending_approval|approved|rejected|suspended)$"), + search: str | None = Query(None), + _: AuthContext = Depends(require_admin), + db: Session = Depends(get_db), +): + return service.list_admin_venues(db, status=status, search=search, page=page, page_size=page_size) + + +@router.patch("/venues/{venue_id}/approve", status_code=204) +def approve_venue( + venue_id: UUID, + body: VenueActionRequest, + auth: AuthContext = Depends(require_admin), + db: Session = Depends(get_db), +): + service.approve_venue(db, admin_id=auth.user_id, venue_id=venue_id, reason=body.reason) + + +@router.patch("/venues/{venue_id}/reject", status_code=204) +def reject_venue( + venue_id: UUID, + body: VenueActionRequest, + auth: AuthContext = Depends(require_admin), + db: Session = Depends(get_db), +): + service.reject_venue(db, admin_id=auth.user_id, venue_id=venue_id, reason=body.reason) + + +@router.patch("/venues/{venue_id}/suspend", status_code=204) +def suspend_venue( + venue_id: UUID, + body: VenueActionRequest, + auth: AuthContext = Depends(require_admin), + db: Session = Depends(get_db), +): + service.suspend_venue(db, admin_id=auth.user_id, venue_id=venue_id, reason=body.reason) + + +@router.patch("/venues/{venue_id}/reactivate", status_code=204) +def reactivate_venue( + venue_id: UUID, + body: VenueActionRequest, + auth: AuthContext = Depends(require_admin), + db: Session = Depends(get_db), +): + service.reactivate_venue(db, admin_id=auth.user_id, venue_id=venue_id, reason=body.reason) + + +@router.get("/users", response_model=UserListResponse) +def list_users( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + search: str | None = Query(None), + status: str | None = Query(None, pattern="^(active|suspended|pending|rejected)$"), + role: str | None = Query(None, pattern="^(customer|venue_owner|super_admin)$"), + _: AuthContext = Depends(require_admin), + db: Session = Depends(get_db), +): + return service.list_users( + db, + page=page, + page_size=page_size, + search=search, + status=status, + role=role, + ) + + +@router.get("/users/{user_id}", response_model=UserSummary) +def get_user( + user_id: UUID, + _: AuthContext = Depends(require_admin), + db: Session = Depends(get_db), +): + return service.get_user(db, user_id) + + +@router.patch("/users/{user_id}/suspend", status_code=204) +def suspend_user( + user_id: UUID, + body: SuspendUserRequest, + auth: AuthContext = Depends(require_admin), + db: Session = Depends(get_db), +): + service.suspend_user(db, admin_id=auth.user_id, user_id=user_id, reason=body.reason) + + +@router.patch("/users/{user_id}/reactivate", status_code=204) +def reactivate_user( + user_id: UUID, + body: ReactivateUserRequest, + auth: AuthContext = Depends(require_admin), + db: Session = Depends(get_db), +): + service.reactivate_user(db, admin_id=auth.user_id, user_id=user_id, reason=body.reason) + + +@router.get("/venue-owners/stats", response_model=OwnerStatsResponse) +def get_owner_stats( + _: AuthContext = Depends(require_admin), + db: Session = Depends(get_db), +): + return service.get_owner_stats(db) + + +@router.patch("/venue-owners/{user_id}/approve", status_code=204) +def approve_owner( + user_id: UUID, + body: OwnerApprovalRequest, + auth: AuthContext = Depends(require_admin), + db: Session = Depends(get_db), +): + service.approve_owner(db, admin_id=auth.user_id, user_id=user_id, reason=body.reason) + + +@router.patch("/venue-owners/{user_id}/reject", status_code=204) +def reject_owner( + user_id: UUID, + body: OwnerApprovalRequest, + auth: AuthContext = Depends(require_admin), + db: Session = Depends(get_db), +): + service.reject_owner(db, admin_id=auth.user_id, user_id=user_id, reason=body.reason) + + +@router.get("/actions", response_model=AdminActionListResponse) +def list_actions( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + target_type: str | None = Query(None, pattern="^(user|venue|booking|amenity)$"), + action_type: str | None = Query(None), + # Legacy convenience: limit=N returns N items on page 1 (used by dashboard) + limit: int | None = Query(None, ge=1, le=100), + _: AuthContext = Depends(require_admin), + db: Session = Depends(get_db), +): + return service.list_actions( + db, page=page, page_size=page_size, + target_type=target_type, action_type=action_type, limit=limit, + ) + + +@router.get("/amenities", response_model=AmenityListResponse) +def list_amenities( + include_deleted: bool = Query(False), + _: AuthContext = Depends(require_admin), + db: Session = Depends(get_db), +): + return service.list_amenities(db, include_deleted=include_deleted) + + +@router.post("/amenities", response_model=AdminAmenityResponse, status_code=201) +def create_amenity( + body: AmenityCreateRequest, + auth: AuthContext = Depends(require_admin), + db: Session = Depends(get_db), +): + return service.create_amenity(db, admin_id=auth.user_id, name=body.name, icon=body.icon) + + +@router.patch("/amenities/{amenity_id}", response_model=AdminAmenityResponse) +def update_amenity( + amenity_id: UUID, + body: AmenityUpdateRequest, + auth: AuthContext = Depends(require_admin), + db: Session = Depends(get_db), +): + return service.update_amenity(db, admin_id=auth.user_id, amenity_id=amenity_id, body=body) + + +@router.delete("/amenities/{amenity_id}", response_model=AmenityDeleteResponse) +def delete_amenity( + amenity_id: UUID, + auth: AuthContext = Depends(require_admin), + db: Session = Depends(get_db), +): + return service.delete_amenity(db, admin_id=auth.user_id, amenity_id=amenity_id) + + +# ── Category routes ──────────────────────────────────────────────────────────── + +@router.get("/categories", response_model=CategoryListResponse) +def list_categories( + include_deleted: bool = Query(False), + _: AuthContext = Depends(require_admin), + db: Session = Depends(get_db), +): + return service.list_categories(db, include_deleted=include_deleted) + + +@router.post("/categories", response_model=AdminCategoryResponse, status_code=201) +def create_category( + body: CategoryCreateRequest, + auth: AuthContext = Depends(require_admin), + db: Session = Depends(get_db), +): + return service.create_category( + db, + admin_id=auth.user_id, + slug=body.slug, + label=body.label, + icon=body.icon, + sort_order=body.sort_order, + ) + + +@router.patch("/categories/{category_id}", response_model=AdminCategoryResponse) +def update_category( + category_id: UUID, + body: CategoryUpdateRequest, + auth: AuthContext = Depends(require_admin), + db: Session = Depends(get_db), +): + return service.update_category(db, admin_id=auth.user_id, category_id=category_id, body=body) + + +@router.post("/categories/{category_id}/banner-image", response_model=CategoryBannerResponse) +async def upload_category_banner( + category_id: UUID, + file: UploadFile = File(...), + auth: AuthContext = Depends(require_admin), + db: Session = Depends(get_db), +): + if not file.content_type or not file.content_type.startswith("image/"): + raise HTTPException(status_code=400, detail="File must be an image") + file_bytes = await file.read() + return service.upload_category_banner(db, admin_id=auth.user_id, category_id=category_id, file_bytes=file_bytes) + + +@router.delete("/categories/{category_id}/banner-image", response_model=CategoryBannerResponse) +def delete_category_banner( + category_id: UUID, + auth: AuthContext = Depends(require_admin), + db: Session = Depends(get_db), +): + return service.delete_category_banner(db, admin_id=auth.user_id, category_id=category_id) + + +@router.delete("/categories/{category_id}", response_model=CategoryDeleteResponse) +def delete_category( + category_id: UUID, + auth: AuthContext = Depends(require_admin), + db: Session = Depends(get_db), +): + return service.delete_category(db, admin_id=auth.user_id, category_id=category_id) + + +# ─── Booking routes ──────────────────────────────────────────────────────────── + +@router.get("/bookings/stats", response_model=BookingStatsResponse) +def get_booking_stats( + _: AuthContext = Depends(require_admin), + db: Session = Depends(get_db), +): + return service.get_booking_stats(db) + + +@router.get("/bookings", response_model=AdminBookingListResponse) +def list_bookings( + page: int = Query(1, ge=1), + page_size: int = Query(25, ge=1, le=100), + status: str | None = Query(None), + search: str | None = Query(None), + _: AuthContext = Depends(require_admin), + db: Session = Depends(get_db), +): + return service.list_admin_bookings(db, status=status, search=search, page=page, page_size=page_size) + + +@router.get("/growth-stats", response_model=GrowthStatsResponse) +def get_growth_stats( + period: str = Query("6m", pattern="^(7d|30d|3m|6m|12m)$"), + _: AuthContext = Depends(require_admin), + db: Session = Depends(get_db), +): + return service.get_growth_stats(db, period=period) + + +# ─── Deep Research observability ─────────────────────────────────────────────── + +@router.get("/deep-research/stats", response_model=DeepResearchStatsResponse) +def get_deep_research_stats( + days: int = Query(30, ge=1, le=90), + _: AuthContext = Depends(require_admin), + db: Session = Depends(get_db), +): + return service.get_deep_research_stats(db, days=days) + + +@router.get("/deep-research/queries", response_model=DeepResearchQueryListResponse) +def list_deep_research_queries( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + search: str | None = Query(None), + _: AuthContext = Depends(require_admin), + db: Session = Depends(get_db), +): + return service.list_deep_research_queries(db, page=page, page_size=page_size, search=search) + + +@router.get("/deep-research/queries/{query_id}", response_model=DeepResearchQueryDetail) +def get_deep_research_query( + query_id: UUID, + _: AuthContext = Depends(require_admin), + db: Session = Depends(get_db), +): + return service.get_deep_research_query(db, query_id) diff --git a/apps/api/app/modules/admin/schemas.py b/apps/api/app/modules/admin/schemas.py new file mode 100644 index 000000000..e7e535ec4 --- /dev/null +++ b/apps/api/app/modules/admin/schemas.py @@ -0,0 +1,319 @@ +import uuid +from datetime import datetime +from pydantic import BaseModel, Field +from typing import Literal, Optional + + +class VenueApprovalRequest(BaseModel): + action: Literal["approve", "reject"] + reason: str = "" + + +class UserSummary(BaseModel): + id: uuid.UUID + full_name: str | None + email: str | None + phone: str | None + status: str + roles: list[str] + created_at: datetime + is_super_admin: bool + + model_config = {"from_attributes": True} + + +class UserStats(BaseModel): + total: int + active: int + suspended: int + pending: int + rejected: int + + +class UserListResponse(BaseModel): + items: list[UserSummary] + total: int + page: int + page_size: int + total_pages: int + stats: UserStats + + +class SuspendUserRequest(BaseModel): + reason: str + + +class ReactivateUserRequest(BaseModel): + reason: str = "" + + +class AdminActionResponse(BaseModel): + id: uuid.UUID + admin_id: uuid.UUID + admin_name: str | None + action_type: str + target_type: str + target_id: uuid.UUID + target_name: str | None + reason: str | None + created_at: datetime + + model_config = {"from_attributes": True} + + +class AdminActionListResponse(BaseModel): + items: list[AdminActionResponse] + total: int + page: int + page_size: int + total_pages: int + + +class OwnerApprovalRequest(BaseModel): + reason: str = "" + + +class OwnerStatsResponse(BaseModel): + total: int + pending: int + active: int + rejected: int + suspended: int + + +class AmenityCreateRequest(BaseModel): + name: str = Field(..., min_length=1, max_length=100) + icon: Optional[str] = None + + +class AmenityUpdateRequest(BaseModel): + name: Optional[str] = Field(None, min_length=1, max_length=100) + icon: Optional[str] = None + + +class AdminAmenityResponse(BaseModel): + id: uuid.UUID + name: str + icon: Optional[str] + created_at: datetime + deleted_at: Optional[datetime] + active_venue_count: int + + model_config = {"from_attributes": True} + + +class AmenityListResponse(BaseModel): + items: list[AdminAmenityResponse] + total: int + + +class AmenityDeleteResponse(BaseModel): + deleted: bool + active_venue_count: int + + +# ─── Venue category admin schemas ───────────────────────────────────────────── + +class CategoryCreateRequest(BaseModel): + slug: str = Field(..., min_length=1, max_length=100, pattern=r'^[a-z0-9_]+$') + label: str = Field(..., min_length=1, max_length=100) + icon: Optional[str] = None + sort_order: int = Field(default=0, ge=0) + + +class CategoryUpdateRequest(BaseModel): + label: Optional[str] = Field(None, min_length=1, max_length=100) + icon: Optional[str] = None + sort_order: Optional[int] = Field(None, ge=0) + is_active: Optional[bool] = None + + +class AdminCategoryResponse(BaseModel): + id: uuid.UUID + slug: str + label: str + icon: Optional[str] + banner_image: Optional[str] + is_active: bool + sort_order: int + created_at: datetime + deleted_at: Optional[datetime] + venue_count: int + + model_config = {"from_attributes": True} + + +class CategoryListResponse(BaseModel): + items: list[AdminCategoryResponse] + total: int + + +class CategoryDeleteResponse(BaseModel): + deleted: bool + venue_count: int + + +class CategoryBannerResponse(BaseModel): + banner_image: str + + +# ─── Booking admin schemas ───────────────────────────────────────────────────── + +class BookingStatsResponse(BaseModel): + total: int + requested: int + confirmed: int + completed: int + cancelled: int + + +class AdminBookingSummary(BaseModel): + id: uuid.UUID + venue_id: uuid.UUID + venue_name: str + customer_name: str | None + customer_email: str | None + customer_phone: str | None + owner_id: uuid.UUID + owner_name: str | None + owner_email: str | None + owner_phone: str | None + status: str + payment_status: str + event_date: str + guest_count: int + created_at: datetime + + model_config = {"from_attributes": True} + + +class AdminBookingListResponse(BaseModel): + items: list[AdminBookingSummary] + total: int + page: int + page_size: int + total_pages: int + stats: BookingStatsResponse + + +# ─── Venue admin schemas ─────────────────────────────────────────────────────── + +class GrowthStatsResponse(BaseModel): + labels: list[str] + users: list[int] + owners: list[int] + venues: list[int] + bookings: list[int] + totals: dict[str, int] + + +class VenueStatsResponse(BaseModel): + total: int + pending_approval: int + approved: int + rejected: int + suspended: int + draft: int + + +class VenueActionRequest(BaseModel): + reason: str = "" + + +class AdminVenueOwner(BaseModel): + id: uuid.UUID + full_name: str | None + email: str | None + + +class AdminVenueItem(BaseModel): + id: uuid.UUID + name: str + slug: str | None + description: str | None + category_slug: str + address_line1: str + city: str + state: str + country: str + min_capacity: int | None + max_capacity: int + open_time: str + close_time: str + pricing_mode: str + starting_price_paise: int | None + hourly_rate_paise: int | None + advance_pct: float + platform_commission_pct: float + status: str + is_active: bool + cover_photo_url: str | None + amenities: list[str] + owner: AdminVenueOwner + created_at: datetime + updated_at: datetime + + model_config = {"from_attributes": True} + + +class AdminVenueStats(BaseModel): + total: int + pending_approval: int + approved: int + rejected: int + suspended: int + draft: int + + +class AdminVenueListResponse(BaseModel): + items: list[AdminVenueItem] + total: int + page: int + page_size: int + total_pages: int + + +# ── Deep Research observability ──────────────────────────────────────────────── +# Read-only reporting over app.modules.deep_research.models.DeepResearchQuery. +# No admin_actions audit entries needed here (nothing mutates) — contrast with +# the Phase 3 lead/reservation admin endpoints, which will need audit logging. + + +class DeepResearchTopResult(BaseModel): + id: str + name: str + match_source: Optional[str] = None + match_score: Optional[float] = None + + +class DeepResearchQuerySummary(BaseModel): + model_config = {"from_attributes": True} + + id: uuid.UUID + user_id: uuid.UUID + query_text: str + city_filter: Optional[str] = None + result_count: int + avg_match_score: Optional[float] = None + created_at: datetime + + +class DeepResearchQueryDetail(DeepResearchQuerySummary): + understanding_json: Optional[dict] = None + top_results_json: Optional[list[DeepResearchTopResult]] = None + + +class DeepResearchQueryListResponse(BaseModel): + items: list[DeepResearchQuerySummary] + total: int + page: int + page_size: int + + +class DeepResearchStatsResponse(BaseModel): + labels: list[str] + query_counts: list[int] + avg_match_scores: list[Optional[float]] + total_queries: int + avg_result_count: float + avg_match_score_overall: Optional[float] = None diff --git a/apps/api/app/modules/admin/service.py b/apps/api/app/modules/admin/service.py new file mode 100644 index 000000000..5caed2324 --- /dev/null +++ b/apps/api/app/modules/admin/service.py @@ -0,0 +1,1300 @@ +import json +import logging +import math +import urllib.request +import uuid +from datetime import datetime, timedelta, timezone +from sqlalchemy.orm import Session, joinedload +from sqlalchemy import func, case, text, cast +from sqlalchemy.dialects.postgresql import UUID as PGUUID +from sqlalchemy.exc import IntegrityError + +from app.core.config import settings +from app.core.database import SessionLocal +from app.core.exceptions import NotFoundError, ForbiddenError, ConflictError +from app.modules.admin.models import AdminAction +from app.modules.admin.schemas import ( + AmenityUpdateRequest, + CategoryUpdateRequest, + DeepResearchQueryDetail, + DeepResearchQueryListResponse, + DeepResearchQuerySummary, + DeepResearchStatsResponse, +) +from app.modules.profile.models import Profile, UserRoleAssignment, UserRole, ProfileStatus +from app.modules.venue.models import Amenity, VenueAmenity, Venue, VenueCategory, VenueStatus +from app.modules.booking.models import Booking, BookingSlot, BookingStatus +from app.modules.deep_research.models import DeepResearchQuery +from app.core.storage import upload_image_to_cloudinary, delete_image_from_cloudinary + + + +logger = logging.getLogger(__name__) + +def _month_start(year: int, month: int) -> datetime: + return datetime(year, month, 1, tzinfo=timezone.utc) + + +def _add_months(dt: datetime, n: int) -> datetime: + total = dt.year * 12 + (dt.month - 1) + n + y, m = divmod(total, 12) + return _month_start(y, m + 1) + + +def get_growth_stats(db: Session, period: str = "6m") -> dict: + from datetime import timedelta + + now = datetime.now(timezone.utc) + + # Parse period → build (start, end, label) buckets + trunc unit + VALID = {"7d", "30d", "3m", "6m", "12m"} + if period not in VALID: + period = "6m" + + use_days = period.endswith("d") + buckets: list[tuple[datetime, datetime, str]] = [] + + if use_days: + n_days = int(period[:-1]) + today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) + for i in range(n_days - 1, -1, -1): + start = today_start - timedelta(days=i) + end = start + timedelta(days=1) + label = f"{start.day} {start.strftime('%b')}" + buckets.append((start, end, label)) + trunc = "day" + else: + n_months = int(period[:-1]) + for i in range(n_months - 1, -1, -1): + start = _month_start(now.year, now.month) + start = _add_months(start, -i) + end = _add_months(start, 1) + label = start.strftime("%b %y") + buckets.append((start, end, label)) + trunc = "month" + + period_start = buckets[0][0] + + def baseline(model, extra_filter=None): + q = db.query(func.count(model.id)).filter(model.created_at < period_start) + if extra_filter is not None: + q = q.filter(extra_filter) + return q.scalar() or 0 + + def bucket_new(model, extra_filter=None): + q = ( + db.query( + func.date_trunc(trunc, model.created_at).label("bucket"), + func.count(model.id).label("cnt"), + ) + .filter(model.created_at >= period_start) + ) + if extra_filter is not None: + q = q.filter(extra_filter) + rows = q.group_by("bucket").all() + return {r.bucket.replace(tzinfo=timezone.utc): r.cnt for r in rows} + + base_users = baseline(Profile) + base_owners = baseline(UserRoleAssignment, UserRoleAssignment.role == UserRole.venue_owner) + base_venues = baseline(Venue, Venue.deleted_at.is_(None)) + base_bookings = baseline(Booking, Booking.deleted_at.is_(None)) + + new_users = bucket_new(Profile) + new_owners = bucket_new(UserRoleAssignment, UserRoleAssignment.role == UserRole.venue_owner) + new_venues = bucket_new(Venue, Venue.deleted_at.is_(None)) + new_bookings = bucket_new(Booking, Booking.deleted_at.is_(None)) + + labels, users_s, owners_s, venues_s, bookings_s = [], [], [], [], [] + cum_u = base_users; cum_o = base_owners; cum_v = base_venues; cum_b = base_bookings + + for start, _end, label in buckets: + cum_u += new_users.get(start, 0) + cum_o += new_owners.get(start, 0) + cum_v += new_venues.get(start, 0) + cum_b += new_bookings.get(start, 0) + labels.append(label) + users_s.append(cum_u) + owners_s.append(cum_o) + venues_s.append(cum_v) + bookings_s.append(cum_b) + + return { + "labels": labels, + "users": users_s, + "owners": owners_s, + "venues": venues_s, + "bookings": bookings_s, + "totals": { + "users": users_s[-1] if users_s else 0, + "owners": owners_s[-1] if owners_s else 0, + "venues": venues_s[-1] if venues_s else 0, + "bookings": bookings_s[-1] if bookings_s else 0, + }, + } + + +def get_venue_stats(db: Session) -> dict: + row = db.query(Venue).filter(Venue.deleted_at.is_(None)).with_entities( + func.count(Venue.id).label("total"), + func.count(case((Venue.status == VenueStatus.pending_approval, 1))).label("pending_approval"), + func.count(case((Venue.status == VenueStatus.approved, 1))).label("approved"), + func.count(case((Venue.status == VenueStatus.rejected, 1))).label("rejected"), + func.count(case((Venue.status == VenueStatus.suspended, 1))).label("suspended"), + func.count(case((Venue.status == VenueStatus.draft, 1))).label("draft"), + ).one() + return { + "total": row.total, + "pending_approval": row.pending_approval, + "approved": row.approved, + "rejected": row.rejected, + "suspended": row.suspended, + "draft": row.draft, + } + + +def list_admin_venues( + db: Session, + *, + status: str | None = None, + search: str | None = None, + page: int = 1, + page_size: int = 20, +) -> dict: + base = db.query(Venue).filter(Venue.deleted_at.is_(None)) + + stats_row = base.with_entities( + func.count(Venue.id).label("total"), + func.count(case((Venue.status == VenueStatus.pending_approval, 1))).label("pending_approval"), + func.count(case((Venue.status == VenueStatus.approved, 1))).label("approved"), + func.count(case((Venue.status == VenueStatus.rejected, 1))).label("rejected"), + func.count(case((Venue.status == VenueStatus.suspended, 1))).label("suspended"), + func.count(case((Venue.status == VenueStatus.draft, 1))).label("draft"), + ).one() + + filtered = base + if status: + filtered = filtered.filter(Venue.status == VenueStatus(status)) + if search: + safe = search.strip().replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + filtered = filtered.filter(Venue.name.ilike(f"%{safe}%")) + + total = filtered.with_entities(func.count(Venue.id)).scalar() + venues = ( + filtered + .options(joinedload(Venue.photos), joinedload(Venue.amenities), joinedload(Venue.category)) + .order_by(Venue.updated_at.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + .all() + ) + + owner_ids = [v.owner_id for v in venues] + profiles = db.query(Profile).filter(Profile.id.in_(owner_ids)).all() + profile_by_id = {p.id: p for p in profiles} + + items = [] + for v in venues: + cover = next((p.image_url for p in v.photos if p.is_cover and p.deleted_at is None), None) + owner = profile_by_id.get(v.owner_id) + items.append({ + "id": v.id, + "name": v.name, + "slug": v.slug, + "description": v.description, + "category_slug": v.category.slug, + "address_line1": v.address_line1, + "city": v.city, + "state": v.state, + "country": v.country, + "min_capacity": v.min_capacity, + "max_capacity": v.max_capacity, + "open_time": str(v.open_time), + "close_time": str(v.close_time), + "pricing_mode": v.pricing_mode, + "starting_price_paise": v.starting_price_paise, + "hourly_rate_paise": v.hourly_rate_paise, + "advance_pct": float(v.advance_pct), + "platform_commission_pct": float(v.platform_commission_pct), + "status": v.status.value, + "is_active": v.is_active, + "cover_photo_url": cover, + "amenities": [a.name for a in v.amenities], + "owner": { + "id": owner.id if owner else v.owner_id, + "full_name": owner.full_name if owner else None, + "email": owner.email if owner else None, + }, + "created_at": v.created_at, + "updated_at": v.updated_at, + }) + + return { + "items": items, + "total": total, + "page": page, + "page_size": page_size, + "total_pages": math.ceil(total / page_size) if total else 1, + "stats": { + "total": stats_row.total, + "pending_approval": stats_row.pending_approval, + "approved": stats_row.approved, + "rejected": stats_row.rejected, + "suspended": stats_row.suspended, + "draft": stats_row.draft, + }, + } + + +def _get_venue_or_404(db: Session, venue_id: uuid.UUID) -> Venue: + venue = db.query(Venue).filter(Venue.id == venue_id, Venue.deleted_at.is_(None)).first() + if not venue: + raise NotFoundError("Venue not found") + return venue + + +def _check_no_active_bookings(db: Session, venue_id: uuid.UUID) -> None: + # to_regclass returns NULL (not an error) when the table doesn't exist, so this query + # never fails and never poisons the current transaction. + table_exists = db.execute(text("SELECT to_regclass('public.bookings') IS NOT NULL")).scalar() + if not table_exists: + return # Bookings not yet migrated — skip check + try: + from app.modules.booking.models import Booking, BookingStatus # noqa: PLC0415 + count = ( + db.query(func.count(Booking.id)) + .filter( + cast(Booking.venue_id, PGUUID) == venue_id, + Booking.status.in_([ + BookingStatus.requested, BookingStatus.accepted, BookingStatus.confirmed, + ]), + ) + .scalar() + ) + if count and count > 0: + raise ConflictError(f"Cannot suspend: venue has {count} active booking(s)") + except ConflictError: + raise + except Exception as e: + logger.warning("Booking check error for venue %s: %s", venue_id, e) + + +def approve_venue( + db: Session, + *, + admin_id: uuid.UUID, + venue_id: uuid.UUID, + reason: str = "", +) -> None: + venue = _get_venue_or_404(db, venue_id) + if venue.status != VenueStatus.pending_approval: + raise ConflictError("Venue is not pending approval") + venue.status = VenueStatus.approved + db.add(AdminAction( + admin_id=admin_id, action_type="venue_approved", + target_type="venue", target_id=venue_id, reason=reason or None, + )) + db.commit() + + from app.modules.search.indexer import enqueue_job + enqueue_job(db, venue_id, "update") + + +def reject_venue( + db: Session, + *, + admin_id: uuid.UUID, + venue_id: uuid.UUID, + reason: str = "", +) -> None: + venue = _get_venue_or_404(db, venue_id) + if venue.status not in (VenueStatus.pending_approval, VenueStatus.approved): + raise ConflictError("Venue cannot be rejected in its current state") + venue.status = VenueStatus.rejected + db.add(AdminAction( + admin_id=admin_id, action_type="venue_rejected", + target_type="venue", target_id=venue_id, reason=reason or None, + )) + db.commit() + + +def suspend_venue( + db: Session, + *, + admin_id: uuid.UUID, + venue_id: uuid.UUID, + reason: str, +) -> None: + if not reason.strip(): + raise ConflictError("Reason is required to suspend a venue") + venue = _get_venue_or_404(db, venue_id) + if venue.status == VenueStatus.suspended: + raise ConflictError("Venue is already suspended") + if venue.status != VenueStatus.approved: + raise ConflictError("Only approved venues can be suspended") + _check_no_active_bookings(db, venue_id) + venue.status = VenueStatus.suspended + db.add(AdminAction( + admin_id=admin_id, action_type="venue_suspended", + target_type="venue", target_id=venue_id, reason=reason, + )) + db.commit() + + +def reactivate_venue( + db: Session, + *, + admin_id: uuid.UUID, + venue_id: uuid.UUID, + reason: str = "", +) -> None: + venue = _get_venue_or_404(db, venue_id) + if venue.status not in (VenueStatus.suspended, VenueStatus.rejected): + raise ConflictError("Only suspended or rejected venues can be reactivated") + venue.status = VenueStatus.approved + db.add(AdminAction( + admin_id=admin_id, action_type="venue_reactivated", + target_type="venue", target_id=venue_id, reason=reason or None, + )) + db.commit() + + from app.modules.search.indexer import enqueue_job + enqueue_job(db, venue_id, "update") + + +def approve_owner( + db: Session, + *, + admin_id: uuid.UUID, + user_id: uuid.UUID, + reason: str = "", +) -> None: + profile = db.query(Profile).filter( + Profile.id == user_id, + Profile.deleted_at.is_(None), + ).first() + if not profile: + raise NotFoundError("User not found") + if profile.status != ProfileStatus.pending: + raise ConflictError("User is not in pending status") + + profile.status = ProfileStatus.active + db.add(AdminAction( + admin_id=admin_id, + action_type="venue_owner_approved", + target_type="user", + target_id=user_id, + reason=reason or None, + )) + db.commit() + + +def reject_owner( + db: Session, + *, + admin_id: uuid.UUID, + user_id: uuid.UUID, + reason: str = "", +) -> None: + profile = db.query(Profile).filter( + Profile.id == user_id, + Profile.deleted_at.is_(None), + ).first() + if not profile: + raise NotFoundError("User not found") + if profile.status != ProfileStatus.pending: + raise ConflictError("User is not in pending status") + + profile.status = ProfileStatus.rejected + db.add(AdminAction( + admin_id=admin_id, + action_type="venue_owner_rejected", + target_type="user", + target_id=user_id, + reason=reason or None, + )) + db.commit() + + +def _build_user_dict( + profile: Profile, + roles: list[str], + email: str | None, +) -> dict: + return { + "id": profile.id, + "full_name": profile.full_name, + "email": email, + "phone": profile.phone, + "status": profile.status.value, + "roles": roles, + "created_at": profile.created_at, + "is_super_admin": "super_admin" in roles, + } + + +def get_owner_stats(db: Session) -> dict: + owner_subq = ( + db.query(UserRoleAssignment.user_id) + .filter(UserRoleAssignment.role == UserRole.venue_owner) + .subquery() + ) + base = db.query(Profile).filter( + Profile.deleted_at.is_(None), + Profile.id.in_(owner_subq), + ) + row = base.with_entities( + func.count(Profile.id).label("total"), + func.count(case((Profile.status == ProfileStatus.pending, 1))).label("pending"), + func.count(case((Profile.status == ProfileStatus.active, 1))).label("active"), + func.count(case((Profile.status == ProfileStatus.rejected, 1))).label("rejected"), + func.count(case((Profile.status == ProfileStatus.suspended, 1))).label("suspended"), + ).one() + return { + "total": row.total, + "pending": row.pending, + "active": row.active, + "rejected": row.rejected, + "suspended": row.suspended, + } + + +def list_users( + db: Session, + *, + page: int = 1, + page_size: int = 20, + search: str | None = None, + status: str | None = None, + role: str | None = None, +) -> dict: + base = db.query(Profile).filter(Profile.deleted_at.is_(None)) + + filtered = base + if search: + safe = search.strip().replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + pattern = f"%{safe}%" + filtered = filtered.filter( + Profile.full_name.ilike(pattern) | Profile.email.ilike(pattern) + ) + if status: + filtered = filtered.filter(Profile.status == ProfileStatus(status)) + if role: + role_subq = ( + db.query(UserRoleAssignment.user_id) + .filter(UserRoleAssignment.role == UserRole(role)) + .subquery() + ) + filtered = filtered.filter(Profile.id.in_(role_subq)) + + total = filtered.with_entities(func.count(Profile.id)).scalar() + + # Global stats — always reflect platform-wide counts regardless of filters + stats_row = base.with_entities( + func.count(Profile.id).label("total"), + func.count(case((Profile.status == ProfileStatus.active, 1))).label("active"), + func.count(case((Profile.status == ProfileStatus.suspended, 1))).label("suspended"), + func.count(case((Profile.status == ProfileStatus.pending, 1))).label("pending"), + func.count(case((Profile.status == ProfileStatus.rejected, 1))).label("rejected"), + ).one() + + profiles = ( + filtered.order_by(Profile.created_at.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + .all() + ) + + profile_ids = [p.id for p in profiles] + + role_rows = ( + db.query(UserRoleAssignment) + .filter(UserRoleAssignment.user_id.in_(profile_ids)) + .all() + ) + roles_by_user: dict[uuid.UUID, list[str]] = {pid: [] for pid in profile_ids} + for rr in role_rows: + if rr.user_id in roles_by_user: + roles_by_user[rr.user_id].append(rr.role.value) + + items = [ + _build_user_dict(p, roles_by_user.get(p.id, []), p.email) + for p in profiles + ] + + return { + "items": items, + "total": total, + "page": page, + "page_size": page_size, + "total_pages": math.ceil(total / page_size) if total else 1, + "stats": { + "total": stats_row.total, + "active": stats_row.active, + "suspended": stats_row.suspended, + "pending": stats_row.pending, + "rejected": stats_row.rejected, + }, + } + + +def get_user(db: Session, user_id: uuid.UUID) -> dict: + profile = db.query(Profile).filter( + Profile.id == user_id, + Profile.deleted_at.is_(None), + ).first() + if not profile: + raise NotFoundError("User not found") + + role_rows = db.query(UserRoleAssignment).filter( + UserRoleAssignment.user_id == user_id, + ).all() + roles = [r.role.value for r in role_rows] + return _build_user_dict(profile, roles, profile.email) + + +def suspend_user( + db: Session, + *, + admin_id: uuid.UUID, + user_id: uuid.UUID, + reason: str, +) -> None: + if not reason.strip(): + raise ConflictError("Reason is required") + + profile = db.query(Profile).filter( + Profile.id == user_id, + Profile.deleted_at.is_(None), + ).first() + if not profile: + raise NotFoundError("User not found") + + is_super_admin = db.query(UserRoleAssignment).filter( + UserRoleAssignment.user_id == user_id, + UserRoleAssignment.role == UserRole.super_admin, + ).first() + if is_super_admin: + raise ForbiddenError("Super admin accounts cannot be suspended") + + if profile.status == ProfileStatus.suspended: + raise ConflictError("User is already suspended") + + profile.status = ProfileStatus.suspended + db.add(AdminAction( + admin_id=admin_id, + action_type="user_suspended", + target_type="user", + target_id=user_id, + reason=reason, + )) + db.commit() + + +def reactivate_user( + db: Session, + *, + admin_id: uuid.UUID, + user_id: uuid.UUID, + reason: str = "", +) -> None: + profile = db.query(Profile).filter( + Profile.id == user_id, + Profile.deleted_at.is_(None), + ).first() + if not profile: + raise NotFoundError("User not found") + + if profile.status == ProfileStatus.active: + raise ConflictError("User is already active") + + profile.status = ProfileStatus.active + db.add(AdminAction( + admin_id=admin_id, + action_type="user_reactivated", + target_type="user", + target_id=user_id, + reason=reason, + )) + db.commit() + + +def _get_amenity_or_404(db: Session, amenity_id: uuid.UUID) -> Amenity: + amenity = db.query(Amenity).filter(Amenity.id == amenity_id).first() + if not amenity: + raise NotFoundError("Amenity not found") + return amenity + + +def _count_active_venues(db: Session, amenity_id: uuid.UUID) -> int: + return db.query(VenueAmenity).filter(VenueAmenity.amenity_id == amenity_id).count() + + +def _amenity_to_dict(amenity: Amenity, active_venue_count: int) -> dict: + return { + "id": amenity.id, + "name": amenity.name, + "icon": amenity.icon, + "created_at": amenity.created_at, + "deleted_at": amenity.deleted_at, + "active_venue_count": active_venue_count, + } + + +def list_amenities(db: Session, *, include_deleted: bool = False) -> dict: + query = db.query(Amenity) + if not include_deleted: + query = query.filter(Amenity.deleted_at.is_(None)) + + amenities = query.order_by(Amenity.name.asc()).all() + amenity_ids = [a.id for a in amenities] + + count_rows = ( + db.query(VenueAmenity.amenity_id, func.count(VenueAmenity.venue_id).label("cnt")) + .filter(VenueAmenity.amenity_id.in_(amenity_ids)) + .group_by(VenueAmenity.amenity_id) + .all() + ) if amenity_ids else [] + counts = {row.amenity_id: row.cnt for row in count_rows} + + items = [_amenity_to_dict(a, counts.get(a.id, 0)) for a in amenities] + return {"items": items, "total": len(items)} + + +def create_amenity( + db: Session, + *, + admin_id: uuid.UUID, + name: str, + icon: str | None, +) -> dict: + normalized = name.strip() + # Serialize concurrent creates for the same name — prevents two transactions + # from both reading "not found" and both inserting, which DB constraints alone can't stop. + db.execute(text("SELECT pg_advisory_xact_lock(hashtext(:n))"), {"n": normalized.lower()}) + amenity = Amenity(name=normalized, icon=icon) + db.add(amenity) + try: + db.flush() + except IntegrityError: + db.rollback() + raise ConflictError("An amenity with this name already exists") + + db.add(AdminAction( + admin_id=admin_id, + action_type="amenity_created", + target_type="amenity", + target_id=amenity.id, + )) + db.commit() + db.refresh(amenity) + return _amenity_to_dict(amenity, 0) + + +def update_amenity( + db: Session, + *, + admin_id: uuid.UUID, + amenity_id: uuid.UUID, + body: AmenityUpdateRequest, +) -> dict: + amenity = _get_amenity_or_404(db, amenity_id) + if amenity.deleted_at is not None: + raise ConflictError("Cannot update a deleted amenity") + + if "name" in body.model_fields_set and body.name is not None: + normalized = body.name.strip() + db.execute(text("SELECT pg_advisory_xact_lock(hashtext(:n))"), {"n": normalized.lower()}) + amenity.name = normalized + if "icon" in body.model_fields_set: + amenity.icon = body.icon + + try: + db.flush() + except IntegrityError: + db.rollback() + raise ConflictError("An amenity with this name already exists") + + db.add(AdminAction( + admin_id=admin_id, + action_type="amenity_updated", + target_type="amenity", + target_id=amenity.id, + )) + db.commit() + db.refresh(amenity) + active_count = _count_active_venues(db, amenity.id) + return _amenity_to_dict(amenity, active_count) + + +def delete_amenity( + db: Session, + *, + admin_id: uuid.UUID, + amenity_id: uuid.UUID, +) -> dict: + amenity = _get_amenity_or_404(db, amenity_id) + if amenity.deleted_at is not None: + raise ConflictError("Amenity is already deleted") + + active_count = _count_active_venues(db, amenity.id) + amenity.deleted_at = datetime.now(timezone.utc) + + db.add(AdminAction( + admin_id=admin_id, + action_type="amenity_deleted", + target_type="amenity", + target_id=amenity.id, + )) + db.commit() + return {"deleted": True, "active_venue_count": active_count} + + +def _get_category_or_404(db: Session, category_id: uuid.UUID) -> VenueCategory: + cat = db.query(VenueCategory).filter(VenueCategory.id == category_id).first() + if not cat: + raise NotFoundError("Category not found") + return cat + + +def _count_category_venues(db: Session, category_id: uuid.UUID) -> int: + return db.query(Venue).filter(Venue.category_id == category_id, Venue.deleted_at.is_(None)).count() + + +def _category_to_dict(cat: VenueCategory, venue_count: int) -> dict: + return { + "id": cat.id, + "slug": cat.slug, + "label": cat.label, + "icon": cat.icon, + "banner_image": cat.banner_image, + "is_active": cat.is_active, + "sort_order": cat.sort_order, + "created_at": cat.created_at, + "deleted_at": cat.deleted_at, + "venue_count": venue_count, + } + + +def list_categories(db: Session, *, include_deleted: bool = False) -> dict: + query = db.query(VenueCategory) + if not include_deleted: + query = query.filter(VenueCategory.deleted_at.is_(None)) + cats = query.order_by(VenueCategory.sort_order.asc(), VenueCategory.label.asc()).all() + + cat_ids = [c.id for c in cats] + count_rows = ( + db.query(Venue.category_id, func.count(Venue.id).label("cnt")) + .filter(Venue.category_id.in_(cat_ids), Venue.deleted_at.is_(None)) + .group_by(Venue.category_id) + .all() + ) if cat_ids else [] + counts = {row.category_id: row.cnt for row in count_rows} + + items = [_category_to_dict(c, counts.get(c.id, 0)) for c in cats] + return {"items": items, "total": len(items)} + + +def create_category( + db: Session, + *, + admin_id: uuid.UUID, + slug: str, + label: str, + icon: str | None, + sort_order: int, +) -> dict: + normalized_slug = slug.strip().lower() + db.execute(text("SELECT pg_advisory_xact_lock(hashtext(:s))"), {"s": normalized_slug}) + cat = VenueCategory(slug=normalized_slug, label=label.strip(), icon=icon, sort_order=sort_order) + db.add(cat) + try: + db.flush() + except IntegrityError: + db.rollback() + raise ConflictError("A category with this slug already exists") + + db.add(AdminAction( + admin_id=admin_id, + action_type="category_created", + target_type="category", + target_id=cat.id, + )) + db.commit() + db.refresh(cat) + return _category_to_dict(cat, 0) + + +def update_category( + db: Session, + *, + admin_id: uuid.UUID, + category_id: uuid.UUID, + body: CategoryUpdateRequest, +) -> dict: + cat = _get_category_or_404(db, category_id) + if cat.deleted_at is not None: + raise ConflictError("Cannot update a deleted category") + + if "label" in body.model_fields_set and body.label is not None: + cat.label = body.label.strip() + if "icon" in body.model_fields_set: + cat.icon = body.icon + if "sort_order" in body.model_fields_set and body.sort_order is not None: + cat.sort_order = body.sort_order + if "is_active" in body.model_fields_set and body.is_active is not None: + cat.is_active = body.is_active + + db.add(AdminAction( + admin_id=admin_id, + action_type="category_updated", + target_type="category", + target_id=cat.id, + )) + db.commit() + db.refresh(cat) + venue_count = _count_category_venues(db, cat.id) + return _category_to_dict(cat, venue_count) + + +def upload_category_banner( + db: Session, + *, + admin_id: uuid.UUID, + category_id: uuid.UUID, + file_bytes: bytes, +) -> dict: + cat = _get_category_or_404(db, category_id) + if cat.deleted_at is not None: + raise ConflictError("Cannot update a deleted category") + + if cat.banner_image: + try: + delete_image_from_cloudinary(cat.banner_image) + except Exception: + pass + + banner_url = upload_image_to_cloudinary(file_bytes, folder=f"categories/{category_id}") + cat.banner_image = banner_url + + db.add(AdminAction( + admin_id=admin_id, + action_type="category_banner_updated", + target_type="category", + target_id=cat.id, + )) + db.commit() + db.refresh(cat) + return {"banner_image": cat.banner_image} + + +def delete_category_banner( + db: Session, + *, + admin_id: uuid.UUID, + category_id: uuid.UUID, +) -> dict: + cat = _get_category_or_404(db, category_id) + if cat.banner_image: + try: + delete_image_from_cloudinary(cat.banner_image) + except Exception: + pass + cat.banner_image = None + db.add(AdminAction( + admin_id=admin_id, + action_type="category_banner_deleted", + target_type="category", + target_id=cat.id, + )) + db.commit() + return {"banner_image": None} + + +def delete_category( + db: Session, + *, + admin_id: uuid.UUID, + category_id: uuid.UUID, +) -> dict: + cat = _get_category_or_404(db, category_id) + if cat.deleted_at is not None: + raise ConflictError("Category is already deleted") + + venue_count = _count_category_venues(db, cat.id) + cat.deleted_at = datetime.now(timezone.utc) + cat.is_active = False + + db.add(AdminAction( + admin_id=admin_id, + action_type="category_deleted", + target_type="category", + target_id=cat.id, + )) + db.commit() + return {"deleted": True, "venue_count": venue_count} + + +def list_actions( + db: Session, + *, + page: int = 1, + page_size: int = 20, + target_type: str | None = None, + action_type: str | None = None, + # Legacy — used by dashboard summary (returns at most `limit` items, page 1) + limit: int | None = None, +) -> dict: + query = db.query(AdminAction) + if target_type: + query = query.filter(AdminAction.target_type == target_type) + if action_type: + query = query.filter(AdminAction.action_type == action_type) + + total = query.with_entities(func.count(AdminAction.id)).scalar() + + effective_page_size = limit if limit is not None else page_size + offset = 0 if limit is not None else (page - 1) * page_size + + items = ( + query + .order_by(AdminAction.created_at.desc()) + .offset(offset) + .limit(effective_page_size) + .all() + ) + + # Batch-enrich admin names + admin_ids = list({a.admin_id for a in items}) + admin_profiles = db.query(Profile).filter(Profile.id.in_(admin_ids)).all() + admin_name_by_id = {p.id: p.full_name for p in admin_profiles} + + # Batch-resolve target names grouped by target_type + target_name_by_id: dict[uuid.UUID, str] = {} + + user_ids = [a.target_id for a in items if a.target_type == "user"] + if user_ids: + rows = db.query(Profile.id, Profile.full_name).filter(Profile.id.in_(user_ids)).all() + target_name_by_id.update({r.id: r.full_name for r in rows if r.full_name}) + + venue_ids = [a.target_id for a in items if a.target_type == "venue"] + if venue_ids: + rows = db.query(Venue.id, Venue.name).filter(Venue.id.in_(venue_ids)).all() + target_name_by_id.update({r.id: r.name for r in rows}) + + amenity_ids = [a.target_id for a in items if a.target_type == "amenity"] + if amenity_ids: + rows = db.query(Amenity.id, Amenity.name).filter(Amenity.id.in_(amenity_ids)).all() + target_name_by_id.update({r.id: r.name for r in rows}) + + enriched = [ + { + "id": a.id, + "admin_id": a.admin_id, + "admin_name": admin_name_by_id.get(a.admin_id), + "action_type": a.action_type, + "target_type": a.target_type, + "target_id": a.target_id, + "target_name": target_name_by_id.get(a.target_id), + "reason": a.reason, + "created_at": a.created_at, + } + for a in items + ] + + return { + "items": enriched, + "total": total, + "page": 1 if limit is not None else page, + "page_size": effective_page_size, + "total_pages": math.ceil(total / effective_page_size) if total else 1, + } + + +def seed_super_admin() -> None: + """ + Idempotent. Creates the super admin user in Supabase on first run. + Skipped entirely if SUPER_ADMIN_EMAIL or SUPER_ADMIN_PASSWORD + are not set in the environment. + """ + email = settings.super_admin_email + password = settings.super_admin_password + name = settings.super_admin_name + + if not email or not password: + logger.info("SUPER_ADMIN_EMAIL/PASSWORD not set, skipping super admin seed") + return + + db: Session = SessionLocal() + try: + _seed(db, email, password, name) + finally: + db.close() + + +def _seed(db: Session, email: str, password: str, name: str) -> None: + auth_user_id = _resolve_supabase_user(email, password, name) + + profile = db.query(Profile).filter(Profile.id == auth_user_id).first() + if not profile: + profile = Profile( + id=auth_user_id, + full_name=name or "Super Admin", + status=ProfileStatus.active, + ) + db.add(profile) + db.flush() + logger.info("Created profile for super admin %s", email) + elif name and profile.full_name != name: + profile.full_name = name + db.flush() + logger.info("Updated full_name for super admin %s", email) + + role_exists = db.query(UserRoleAssignment).filter( + UserRoleAssignment.user_id == auth_user_id, + UserRoleAssignment.role == UserRole.super_admin, + ).first() + + if not role_exists: + db.add(UserRoleAssignment(user_id=auth_user_id, role=UserRole.super_admin)) + logger.info("Assigned super_admin role to %s", email) + + db.commit() + logger.info("Super admin seed complete for %s", email) + + +def _supabase_admin_request(method: str, path: str, body: dict | None = None) -> dict: + """Makes a request to the Supabase Auth Admin REST API.""" + url = f"{settings.supabase_url}/auth/v1/admin/{path}" + data = json.dumps(body).encode() if body else None + req = urllib.request.Request( + url, + data=data, + method=method, + headers={ + "Authorization": f"Bearer {settings.supabase_service_role_key}", + "apikey": settings.supabase_service_role_key, + "Content-Type": "application/json", + }, + ) + with urllib.request.urlopen(req, timeout=10) as resp: # noqa: S310 + return json.loads(resp.read()) + + +def _resolve_supabase_user(email: str, password: str, name: str = "") -> uuid.UUID: + """ + Returns the UUID of the Supabase auth user, creating them if they + don't exist. Uses the Admin API so no confirmation email is triggered. + """ + # List users and search by email + page = 1 + per_page = 1000 + while True: + data = _supabase_admin_request("GET", f"users?page={page}&per_page={per_page}") + users = data.get("users", []) + for u in users: + if u.get("email") == email: + logger.info("Super admin already exists in Supabase: %s", email) + return uuid.UUID(u["id"]) + if len(users) < per_page: + break + page += 1 + + # Not found — create via Admin API (email_confirm skips verification email) + result = _supabase_admin_request("POST", "users", { + "email": email, + "password": password, + "email_confirm": True, + "user_metadata": {"full_name": name or "Super Admin"}, + }) + logger.info("Created Supabase auth user for super admin: %s", email) + return uuid.UUID(result["id"]) + + +# ─── Booking admin service ───────────────────────────────────────────────────── + +_CANCELLED_STATUSES = ( + BookingStatus.hold_expired, + BookingStatus.request_expired, + BookingStatus.conflict_cancelled, + BookingStatus.user_cancelled, + BookingStatus.admin_cancelled, + BookingStatus.owner_rejected, + BookingStatus.balance_overdue_cancelled, +) + + +def get_booking_stats(db: Session) -> dict: + row = db.query( + func.count(Booking.id).label("total"), + func.count(case((Booking.status == BookingStatus.requested, 1))).label("requested"), + func.count(case((Booking.status == BookingStatus.confirmed, 1))).label("confirmed"), + func.count(case((Booking.status == BookingStatus.completed, 1))).label("completed"), + func.count(case((Booking.status.in_(_CANCELLED_STATUSES), 1))).label("cancelled"), + ).filter(Booking.deleted_at.is_(None)).one() + return { + "total": row.total, + "requested": row.requested, + "confirmed": row.confirmed, + "completed": row.completed, + "cancelled": row.cancelled, + } + + +def list_admin_bookings( + db: Session, + *, + status: str | None = None, + search: str | None = None, + page: int = 1, + page_size: int = 25, +) -> dict: + stats = get_booking_stats(db) + + base = ( + db.query(Booking) + .join(Venue, Venue.id == Booking.venue_id) + .join(Profile, Profile.id == Booking.user_id) + .filter(Booking.deleted_at.is_(None)) + ) + + if status: + if status == "cancelled": + base = base.filter(Booking.status.in_(_CANCELLED_STATUSES)) + else: + base = base.filter(Booking.status == BookingStatus(status)) + + if search: + safe = search.strip().replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + base = base.filter(Venue.name.ilike(f"%{safe}%") | Profile.full_name.ilike(f"%{safe}%")) + + total = base.count() + total_pages = max(1, math.ceil(total / page_size)) + rows = ( + base + .order_by(Booking.created_at.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + .all() + ) + + slot_map: dict = {} + owner_map: dict = {} + if rows: + booking_ids = [b.id for b in rows] + slots = db.query(BookingSlot).filter(BookingSlot.booking_id.in_(booking_ids)).all() + slot_map = {s.booking_id: s for s in slots} + + owner_ids = list({b.venue.owner_id for b in rows}) + owners = db.query(Profile).filter(Profile.id.in_(owner_ids)).all() + owner_map = {o.id: o for o in owners} + + items: list[dict] = [] + for b in rows: + slot = slot_map.get(b.id) + event_date = slot.starts_at.date().isoformat() if slot else "" + owner = owner_map.get(b.venue.owner_id) + items.append({ + "id": b.id, + "venue_id": b.venue_id, + "venue_name": b.venue.name, + "customer_name": b.user.full_name, + "customer_email": b.user.email, + "customer_phone": b.user.phone, + "owner_id": b.venue.owner_id, + "owner_name": owner.full_name if owner else None, + "owner_email": owner.email if owner else None, + "owner_phone": owner.phone if owner else None, + "status": b.status.value, + "payment_status": b.payment_status.value, + "event_date": event_date, + "guest_count": b.guest_count, + "created_at": b.created_at, + }) + + return { + "items": items, + "total": total, + "page": page, + "page_size": page_size, + "total_pages": total_pages, + "stats": stats, + } + + +# ─── Deep Research observability ─────────────────────────────────────────────── +# Read-only reporting over deep_research.models.DeepResearchQuery. No +# admin_actions audit entries here — nothing mutates state (contrast with +# Phase 3's lead/reservation admin endpoints, which will need audit logging). + + +def list_deep_research_queries( + db: Session, page: int, page_size: int, search: str | None = None +) -> DeepResearchQueryListResponse: + q = db.query(DeepResearchQuery) + if search: + q = q.filter(DeepResearchQuery.query_text.ilike(f"%{search}%")) + + total = q.count() + rows = ( + q.order_by(DeepResearchQuery.created_at.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + .all() + ) + + return DeepResearchQueryListResponse( + items=[DeepResearchQuerySummary.model_validate(r) for r in rows], + total=total, + page=page, + page_size=page_size, + ) + + +def get_deep_research_query(db: Session, query_id: uuid.UUID) -> DeepResearchQueryDetail: + row = db.query(DeepResearchQuery).filter(DeepResearchQuery.id == query_id).first() + if not row: + raise NotFoundError("Deep research query not found") + return DeepResearchQueryDetail.model_validate(row) + + +def get_deep_research_stats(db: Session, days: int = 30) -> DeepResearchStatsResponse: + now = datetime.now(timezone.utc) + today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) + period_start = today_start - timedelta(days=days - 1) + + rows = ( + db.query( + func.date_trunc("day", DeepResearchQuery.created_at).label("bucket"), + func.count(DeepResearchQuery.id).label("cnt"), + func.avg(DeepResearchQuery.avg_match_score).label("avg_score"), + ) + .filter(DeepResearchQuery.created_at >= period_start) + .group_by("bucket") + .all() + ) + by_bucket = {r.bucket.replace(tzinfo=timezone.utc): r for r in rows} + + labels: list[str] = [] + counts: list[int] = [] + avg_scores: list[float | None] = [] + for i in range(days - 1, -1, -1): + day = today_start - timedelta(days=i) + row = by_bucket.get(day) + labels.append(f"{day.day} {day.strftime('%b')}") + counts.append(row.cnt if row else 0) + avg_scores.append(float(row.avg_score) if row and row.avg_score is not None else None) + + total_queries = db.query(func.count(DeepResearchQuery.id)).scalar() or 0 + avg_result_count = db.query(func.avg(DeepResearchQuery.result_count)).scalar() + avg_match_score_overall = db.query(func.avg(DeepResearchQuery.avg_match_score)).scalar() + + return DeepResearchStatsResponse( + labels=labels, + query_counts=counts, + avg_match_scores=avg_scores, + total_queries=total_queries, + avg_result_count=float(avg_result_count) if avg_result_count is not None else 0.0, + avg_match_score_overall=( + float(avg_match_score_overall) if avg_match_score_overall is not None else None + ), + ) diff --git a/apps/api/app/modules/auth/__init__.py b/apps/api/app/modules/auth/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apps/api/app/modules/auth/dependencies.py b/apps/api/app/modules/auth/dependencies.py new file mode 100644 index 000000000..0c6ee7933 --- /dev/null +++ b/apps/api/app/modules/auth/dependencies.py @@ -0,0 +1,145 @@ +from dataclasses import dataclass +from uuid import UUID +from fastapi import Depends, Header +from sqlalchemy.orm import Session + +from app.core.database import get_db +from app.core.exceptions import UnauthorizedError, ForbiddenError +from app.modules.auth.providers.supabase import SupabaseAuthProvider +from app.modules.profile.models import Profile, UserRole, UserRoleAssignment, ProfileStatus + +_auth_provider = SupabaseAuthProvider() + + +@dataclass +class AuthContext: + user_id: UUID + email: str | None + full_name: str | None + phone: str | None + avatar_url: str | None + status: str + roles: list[str] + + def has_role(self, role: str) -> bool: + return role in self.roles + + def is_admin(self) -> bool: + return "super_admin" in self.roles + + def is_owner(self) -> bool: + return "venue_owner" in self.roles or self.is_admin() + + +def _extract_bearer_token(authorization: str) -> str: + parts = authorization.split() + if len(parts) != 2 or parts[0].lower() != "bearer": + raise UnauthorizedError("Invalid authorization header") + return parts[1] + + +def get_current_user( + authorization: str = Header(...), + db: Session = Depends(get_db), +) -> AuthContext: + token = _extract_bearer_token(authorization) + provider_user = _auth_provider.verify_token(token) + + profile = db.query(Profile).filter( + Profile.id == provider_user.id, + Profile.deleted_at.is_(None), + ).first() + + if not profile: + raise ForbiddenError("Account not found") + + if profile.status == ProfileStatus.suspended: + raise ForbiddenError("Account suspended") + + # Keep email in profiles in sync with the authoritative JWT value + if provider_user.email and profile.email != provider_user.email: + profile.email = provider_user.email + + role_rows = db.query(UserRoleAssignment).filter( + UserRoleAssignment.user_id == provider_user.id + ).all() + + roles = [r.role.value for r in role_rows] + + # Every account carries 'customer' as a baseline role (see CLAUDE.md role + # model). Accounts created before the signup trigger existed, or via + # out-of-band inserts (admin scripts, direct SQL), may be missing it — + # self-heal here so login works without a manual backfill. + if UserRole.customer.value not in roles: + db.add(UserRoleAssignment(user_id=profile.id, role=UserRole.customer)) + db.commit() + roles.append(UserRole.customer.value) + + return AuthContext( + user_id=profile.id, + email=provider_user.email, + full_name=profile.full_name, + phone=profile.phone, + avatar_url=profile.avatar_url, + status=profile.status.value, + roles=roles, + ) + + +def get_current_user_optional( + authorization: str | None = Header(default=None), + db: Session = Depends(get_db), +) -> AuthContext | None: + if not authorization: + return None + try: + return get_current_user(authorization, db) + except Exception: + return None + + +def require_auth( + current_user: AuthContext = Depends(get_current_user), +) -> AuthContext: + return current_user + + +def require_role(*roles: str): + def dependency( + current_user: AuthContext = Depends(get_current_user), + ) -> AuthContext: + if not any(r in current_user.roles for r in roles): + raise ForbiddenError("Insufficient permissions") + return current_user + return dependency + + +def require_any_role(roles: list[str]): + def dependency( + current_user: AuthContext = Depends(get_current_user), + ) -> AuthContext: + if not any(r in current_user.roles for r in roles): + raise ForbiddenError("Insufficient permissions") + return current_user + return dependency + + +def require_admin( + current_user: AuthContext = Depends(get_current_user), +) -> AuthContext: + if not current_user.is_admin(): + raise ForbiddenError("Admin access required") + return current_user + + +def require_owner( + current_user: AuthContext = Depends(get_current_user), +) -> AuthContext: + if not current_user.is_owner(): + raise ForbiddenError("Venue owner access required") + if not current_user.is_admin(): + if current_user.status == "pending": + raise ForbiddenError("account_pending") + if current_user.status == "rejected": + raise ForbiddenError("account_rejected") + return current_user diff --git a/apps/api/app/modules/auth/providers/__init__.py b/apps/api/app/modules/auth/providers/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apps/api/app/modules/auth/providers/base.py b/apps/api/app/modules/auth/providers/base.py new file mode 100644 index 000000000..cb5f35924 --- /dev/null +++ b/apps/api/app/modules/auth/providers/base.py @@ -0,0 +1,18 @@ +from abc import ABC, abstractmethod +from dataclasses import dataclass +from uuid import UUID + + +@dataclass +class ProviderUser: + id: UUID + email: str | None + + +class AuthProvider(ABC): + @abstractmethod + def verify_token(self, token: str) -> ProviderUser: + """Verify the token and return a normalized ProviderUser. + Raise UnauthorizedError if the token is invalid or expired. + """ + ... diff --git a/apps/api/app/modules/auth/providers/supabase.py b/apps/api/app/modules/auth/providers/supabase.py new file mode 100644 index 000000000..67d0903d5 --- /dev/null +++ b/apps/api/app/modules/auth/providers/supabase.py @@ -0,0 +1,55 @@ +import json +import urllib.request +from uuid import UUID + +from jose import jwt, JWTError + +from app.core.config import settings +from app.core.exceptions import UnauthorizedError +from app.modules.auth.providers.base import AuthProvider, ProviderUser + + +class SupabaseAuthProvider(AuthProvider): + def __init__(self) -> None: + self._jwks_cache: list | None = None + + def _get_jwks(self) -> list: + if self._jwks_cache is None: + url = f"{settings.supabase_url}/auth/v1/.well-known/jwks.json" + with urllib.request.urlopen(url, timeout=5) as resp: # noqa: S310 + self._jwks_cache = json.loads(resp.read()).get("keys", []) + return self._jwks_cache + + def verify_token(self, token: str) -> ProviderUser: + try: + header = jwt.get_unverified_header(token) + alg = header.get("alg", "HS256") + + if alg == "HS256": + payload = jwt.decode( + token, + settings.supabase_jwt_secret, + algorithms=["HS256"], + audience="authenticated", + ) + else: + # RS256 or other asymmetric alg — verify via Supabase JWKS + kid = header.get("kid") + keys = self._get_jwks() + key = next((k for k in keys if k.get("kid") == kid), None) or (keys[0] if keys else None) + if key is None: + raise UnauthorizedError("No matching JWKS key found") + payload = jwt.decode( + token, + key, + algorithms=[alg], + audience="authenticated", + ) + except JWTError as exc: + raise UnauthorizedError(f"JWT error: {exc}") + + sub = payload.get("sub") + if not sub: + raise UnauthorizedError("Token missing subject claim") + + return ProviderUser(id=UUID(sub), email=payload.get("email")) diff --git a/apps/api/app/modules/auth/routes.py b/apps/api/app/modules/auth/routes.py new file mode 100644 index 000000000..5d3cd5538 --- /dev/null +++ b/apps/api/app/modules/auth/routes.py @@ -0,0 +1,30 @@ +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +from app.core.database import get_db +from app.modules.auth.dependencies import get_current_user, require_auth, AuthContext +from app.modules.auth.schemas import AuthMeResponse +from app.modules.auth import service + +router = APIRouter() + + +@router.get("/me", response_model=AuthMeResponse) +def me(current_user: AuthContext = Depends(get_current_user)): + return service.get_me(current_user) + + +@router.post("/register-owner", status_code=204) +def register_owner( + current_user: AuthContext = Depends(require_auth), + db: Session = Depends(get_db), +): + service.register_owner(current_user.user_id, db) + + +@router.post("/reapply-owner", status_code=204) +def reapply_owner( + current_user: AuthContext = Depends(require_auth), + db: Session = Depends(get_db), +): + service.reapply_owner(current_user.user_id, db) diff --git a/apps/api/app/modules/auth/schemas.py b/apps/api/app/modules/auth/schemas.py new file mode 100644 index 000000000..23013a3d8 --- /dev/null +++ b/apps/api/app/modules/auth/schemas.py @@ -0,0 +1,16 @@ +from pydantic import BaseModel +from uuid import UUID + + +class ProfileResponse(BaseModel): + full_name: str | None + phone: str | None + avatar_url: str | None + status: str + + +class AuthMeResponse(BaseModel): + id: UUID + email: str | None + profile: ProfileResponse + roles: list[str] diff --git a/apps/api/app/modules/auth/service.py b/apps/api/app/modules/auth/service.py new file mode 100644 index 000000000..76c731a5a --- /dev/null +++ b/apps/api/app/modules/auth/service.py @@ -0,0 +1,58 @@ +from sqlalchemy.orm import Session + +from app.core.exceptions import ForbiddenError +from app.modules.auth.schemas import AuthMeResponse, ProfileResponse +from app.modules.profile.models import Profile, ProfileStatus, UserRoleAssignment, UserRole + + +def register_owner(user_id, db: Session) -> None: + profile = db.query(Profile).filter( + Profile.id == user_id, + Profile.deleted_at.is_(None), + ).first() + if not profile: + raise ForbiddenError("Account not found") + + has_owner_role = db.query(UserRoleAssignment).filter( + UserRoleAssignment.user_id == user_id, + UserRoleAssignment.role == "venue_owner", + ).first() + + if has_owner_role and profile.status == ProfileStatus.pending: + return # idempotent — already registered as owner + + if has_owner_role and profile.status not in (ProfileStatus.active, ProfileStatus.rejected): + raise ForbiddenError("Cannot register as owner in current account state") + + if not has_owner_role: + db.add(UserRoleAssignment(user_id=user_id, role=UserRole.venue_owner)) + + profile.status = ProfileStatus.pending + db.commit() + + +def reapply_owner(user_id, db: Session) -> None: + profile = db.query(Profile).filter( + Profile.id == user_id, + Profile.deleted_at.is_(None), + ).first() + if not profile: + raise ForbiddenError("Account not found") + if profile.status != ProfileStatus.rejected: + raise ForbiddenError("Only rejected accounts can re-apply") + profile.status = ProfileStatus.pending + db.commit() + + +def get_me(current_user) -> AuthMeResponse: + return AuthMeResponse( + id=current_user.user_id, + email=current_user.email, + profile=ProfileResponse( + full_name=current_user.full_name, + phone=current_user.phone, + avatar_url=current_user.avatar_url, + status=current_user.status, + ), + roles=current_user.roles, + ) diff --git a/apps/api/app/modules/availability/__init__.py b/apps/api/app/modules/availability/__init__.py new file mode 100644 index 000000000..cf7c7a6bd --- /dev/null +++ b/apps/api/app/modules/availability/__init__.py @@ -0,0 +1 @@ +"""Availability module package""" diff --git a/apps/api/app/modules/availability/routes.py b/apps/api/app/modules/availability/routes.py new file mode 100644 index 000000000..96892ff19 --- /dev/null +++ b/apps/api/app/modules/availability/routes.py @@ -0,0 +1,128 @@ +from datetime import date, datetime +from uuid import UUID + +from fastapi import APIRouter, Depends, Query +from sqlalchemy.orm import Session + +from app.core.database import get_db +from app.modules.auth.dependencies import AuthContext, require_owner +from app.modules.availability import service +from app.modules.availability.schemas import AvailabilityResponse, CalendarResponse, ValidationResponse +from app.modules.venue.schemas import BookingType, PricingQuote + +router = APIRouter() + + +@router.get( + "/venues/{venue_id}/availability", + response_model=AvailabilityResponse, +) +def availability_for_date_query( + venue_id: str, + availability_date: date = Query(..., alias="date"), + db: Session = Depends(get_db), +): + return service.get_availability_for_date( + db=db, + venue_id=venue_id, + booking_date=availability_date, + ) + + +@router.get( + "/venues/{venue_id}/calendar", + response_model=CalendarResponse, +) +def calendar( + venue_id: UUID, + start_date: date = Query(...), + end_date: date = Query(...), + db: Session = Depends(get_db), +): + return service.get_calendar( + db=db, + venue_id=venue_id, + start_date=start_date, + end_date=end_date, + ) + + +@router.get( + "/venues/{venue_id}/calendar/owner", + response_model=CalendarResponse, +) +def owner_calendar( + venue_id: UUID, + start_date: date = Query(...), + end_date: date = Query(...), + auth: AuthContext = Depends(require_owner), + db: Session = Depends(get_db), +): + return service.get_owner_calendar( + db=db, + venue_id=venue_id, + owner_id=auth.user_id, + start_date=start_date, + end_date=end_date, + allow_admin=auth.is_admin(), + ) + + +@router.get( + "/venues/{venue_id}/date/{booking_date}", + response_model=AvailabilityResponse, +) +def availability_for_date( + venue_id: str, + booking_date: date, + db: Session = Depends(get_db), +): + return service.get_availability_for_date( + db=db, + venue_id=venue_id, + booking_date=booking_date, + ) + + +@router.get( + "/venues/{venue_id}/quote", + response_model=PricingQuote, +) +def pricing_quote( + venue_id: str, + starts_at: datetime = Query(...), + ends_at: datetime = Query(...), + booking_type: BookingType = Query(...), + db: Session = Depends(get_db), +): + return service.get_pricing_quote( + db=db, + venue_id=venue_id, + starts_at=starts_at, + ends_at=ends_at, + booking_type=booking_type.value, + ) + + +@router.post( + "/venues/{venue_id}/validate", + response_model=ValidationResponse, +) +def validate_slot( + venue_id: str, + booking_type: BookingType, + starts_at: datetime | None = Query(None), + ends_at: datetime | None = Query(None), + booking_date: date | None = Query(None), + guest_count: int | None = Query(None, gt=0), + db: Session = Depends(get_db), +): + return service.validate_slot( + db=db, + venue_id=venue_id, + booking_type=booking_type.value, + starts_at=starts_at, + ends_at=ends_at, + booking_date=booking_date, + guest_count=guest_count, + ) diff --git a/apps/api/app/modules/availability/schemas.py b/apps/api/app/modules/availability/schemas.py new file mode 100644 index 000000000..44f156e9f --- /dev/null +++ b/apps/api/app/modules/availability/schemas.py @@ -0,0 +1,77 @@ +from datetime import date, datetime, time +from typing import Literal +from uuid import UUID + +from pydantic import BaseModel, Field + + +class OperatingWindow(BaseModel): + is_available: bool + opens_at: time | None = None + closes_at: time | None = None + spans_next_day: bool = False + + +class BlockedRange(BaseModel): + starts_at: datetime + ends_at: datetime + + +class AvailabilityResponse(BaseModel): + date: date + operating_window: OperatingWindow + blocked_slots: list[BlockedRange] = Field(default_factory=list) + + +class ValidationResponse(BaseModel): + valid: bool + effective_starts_at: datetime + effective_ends_at: datetime + + +CalendarDayStatus = Literal[ + "available", + "partially_booked", + "fully_booked", + "blocked", + "closed", +] + + +class CalendarBlockedRange(BaseModel): + starts_at: datetime + ends_at: datetime + source: Literal["booking", "venue_block"] + reason: str | None = None + + +class CalendarBookingSummary(BaseModel): + id: UUID + booking_type: str + status: str + starts_at: datetime + ends_at: datetime + effective_starts_at: datetime + effective_ends_at: datetime + is_blocking: bool + guest_count: int + event_type: str | None = None + user_id: UUID | None = None + + +class CalendarDay(BaseModel): + date: date + operating_window: OperatingWindow + status: CalendarDayStatus + is_bookable: bool + available_for_full_day: bool + blocked_ranges: list[CalendarBlockedRange] = Field(default_factory=list) + bookings: list[CalendarBookingSummary] = Field(default_factory=list) + + +class CalendarResponse(BaseModel): + venue_id: UUID + timezone: str + start_date: date + end_date: date + days: list[CalendarDay] diff --git a/apps/api/app/modules/availability/service.py b/apps/api/app/modules/availability/service.py new file mode 100644 index 000000000..46f5c8d6c --- /dev/null +++ b/apps/api/app/modules/availability/service.py @@ -0,0 +1,729 @@ +from datetime import date, datetime, time, timezone, timedelta +from uuid import UUID +from zoneinfo import ZoneInfo + +from fastapi import HTTPException, status +from sqlalchemy.orm import Session, joinedload + +from app.modules.booking.models import BookingSlot +from app.modules.booking.models import Booking +from app.modules.profile.models import Profile # noqa: F401 + +from app.modules.availability.schemas import ( + AvailabilityResponse, + OperatingWindow, + BlockedRange, + ValidationResponse, + CalendarBlockedRange, + CalendarBookingSummary, + CalendarDay, + CalendarResponse, +) + +from app.modules.venue.service import ( + _get_active_venue_or_404, + _get_venue_or_404, + _assert_owner, + get_pricing_quote_for_slot, +) +from app.modules.venue.models import VenueAvailability, VenueBlockedDate + + +def _to_utc(value: datetime) -> datetime: + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + + return value.astimezone(timezone.utc) + + +def _localize(booking_date: date, value: time, tz: ZoneInfo) -> datetime: + return datetime.combine(booking_date, value).replace(tzinfo=tz) + + +def _minute_of_day(value: datetime) -> int: + return (value.hour * 60) + value.minute + + +def _local_day_bounds(venue, booking_date: date) -> tuple[datetime, datetime]: + tz = ZoneInfo(venue.timezone) + starts_at = datetime.combine(booking_date, datetime.min.time()).replace(tzinfo=tz) + ends_at = starts_at + timedelta(days=1) + + return starts_at.astimezone(timezone.utc), ends_at.astimezone(timezone.utc) + + +def _date_range(start_date: date, end_date: date) -> list[date]: + days = (end_date - start_date).days + return [start_date + timedelta(days=offset) for offset in range(days + 1)] + + +def _has_overlap( + starts_at: datetime, + ends_at: datetime, + range_starts_at: datetime, + range_ends_at: datetime, +) -> bool: + return _to_utc(starts_at) < _to_utc(range_ends_at) and _to_utc(ends_at) > _to_utc(range_starts_at) + + +def _covers_range( + ranges: list[tuple[datetime, datetime]], + starts_at: datetime, + ends_at: datetime, +) -> bool: + if not ranges: + return False + + starts_at = _to_utc(starts_at) + ends_at = _to_utc(ends_at) + clipped = sorted( + ( + (max(_to_utc(range_start), starts_at), min(_to_utc(range_end), ends_at)) + for range_start, range_end in ranges + if _has_overlap(range_start, range_end, starts_at, ends_at) + ), + key=lambda item: item[0], + ) + + covered_until = starts_at + for range_start, range_end in clipped: + if range_start > covered_until: + return False + if range_end > covered_until: + covered_until = range_end + if covered_until >= ends_at: + return True + + return False + + +def compute_effective_range( + starts_at: datetime, + ends_at: datetime, + pre_buffer_minutes: int, + post_buffer_minutes: int, +) -> tuple[datetime, datetime]: + return ( + starts_at - timedelta(minutes=pre_buffer_minutes), + ends_at + timedelta(minutes=post_buffer_minutes), + ) + + +def resolve_operating_window( + venue, + booking_date: date, + db_override: VenueAvailability | None = None, +) -> OperatingWindow: + if isinstance(booking_date, datetime): + booking_date = booking_date.date() + + day = booking_date.weekday() + + availability = db_override + if availability is None: + availability = next( + ( + a + for a in getattr(venue, "availability", []) + if a.day_of_week == day and a.deleted_at is None + ), + None, + ) + + if availability is not None: + if not availability.is_available: + return OperatingWindow(is_available=False) + + return OperatingWindow( + is_available=True, + opens_at=availability.opens_at, + closes_at=availability.closes_at, + spans_next_day=availability.spans_next_day, + ) + + return OperatingWindow( + is_available=True, + opens_at=venue.open_time, + closes_at=venue.close_time, + spans_next_day=venue.spans_next_day, + ) + + +def is_date_blocked( + venue, + starts_at: datetime, + ends_at: datetime, +) -> bool: + starts_at = _to_utc(starts_at) + ends_at = _to_utc(ends_at) + + for blocked in getattr(venue, "blocked_dates", []): + + if blocked.deleted_at: + continue + + overlap = starts_at < _to_utc(blocked.ends_at) and ends_at > _to_utc( + blocked.starts_at, + ) + + if overlap: + return True + + return False + + +def is_slot_blocked( + db: Session, + venue_id, + effective_starts_at: datetime, + effective_ends_at: datetime, +) -> bool: + return ( + db.query(BookingSlot) + .filter( + BookingSlot.venue_id == venue_id, + BookingSlot.is_blocking.is_(True), + BookingSlot.deleted_at.is_(None), + BookingSlot.effective_starts_at < effective_ends_at, + BookingSlot.effective_ends_at > effective_starts_at, + ) + .first() + is not None + ) + + +def expand_full_day_slot( + venue, + booking_date: date, + db_override: VenueAvailability | None = None, +) -> tuple[datetime, datetime]: + operating_window = resolve_operating_window( + venue=venue, + booking_date=booking_date, + db_override=db_override, + ) + + if not operating_window.is_available: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Venue closed on selected date", + ) + + tz = ZoneInfo(venue.timezone) + + starts_at = _localize( + booking_date, + operating_window.opens_at, + tz, + ) + + ends_at = _localize( + booking_date, + operating_window.closes_at, + tz, + ) + + if operating_window.spans_next_day: + ends_at += timedelta(days=1) + + return ( + starts_at.astimezone(timezone.utc), + ends_at.astimezone(timezone.utc), + ) + + +def validate_booking_request( + db: Session, + venue, + starts_at: datetime | None, + ends_at: datetime | None, + booking_type: str, + booking_date: date | None = None, + guest_count: int | None = None, +) -> ValidationResponse: + venue_tz = ZoneInfo(venue.timezone) + + if booking_type not in venue.allowed_booking_types: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Booking type not allowed", + ) + + if booking_type == "full_day": + if starts_at is None or ends_at is None: + # Original single-day full_day logic + if booking_date is None: + if starts_at is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="booking_date is required for single-day full day bookings", + ) + booking_date = _to_utc(starts_at).astimezone(venue_tz).date() + + starts_at, ends_at = expand_full_day_slot( + venue=venue, + booking_date=booking_date, + ) + else: + # Multi-day full_day support + starts_at = _to_utc(starts_at) + ends_at = _to_utc(ends_at) + elif starts_at is None or ends_at is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="starts_at and ends_at are required for time slot bookings", + ) + else: + starts_at = _to_utc(starts_at) + ends_at = _to_utc(ends_at) + + if ends_at <= starts_at: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="End time must be after start time", + ) + + if starts_at <= datetime.now(timezone.utc): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Booking must be in the future", + ) + + if guest_count is not None and guest_count > venue.max_capacity: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Guest count exceeds venue capacity", + ) + + duration_minutes = int((ends_at - starts_at).total_seconds() / 60) + + if duration_minutes < venue.min_booking_duration_minutes: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Booking duration too short", + ) + + # Duration limit - relaxed for full_day multi-day + if booking_type == "full_day": + max_multi_day_minutes = ( + getattr(venue, "max_multi_day_duration_days", 7) * 24 * 60 + ) + if duration_minutes > max_multi_day_minutes: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Multi-day booking cannot exceed {getattr(venue, 'max_multi_day_duration_days', 7)} days", + ) + elif duration_minutes > venue.max_booking_duration_minutes: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Booking duration exceeds limit", + ) + + local_starts_at = starts_at.astimezone(venue_tz) + local_ends_at = ends_at.astimezone(venue_tz) + + # Original time_slot interval validation + if booking_type == "time_slot" and ( + _minute_of_day(local_starts_at) % venue.slot_interval_minutes != 0 + or _minute_of_day(local_ends_at) % venue.slot_interval_minutes != 0 + or duration_minutes % venue.slot_interval_minutes != 0 + ): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Booking times must align with slot interval", + ) + + + # === Multi-day Operating Window Validation === + current_date = local_starts_at.date() + end_date = local_ends_at.date() + + while current_date <= end_date: + operating_window = resolve_operating_window(venue, current_date) + + if not operating_window.is_available: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Venue unavailable on {current_date}", + ) + + window_starts_at = _localize(current_date, operating_window.opens_at, venue_tz) + window_ends_at = _localize(current_date, operating_window.closes_at, venue_tz) + + if operating_window.spans_next_day: + window_ends_at += timedelta(days=1) + + if current_date == local_starts_at.date(): + if local_starts_at < window_starts_at: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Booking start must be within operating window", + ) + elif current_date == local_ends_at.date(): + # Relaxed: Accept whatever end time the frontend sends on the last day + # (No strict check on end time for last day) + pass + # Middle days: only require availability + + current_date += timedelta(days=1) + + # Original effective range + conflict checks + effective_starts_at, effective_ends_at = compute_effective_range( + starts_at, + ends_at, + venue.pre_buffer_minutes, + venue.post_buffer_minutes, + ) + + if is_date_blocked(venue, effective_starts_at, effective_ends_at): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Venue blocked for selected period", + ) + + conflict_exists = is_slot_blocked( + db=db, + venue_id=venue.id, + effective_starts_at=effective_starts_at, + effective_ends_at=effective_ends_at, + ) + + if conflict_exists: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Selected slot unavailable", + ) + + return ValidationResponse( + valid=True, + effective_starts_at=effective_starts_at, + effective_ends_at=effective_ends_at, + ) + + +def get_availability_for_date( + db: Session, + venue_id, + booking_date: date, +) -> AvailabilityResponse: + + venue = _get_active_venue_or_404( + db, + venue_id, + ) + + operating_window = resolve_operating_window( + venue, + booking_date, + ) + + if operating_window.is_available: + window_start, window_end = expand_full_day_slot(venue, booking_date) + else: + window_start, window_end = _local_day_bounds(venue, booking_date) + blocked_slots = ( + db.query(BookingSlot) + .filter( + BookingSlot.venue_id == venue.id, + BookingSlot.is_blocking.is_(True), + BookingSlot.deleted_at.is_(None), + BookingSlot.effective_starts_at < window_end, + BookingSlot.effective_ends_at > window_start, + ) + .all() + ) + + return AvailabilityResponse( + date=booking_date, + operating_window=operating_window, + blocked_slots=[ + BlockedRange( + starts_at=slot.starts_at, + ends_at=slot.ends_at, + ) + for slot in blocked_slots + ], + ) + + +def _build_calendar_for_venue( + db: Session, + venue, + start_date: date, + end_date: date, + include_owner_details: bool = False, +) -> CalendarResponse: + if end_date < start_date: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="end_date must be on or after start_date", + ) + + if (end_date - start_date).days > 370: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Calendar range cannot exceed 370 days", + ) + + range_start, _ = _local_day_bounds(venue, start_date) + _, range_end = _local_day_bounds(venue, end_date) + range_start -= timedelta(minutes=venue.pre_buffer_minutes) + range_end += timedelta(days=1, minutes=venue.post_buffer_minutes) + + blocking_slots = ( + db.query(BookingSlot) + .filter( + BookingSlot.venue_id == venue.id, + BookingSlot.is_blocking.is_(True), + BookingSlot.deleted_at.is_(None), + BookingSlot.effective_starts_at < range_end, + BookingSlot.effective_ends_at > range_start, + ) + .all() + ) + + owner_bookings = [] + if include_owner_details: + owner_bookings = ( + db.query(Booking) + .options(joinedload(Booking.slot)) + .join(BookingSlot, BookingSlot.booking_id == Booking.id) + .filter( + Booking.venue_id == venue.id, + Booking.deleted_at.is_(None), + BookingSlot.deleted_at.is_(None), + BookingSlot.effective_starts_at < range_end, + BookingSlot.effective_ends_at > range_start, + ) + .order_by(BookingSlot.starts_at.asc()) + .all() + ) + + venue_blocks = ( + db.query(VenueBlockedDate) + .filter( + VenueBlockedDate.venue_id == venue.id, + VenueBlockedDate.deleted_at.is_(None), + VenueBlockedDate.starts_at < range_end, + VenueBlockedDate.ends_at > range_start, + ) + .all() + ) + + now = datetime.now(timezone.utc) + days: list[CalendarDay] = [] + + for calendar_date in _date_range(start_date, end_date): + operating_window = resolve_operating_window( + venue, + calendar_date, + ) + + if operating_window.is_available: + window_start, window_end = expand_full_day_slot(venue, calendar_date) + else: + window_start, window_end = _local_day_bounds(venue, calendar_date) + + effective_window_start, effective_window_end = compute_effective_range( + window_start, + window_end, + venue.pre_buffer_minutes, + venue.post_buffer_minutes, + ) + + day_blocks = [ + block + for block in venue_blocks + if _has_overlap( + block.starts_at, + block.ends_at, + effective_window_start, + effective_window_end, + ) + ] + day_blocking_slots = [ + slot + for slot in blocking_slots + if _has_overlap( + slot.effective_starts_at, + slot.effective_ends_at, + effective_window_start, + effective_window_end, + ) + ] + + blocked_ranges = [ + CalendarBlockedRange( + starts_at=block.starts_at, + ends_at=block.ends_at, + source="venue_block", + reason=block.reason, + ) + for block in day_blocks + ] + blocked_ranges.extend( + CalendarBlockedRange( + starts_at=slot.starts_at, + ends_at=slot.ends_at, + source="booking", + ) + for slot in day_blocking_slots + ) + blocked_ranges.sort(key=lambda item: item.starts_at) + + day_bookings = [] + if include_owner_details: + day_bookings = [ + CalendarBookingSummary( + id=booking.id, + booking_type=booking.booking_type.value, + status=booking.status.value, + starts_at=booking.slot.starts_at, + ends_at=booking.slot.ends_at, + effective_starts_at=booking.slot.effective_starts_at, + effective_ends_at=booking.slot.effective_ends_at, + is_blocking=booking.slot.is_blocking, + guest_count=booking.guest_count, + event_type=booking.event_type, + user_id=booking.user_id, + ) + for booking in owner_bookings + if booking.slot + and _has_overlap( + booking.slot.effective_starts_at, + booking.slot.effective_ends_at, + effective_window_start, + effective_window_end, + ) + ] + + venue_block_ranges = [(block.starts_at, block.ends_at) for block in day_blocks] + blocking_ranges = venue_block_ranges + [ + (slot.starts_at, slot.ends_at) for slot in day_blocking_slots + ] + + has_conflict = bool(day_blocks or day_blocking_slots) + is_future_window = window_end > now + supports_full_day = "full_day" in venue.allowed_booking_types + available_for_full_day = ( + operating_window.is_available + and supports_full_day + and is_future_window + and not has_conflict + ) + + if not operating_window.is_available: + day_status = "closed" + elif _covers_range(venue_block_ranges, window_start, window_end): + day_status = "blocked" + elif _covers_range(blocking_ranges, window_start, window_end): + day_status = "fully_booked" + elif has_conflict: + day_status = "partially_booked" + else: + day_status = "available" + + days.append( + CalendarDay( + date=calendar_date, + operating_window=operating_window, + status=day_status, + is_bookable=day_status in ("available", "partially_booked") and is_future_window, + available_for_full_day=available_for_full_day, + blocked_ranges=blocked_ranges, + bookings=day_bookings, + ) + ) + + return CalendarResponse( + venue_id=venue.id, + timezone=venue.timezone, + start_date=start_date, + end_date=end_date, + days=days, + ) + + +def get_calendar( + db: Session, + venue_id: UUID, + start_date: date, + end_date: date, +) -> CalendarResponse: + venue = _get_active_venue_or_404( + db, + venue_id, + ) + + return _build_calendar_for_venue( + db=db, + venue=venue, + start_date=start_date, + end_date=end_date, + ) + + +def get_owner_calendar( + db: Session, + venue_id: UUID, + owner_id: UUID, + start_date: date, + end_date: date, + allow_admin: bool = False, +) -> CalendarResponse: + venue = _get_venue_or_404( + db, + venue_id, + ) + if not allow_admin: + _assert_owner(venue, owner_id) + + return _build_calendar_for_venue( + db=db, + venue=venue, + start_date=start_date, + end_date=end_date, + include_owner_details=True, + ) + + +def validate_slot( + db: Session, + venue_id, + booking_type, + starts_at, + ends_at, + booking_date: date | None = None, + guest_count: int | None = None, +): + venue = _get_active_venue_or_404( + db, + venue_id, + ) + + return validate_booking_request( + db=db, + venue=venue, + starts_at=starts_at, + ends_at=ends_at, + booking_type=booking_type, + booking_date=booking_date, + guest_count=guest_count, + ) + + +def get_pricing_quote( + db: Session, + venue_id: UUID, + starts_at: datetime, + ends_at: datetime, + booking_type: str, +): + _get_active_venue_or_404(db, venue_id) + return get_pricing_quote_for_slot( + db=db, + venue_id=venue_id, + starts_at=_to_utc(starts_at), + ends_at=_to_utc(ends_at), + booking_type=booking_type, + ) diff --git a/apps/api/app/modules/booking/__init__.py b/apps/api/app/modules/booking/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apps/api/app/modules/booking/cancellation.py b/apps/api/app/modules/booking/cancellation.py new file mode 100644 index 000000000..43f836cbd --- /dev/null +++ b/apps/api/app/modules/booking/cancellation.py @@ -0,0 +1,303 @@ +import math +from dataclasses import dataclass +from datetime import datetime +from uuid import UUID + +from fastapi import HTTPException, status +from sqlalchemy.orm import Session + +from app.modules.payment import service as payment_service +from app.modules.notification import service as notifications +from app.modules.notification.types import NotificationType +from app.modules.booking.helpers import ( + _now, + _format_inr, + _history, + _booking_or_404, + _assert_booking_user, + _assert_booking_owner, + _slot_for_update, + _booking_out, + _load_policy, + TERMINAL_STATUSES, +) +from app.modules.booking.models import ( + Booking, + BookingStatus, + PaymentStatus, +) +from app.modules.booking.schemas import ( + CancellationDisplay, + CancellationPreviewOut, + BookingOut, +) +from app.modules.venue.models import VenueCancellationPolicy + + +@dataclass +class RefundComputation: + refund_amount_paise: int + penalty_amount_paise: int + refund_pct_applied: float + tier_matched: str | None + + +def _compute_refund( + booking: Booking, + policy: VenueCancellationPolicy | None, + cancelled_at: datetime | None = None, +) -> RefundComputation: + cancelled_at = cancelled_at or _now() + starts_at = booking.slot.starts_at + paid_amount = booking.amount_paid_paise + + if paid_amount <= 0: + return RefundComputation(0, 0, 0.0, None) + + hours_notice = (starts_at - cancelled_at).total_seconds() / 3600 + refund_pct = 0.0 + tier = "no_show" + + if policy: + tiers = [ + ("tier_1", policy.tier_1_hours, policy.tier_1_refund_pct), + ("tier_2", policy.tier_2_hours, policy.tier_2_refund_pct), + ("tier_3", policy.tier_3_hours, policy.tier_3_refund_pct), + ] + for tier_name, hours, pct in tiers: + if hours is not None and pct is not None and hours_notice >= hours: + tier = tier_name + refund_pct = float(pct) + break + else: + refund_pct = float(policy.no_show_refund_pct) + else: + tier = None + + if policy and not policy.platform_fee_refundable: + # Calculate refund on the portion excluding platform fee + base_refundable = max(0, paid_amount - booking.platform_fee_paise) + refund_amount = round(base_refundable * refund_pct / 100) + else: + refund_amount = round(paid_amount * refund_pct / 100) + + penalty_amount = max(0, paid_amount - refund_amount) + return RefundComputation( + refund_amount_paise=refund_amount, + penalty_amount_paise=penalty_amount, + refund_pct_applied=refund_pct, + tier_matched=tier, + ) + + +def get_cancellation_preview( + db: Session, + booking_id: UUID, + user_id: UUID, +) -> CancellationPreviewOut: + booking = _booking_or_404(db, booking_id) + _assert_booking_user(booking, user_id) + + if booking.status not in ( + BookingStatus.requested, + BookingStatus.owner_accepted, + BookingStatus.confirmed, + ): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Booking cannot be cancelled at this stage", + ) + + if ( + booking.status == BookingStatus.requested + or booking.status == BookingStatus.owner_accepted + ): + # No refund for pre-payment + return CancellationPreviewOut( + refund_amount_paise=0, + penalty_amount_paise=0, + refund_pct_applied=0.0, + tier_matched=None, + display=CancellationDisplay(refund_amount="₹0", penalty_amount="₹0"), + ) + + refund = _compute_refund(booking, _load_policy(db, booking.venue_id)) + return CancellationPreviewOut( + refund_amount_paise=refund.refund_amount_paise, + penalty_amount_paise=refund.penalty_amount_paise, + refund_pct_applied=refund.refund_pct_applied, + tier_matched=refund.tier_matched, + display=CancellationDisplay( + refund_amount=_format_inr(refund.refund_amount_paise), + penalty_amount=_format_inr(refund.penalty_amount_paise), + ), + ) + + +def user_cancel_booking(db: Session, booking_id: UUID, user_id: UUID) -> BookingOut: + booking = _booking_or_404(db, booking_id, for_update=True) + _assert_booking_user(booking, user_id) + + if booking.status in TERMINAL_STATUSES: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Cannot cancel a terminal booking", + ) + + # Per spec: allowed on requested, owner_accepted, confirmed + if booking.status not in ( + BookingStatus.requested, + BookingStatus.owner_accepted, + BookingStatus.confirmed, + ): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Booking cannot be cancelled at this stage", + ) + + old_status = booking.status + slot = ( + _slot_for_update(db, booking.id) + if hasattr(booking, "slot") and booking.slot + else None + ) + if slot: + slot.is_blocking = False + + booking.status = BookingStatus.user_cancelled + booking.cancelled_at = _now() + + metadata = None + if old_status == BookingStatus.requested: + # Simple cancel - no payment + pass + elif old_status == BookingStatus.owner_accepted: + # Cancel pending PaymentIntent + if booking.stripe_advance_payment_intent_id: + payment_service.cancel_payment_intent(booking.stripe_advance_payment_intent_id) + else: # confirmed + refund = _compute_refund( + booking, _load_policy(db, booking.venue_id), booking.cancelled_at + ) + if refund.refund_amount_paise > 0: + payment_service.refund_for_cancellation( + db, booking, refund.refund_amount_paise, "user_cancellation" + ) + booking.refund_amount_paise = refund.refund_amount_paise + + if refund.refund_amount_paise == 0: + pass # keep existing payment_status + elif refund.refund_amount_paise >= booking.amount_paid_paise: + booking.payment_status = PaymentStatus.refunded + else: + booking.payment_status = PaymentStatus.partially_refunded + + metadata = { + "refund_amount_paise": refund.refund_amount_paise, + "penalty_amount_paise": refund.penalty_amount_paise, + "refund_pct_applied": refund.refund_pct_applied, + "tier_matched": refund.tier_matched, + } + + db.add( + _history( + booking, + old_status, + BookingStatus.user_cancelled, + changed_by=user_id, + metadata=metadata, + ) + ) + db.flush() + db.refresh(booking) + + # Notifications (spec: notify owner) + notifications.notify( + db, booking.venue.owner_id, NotificationType.BOOKING_CANCELED, + context={"venue_name": booking.venue.name}, booking_id=booking.id, + ) + return _booking_out(booking) + + +def owner_cancel_forfeit(db: Session, booking_id: UUID, owner_id: UUID) -> BookingOut: + booking = _booking_or_404(db, booking_id, for_update=True) + _assert_booking_owner(booking, owner_id) + if booking.status != BookingStatus.confirmed or booking.balance_overdue_at is None: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Booking is not balance overdue") + + _slot_for_update(db, booking.id).is_blocking = False + old_status = booking.status + owner_share = math.floor( + booking.advance_due_paise * (1 - (float(booking.platform_commission_pct) / 100)) + ) + booking.status = BookingStatus.balance_overdue_cancelled + booking.cancelled_at = _now() + db.add(_history( + booking, + old_status, + BookingStatus.balance_overdue_cancelled, + changed_by=owner_id, + metadata={"owner_share_paise": owner_share, "refund_amount_paise": 0}, + )) + db.flush() + db.refresh(booking) + notifications.notify( + db, booking.user_id, NotificationType.BOOKING_CANCELED, + context={"venue_name": booking.venue.name}, booking_id=booking.id, + ) + return _booking_out(booking) + + +def owner_cancel_goodwill(db: Session, booking_id: UUID, owner_id: UUID) -> BookingOut: + booking = _booking_or_404(db, booking_id, for_update=True) + _assert_booking_owner(booking, owner_id) + if booking.status != BookingStatus.confirmed or booking.balance_overdue_at is None: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Booking is not balance overdue") + + _slot_for_update(db, booking.id).is_blocking = False + old_status = booking.status + refund_amount = math.floor( + booking.advance_due_paise * (float(booking.overdue_advance_refund_pct) / 100) + ) + if refund_amount > 0: + payment_service.refund_for_cancellation(db, booking, refund_amount, "owner_goodwill") + + booking.status = BookingStatus.user_cancelled + booking.cancelled_at = _now() + booking.refund_amount_paise = refund_amount + booking.payment_status = PaymentStatus.partially_refunded if refund_amount > 0 else booking.payment_status + db.add(_history( + booking, + old_status, + BookingStatus.user_cancelled, + changed_by=owner_id, + metadata={"refund_amount_paise": refund_amount, "goodwill": True}, + )) + db.flush() + db.refresh(booking) + notifications.notify( + db, booking.user_id, NotificationType.BOOKING_CANCELED, + context={"venue_name": booking.venue.name}, booking_id=booking.id, + ) + return _booking_out(booking) + + +def admin_force_cancel( + db: Session, + booking_id: UUID, + admin_id: UUID, + reason: str, +) -> BookingOut: + booking = _booking_or_404(db, booking_id, for_update=True) + if booking.status in TERMINAL_STATUSES: + return _booking_out(booking) + + old_status = booking.status + if booking.slot: + booking.slot.is_blocking = False + booking.status = BookingStatus.admin_cancelled + booking.cancelled_at = _now() + db.add(_history(booking, old_status, BookingStatus.admin_cancelled, changed_by=admin_id, reason=reason)) + db.flush() + db.refresh(booking) + return _booking_out(booking) diff --git a/apps/api/app/modules/booking/dependencies.py b/apps/api/app/modules/booking/dependencies.py new file mode 100644 index 000000000..d3c6ac36d --- /dev/null +++ b/apps/api/app/modules/booking/dependencies.py @@ -0,0 +1,47 @@ +from uuid import UUID + +from fastapi import Depends +from sqlalchemy.orm import Session + +from app.core.database import get_db +from app.core.exceptions import ForbiddenError, NotFoundError +from app.modules.auth.dependencies import AuthContext, require_auth +from app.modules.booking.models import Booking + + +def get_booking_or_404( + booking_id: UUID, + db: Session = Depends(get_db), +) -> Booking: + booking = ( + db.query(Booking) + .filter( + Booking.id == booking_id, + Booking.deleted_at.is_(None), + ) + .first() + ) + if not booking: + raise NotFoundError("Booking not found") + + return booking + + +def require_booking_user( + booking: Booking = Depends(get_booking_or_404), + current_user: AuthContext = Depends(require_auth), +) -> Booking: + if booking.user_id != current_user.user_id: + raise ForbiddenError("Booking access denied") + + return booking + + +def require_booking_owner( + booking: Booking = Depends(get_booking_or_404), + current_user: AuthContext = Depends(require_auth), +) -> Booking: + if booking.venue.owner_id != current_user.user_id: + raise ForbiddenError("Booking owner access denied") + + return booking diff --git a/apps/api/app/modules/booking/helpers.py b/apps/api/app/modules/booking/helpers.py new file mode 100644 index 000000000..fb2e27a73 --- /dev/null +++ b/apps/api/app/modules/booking/helpers.py @@ -0,0 +1,227 @@ +import uuid +import logging +from datetime import datetime, timezone, date +from uuid import UUID +from fastapi import HTTPException, status +from sqlalchemy.orm import Session, selectinload + +from app.modules.booking.models import ( + Booking, + BookingSlot, + BookingStatus, + BookingStatusHistory, + PaymentStatus, +) +from app.modules.booking.schemas import BookingOut, BookingDisplay, PaymentOption, PaymentOptions +from app.modules.venue.models import Venue, VenueCancellationPolicy + +logger = logging.getLogger(__name__) + +MAX_DEADLINE_EXTENSIONS = 2 +USER_PAYMENT_HOLD_HOURS = 24 +REQUEST_EXPIRY_DAYS = 7 +INSTANT_BOOKING_PAYMENT_TIMEOUT_MINUTES = 15 + +TERMINAL_STATUSES = { + BookingStatus.completed, + BookingStatus.hold_expired, + BookingStatus.request_expired, + BookingStatus.conflict_cancelled, + BookingStatus.user_cancelled, + BookingStatus.admin_cancelled, + BookingStatus.owner_rejected, + BookingStatus.balance_overdue_cancelled, +} + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +def _format_inr(paise: int) -> str: + rupees = paise / 100 + return f"INR {rupees:,.0f}" + + +def _enum_value(value) -> str: + return value.value if hasattr(value, "value") else str(value) + + +def _history( + booking: Booking, + old_status: BookingStatus | None, + new_status: BookingStatus, + changed_by: UUID | None = None, + reason: str | None = None, + metadata: dict | None = None, +) -> BookingStatusHistory: + return BookingStatusHistory( + id=uuid.uuid4(), + booking_id=booking.id, + old_status=old_status, + new_status=new_status, + changed_by=changed_by, + reason=reason, + change_metadata=metadata, + ) + + +def _booking_or_404( + db: Session, + booking_id: UUID, + for_update: bool = False, +) -> Booking: + query = db.query(Booking).options( + selectinload(Booking.slot), + selectinload(Booking.venue).selectinload(Venue.photos), + selectinload(Booking.user), + ).filter( + Booking.id == booking_id, + Booking.deleted_at.is_(None), + ) + if for_update: + query = query.with_for_update() + + booking = query.first() + if not booking: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Booking not found") + + return booking + + +def _assert_booking_user(booking: Booking, user_id: UUID) -> None: + if booking.user_id != user_id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Booking access denied") + + +def _assert_booking_owner(booking: Booking, owner_id: UUID) -> None: + if booking.venue.owner_id != owner_id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Booking owner access denied") + + +def _slot_for_update(db: Session, booking_id: UUID) -> BookingSlot: + slot = ( + db.query(BookingSlot) + .filter( + BookingSlot.booking_id == booking_id, + BookingSlot.deleted_at.is_(None), + ) + .with_for_update() + .first() + ) + if not slot: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Booking slot not found") + + return slot + + +def _booking_out(booking: Booking) -> BookingOut: + slot = booking.slot + + cover_photo = next( + ( + photo.image_url + for photo in booking.venue.photos + if photo.is_cover and photo.deleted_at is None + ), + None, + ) + + payment_required = (booking.status == BookingStatus.payment_pending) + + payment_options = None + if booking.status in (BookingStatus.payment_pending, BookingStatus.owner_accepted): + payment_options = PaymentOptions( + advance=PaymentOption( + label="Advance", + amount_paise=booking.advance_due_paise, + display_amount=_format_inr(booking.advance_due_paise), + ), + full=PaymentOption( + label="Full", + amount_paise=booking.quoted_price_paise, + display_amount=_format_inr(booking.quoted_price_paise), + ), + ) + + client_secret = None + if booking.status in (BookingStatus.payment_pending, BookingStatus.owner_accepted): + from sqlalchemy.orm import object_session + session = object_session(booking) + if session: + from app.modules.payment.models import Payment, PaymentAttemptStatus + pending_payment = ( + session.query(Payment) + .filter( + Payment.booking_id == booking.id, + Payment.status == PaymentAttemptStatus.pending + ) + .order_by(Payment.created_at.desc()) + .first() + ) + if pending_payment: + client_secret = pending_payment.stripe_client_secret + + return BookingOut( + id=booking.id, + venue_id=booking.venue_id, + venue_name=booking.venue.name, + venue_city=booking.venue.city, + venue_cover_photo_url=cover_photo, + user_id=booking.user_id, + user_full_name=booking.user.full_name if booking.user else None, + user_email=booking.user.email if booking.user else None, + user_phone=booking.user.phone if booking.user else None, + booking_type=_enum_value(booking.booking_type), + status=_enum_value(booking.status), + payment_status=_enum_value(booking.payment_status), + starts_at=slot.starts_at, + ends_at=slot.ends_at, + effective_starts_at=slot.effective_starts_at, + effective_ends_at=slot.effective_ends_at, + guest_count=booking.guest_count, + event_type=booking.event_type, + user_notes=booking.user_notes, + owner_notes=booking.owner_notes, + quoted_price_paise=booking.quoted_price_paise, + platform_commission_pct=float(booking.platform_commission_pct), + platform_fee_paise=booking.platform_fee_paise, + owner_payout_paise=booking.owner_payout_paise, + advance_pct=float(booking.advance_pct), + advance_due_paise=booking.advance_due_paise, + balance_due_paise=booking.balance_due_paise, + balance_due_date=booking.balance_due_date, + hold_expires_at=booking.hold_expires_at, + confirmed_at=booking.confirmed_at, + cancelled_at=booking.cancelled_at, + expired_at=booking.expired_at, + amount_paid_paise=booking.amount_paid_paise, + refund_amount_paise=booking.refund_amount_paise, + stripe_advance_payment_intent_id=booking.stripe_advance_payment_intent_id, + stripe_balance_payment_intent_id=booking.stripe_balance_payment_intent_id, + deadline_extension_count=booking.deadline_extension_count, + balance_overdue_at=booking.balance_overdue_at, + owner_action_deadline=booking.owner_action_deadline, + created_at=booking.created_at, + display=BookingDisplay( + quoted_price=_format_inr(booking.quoted_price_paise), + advance_due=_format_inr(booking.advance_due_paise), + balance_due=_format_inr(booking.balance_due_paise), + platform_fee=_format_inr(booking.platform_fee_paise), + owner_payout=_format_inr(booking.owner_payout_paise), + ), + payment_required=payment_required, + payment_options=payment_options, + client_secret=client_secret, + payment_expires_at=booking.payment_expires_at, + auto_confirmed_at=booking.auto_confirmed_at, + confirmed_by=booking.confirmed_by, + ) + + +def _load_policy(db: Session, venue_id: UUID) -> VenueCancellationPolicy | None: + return ( + db.query(VenueCancellationPolicy) + .filter(VenueCancellationPolicy.venue_id == venue_id) + .first() + ) diff --git a/apps/api/app/modules/booking/models.py b/apps/api/app/modules/booking/models.py new file mode 100644 index 000000000..4c7a34d48 --- /dev/null +++ b/apps/api/app/modules/booking/models.py @@ -0,0 +1,464 @@ +import enum +import uuid +from datetime import datetime, date + +from sqlalchemy import ( + CheckConstraint, + Enum, + DateTime, + Date, + ForeignKey, + func, + Integer, + BigInteger, + Numeric, + Text, + String, + Boolean, +) +from sqlalchemy.dialects.postgresql import UUID, JSONB +from sqlalchemy.orm import ( + Mapped, + mapped_column, + relationship, +) + +from typing import TYPE_CHECKING + +from app.core.database import Base + +if TYPE_CHECKING: + from app.modules.profile.models import Profile + from app.modules.venue.models import Venue + + +class BookingType(str, enum.Enum): + full_day = "full_day" + time_slot = "time_slot" + + +class BookingStatus(str, enum.Enum): + requested = "requested" + owner_accepted = "owner_accepted" + confirmed = "confirmed" + completed = "completed" + hold_expired = "hold_expired" + request_expired = "request_expired" + conflict_cancelled = "conflict_cancelled" + user_cancelled = "user_cancelled" + admin_cancelled = "admin_cancelled" + owner_rejected = "owner_rejected" + balance_overdue_cancelled = "balance_overdue_cancelled" + payment_pending = "payment_pending" + + +class PaymentStatus(str, enum.Enum): + unpaid = "unpaid" + advance_paid = "advance_paid" + fully_paid = "fully_paid" + refunded = "refunded" + partially_refunded = "partially_refunded" + +class Booking(Base): + __tablename__ = "bookings" + + __table_args__ = ( + CheckConstraint( + "guest_count > 0", + name="ck_bookings_guest_count", + ), + CheckConstraint( + "deadline_extension_count <= 2", + name="ck_bookings_deadline_extensions", + ), + CheckConstraint( + "advance_due_paise + balance_due_paise = quoted_price_paise", + name="ck_bookings_price_split", + ), + ) + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + primary_key=True, + default=uuid.uuid4, + ) + + venue_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("venues.id"), + nullable=False, + ) + + user_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("profiles.id"), + nullable=False, + ) + + booking_type: Mapped[BookingType] = mapped_column( + Enum(BookingType, name="booking_type"), + nullable=False, + ) + + event_type: Mapped[str | None] = mapped_column(Text, nullable=True) + + guest_count: Mapped[int] = mapped_column( + Integer, + nullable=False, + default=1, + ) + + user_notes: Mapped[str | None] = mapped_column(Text, nullable=True) + + owner_notes: Mapped[str | None] = mapped_column(Text, nullable=True) + + status: Mapped[BookingStatus] = mapped_column( + Enum(BookingStatus, name="booking_status"), + nullable=False, + default=BookingStatus.requested, + ) + + requested_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + server_default=func.now(), + ) + + owner_responded_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), + nullable=True, + ) + + hold_expires_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), + nullable=True, + ) + + confirmed_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), + nullable=True, + ) + + balance_due_date: Mapped[date | None] = mapped_column( + Date, + nullable=True, + ) + + balance_overdue_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), + nullable=True, + ) + + owner_action_deadline: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), + nullable=True, + ) + + completed_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), + nullable=True, + ) + + cancelled_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), + nullable=True, + ) + + expired_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), + nullable=True, + ) + + deadline_extension_count: Mapped[int] = mapped_column( + Integer, + nullable=False, + default=0, + ) + + pricing_mode: Mapped[str | None] = mapped_column( + String, + nullable=True, + ) + + quoted_price_paise: Mapped[int] = mapped_column( + BigInteger, + nullable=False, + default=0, + ) + + pricing_breakdown: Mapped[list[dict] | None] = mapped_column( + JSONB, + nullable=True, + ) + + platform_commission_pct: Mapped[float] = mapped_column( + Numeric(5, 2), + nullable=False, + default=0, + ) + + platform_fee_paise: Mapped[int] = mapped_column( + BigInteger, + nullable=False, + default=0, + ) + + owner_payout_paise: Mapped[int] = mapped_column( + BigInteger, + nullable=False, + default=0, + ) + + advance_pct: Mapped[float] = mapped_column( + Numeric(5, 2), + nullable=False, + default=0, + ) + + advance_due_paise: Mapped[int] = mapped_column( + BigInteger, + nullable=False, + default=0, + ) + + balance_due_paise: Mapped[int] = mapped_column( + BigInteger, + nullable=False, + default=0, + ) + + overdue_advance_refund_pct: Mapped[float] = mapped_column( + Numeric(5, 2), + nullable=False, + default=0, + ) + + payment_status: Mapped[PaymentStatus] = mapped_column( + Enum(PaymentStatus, name="payment_status"), + nullable=False, + default=PaymentStatus.unpaid, + ) + + amount_paid_paise: Mapped[int] = mapped_column( + BigInteger, + nullable=False, + default=0, + ) + + refund_amount_paise: Mapped[int] = mapped_column( + BigInteger, + nullable=False, + default=0, + ) + + stripe_advance_payment_intent_id: Mapped[str | None] = mapped_column( + Text, + nullable=True, + ) + + stripe_balance_payment_intent_id: Mapped[str | None] = mapped_column( + Text, + nullable=True, + ) + + payment_expires_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), + nullable=True, + ) + + auto_confirmed_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), + nullable=True, + ) + + confirmed_by: Mapped[str | None] = mapped_column( + Text, + nullable=True, + ) + + deleted_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), + nullable=True, + ) + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + server_default=func.now(), + ) + + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + server_default=func.now(), + onupdate=func.now(), + ) + + slot: Mapped["BookingSlot"] = relationship( + back_populates="booking", + uselist=False, + ) + + status_history: Mapped[list["BookingStatusHistory"]] = relationship( + back_populates="booking", + cascade="all, delete-orphan", + ) + + venue: Mapped["Venue"] = relationship( + back_populates="bookings", + ) + + user: Mapped["Profile"] = relationship( + back_populates="bookings", + ) + + +class BookingSlot(Base): + __tablename__ = "booking_slots" + + __table_args__ = ( + CheckConstraint( + "ends_at > starts_at", + name="ck_booking_slots_time_order", + ), + CheckConstraint( + "effective_starts_at <= starts_at", + name="ck_booking_slots_effective_start", + ), + CheckConstraint( + "effective_ends_at >= ends_at", + name="ck_booking_slots_effective_end", + ), + ) + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + primary_key=True, + default=uuid.uuid4, + ) + + booking_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("bookings.id"), + nullable=False, + ) + + venue_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("venues.id"), + nullable=False, + ) + + starts_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + ) + + ends_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + ) + + effective_starts_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + ) + + effective_ends_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + ) + + is_blocking: Mapped[bool] = mapped_column( + Boolean, + nullable=False, + default=False, + ) + + deleted_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), + nullable=True, + ) + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + server_default=func.now(), + ) + + booking: Mapped["Booking"] = relationship( + back_populates="slot", + ) + +class BookingStatusHistory(Base): + __tablename__ = "booking_status_history" + + __table_args__ = ( + # Update or expand the CHECK constraint to match spec + CheckConstraint( + """ + (old_status IS NULL) OR + (old_status = 'requested' AND new_status IN ('owner_accepted', 'owner_rejected', 'user_cancelled', 'conflict_cancelled', 'request_expired')) OR + (old_status = 'owner_accepted' AND new_status IN ('confirmed', 'hold_expired', 'user_cancelled')) OR + (old_status = 'confirmed' AND new_status IN ('completed', 'user_cancelled', 'admin_cancelled', 'balance_overdue_cancelled')) OR + (old_status = 'hold_expired' AND new_status = 'owner_accepted') OR + (old_status = 'payment_pending' AND new_status IN ('confirmed', 'hold_expired', 'user_cancelled', 'admin_cancelled', 'conflict_cancelled')) + """, + name="ck_booking_status_history_transition", + ), + ) + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + primary_key=True, + default=uuid.uuid4, + ) + + booking_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("bookings.id"), + nullable=False, + ) + + old_status: Mapped[BookingStatus | None] = mapped_column( + Enum(BookingStatus, name="booking_status"), + nullable=True, + ) + + new_status: Mapped[BookingStatus] = mapped_column( + Enum(BookingStatus, name="booking_status"), + nullable=False, + ) + + changed_by: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + ForeignKey("profiles.id"), + nullable=True, + ) + + reason: Mapped[str | None] = mapped_column( + Text, + nullable=True, + ) + + change_metadata: Mapped[dict | None] = mapped_column( + "metadata", + JSONB, + nullable=True, + ) + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + server_default=func.now(), + ) + + booking: Mapped["Booking"] = relationship( + back_populates="status_history", + ) + + changed_by_user: Mapped["Profile"] = relationship( + "Profile", + foreign_keys=[changed_by], + primaryjoin="BookingStatusHistory.changed_by == Profile.id", + ) diff --git a/apps/api/app/modules/booking/routes.py b/apps/api/app/modules/booking/routes.py new file mode 100644 index 000000000..b2b988519 --- /dev/null +++ b/apps/api/app/modules/booking/routes.py @@ -0,0 +1,148 @@ +from uuid import UUID + +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +from app.core.database import get_db +from app.modules.auth.dependencies import AuthContext, require_auth, require_owner +from app.modules.booking import service +from app.modules.booking.schemas import ( + BookingOut, + BookingRequestIn, + CancellationPreviewOut, + ExtendDeadlineIn, + OwnerRejectIn, + UpdateOwnerNotesIn, +) + +router = APIRouter() + + +@router.post("/", response_model=BookingOut, status_code=201) +def create_booking( + body: BookingRequestIn, + auth: AuthContext = Depends(require_auth), + db: Session = Depends(get_db), +): + return service.create_booking_request(db, auth.user_id, body) + + +@router.get("/", response_model=list[BookingOut]) +def list_my_bookings( + auth: AuthContext = Depends(require_auth), + db: Session = Depends(get_db), +): + return service.list_user_bookings(db, auth.user_id) + + +@router.get("/owner", response_model=list[BookingOut]) +def list_owner_bookings( + tab: str | None = None, + venue_id: str | None = None, + search: str | None = None, + auth: AuthContext = Depends(require_owner), + db: Session = Depends(get_db), +): + return service.list_all_owner_bookings(db, auth.user_id, tab, venue_id, search) + + +@router.get("/venues/{venue_id}/bookings", response_model=list[BookingOut]) +def list_venue_bookings( + venue_id: UUID, + auth: AuthContext = Depends(require_owner), + db: Session = Depends(get_db), +): + return service.list_venue_bookings(db, venue_id, auth.user_id) + + +@router.get("/venues/{venue_id}/bookings/pending", response_model=list[BookingOut]) +def list_pending_venue_bookings( + venue_id: UUID, + auth: AuthContext = Depends(require_owner), + db: Session = Depends(get_db), +): + return service.list_venue_bookings(db, venue_id, auth.user_id, pending_only=True) + + +@router.get("/{booking_id}", response_model=BookingOut) +def get_booking( + booking_id: UUID, + auth: AuthContext = Depends(require_auth), + db: Session = Depends(get_db), +): + return service.get_booking(db, booking_id, auth.user_id) + + +@router.get("/{booking_id}/cancellation-preview", response_model=CancellationPreviewOut) +def cancellation_preview( + booking_id: UUID, + auth: AuthContext = Depends(require_auth), + db: Session = Depends(get_db), +): + return service.get_cancellation_preview(db, booking_id, auth.user_id) + + +@router.post("/{booking_id}/cancel", response_model=BookingOut) +def user_cancel_booking( + booking_id: UUID, + auth: AuthContext = Depends(require_auth), + db: Session = Depends(get_db), +): + return service.user_cancel_booking(db, booking_id, auth.user_id) + + +@router.post("/{booking_id}/accept", response_model=BookingOut) +def owner_accept_booking( + booking_id: UUID, + auth: AuthContext = Depends(require_owner), + db: Session = Depends(get_db), +): + return service.owner_accept_booking(db, booking_id, auth.user_id) + + +@router.post("/{booking_id}/reject", response_model=BookingOut) +def owner_reject_booking( + booking_id: UUID, + body: OwnerRejectIn, + auth: AuthContext = Depends(require_owner), + db: Session = Depends(get_db), +): + return service.owner_reject_booking(db, booking_id, auth.user_id, body.reason) + + +@router.post("/{booking_id}/extend-balance-deadline", response_model=BookingOut) +def owner_extend_deadline( + booking_id: UUID, + body: ExtendDeadlineIn, + auth: AuthContext = Depends(require_owner), + db: Session = Depends(get_db), +): + return service.owner_extend_deadline(db, booking_id, auth.user_id, body) + + +@router.post("/{booking_id}/cancel-forfeit", response_model=BookingOut) +def owner_cancel_forfeit( + booking_id: UUID, + auth: AuthContext = Depends(require_owner), + db: Session = Depends(get_db), +): + return service.owner_cancel_forfeit(db, booking_id, auth.user_id) + + +@router.post("/{booking_id}/cancel-goodwill", response_model=BookingOut) +def owner_cancel_goodwill( + booking_id: UUID, + auth: AuthContext = Depends(require_owner), + db: Session = Depends(get_db), +): + return service.owner_cancel_goodwill(db, booking_id, auth.user_id) + + +@router.patch("/{booking_id}/owner-notes", response_model=BookingOut) +def update_owner_notes( + booking_id: UUID, + body: UpdateOwnerNotesIn, + auth: AuthContext = Depends(require_owner), + db: Session = Depends(get_db), +): + return service.update_owner_notes(db, booking_id, auth.user_id, body.notes) diff --git a/apps/api/app/modules/booking/schemas.py b/apps/api/app/modules/booking/schemas.py new file mode 100644 index 000000000..e7b551e81 --- /dev/null +++ b/apps/api/app/modules/booking/schemas.py @@ -0,0 +1,124 @@ +from datetime import date, datetime +from typing import Literal +from uuid import UUID + +from pydantic import BaseModel, Field + + +BookingTypeValue = Literal["full_day", "time_slot"] + + +class BookingRequestIn(BaseModel): + venue_id: UUID + venue_name: str + venue_cover_image: str | None + booking_type: BookingTypeValue + starts_at: datetime | None = None + ends_at: datetime | None = None + booking_date: date | None = None + guest_count: int = Field(gt=0) + event_type: str | None = None + user_notes: str | None = None + expected_total_paise: int | None = None + + +class PaymentOption(BaseModel): + label: str + amount_paise: int + display_amount: str + + +class PaymentOptions(BaseModel): + advance: PaymentOption + full: PaymentOption + + +class BookingDisplay(BaseModel): + quoted_price: str + advance_due: str + balance_due: str + platform_fee: str + owner_payout: str + + +class BookingOut(BaseModel): + id: UUID + venue_id: UUID + venue_name: str + venue_city: str + venue_cover_photo_url: str | None = None + user_id: UUID + user_full_name: str | None = None + user_email: str | None = None + user_phone: str | None = None + booking_type: str + status: str + payment_status: str + starts_at: datetime + ends_at: datetime + created_at: datetime | None = None + effective_starts_at: datetime + effective_ends_at: datetime + guest_count: int + event_type: str | None = None + user_notes: str | None = None + owner_notes: str | None = None + quoted_price_paise: int + platform_commission_pct: float + platform_fee_paise: int + owner_payout_paise: int + advance_pct: float + advance_due_paise: int + balance_due_paise: int + balance_due_date: date | None = None + hold_expires_at: datetime | None = None + confirmed_at: datetime | None = None + cancelled_at: datetime | None = None + expired_at: datetime | None = None + amount_paid_paise: int + refund_amount_paise: int + stripe_advance_payment_intent_id: str | None = None + stripe_balance_payment_intent_id: str | None = None + deadline_extension_count: int + balance_overdue_at: datetime | None = None + owner_action_deadline: datetime | None = None + display: BookingDisplay + payment_required: bool = False + payment_options: PaymentOptions | None = None + client_secret: str | None = None + payment_expires_at: datetime | None = None + auto_confirmed_at: datetime | None = None + confirmed_by: str | None = None + + +class CancellationDisplay(BaseModel): + refund_amount: str + penalty_amount: str + + +class CancellationPreviewOut(BaseModel): + refund_amount_paise: int + penalty_amount_paise: int + refund_pct_applied: float + tier_matched: str | None + display: CancellationDisplay + + +class OwnerAcceptIn(BaseModel): + pass + + +class OwnerRejectIn(BaseModel): + reason: str = Field(min_length=1) + + +class ExtendDeadlineIn(BaseModel): + new_due_date: date + + +class UpdateOwnerNotesIn(BaseModel): + notes: str | None + + +BookingResponse = BookingOut +CreateBookingRequest = BookingRequestIn diff --git a/apps/api/app/modules/booking/service.py b/apps/api/app/modules/booking/service.py new file mode 100644 index 000000000..e3d351497 --- /dev/null +++ b/apps/api/app/modules/booking/service.py @@ -0,0 +1,410 @@ +import logging +import uuid +from datetime import date, timedelta +from uuid import UUID + +from fastapi import HTTPException, status +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session, joinedload, selectinload +from sqlalchemy import or_, and_, func + +from app.modules.availability.service import validate_booking_request +from app.modules.notification import service as notifications +from app.modules.notification.types import NotificationType +from app.modules.booking.helpers import ( + _now, + _history, + _booking_or_404, + _assert_booking_owner, + _slot_for_update, + _booking_out, + MAX_DEADLINE_EXTENSIONS, + USER_PAYMENT_HOLD_HOURS, +) +from app.modules.booking.models import ( + Booking, + BookingSlot, + BookingStatus, + BookingType, + PaymentStatus, +) +from app.modules.booking.schemas import ( + BookingOut, + BookingRequestIn, + ExtendDeadlineIn, +) +from app.modules.venue.models import Venue, VenueStatus +from app.modules.profile.models import Profile +from app.modules.venue.service import ( get_pricing_quote_for_slot, _get_active_venue_or_404 ) + +# Re-expose functions from cancellation module +from app.modules.booking.cancellation import ( + _compute_refund, + get_cancellation_preview, + user_cancel_booking, + owner_cancel_forfeit, + owner_cancel_goodwill, + admin_force_cancel, +) + +logger = logging.getLogger(__name__) + + +def create_booking_request( + db: Session, + user_id: UUID, + payload: BookingRequestIn, +) -> BookingOut: + # Acquire exclusive write lock on Venue to serialize slot check and creation + venue = _get_active_venue_or_404( + db, + payload.venue_id, + for_update=True, + ) + + validation = validate_booking_request( + db=db, + venue=venue, + starts_at=payload.starts_at, + ends_at=payload.ends_at, + booking_type=payload.booking_type, + booking_date=payload.booking_date, + guest_count=payload.guest_count, + ) + starts_at = payload.starts_at + ends_at = payload.ends_at + + quote = get_pricing_quote_for_slot( + db=db, + venue_id=venue.id, + starts_at=starts_at, + ends_at=ends_at, + booking_type=payload.booking_type, + ) + + if ( + payload.expected_total_paise is not None + and payload.expected_total_paise != quote.quoted_price_paise + ): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail={ + "error": "PRICE_CHANGED", + "message": "Pricing has changed since you last viewed this booking.", + "quoted_price_paise": quote.quoted_price_paise, + "breakdown": [item.model_dump(mode="json") for item in quote.breakdown], + }, + ) + + is_instant = (venue.booking_mode == "INSTANT") + initial_status = BookingStatus.payment_pending if is_instant else BookingStatus.requested + payment_expires_at = _now() + timedelta(minutes=15) if is_instant else None + + booking = Booking( + id=uuid.uuid4(), + venue_id=venue.id, + user_id=user_id, + booking_type=BookingType(payload.booking_type), + event_type=payload.event_type, + guest_count=payload.guest_count, + user_notes=payload.user_notes, + status=initial_status, + payment_expires_at=payment_expires_at, + balance_due_date=starts_at.date() - timedelta(days=venue.balance_due_days_before_event), + pricing_mode=quote.pricing_mode, + quoted_price_paise=quote.quoted_price_paise, + pricing_breakdown=[item.model_dump(mode="json") for item in quote.breakdown] or None, + platform_commission_pct=quote.platform_commission_pct, + platform_fee_paise=quote.platform_fee_paise, + owner_payout_paise=quote.owner_payout_paise, + advance_pct=quote.advance_pct, + advance_due_paise=quote.advance_due_paise, + balance_due_paise=quote.balance_due_paise, + overdue_advance_refund_pct=venue.overdue_advance_refund_pct, + payment_status=PaymentStatus.unpaid, + ) + db.add(booking) + db.flush() + + slot = BookingSlot( + id=uuid.uuid4(), + booking_id=booking.id, + venue_id=venue.id, + starts_at=starts_at, + ends_at=ends_at, + effective_starts_at=validation.effective_starts_at, + effective_ends_at=validation.effective_ends_at, + is_blocking=is_instant, + ) + db.add(slot) + db.add(_history(booking, None, initial_status, changed_by=user_id)) + db.flush() + + if is_instant: + from app.modules.payment.service import create_payment_intent + try: + create_payment_intent(db, user_id, booking.id, payment_type="advance") + except Exception as e: + logger.exception("Failed to create initial payment intent for instant booking %s", booking.id) + db.rollback() + if isinstance(e, HTTPException): + raise e + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to initialize payment for instant booking" + ) from e + else: + notifications.notify( + db, venue.owner_id, NotificationType.NEW_REQUEST_OWNER, + context={"venue_name": venue.name}, booking_id=booking.id, + ) + notifications.notify( + db, user_id, NotificationType.REQUEST_RECEIVED, + context={"venue_name": venue.name}, booking_id=booking.id, + ) + + db.refresh(booking) + return _booking_out(booking) + + +def get_booking(db: Session, booking_id: UUID, user_id: UUID | None = None) -> BookingOut: + booking = _booking_or_404(db, booking_id) + if user_id is not None and booking.user_id != user_id and booking.venue.owner_id != user_id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Booking access denied") + + return _booking_out(booking) + + +def list_user_bookings(db: Session, user_id: UUID) -> list[BookingOut]: + bookings = ( + db.query(Booking) + .options( + joinedload(Booking.slot), + joinedload(Booking.user), + joinedload(Booking.venue).selectinload(Venue.photos) + ) + .filter(Booking.user_id == user_id, Booking.deleted_at.is_(None)) + .order_by(Booking.created_at.desc()) + .all() + ) + return [_booking_out(booking) for booking in bookings] + + +def list_all_owner_bookings( + db: Session, + owner_id: UUID, + tab: str | None = None, + venue_id: str | None = None, + search: str | None = None +) -> list[BookingOut]: + query = ( + db.query(Booking) + .options( + joinedload(Booking.slot), + joinedload(Booking.user), + joinedload(Booking.venue).selectinload(Venue.photos) + ) + .join(Venue, Booking.venue_id == Venue.id) + .join(Profile, Booking.user_id == Profile.id) + .filter(Venue.owner_id == owner_id, Booking.deleted_at.is_(None)) + ) + + if venue_id and venue_id != "all": + query = query.filter(Booking.venue_id == venue_id) + + if search: + search_term = f"%{search}%" + query = query.filter( + or_( + Venue.name.ilike(search_term), + Profile.full_name.ilike(search_term) + ) + ) + + if tab and tab != "all": + if tab == "requested": + query = query.filter(Booking.status == BookingStatus.requested) + elif tab == "owner_accepted": + query = query.filter(Booking.status == BookingStatus.owner_accepted) + elif tab == "confirmed": + query = query.filter(Booking.status == BookingStatus.confirmed) + elif tab == "completed": + query = query.filter(Booking.status == BookingStatus.completed) + elif tab == "cancelled": + query = query.filter( + Booking.status.in_([ + BookingStatus.conflict_cancelled, + BookingStatus.user_cancelled, + BookingStatus.admin_cancelled, + BookingStatus.owner_rejected, + BookingStatus.balance_overdue_cancelled, + BookingStatus.hold_expired, + BookingStatus.request_expired + ]) + ) + elif tab == "overdue": + query = query.filter( + or_( + and_( + Booking.status == BookingStatus.confirmed, + Booking.balance_overdue_at != None, + Booking.balance_overdue_at < func.now() + ), + and_( + Booking.status == BookingStatus.owner_accepted, + Booking.hold_expires_at != None, + Booking.hold_expires_at < func.now() + ) + ) + ) + + bookings = query.order_by(Booking.created_at.desc()).all() + return [_booking_out(booking) for booking in bookings] + + +def list_venue_bookings( + db: Session, + venue_id: UUID, + owner_id: UUID, + pending_only: bool = False, +) -> list[BookingOut]: + from app.modules.venue.service import _get_active_venue_or_404 + venue = _get_active_venue_or_404(db, venue_id) + if venue.owner_id != owner_id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Venue owner access denied") + + query = ( + db.query(Booking) + .options( + joinedload(Booking.slot), + joinedload(Booking.user), + joinedload(Booking.venue).selectinload(Venue.photos) + ) + .filter( + Booking.venue_id == venue_id, + Booking.deleted_at.is_(None), + ) + ) + if pending_only: + query = query.filter(Booking.status == BookingStatus.requested) + + return [_booking_out(booking) for booking in query.order_by(Booking.requested_at.asc()).all()] + + +def owner_accept_booking(db: Session, booking_id: UUID, owner_id: UUID) -> BookingOut: + booking = _booking_or_404(db, booking_id, for_update=True) + _assert_booking_owner(booking, owner_id) + + # Idempotency: If already accepted, return current state + if booking.status == BookingStatus.owner_accepted: + db.refresh(booking) + return _booking_out(booking) + + if booking.status != BookingStatus.requested: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Booking is not pending") + + slot = _slot_for_update(db, booking.id) + old_status = booking.status + slot.is_blocking = True + booking.status = BookingStatus.owner_accepted + booking.owner_responded_at = _now() + booking.hold_expires_at = booking.owner_responded_at + timedelta(hours=USER_PAYMENT_HOLD_HOURS) + + try: + db.flush() + except IntegrityError as exc: + db.rollback() + if "booking_slots_no_overlap" in str(exc.orig): + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Slot already blocked") from exc + raise + + # The real advance PaymentIntent (and stripe_advance_payment_intent_id) is created + # by payment.service.create_payment_intent when the customer pays the advance. + db.add(_history(booking, old_status, BookingStatus.owner_accepted, changed_by=owner_id)) + db.flush() + db.refresh(booking) + + notifications.notify( + db, booking.user_id, NotificationType.REQUEST_ACCEPTED, + context={"venue_name": booking.venue.name}, booking_id=booking.id, + ) + return _booking_out(booking) + + +def owner_reject_booking( + db: Session, + booking_id: UUID, + owner_id: UUID, + reason: str, +) -> BookingOut: + booking = _booking_or_404(db, booking_id, for_update=True) + _assert_booking_owner(booking, owner_id) + if booking.status != BookingStatus.requested: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Booking is not pending") + + old_status = booking.status + booking.status = BookingStatus.owner_rejected + booking.owner_responded_at = _now() + db.add(_history(booking, old_status, BookingStatus.owner_rejected, changed_by=owner_id, reason=reason)) + db.flush() + db.refresh(booking) + + notifications.notify( + db, booking.user_id, NotificationType.BOOKING_REJECTED, + context={"venue_name": booking.venue.name}, booking_id=booking.id, + ) + return _booking_out(booking) + + +def owner_extend_deadline( + db: Session, + booking_id: UUID, + owner_id: UUID, + body: ExtendDeadlineIn, +) -> BookingOut: + booking = _booking_or_404(db, booking_id, for_update=True) + _assert_booking_owner(booking, owner_id) + if ( + booking.status != BookingStatus.confirmed + or booking.payment_status != PaymentStatus.advance_paid + or booking.balance_overdue_at is None + ): + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Booking is not balance overdue") + if booking.deadline_extension_count >= MAX_DEADLINE_EXTENSIONS: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Deadline extension limit reached") + + # Ensure event has not already started + if booking.slot.starts_at <= _now(): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot extend deadline for a past or ongoing event") + + # Ensure new due date is in the future and before the event date + if body.new_due_date <= date.today(): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="New due date must be in the future") + if body.new_due_date >= booking.slot.starts_at.date(): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="New due date must be before the event date") + + booking.balance_due_date = body.new_due_date + booking.deadline_extension_count += 1 + booking.balance_overdue_at = None + booking.owner_action_deadline = None + # No status change — skip history insert (DB constraint disallows same-status transitions) + + db.flush() + db.refresh(booking) + notifications.notify( + db, booking.user_id, NotificationType.BALANCE_DEADLINE_EXTENDED, + context={"venue_name": booking.venue.name}, booking_id=booking.id, + ) + return _booking_out(booking) + +def update_owner_notes(db: Session, booking_id: UUID, owner_id: UUID, notes: str | None) -> BookingOut: + booking = _booking_or_404(db, booking_id, for_update=True) + _assert_booking_owner(booking, owner_id) + booking.owner_notes = notes + db.flush() + db.refresh(booking) + return _booking_out(booking) + + +def create_booking(user_id: UUID, body: BookingRequestIn, db: Session) -> BookingOut: + return create_booking_request(db, user_id, body) diff --git a/apps/api/app/modules/booking/state_machine.py b/apps/api/app/modules/booking/state_machine.py new file mode 100644 index 000000000..a0ef61d8f --- /dev/null +++ b/apps/api/app/modules/booking/state_machine.py @@ -0,0 +1,43 @@ +from app.modules.booking.models import BookingStatus + + +VALID_TRANSITIONS: dict[BookingStatus, set[BookingStatus]] = { + BookingStatus.requested: { + BookingStatus.owner_accepted, + BookingStatus.owner_rejected, + BookingStatus.request_expired, + BookingStatus.conflict_cancelled, + BookingStatus.admin_cancelled, + }, + BookingStatus.payment_pending: { + BookingStatus.confirmed, + BookingStatus.hold_expired, + BookingStatus.user_cancelled, + BookingStatus.admin_cancelled, + BookingStatus.conflict_cancelled, + }, + BookingStatus.owner_accepted: { + BookingStatus.confirmed, + BookingStatus.hold_expired, + BookingStatus.user_cancelled, + BookingStatus.admin_cancelled, + }, + BookingStatus.confirmed: { + BookingStatus.completed, + BookingStatus.user_cancelled, + BookingStatus.admin_cancelled, + BookingStatus.balance_overdue_cancelled, + }, + BookingStatus.completed: set(), + BookingStatus.hold_expired: set(), + BookingStatus.request_expired: set(), + BookingStatus.conflict_cancelled: set(), + BookingStatus.user_cancelled: set(), + BookingStatus.admin_cancelled: set(), + BookingStatus.owner_rejected: set(), + BookingStatus.balance_overdue_cancelled: set(), +} + + +def can_transition(current: BookingStatus, next_status: BookingStatus) -> bool: + return next_status in VALID_TRANSITIONS.get(current, set()) diff --git a/apps/api/app/modules/deep_research/__init__.py b/apps/api/app/modules/deep_research/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apps/api/app/modules/deep_research/external_source.py b/apps/api/app/modules/deep_research/external_source.py new file mode 100644 index 000000000..67429a691 --- /dev/null +++ b/apps/api/app/modules/deep_research/external_source.py @@ -0,0 +1,95 @@ +import logging +from typing import Any +import httpx +from app.core.config import settings + +logger = logging.getLogger(__name__) + +PLACES_TEXT_SEARCH_URL = "https://places.googleapis.com/v1/places:searchText" +PLACES_DETAILS_URL = "https://places.googleapis.com/v1/places/{place_id}" + + +TEXT_SEARCH_FIELD_MASK = ( + "places.id," # Essentials + "places.displayName," # Pro — venue name + "places.formattedAddress," # Pro — full address + "places.location," # Pro — lat/lng for map pin + "places.postalAddress," # Pro + "places.types," # Pro — category tags + "places.photos," # Pro — photo reference (uploaded to Cloudinary once, cached forever) + "places.businessStatus," # Pro +) + +DETAILS_FIELD_MASK = ( + "formattedAddress," + "priceLevel," + "googleMapsUri," + "internationalPhoneNumber," + "websiteUri," + "rating," + "userRatingCount," + "regularOpeningHours," + "editorialSummary" +) + + +class ExternalSourceError(Exception): + pass + + +class GooglePlacesSource: + def __init__(self, api_key: str | None = None, timeout: float = 10.0): + self.api_key = api_key or settings.google_places_api_key + self.timeout = timeout + + async def text_search( + self, + query: str, + latitude: float | None = None, + longitude: float | None = None, + radius_meters: int = 15000, + max_results: int = 5, + ) -> list[dict[str, Any]]: + payload: dict[str, Any] = { + "textQuery": query, + "maxResultCount": max_results, + } + + if latitude is not None and longitude is not None: + payload["locationBias"] = { + "circle": { + "center": {"latitude": latitude, "longitude": longitude}, + "radius": radius_meters, + } + } + + headers = { + "Content-Type": "application/json", + "X-Goog-Api-Key": self.api_key, + "X-Goog-FieldMask": TEXT_SEARCH_FIELD_MASK, + } + try: + async with httpx.AsyncClient(timeout=self.timeout) as client: + resp = await client.post(PLACES_TEXT_SEARCH_URL, json=payload, headers=headers) + resp.raise_for_status() + except httpx.HTTPError as e: + raise ExternalSourceError(f"text_search failed: {e}") from e + return resp.json().get("places", []) + + async def place_details(self, place_id: str) -> dict[str, Any]: + headers = { + "X-Goog-Api-Key": self.api_key, + "X-Goog-FieldMask": DETAILS_FIELD_MASK, + } + try: + async with httpx.AsyncClient(timeout=self.timeout) as client: + resp = await client.get( + PLACES_DETAILS_URL.format(place_id=place_id), headers=headers + ) + resp.raise_for_status() + except httpx.HTTPError as e: + raise ExternalSourceError(f"place_details failed: {e}") from e + return resp.json() + + +external_source = GooglePlacesSource() diff --git a/apps/api/app/modules/deep_research/models.py b/apps/api/app/modules/deep_research/models.py new file mode 100644 index 000000000..70f7316be --- /dev/null +++ b/apps/api/app/modules/deep_research/models.py @@ -0,0 +1,121 @@ +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, Float, ForeignKey, Integer, Text, func, BigInteger, Date, Enum +from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship +import enum +from datetime import date as date_type + +from app.core.database import Base + + +class ExternalLeadStatus(str, enum.Enum): + DISCOVERED = "discovered" + CONTACTED = "contacted" + ONBOARDED = "onboarded" + REJECTED = "rejected" + + +class LeadReservationStatus(str, enum.Enum): + NEW = "new" + CONTACTED = "contacted" + OWNER_INTERESTED = "owner_interested" + OWNER_INVITED = "owner_invited" + OWNER_ONBOARDED = "owner_onboarded" + VENUE_DRAFT_CREATED = "venue_draft_created" + VENUE_PENDING_APPROVAL = "venue_pending_approval" + VENUE_APPROVED = "venue_approved" + BOOKING_CREATED = "booking_created" + CLOSED = "closed" + CANCELLED = "cancelled" + REJECTED = "rejected" + + + +class DeepResearchQuery(Base): + """Logs each Deep Research prompt. Exists primarily so external discovery + (Phase 2, see docs/DEEP-RESEARCH-PRD.md) can attach `external_venue_leads` + to `discovered_via_query_id` once the user asks us to search beyond the + internal catalog. + + Also the source of truth for the admin observability page — the columns + below capture the LLM breakdown and result summary at search time, since + those are otherwise only ever logged (not queryable) and the raw match + scores are computed fresh per-request and never persisted anywhere else. + """ + + __tablename__ = "deep_research_queries" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("profiles.id"), nullable=False) + query_text: Mapped[str] = mapped_column(Text, nullable=False) + city_filter: Mapped[str | None] = mapped_column(Text, nullable=True) + # Full QueryUnderstanding.model_dump() — intent/venue_type/capacity/etc. + understanding_json: Mapped[dict | None] = mapped_column(JSONB, nullable=True) + result_count: Mapped[int] = mapped_column(Integer, nullable=False, server_default="0") + avg_match_score: Mapped[float | None] = mapped_column(Float, nullable=True) + # Top few results as [{id, name, match_source, match_score}, ...] + top_results_json: Mapped[list | None] = mapped_column(JSONB, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + + +class ExternalDiscoveryRequest(Base): + """ + Owned by external-discovery, NOT deep_research_queries — holds the + device location the frontend supplies only when the user explicitly + asks to search externally. Keeping this separate avoids needing a + migration on a table someone else owns. + """ + __tablename__ = "external_discovery_requests" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + query_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("deep_research_queries.id"), nullable=False, unique=True + ) + latitude: Mapped[float] = mapped_column(Float, nullable=False) + longitude: Mapped[float] = mapped_column(Float, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + +class ExternalVenueLead(Base): + __tablename__ = "external_venue_leads" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + discovered_via_query_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("deep_research_queries.id"), nullable=False + ) + source: Mapped[str] = mapped_column(Text, nullable=False, default="google_places") + source_ref: Mapped[str] = mapped_column(Text, nullable=False, index=True) + name: Mapped[str] = mapped_column(Text, nullable=False) + city: Mapped[str | None] = mapped_column(Text, nullable=True) + category_guess: Mapped[str | None] = mapped_column(Text, nullable=True) + formatted_address: Mapped[str | None] = mapped_column(Text, nullable=True) + cover_photo_url: Mapped[str | None] = mapped_column(Text, nullable=True) # Cloudinary URL, cached forever + raw_contact_info: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict) # admin-only + status: Mapped[ExternalLeadStatus] = mapped_column( + Enum(ExternalLeadStatus, name="external_lead_status"), nullable=False, default=ExternalLeadStatus.DISCOVERED + ) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + +class LeadReservation(Base): + __tablename__ = "lead_reservations" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + lead_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("external_venue_leads.id", ondelete="CASCADE"), nullable=False) + user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("profiles.id"), nullable=False) + status: Mapped[LeadReservationStatus] = mapped_column( + Enum(LeadReservationStatus, name="lead_reservation_status", values_callable=lambda obj: [e.value for e in obj]), nullable=False, default=LeadReservationStatus.NEW + ) + platform_fee_paise: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0) + event_date: Mapped[date_type | None] = mapped_column(Date, nullable=True) + guest_count: Mapped[int | None] = mapped_column(Integer, nullable=True) + phone: Mapped[str | None] = mapped_column(Text, nullable=True) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + lead: Mapped["ExternalVenueLead"] = relationship("ExternalVenueLead") + diff --git a/apps/api/app/modules/deep_research/prompts.py b/apps/api/app/modules/deep_research/prompts.py new file mode 100644 index 000000000..57cad72a2 --- /dev/null +++ b/apps/api/app/modules/deep_research/prompts.py @@ -0,0 +1,22 @@ +"""LLM prompts for the Deep Research module, kept separate from the calling +service so wording can be iterated on without touching pipeline logic. +""" + +QUERY_UNDERSTANDING_SYSTEM_PROMPT = """You are a query-understanding engine for an Indian venue \ +booking marketplace. Break the user's free-text venue search into structured \ +JSON with exactly these keys: +- intent: short phrase describing what kind of event/venue they want +- city: city name, or null +- venue_type: e.g. "marriage hall", "rooftop", "conference room", or null +- capacity: integer guest count, or null +- budget_hint: free text like "under 1 lakh" or "cheap", or null +- date_hint: free text like "this weekend" or "August", or null +- required_amenities: array of short normalized tags for amenities/services \ +explicitly requested — e.g. "ac", "food_catering", "parking", "travel_service", \ +"pet_friendly", "decoration", "dj_music", "generator_backup". Only include \ +amenities the query actually asks for; do not invent ones it didn't mention. +- special_requirements: array of short free-text notes for anything else \ +notable that doesn't fit the other fields (accessibility needs, religious/\ +cultural requirements, timing constraints, etc). Empty array if none. + +Respond with ONLY the JSON object, no other text.""" diff --git a/apps/api/app/modules/deep_research/query_enrichment.py b/apps/api/app/modules/deep_research/query_enrichment.py new file mode 100644 index 000000000..991ced99f --- /dev/null +++ b/apps/api/app/modules/deep_research/query_enrichment.py @@ -0,0 +1,23 @@ +"""Builds the query string actually sent to internal retrieval +(/search/hybrid), from the LLM's structured breakdown rather than the raw +user sentence. + +Why this exists: search_hybrid's FTS half uses plainto_tsquery, which ANDs +every lexeme together — a raw sentence like "best club venues in ernakulam" +requires "best" AND "club" AND "venue" AND "ernakulam" to all appear in a +venue's indexed text, so filler words alone can sink an otherwise-good match. +City/date/budget are already passed as separate structured filters (or +ignored, for hints hybrid search has no filter for), so they're dropped here +too — this function keeps only the words that are actually useful lexical/ +semantic anchors: intent, venue type, and required amenities. +""" + +from app.modules.deep_research.schemas import QueryUnderstanding + + +def build_internal_search_query(raw_query: str, breakdown: QueryUnderstanding) -> str: + terms = [breakdown.intent, breakdown.venue_type, *breakdown.required_amenities] + # dict.fromkeys dedupes while preserving first-seen order (e.g. intent and + # venue_type are often the same phrase from the LLM). + cleaned = " ".join(dict.fromkeys(t.strip() for t in terms if t and t.strip())) + return cleaned or raw_query diff --git a/apps/api/app/modules/deep_research/query_understanding.py b/apps/api/app/modules/deep_research/query_understanding.py new file mode 100644 index 000000000..249fc14b9 --- /dev/null +++ b/apps/api/app/modules/deep_research/query_understanding.py @@ -0,0 +1,63 @@ +"""Deep Research's query-understanding stage. + +First step of the Deep Research pipeline (see docs/deep-research-architecture.md): +takes the user's free-text prompt and asks Groq to break it into structured +signals (intent, city, venue type, capacity, budget/date hints, amenities, +and any other requirements). This is deliberately separate from +app.modules.search.query_normalizer, which is a cheap fuzzy-typo corrector +with no LLM call — this stage is the new, LLM-backed layer that sits in +front of it. + +The breakdown returned here is logged, then consumed by +app.modules.deep_research.service.run_search, which persists the query and +builds the internal /search/hybrid request from it (see query_enrichment.py). +""" + +import json +import logging + +from app.infrastructure.llm import groq +from app.modules.deep_research.prompts import QUERY_UNDERSTANDING_SYSTEM_PROMPT +from app.modules.deep_research.schemas import QueryUnderstanding + +logger = logging.getLogger(__name__) + + +def understand_query(query: str) -> QueryUnderstanding: + """Call Groq to break the raw query into structured signals, log the + breakdown, and return it. + + Degrades to a raw-query-only breakdown (rather than raising) on any + failure — missing/invalid GROQ_API_KEY, Groq outage, timeout, or an + unparseable response — so a Groq problem takes down query understanding + only, not the whole /deep-research/search request. Internal retrieval + still runs on the raw query in that case; it just misses the structured + city/capacity/amenity signals for this one search. + """ + try: + raw_content = groq.chat_completion( + messages=[ + {"role": "system", "content": QUERY_UNDERSTANDING_SYSTEM_PROMPT}, + {"role": "user", "content": query}, + ] + ) + parsed = json.loads(raw_content) + breakdown = QueryUnderstanding(**parsed) + except (json.JSONDecodeError, TypeError, ValueError): + logger.warning( + "deep_research.query_understanding: could not parse Groq response as JSON: %r", + raw_content, + ) + breakdown = QueryUnderstanding(intent=query) + except Exception: + logger.exception( + "deep_research.query_understanding: Groq call failed for query=%r", query + ) + breakdown = QueryUnderstanding(intent=query) + + logger.info( + "deep_research.query_understanding: query=%r breakdown=%s", + query, + breakdown.model_dump(), + ) + return breakdown diff --git a/apps/api/app/modules/deep_research/routes.py b/apps/api/app/modules/deep_research/routes.py new file mode 100644 index 000000000..840f305b9 --- /dev/null +++ b/apps/api/app/modules/deep_research/routes.py @@ -0,0 +1,60 @@ +from fastapi import APIRouter, Depends, HTTPException +from uuid import UUID +from sqlalchemy.orm import Session + +from app.core.database import get_db +from app.modules.auth.dependencies import AuthContext, require_auth +from app.modules.deep_research import service +from app.modules.deep_research.schemas import ( + DeepResearchSearchRequest, DeepResearchSearchResponse, + TriggerExternalDiscoveryRequest, ExternalLeadPublic, + ReserveLeadRequest, ReserveLeadResponse, + UserReservationResponse, +) + +router = APIRouter() + + +@router.post("/search", response_model=DeepResearchSearchResponse) +def search( + body: DeepResearchSearchRequest, + db: Session = Depends(get_db), + auth: AuthContext = Depends(require_auth), +): + return service.run_search(db, auth.user_id, body.query, body.page, body.page_size) + + +@router.post("/external", response_model=list[ExternalLeadPublic]) +async def trigger_external( + body: TriggerExternalDiscoveryRequest, + db: Session = Depends(get_db), + auth: AuthContext = Depends(require_auth), +): + """ + Async endpoint: awaits Google Places + concurrent Cloudinary uploads, + then returns all discovered leads in one response. No jobs, no polling. + """ + return await service.run_external_discovery( + db, body.query_id, body.latitude, body.longitude + ) + + +@router.post("/leads/{lead_id}/reserve", response_model=ReserveLeadResponse) +def reserve( + lead_id: UUID, + body: ReserveLeadRequest, + db: Session = Depends(get_db), + auth: AuthContext = Depends(require_auth), +): + reservation = service.reserve_lead( + db, lead_id, auth.user_id, body.event_date, body.guest_count, body.phone, body.notes + ) + return ReserveLeadResponse(reservation_id=reservation.id, status=reservation.status.value) + + +@router.get("/reservations", response_model=list[UserReservationResponse]) +def get_reservations( + db: Session = Depends(get_db), + auth: AuthContext = Depends(require_auth), +): + return service.get_user_reservations(db, auth.user_id) diff --git a/apps/api/app/modules/deep_research/schemas.py b/apps/api/app/modules/deep_research/schemas.py new file mode 100644 index 000000000..dc1bae509 --- /dev/null +++ b/apps/api/app/modules/deep_research/schemas.py @@ -0,0 +1,74 @@ +from typing import Optional +from uuid import UUID +from datetime import datetime + +from pydantic import BaseModel + +from app.modules.search.schemas import SearchResult +from app.shared.pagination import Page + + +class QueryUnderstanding(BaseModel): + intent: str + city: Optional[str] = None + venue_type: Optional[str] = None + capacity: Optional[int] = None + budget_hint: Optional[str] = None + date_hint: Optional[str] = None + required_amenities: list[str] = [] + special_requirements: list[str] = [] + + +class DeepResearchSearchRequest(BaseModel): + query: str + page: int = 1 + page_size: int = 20 + + +class DeepResearchSearchResponse(BaseModel): + query_id: UUID + understanding: QueryUnderstanding + internal_results: Page[SearchResult] + + +class TriggerExternalDiscoveryRequest(BaseModel): + query_id: UUID + latitude: float + longitude: float + + +class ExternalLeadPublic(BaseModel): + id: UUID + name: str + city: Optional[str] = None + formatted_address: Optional[str] = None + cover_photo_url: Optional[str] = None + category_guess: Optional[str] = None + source: str + disclaimer: str = ( + "Not verified on Venue404. Reservation is a request only, subject to " + "availability, and includes an additional platform fee." + ) + + +class ReserveLeadRequest(BaseModel): + event_date: Optional[str] = None + guest_count: Optional[int] = None + phone: Optional[str] = None + notes: Optional[str] = None + + +class ReserveLeadResponse(BaseModel): + reservation_id: UUID + status: str + + +class UserReservationResponse(BaseModel): + id: UUID + lead: ExternalLeadPublic + status: str + event_date: Optional[str] = None + guest_count: Optional[int] = None + phone: Optional[str] = None + notes: Optional[str] = None + created_at: datetime diff --git a/apps/api/app/modules/deep_research/service.py b/apps/api/app/modules/deep_research/service.py new file mode 100644 index 000000000..17e480ee3 --- /dev/null +++ b/apps/api/app/modules/deep_research/service.py @@ -0,0 +1,369 @@ +"""Deep Research orchestration. + +Phase 1: Query Understanding → Internal Retrieval (hybrid search) → persisted +query log (breakdown + result summary for admin observability). + +Phase 2 (this file): External discovery via Google Places. Runs as a single +async request — no job queue, no polling. Cloudinary photo uploads are +concurrent (asyncio.gather), so total wait time ≈ 1× upload rather than N×. +""" + +import asyncio +import logging +from uuid import UUID + +from sqlalchemy.orm import Session + +from app.modules.deep_research.models import ( + DeepResearchQuery, + ExternalDiscoveryRequest, + ExternalVenueLead, + LeadReservation, +) +from app.modules.deep_research.query_enrichment import build_internal_search_query +from app.modules.deep_research.query_understanding import understand_query +from app.modules.deep_research.schemas import DeepResearchSearchResponse, ExternalLeadPublic +from app.modules.search import service as search_service +from app.modules.search.schemas import SearchParams + +logger = logging.getLogger(__name__) + +_TOP_RESULTS_TRACKED = 5 +MAX_EXTERNAL_RESULTS = 5 +DEFAULT_RADIUS_METERS = 15000 + + +# ─── Phase 1: Internal search ──────────────────────────────────────────────── + +def run_search( + db: Session, user_id: UUID, query: str, page: int, page_size: int +) -> DeepResearchSearchResponse: + breakdown = understand_query(query) + + search_params = SearchParams( + q=build_internal_search_query(query, breakdown), + city=breakdown.city or "", + capacity=breakdown.capacity or 0, + page=page, + page_size=page_size, + ) + internal_results = search_service.search_hybrid(db, search_params) + + match_scores = [r.match_score for r in internal_results.items if r.match_score is not None] + avg_match_score = sum(match_scores) / len(match_scores) if match_scores else None + top_results = [ + { + "id": str(r.id), + "name": r.name, + "match_source": r.match_source, + "match_score": r.match_score, + } + for r in internal_results.items[:_TOP_RESULTS_TRACKED] + ] + + research_query = DeepResearchQuery( + user_id=user_id, + query_text=query, + city_filter=breakdown.city, + understanding_json=breakdown.model_dump(), + result_count=internal_results.total, + avg_match_score=avg_match_score, + top_results_json=top_results, + ) + db.add(research_query) + db.commit() + db.refresh(research_query) + + return DeepResearchSearchResponse( + query_id=research_query.id, + understanding=breakdown, + internal_results=internal_results, + ) + + +# ─── Phase 2: External discovery ───────────────────────────────────────────── + +async def _upload_photo_to_cloudinary( + source_ref: str, photos: list[dict] +) -> str | None: + """ + Upload the first Google Places photo to Cloudinary. + Returns the secure Cloudinary URL, or None if upload fails. + Non-fatal — a lead without a photo is still a valid lead. + """ + import cloudinary + import cloudinary.uploader + from app.core.config import settings + + if not photos: + return None + + photo_name = photos[0].get("name") + if not photo_name: + return None + + google_photo_url = ( + f"https://places.googleapis.com/v1/{photo_name}/media" + f"?key={settings.google_places_api_key}" + f"&maxWidthPx=800&maxHeightPx=500" + ) + try: + # Run the blocking Cloudinary SDK call in a thread so it doesn't + # block the event loop while we wait for the upload. + loop = asyncio.get_event_loop() + result = await loop.run_in_executor( + None, + lambda: cloudinary.uploader.upload( + google_photo_url, + folder="venue404/external_leads", + public_id=f"gplace_{source_ref}", + overwrite=False, + resource_type="image", + ), + ) + url = result.get("secure_url") + logger.info("Uploaded photo for place %s → %s", source_ref, url) + return url + except Exception as exc: + logger.warning("Photo upload failed for %s: %s", source_ref, exc) + return None + + +async def run_external_discovery( + db: Session, query_id: UUID, latitude: float, longitude: float +) -> list[ExternalLeadPublic]: + """ + Async function that: + 1. Saves an audit row for the location supplied by the browser. + 2. Calls Google Places (async httpx). + 3. Deduplicates against our internal Venue table. + 4. Uploads all photos concurrently via asyncio.gather. + 5. Bulk-inserts ExternalVenueLead rows. + 6. Returns the list of ExternalLeadPublic immediately. + """ + import cloudinary + from app.core.config import settings + from app.modules.deep_research.external_source import external_source + from app.modules.venue.models import Venue + from sqlalchemy import func + + # ── 1. Audit row ────────────────────────────────────────────────────────── + ctx = ExternalDiscoveryRequest( + query_id=query_id, latitude=latitude, longitude=longitude + ) + db.add(ctx) + db.commit() + + # ── 2. Load query context ───────────────────────────────────────────────── + query_row = db.get(DeepResearchQuery, query_id) + if query_row is None: + raise ValueError(f"deep_research_queries row {query_id} not found") + + from app.modules.deep_research.schemas import QueryUnderstanding + + breakdown = ( + QueryUnderstanding(**query_row.understanding_json) + if query_row.understanding_json + else QueryUnderstanding(intent="", city=query_row.city_filter) + ) + # For external discovery, we must include the city in the text query + # since Google Places uses it alongside the location bias. + query_text = build_internal_search_query(query_row.query_text, breakdown) + if breakdown.city and breakdown.city.lower() not in query_text.lower(): + query_text = f"{query_text} in {breakdown.city}" + + # If the user explicitly provided a city, rely entirely on the text query + # (e.g. "wedding hall in Bangalore") rather than skewing results to their GPS location. + if breakdown.city: + use_lat, use_lng = None, None + else: + use_lat, use_lng = latitude, longitude + + # ── 3. Google Places call (async) ───────────────────────────────────────── + raw_results = await external_source.text_search( + query=query_text, + latitude=use_lat, + longitude=use_lng, + radius_meters=DEFAULT_RADIUS_METERS, + max_results=10, # fetch extra to allow for dedup filtering + ) + + # ── 4. Deduplicate against internal venues ──────────────────────────────── + valid_raws: list[dict] = [] + for raw in raw_results: + name = raw.get("displayName", {}).get("text", "") + if query_row.city_filter and name: + clash = ( + db.query(Venue) + .filter( + func.lower(Venue.name) == name.lower(), + func.lower(Venue.city) == query_row.city_filter.lower(), + ) + .first() + ) + if clash: + continue + valid_raws.append(raw) + if len(valid_raws) >= MAX_EXTERNAL_RESULTS: + break + + if not valid_raws: + return [] + + # ── 5. Configure Cloudinary once ────────────────────────────────────────── + cloudinary.config( + cloud_name=settings.cloudinary_cloud_name, + api_key=settings.cloudinary_api_key, + api_secret=settings.cloudinary_api_secret, + secure=True, + ) + + # ── 6. Upload photos concurrently ───────────────────────────────────────── + # For each lead, check if we already have a cached Cloudinary URL first. + async def resolve_photo(raw: dict) -> str | None: + source_ref = raw["id"] + cached = ( + db.query(ExternalVenueLead) + .filter( + ExternalVenueLead.source_ref == source_ref, + ExternalVenueLead.cover_photo_url.isnot(None), + ) + .first() + ) + if cached: + logger.debug("Reusing cached photo for place %s", source_ref) + return cached.cover_photo_url + return await _upload_photo_to_cloudinary(source_ref, raw.get("photos", [])) + + photo_urls: list[str | None] = await asyncio.gather( + *[resolve_photo(raw) for raw in valid_raws] + ) + + # ── 7. Persist leads and return ─────────────────────────────────────────── + public_leads: list[ExternalLeadPublic] = [] + for raw, cover_photo_url in zip(valid_raws, photo_urls): + lead = ExternalVenueLead( + discovered_via_query_id=query_id, + source="google_places", + source_ref=raw["id"], + name=raw.get("displayName", {}).get("text", "Unknown venue"), + city=( + raw.get("postalAddress", {}).get("locality") + or query_row.city_filter + ), + formatted_address=raw.get("formattedAddress"), + cover_photo_url=cover_photo_url, + raw_contact_info={}, + ) + db.add(lead) + db.flush() # get lead.id without a full commit yet + public_leads.append(_to_public_lead(lead)) + + db.commit() + return public_leads + + +# ─── Helpers ────────────────────────────────────────────────────────────────── + +def _to_public_lead(lead: ExternalVenueLead) -> ExternalLeadPublic: + return ExternalLeadPublic( + id=lead.id, + name=lead.name, + city=lead.city, + formatted_address=lead.formatted_address, + cover_photo_url=lead.cover_photo_url, + category_guess=lead.category_guess, + source=lead.source, + ) + + +# ─── Reservations ───────────────────────────────────────────────────────────── + +def reserve_lead( + db: Session, + lead_id: UUID, + user_id: UUID, + event_date=None, + guest_count=None, + phone=None, + notes=None, +) -> LeadReservation: + import httpx + from app.core.config import settings + from app.modules.deep_research.external_source import DETAILS_FIELD_MASK, PLACES_DETAILS_URL + + lead = db.get(ExternalVenueLead, lead_id) + if lead is None: + raise ValueError(f"No lead found for id={lead_id}") + + if not lead.raw_contact_info: + # Fetch details synchronously (this is a sync route, one-time fetch per lead) + headers = { + "X-Goog-Api-Key": settings.google_places_api_key, + "X-Goog-FieldMask": DETAILS_FIELD_MASK, + } + try: + resp = httpx.get( + PLACES_DETAILS_URL.format(place_id=lead.source_ref), + headers=headers, + timeout=10.0, + ) + resp.raise_for_status() + details = resp.json() + except Exception as exc: + logger.warning("Could not fetch place details for %s: %s", lead.source_ref, exc) + details = {} + + lead.raw_contact_info = { + "phone": details.get("internationalPhoneNumber"), + "website": details.get("websiteUri"), + "formatted_address": details.get("formattedAddress"), + "price_level": details.get("priceLevel"), + "rating": details.get("rating"), + "user_rating_count": details.get("userRatingCount"), + "regular_opening_hours": details.get("regularOpeningHours"), + "editorial_summary": details.get("editorialSummary"), + "google_maps_uri": details.get("googleMapsUri"), + } + + reservation = LeadReservation( + lead_id=lead.id, + user_id=user_id, + platform_fee_paise=50000, + event_date=event_date, + guest_count=guest_count, + phone=phone, + notes=notes, + ) + db.add(reservation) + db.commit() + db.refresh(reservation) + return reservation + + +def get_user_reservations(db: Session, user_id: UUID) -> list[dict]: + from sqlalchemy.orm import joinedload + + reservations = ( + db.query(LeadReservation) + .options(joinedload(LeadReservation.lead)) + .filter(LeadReservation.user_id == user_id) + .order_by(LeadReservation.created_at.desc()) + .all() + ) + + result = [] + for res in reservations: + result.append( + { + "id": res.id, + "status": res.status.value, + "event_date": res.event_date.isoformat() if res.event_date else None, + "guest_count": res.guest_count, + "phone": res.phone, + "notes": res.notes, + "created_at": res.created_at, + "lead": _to_public_lead(res.lead), + } + ) + return result diff --git a/apps/api/app/modules/internal/__init__.py b/apps/api/app/modules/internal/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apps/api/app/modules/internal/routes.py b/apps/api/app/modules/internal/routes.py new file mode 100644 index 000000000..4c64e71db --- /dev/null +++ b/apps/api/app/modules/internal/routes.py @@ -0,0 +1,52 @@ +"""Machine-to-machine endpoint that runs background jobs on demand. + +Triggered by scheduled GitHub Actions crons (see .github/workflows/jobs.yml), +which pass the cadence-group job names and the shared X-Job-Token secret. This +replaces the in-process APScheduler in production so the free web service can +sleep when idle (ENABLE_JOBS stays false). +""" +import hmac +import logging + +from fastapi import APIRouter, Header, HTTPException, Query + +from app.core.config import settings +from app.jobs.runner import JOBS, run_job + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +def _authorize(x_job_token: str | None) -> None: + if not settings.job_runner_token: + # No token configured -> endpoint is intentionally disabled. + raise HTTPException(status_code=503, detail="Job runner is not configured") + if not x_job_token or not hmac.compare_digest(x_job_token, settings.job_runner_token): + raise HTTPException(status_code=401, detail="Invalid job token") + + +@router.post("/run-jobs") +def run_jobs( + jobs: str = Query(..., description="Comma-separated job names, e.g. hold_expiry,payment_reminders"), + x_job_token: str | None = Header(default=None, alias="X-Job-Token"), +): + _authorize(x_job_token) + + names = [n.strip() for n in jobs.split(",") if n.strip()] + if not names: + raise HTTPException(status_code=400, detail="No jobs specified") + + unknown = [n for n in names if n not in JOBS] + if unknown: + raise HTTPException( + status_code=400, + detail=f"Unknown job(s): {', '.join(unknown)}. Valid: {', '.join(JOBS)}", + ) + + results: dict[str, int] = {} + for name in names: + results[name] = run_job(name) + + logger.info("run-jobs completed: %s", results) + return {"results": results, "ran": len(results)} diff --git a/apps/api/app/modules/notification/models.py b/apps/api/app/modules/notification/models.py new file mode 100644 index 000000000..150900fcb --- /dev/null +++ b/apps/api/app/modules/notification/models.py @@ -0,0 +1,26 @@ +import uuid +from datetime import datetime +from sqlalchemy import String, Text, ForeignKey, DateTime, func +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import mapped_column, Mapped +from app.core.database import Base + + +class InAppNotification(Base): + __tablename__ = "notifications" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + user_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("profiles.id", ondelete="CASCADE"), nullable=False + ) + booking_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("bookings.id", ondelete="CASCADE"), nullable=True + ) + type: Mapped[str] = mapped_column(String, nullable=False) + title: Mapped[str] = mapped_column(String, nullable=False) + body: Mapped[str] = mapped_column(Text, nullable=False) + read_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=func.now() + ) diff --git a/apps/api/app/modules/notification/routes.py b/apps/api/app/modules/notification/routes.py new file mode 100644 index 000000000..1715c1c43 --- /dev/null +++ b/apps/api/app/modules/notification/routes.py @@ -0,0 +1,26 @@ +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +from app.core.database import get_db +from app.modules.auth.dependencies import get_current_user, AuthContext +from app.modules.notification.schemas import NotificationResponse +from app.modules.notification import service + +router = APIRouter() + + +@router.get("/", response_model=list[NotificationResponse]) +def list_notifications( + user: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db), +): + return service.list_notifications(db, user.user_id) + + +@router.patch("/{notification_id}/read", status_code=204) +def mark_read( + notification_id: str, + user: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db), +): + service.mark_read(db, notification_id, user.user_id) diff --git a/apps/api/app/modules/notification/schemas.py b/apps/api/app/modules/notification/schemas.py new file mode 100644 index 000000000..3a6fe0105 --- /dev/null +++ b/apps/api/app/modules/notification/schemas.py @@ -0,0 +1,13 @@ +from pydantic import BaseModel +from datetime import datetime + + +class NotificationResponse(BaseModel): + id: str + user_id: str + booking_id: str | None + type: str + title: str + body: str + read_at: datetime | None + created_at: datetime diff --git a/apps/api/app/modules/notification/service.py b/apps/api/app/modules/notification/service.py new file mode 100644 index 000000000..08b2697a9 --- /dev/null +++ b/apps/api/app/modules/notification/service.py @@ -0,0 +1,134 @@ +import json +import logging +import urllib.request +import urllib.error +from datetime import datetime, timezone +from typing import List + +from sqlalchemy import event +from sqlalchemy.orm import Session + +from app.core.config import settings +from app.core.database import SessionLocal +from app.core.email import send_email +from app.core.exceptions import NotFoundError, ForbiddenError +from app.modules.notification.models import InAppNotification +from app.modules.notification.schemas import NotificationResponse +from app.modules.notification.templates import render_notification + +logger = logging.getLogger(__name__) + + +def notify(db: Session, user_id, type: str, context: dict | None = None, booking_id=None) -> InAppNotification: + """Create an in-app notification and queue its email for after-commit send. + + Always writes the in-app row inside the caller's transaction. The email is + deferred: it is sent only once the transaction commits (see the after_commit + listener), so no network I/O happens while the caller still holds DB row + locks, and a rollback sends nothing. Email delivery is best-effort and never + affects the transaction. The caller owns the commit. + """ + title, body, html = render_notification(type, context, booking_id) + row = InAppNotification(user_id=user_id, booking_id=booking_id, type=type, title=title, body=body) + db.add(row) + db.flush() + + pending = db.info.setdefault("_pending_emails", []) + pending.append({"user_id": str(user_id), "subject": title, "html": html, "notification_id": row.id}) + return row + + +def _send_pending_emails(session: Session) -> None: + """after_commit hook: send any emails queued by notify() during this txn. + + Runs after the lock-holding transaction has committed. sent_at is stamped via + a fresh short-lived session so we never re-enter the just-committed one. + """ + pending = session.info.pop("_pending_emails", None) + if not pending: + return + for item in pending: + try: + email = _get_user_email(item["user_id"]) + if email and send_email(email, item["subject"], item["html"]): + _mark_email_sent(item["notification_id"]) + except Exception: + logger.exception("Deferred notification email failed (notification=%s)", item.get("notification_id")) + + +def _mark_email_sent(notification_id) -> None: + s = SessionLocal() + try: + row = s.get(InAppNotification, notification_id) + if row and row.sent_at is None: + row.sent_at = datetime.now(timezone.utc) + s.commit() + except Exception: + logger.exception("Could not stamp sent_at for notification %s", notification_id) + finally: + s.close() + + +# Drain queued emails whenever any app session commits. The fresh session opened +# in _mark_email_sent re-fires this with an empty queue, so there is no loop. +event.listen(SessionLocal, "after_commit", _send_pending_emails) + + +def list_notifications(db: Session, user_id) -> List[NotificationResponse]: + rows = ( + db.query(InAppNotification) + .filter(InAppNotification.user_id == user_id) + .order_by(InAppNotification.created_at.desc()) + .all() + ) + return [_to_response(r) for r in rows] + + +def mark_read(db: Session, notification_id: str, user_id) -> None: + row = db.query(InAppNotification).filter(InAppNotification.id == notification_id).first() + if not row: + raise NotFoundError("Notification not found") + if str(row.user_id) != str(user_id): + raise ForbiddenError("Not your notification") + if row.read_at is None: + row.read_at = datetime.now(timezone.utc) + db.commit() + + +def _to_response(r: InAppNotification) -> NotificationResponse: + return NotificationResponse( + id=str(r.id), + user_id=str(r.user_id), + booking_id=str(r.booking_id) if r.booking_id else None, + type=r.type, + title=r.title, + body=r.body, + read_at=r.read_at, + created_at=r.created_at, + ) + + +def _get_user_email(user_id) -> str | None: + """Resolve a user's email via the Supabase Auth Admin API. + + Email lives in auth.users (Supabase-managed), not in profiles, so jobs and + webhooks (which have no JWT) look it up here. Returns None on any failure. + """ + if not settings.supabase_url or not settings.supabase_service_role_key: + return None + url = f"{settings.supabase_url}/auth/v1/admin/users/{user_id}" + req = urllib.request.Request( + url, + method="GET", + headers={ + "Authorization": f"Bearer {settings.supabase_service_role_key}", + "apikey": settings.supabase_service_role_key, + }, + ) + try: + with urllib.request.urlopen(req, timeout=10) as resp: # noqa: S310 + data = json.loads(resp.read()) + return data.get("email") + except (urllib.error.URLError, json.JSONDecodeError, KeyError): + logger.warning("Could not resolve email for user %s", user_id) + return None diff --git a/apps/api/app/modules/notification/templates.py b/apps/api/app/modules/notification/templates.py new file mode 100644 index 000000000..e45a63e6a --- /dev/null +++ b/apps/api/app/modules/notification/templates.py @@ -0,0 +1,100 @@ +"""Notification copy + email HTML, keyed by event type. + +render_notification(type, context, booking_id) -> (title, body, html) +`context` keys are best-effort; missing keys degrade gracefully. +""" +from app.core.config import settings + +# type -> (title, body template). Body templates use str.format(**context). +_TEMPLATES: dict[str, tuple[str, str]] = { + "request_received": ( + "Booking request received", + "We received your request for {venue_name}. The owner will respond soon.", + ), + "new_request_owner": ( + "New booking request", + "You have a new booking request for {venue_name}.", + ), + "request_accepted": ( + "Your booking was accepted", + "Pay the token advance within 24 hours to confirm your booking for {venue_name}.", + ), + "booking_rejected": ( + "Booking request declined", + "The owner could not accept your request for {venue_name}.", + ), + "payment_reminder": ( + "Reminder: complete your payment", + "Your 24-hour hold for {venue_name} is expiring soon. Pay the token advance to confirm.", + ), + "payment_confirmed": ( + "Booking confirmed", + "Your booking for {venue_name} is confirmed. We look forward to your event!", + ), + "balance_paid": ( + "Balance paid — booking fully paid", + "Thanks! Your balance for {venue_name} is paid and your booking is fully settled.", + ), + "balance_overdue": ( + "Balance payment overdue", + "The remaining balance for {venue_name} is overdue. Please pay it to keep your booking.", + ), + "balance_deadline_extended": ( + "Balance deadline extended", + "Your balance payment deadline for {venue_name} has been extended.", + ), + "hold_expired": ( + "Payment hold expired", + "Your 24-hour hold for {venue_name} has expired and the slot was released.", + ), + "conflict_canceled": ( + "Booking unavailable", + "Another guest confirmed {venue_name} first. Any payment you made has been refunded.", + ), + "booking_canceled": ( + "Booking canceled", + "Your booking for {venue_name} was canceled.", + ), + "request_expired": ( + "Request expired", + "Your booking request for {venue_name} expired before the owner responded.", + ), + "refund_issued": ( + "Refund issued", + "A refund of ₹{amount_rupees} has been issued for {venue_name}.", + ), + "booking_completed": ( + "Booking completed", + "Your event at {venue_name} is marked complete. Thanks for using Venue404!", + ), +} + +# Spelling/legacy aliases -> canonical template key. Both British and American +# spellings are valid, so either resolves to the same copy. Older raw strings +# used by callers are mapped here too, so nothing falls back to the generic copy. +_ALIASES: dict[str, str] = { + "booking_cancelled": "booking_canceled", + "conflict_cancelled": "conflict_canceled", + "booking_confirmed": "payment_confirmed", + "booking_requested": "new_request_owner", + "booking_accepted": "request_accepted", +} + + +def render_notification( + type: str, context: dict | None = None, booking_id=None +) -> tuple[str, str, str]: + context = {"venue_name": "your venue", **(context or {})} + type = _ALIASES.get(type, type) + title, body_tmpl = _TEMPLATES.get(type, ("Notification", "You have a new notification.")) + try: + body = body_tmpl.format(**context) + except (KeyError, IndexError): + body = body_tmpl # leave placeholders rather than crash + + link = "" + if booking_id is not None: + url = f"{settings.frontend_base_url}/bookings/{booking_id}" + link = f'

View booking

' + html = f"

{title}

{body}

{link}" + return title, body, html diff --git a/apps/api/app/modules/notification/templates/.gitkeep b/apps/api/app/modules/notification/templates/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/apps/api/app/modules/notification/types.py b/apps/api/app/modules/notification/types.py new file mode 100644 index 000000000..8ff432e3b --- /dev/null +++ b/apps/api/app/modules/notification/types.py @@ -0,0 +1,25 @@ +"""Canonical notification type identifiers. + +Producers (booking, payment, jobs) should reference these constants instead of +raw strings so a type can never drift from a template key. Both British and +American spellings resolve to the same template (see ALIASES in templates.py), +so callers are free to use either spelling. +""" + + +class NotificationType: + REQUEST_RECEIVED = "request_received" + NEW_REQUEST_OWNER = "new_request_owner" + REQUEST_ACCEPTED = "request_accepted" + BOOKING_REJECTED = "booking_rejected" + PAYMENT_REMINDER = "payment_reminder" + PAYMENT_CONFIRMED = "payment_confirmed" + BALANCE_PAID = "balance_paid" + BALANCE_OVERDUE = "balance_overdue" + BALANCE_DEADLINE_EXTENDED = "balance_deadline_extended" + HOLD_EXPIRED = "hold_expired" + CONFLICT_CANCELED = "conflict_canceled" + BOOKING_CANCELED = "booking_canceled" + REQUEST_EXPIRED = "request_expired" + REFUND_ISSUED = "refund_issued" + BOOKING_COMPLETED = "booking_completed" diff --git a/apps/api/app/modules/owner/__init__.py b/apps/api/app/modules/owner/__init__.py new file mode 100644 index 000000000..647a7d3f1 --- /dev/null +++ b/apps/api/app/modules/owner/__init__.py @@ -0,0 +1 @@ +# Owner module diff --git a/apps/api/app/modules/owner/routes.py b/apps/api/app/modules/owner/routes.py new file mode 100644 index 000000000..c09f0931e --- /dev/null +++ b/apps/api/app/modules/owner/routes.py @@ -0,0 +1,37 @@ +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +from app.core.database import get_db +from app.modules.auth.dependencies import AuthContext, require_owner +from app.modules.owner import service +from app.modules.owner.schemas import DashboardStats, ChartDataPoint, UpcomingEventOut + +router = APIRouter() + + +@router.get("/dashboard/stats", response_model=DashboardStats) +def get_owner_dashboard_stats( + auth: AuthContext = Depends(require_owner), + db: Session = Depends(get_db), +): + """Aggregated KPI and financial stats for the owner dashboard.""" + return service.get_dashboard_stats(db, auth.user_id) + + +@router.get("/dashboard/chart", response_model=list[ChartDataPoint]) +def get_owner_dashboard_chart( + time_range: str = "6M", + auth: AuthContext = Depends(require_owner), + db: Session = Depends(get_db), +): + """Monthly performance chart data for the owner dashboard.""" + return service.get_dashboard_chart(db, auth.user_id, time_range) + + +@router.get("/dashboard/upcoming-events", response_model=list[UpcomingEventOut]) +def get_owner_dashboard_upcoming_events( + auth: AuthContext = Depends(require_owner), + db: Session = Depends(get_db), +): + """Upcoming confirmed events for the owner dashboard.""" + return service.get_upcoming_events(db, auth.user_id) diff --git a/apps/api/app/modules/owner/schemas.py b/apps/api/app/modules/owner/schemas.py new file mode 100644 index 000000000..527f1280f --- /dev/null +++ b/apps/api/app/modules/owner/schemas.py @@ -0,0 +1,37 @@ +from datetime import datetime +from typing import Optional +from pydantic import BaseModel + + +class DashboardStats(BaseModel): + active_venues: int + pending_requests: int + active_bookings: int + completed_bookings: int + cancelled_bookings: int + # Financial (paise) + gross_volume_paise: int + net_revenue_paise: int + available_balance_paise: int + platform_fees_paise: int + refunds_issued_paise: int + payouts_completed_paise: int + + +class ChartDataPoint(BaseModel): + month: str # e.g. "Jan 26" + enquiries: int + completed: int + cancelled: int + + +class UpcomingEventOut(BaseModel): + booking_id: str + event_type: Optional[str] = None + venue_name: str + status: str + starts_at: Optional[str] = None # ISO string + guest_count: int + + + diff --git a/apps/api/app/modules/owner/service.py b/apps/api/app/modules/owner/service.py new file mode 100644 index 000000000..73884ff1d --- /dev/null +++ b/apps/api/app/modules/owner/service.py @@ -0,0 +1,252 @@ +""" +Owner dashboard service. + +Returns a single aggregated response containing: + - KPI counts (pending requests, active bookings, completed, cancelled) + - Financial stats (reuses ledger logic from payment service) + - Monthly performance chart data (last 6 months) + - Upcoming confirmed events (next 5) +""" +from datetime import datetime, timezone, timedelta +from uuid import UUID +from calendar import month_abbr + +from sqlalchemy.orm import Session +from sqlalchemy import func, case + +from app.modules.booking.models import Booking, BookingStatus, BookingSlot +from app.modules.venue.models import Venue, VenueStatus +from app.modules.payment.models import LedgerEntry +from app.modules.owner.schemas import ( + DashboardStats, + ChartDataPoint, + UpcomingEventOut, +) + +# Statuses considered "cancelled/terminal non-success" +CANCELLED_STATUSES = [ + BookingStatus.conflict_cancelled, + BookingStatus.user_cancelled, + BookingStatus.admin_cancelled, + BookingStatus.owner_rejected, + BookingStatus.balance_overdue_cancelled, + BookingStatus.hold_expired, + BookingStatus.request_expired, +] + + +def get_dashboard_stats(db: Session, owner_id: UUID) -> DashboardStats: + # ------------------------------------------------------------------ # + # 1. Booking counts — one query, grouped by status + # ------------------------------------------------------------------ # + active_venues = ( + db.query(func.count(Venue.id)) + .filter( + Venue.owner_id == owner_id, + Venue.is_active == True, + Venue.status == VenueStatus.approved, + Venue.deleted_at.is_(None) + ) + .scalar() or 0 + ) + + status_counts: dict[str, int] = {} + + rows = ( + db.query(Booking.status, func.count(Booking.id).label("cnt")) + .join(Venue, Booking.venue_id == Venue.id) + .filter( + Venue.owner_id == owner_id, + Booking.deleted_at.is_(None), + ) + .group_by(Booking.status) + .all() + ) + for status_val, cnt in rows: + status_counts[status_val] = cnt + + pending_requests = status_counts.get(BookingStatus.requested, 0) + active_bookings = status_counts.get(BookingStatus.confirmed, 0) + completed_bookings = status_counts.get(BookingStatus.completed, 0) + cancelled_bookings = sum( + status_counts.get(s, 0) for s in CANCELLED_STATUSES + ) + + # ------------------------------------------------------------------ # + # 2. Financial stats — from ledger entries + # ------------------------------------------------------------------ # + ledger_rows = ( + db.query( + LedgerEntry.entry_type, + LedgerEntry.direction, + func.sum(LedgerEntry.amount_paise).label("total"), + ) + .filter(LedgerEntry.owner_id == owner_id) + .group_by(LedgerEntry.entry_type, LedgerEntry.direction) + .all() + ) + + gross_volume = 0 + platform_fees = 0 + refunds_issued = 0 + payouts_completed = 0 + + for entry_type, direction, total in ledger_rows: + val = int(total or 0) + if entry_type == "charge" and direction == "credit": + gross_volume += val + elif entry_type == "platform_fee" and direction == "debit": + platform_fees += val + elif entry_type == "refund" and direction == "debit": + refunds_issued += val + elif entry_type == "payout" and direction == "debit": + payouts_completed += val + + net_revenue = gross_volume - platform_fees - refunds_issued + available_balance = net_revenue - payouts_completed + + stats = DashboardStats( + active_venues=active_venues, + pending_requests=pending_requests, + active_bookings=active_bookings, + completed_bookings=completed_bookings, + cancelled_bookings=cancelled_bookings, + gross_volume_paise=gross_volume, + net_revenue_paise=net_revenue, + available_balance_paise=available_balance, + platform_fees_paise=platform_fees, + refunds_issued_paise=refunds_issued, + payouts_completed_paise=payouts_completed, + ) + + return stats + + +def get_dashboard_chart(db: Session, owner_id: UUID, time_range: str = "6M") -> list[ChartDataPoint]: + # ------------------------------------------------------------------ # + # Chart data — dynamic time range + # ------------------------------------------------------------------ # + now = datetime.now(timezone.utc) + is_daily = time_range in ("7D", "30D") + + num_buckets = 6 + if time_range == "7D": num_buckets = 7 + elif time_range == "30D": num_buckets = 30 + elif time_range == "3M": num_buckets = 3 + elif time_range == "12M": num_buckets = 12 + + buckets: list[tuple[int, int, int]] = [] + + if is_daily: + for delta in range(num_buckets - 1, -1, -1): + t = now - timedelta(days=delta) + buckets.append((t.year, t.month, t.day)) + start_of_window = datetime(buckets[0][0], buckets[0][1], buckets[0][2], tzinfo=timezone.utc) + else: + for delta in range(num_buckets - 1, -1, -1): + y = now.year + m = now.month - delta + while m < 1: + m += 12 + y -= 1 + buckets.append((y, m, 1)) + start_of_window = datetime(buckets[0][0], buckets[0][1], 1, tzinfo=timezone.utc) + + if is_daily: + group_fields = [ + func.extract("year", Booking.created_at).label("yr"), + func.extract("month", Booking.created_at).label("mo"), + func.extract("day", Booking.created_at).label("day"), + ] + else: + group_fields = [ + func.extract("year", Booking.created_at).label("yr"), + func.extract("month", Booking.created_at).label("mo"), + ] + + chart_bookings = ( + db.query(Booking.status, func.count(Booking.id).label("cnt"), *group_fields) + .join(Venue, Booking.venue_id == Venue.id) + .filter( + Venue.owner_id == owner_id, + Booking.deleted_at.is_(None), + Booking.created_at >= start_of_window, + ) + .group_by(Booking.status, *group_fields) + .all() + ) + + bucket_map: dict[tuple[int, int, int], dict[str, int]] = { + b: {"enquiries": 0, "completed": 0, "cancelled": 0} + for b in buckets + } + + for row in chart_bookings: + status_val = row.status + cnt = row.cnt + yr = int(row.yr) + mo = int(row.mo) + day = int(row.day) if is_daily else 1 + + key = (yr, mo, day) + if key not in bucket_map: + continue + + # Every booking created is considered an enquiry, regardless of its final status + bucket_map[key]["enquiries"] += cnt + + if status_val == BookingStatus.completed: + bucket_map[key]["completed"] += cnt + elif status_val in CANCELLED_STATUSES: + bucket_map[key]["cancelled"] += cnt + + chart_data = [] + for b in buckets: + dt = datetime(b[0], b[1], b[2]) + month_label = dt.strftime("%d %b") if is_daily else dt.strftime("%b %y") + + chart_data.append( + ChartDataPoint( + month=month_label, + enquiries=bucket_map[b]["enquiries"], + completed=bucket_map[b]["completed"], + cancelled=bucket_map[b]["cancelled"], + ) + ) + + return chart_data + + +def get_upcoming_events(db: Session, owner_id: UUID) -> list[UpcomingEventOut]: + # ------------------------------------------------------------------ # + # Upcoming confirmed events — next 5 + # ------------------------------------------------------------------ # + now = datetime.now(timezone.utc) + upcoming_rows = ( + db.query(Booking, BookingSlot, Venue) + .join(BookingSlot, BookingSlot.booking_id == Booking.id) + .join(Venue, Booking.venue_id == Venue.id) + .filter( + Venue.owner_id == owner_id, + Booking.deleted_at.is_(None), + Booking.status == BookingStatus.confirmed, + BookingSlot.starts_at > now, + ) + .order_by(BookingSlot.starts_at.asc()) + .limit(5) + .all() + ) + + upcoming_events = [ + UpcomingEventOut( + booking_id=str(booking.id), + event_type=booking.event_type, + venue_name=venue.name, + status=booking.status, + starts_at=slot.starts_at.isoformat() if slot.starts_at else None, + guest_count=booking.guest_count, + ) + for booking, slot, venue in upcoming_rows + ] + + return upcoming_events diff --git a/apps/api/app/modules/payment/models.py b/apps/api/app/modules/payment/models.py new file mode 100644 index 000000000..25883b63d --- /dev/null +++ b/apps/api/app/modules/payment/models.py @@ -0,0 +1,137 @@ +import enum +import uuid +from datetime import datetime +from sqlalchemy import String, BigInteger, Text, Enum, ForeignKey, DateTime, func +from sqlalchemy.dialects.postgresql import UUID, JSONB +from sqlalchemy.orm import mapped_column, Mapped +from app.core.database import Base +from app.shared.models import TimestampMixin + + +class PaymentAttemptStatus(str, enum.Enum): + pending = "pending" + succeeded = "succeeded" + failed = "failed" + refunded = "refunded" + + +class RefundStatus(str, enum.Enum): + pending = "pending" + succeeded = "succeeded" + failed = "failed" + + +class PayoutStatus(str, enum.Enum): + requested = "requested" + processing = "processing" + paid = "paid" + failed = "failed" + + +class Payment(Base, TimestampMixin): + """Read-model of a single Stripe charge attempt for a booking.""" + __tablename__ = "payments" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + booking_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("bookings.id", ondelete="RESTRICT"), nullable=False + ) + amount_paise: Mapped[int] = mapped_column(BigInteger, nullable=False) + currency: Mapped[str] = mapped_column(String, nullable=False, default="inr") + status: Mapped[PaymentAttemptStatus] = mapped_column( + Enum(PaymentAttemptStatus, name="payment_attempt_status", create_constraint=False), + nullable=False, + default=PaymentAttemptStatus.pending, + ) + stripe_payment_intent_id: Mapped[str] = mapped_column(String, nullable=False) + stripe_client_secret: Mapped[str | None] = mapped_column(String, nullable=True) + # "advance" | "balance" — lets the webhook route a capture to the right + # confirmation path (advance confirms the booking; balance settles it). + # server_default keeps autogenerated NOT NULL adds safe on a populated table. + payment_type: Mapped[str] = mapped_column( + String, nullable=False, default="advance", server_default="advance" + ) + + +class Refund(Base, TimestampMixin): + __tablename__ = "refunds" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + payment_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("payments.id", ondelete="RESTRICT"), nullable=False + ) + booking_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("bookings.id", ondelete="RESTRICT"), nullable=False + ) + amount_paise: Mapped[int] = mapped_column(BigInteger, nullable=False) + reason: Mapped[str | None] = mapped_column(Text, nullable=True) + status: Mapped[RefundStatus] = mapped_column( + Enum(RefundStatus, name="refund_status", create_constraint=False), + nullable=False, + default=RefundStatus.pending, + ) + stripe_refund_id: Mapped[str | None] = mapped_column(String, nullable=True) + + +class LedgerEntry(Base): + """Append-only record of every money movement — the single source of truth. + `payments` / `refunds` are convenience read-models over these events. + """ + __tablename__ = "ledger_entries" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + booking_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("bookings.id", ondelete="RESTRICT"), nullable=False + ) + venue_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("venues.id", ondelete="RESTRICT"), nullable=False + ) + owner_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("profiles.id", ondelete="RESTRICT"), nullable=False + ) + user_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("profiles.id", ondelete="RESTRICT"), nullable=False + ) + entry_type: Mapped[str] = mapped_column(String, nullable=False) # charge|refund|payout|platform_fee + amount_paise: Mapped[int] = mapped_column(BigInteger, nullable=False) + direction: Mapped[str] = mapped_column(String, nullable=False) # credit|debit + stripe_pi_ref: Mapped[str | None] = mapped_column(String, nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=func.now() + ) + + +class PayoutRequest(Base, TimestampMixin): + __tablename__ = "payout_requests" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + owner_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("profiles.id", ondelete="RESTRICT"), nullable=False + ) + booking_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("bookings.id", ondelete="RESTRICT"), nullable=False + ) + amount_paise: Mapped[int] = mapped_column(BigInteger, nullable=False) + status: Mapped[PayoutStatus] = mapped_column( + Enum(PayoutStatus, name="payout_status", create_constraint=False), + nullable=False, + default=PayoutStatus.requested, + ) + stripe_transfer_id: Mapped[str | None] = mapped_column(String, nullable=True) + processed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + +class StripeEvent(Base): + """Webhook idempotency guard. PK is the Stripe event id, so a duplicate + insert (replayed webhook) raises IntegrityError and the handler no-ops. + """ + __tablename__ = "stripe_events" + + id: Mapped[str] = mapped_column(String, primary_key=True) # Stripe event id + type: Mapped[str] = mapped_column(String, nullable=False) + processed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + processing_error: Mapped[str | None] = mapped_column(Text, nullable=True) + raw_payload: Mapped[dict | None] = mapped_column(JSONB, nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=func.now() + ) diff --git a/apps/api/app/modules/payment/routes.py b/apps/api/app/modules/payment/routes.py new file mode 100644 index 000000000..aef8a0a13 --- /dev/null +++ b/apps/api/app/modules/payment/routes.py @@ -0,0 +1,65 @@ +from fastapi import APIRouter, Depends, Request +from sqlalchemy.orm import Session + +from app.core.database import get_db +from app.modules.auth.dependencies import get_current_user, AuthContext +from app.modules.payment.schemas import ( + CreatePaymentRequest, PaymentIntentResponse, PaymentResponse, + RefundRequest, RefundResponse, OwnerLedgerStatsResponse, LedgerEntryResponse +) +from typing import Optional +from app.modules.payment import service, webhooks + +router = APIRouter() + + +@router.get("/owner/stats", response_model=OwnerLedgerStatsResponse) +def get_owner_stats( + user: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db), +): + return service.get_owner_ledger_stats(db, user) + + +@router.get("/owner/ledger", response_model=list[LedgerEntryResponse]) +def get_owner_ledger( + entry_type: Optional[str] = None, + user: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db), +): + return service.list_owner_ledger_entries(db, user, entry_type) + + +@router.post("/", response_model=PaymentIntentResponse, status_code=201) +def create_payment( + body: CreatePaymentRequest, + user: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db), +): + """Create a Stripe PaymentIntent for a booking's token advance or balance.""" + return service.create_payment_intent(db, user.user_id, body.booking_id, body.payment_type) + + +@router.post("/webhook") +async def stripe_webhook(request: Request): + """Stripe calls this — no auth dependency; verified by signature instead.""" + return await webhooks.handle(request) + + +@router.post("/refund", response_model=RefundResponse) +def refund( + body: RefundRequest, + user: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db), +): + """Owner/admin full refund of a booking's captured payment.""" + return service.refund_booking(db, body.booking_id, user, body.reason) + + +@router.get("/{booking_id}", response_model=list[PaymentResponse]) +def get_payments( + booking_id: str, + user: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db), +): + return service.list_payments_for_booking(db, booking_id, user) diff --git a/apps/api/app/modules/payment/schemas.py b/apps/api/app/modules/payment/schemas.py new file mode 100644 index 000000000..14b2c7228 --- /dev/null +++ b/apps/api/app/modules/payment/schemas.py @@ -0,0 +1,61 @@ +from typing import Literal + +from pydantic import BaseModel + + +class CreatePaymentRequest(BaseModel): + booking_id: str + # "advance" (token, confirms the booking) or "balance" (settles a confirmed + # booking). Amount is computed server-side from the venue pricing snapshot — + # never trusted from the client. + payment_type: Literal["advance", "balance", "full"] = "advance" + + +class PaymentIntentResponse(BaseModel): + payment_id: str + booking_id: str + client_secret: str | None + amount_paise: int + currency: str + status: str + + +class PaymentResponse(BaseModel): + id: str + booking_id: str + amount_paise: int + currency: str + status: str + stripe_payment_intent_id: str + + +class RefundRequest(BaseModel): + booking_id: str + reason: str | None = None + + +class RefundResponse(BaseModel): + booking_id: str + refunded_paise: int + status: str + + +class OwnerLedgerStatsResponse(BaseModel): + gross_volume_paise: int + platform_fees_paise: int + refunds_issued_paise: int + net_revenue_paise: int + payouts_completed_paise: int + available_balance_paise: int + +class LedgerEntryResponse(BaseModel): + id: str + booking_id: str + venue_id: str + venue_name: str | None = None + user_full_name: str | None = None + entry_type: str + amount_paise: int + direction: str + stripe_pi_ref: str | None = None + created_at: str diff --git a/apps/api/app/modules/payment/service.py b/apps/api/app/modules/payment/service.py new file mode 100644 index 000000000..b3ba3b223 --- /dev/null +++ b/apps/api/app/modules/payment/service.py @@ -0,0 +1,627 @@ +"""Payment service — Stripe charge/refund logic with race-safe confirmation. + +Invariants enforced (see CLAUDE.md): + * money is integer paise, never float + * confirmation is transactional with row locks; the booking_slots GIST + exclusion guarantees only one confirmed booking per slot + * losing payers are auto-refunded + * every money movement writes an append-only ledger entry + +Commit policy: create_payment_intent / refund_booking are called from request +handlers and commit themselves. confirm_payment / fail_payment are called from +the webhook handler, which owns the commit. + +Refund safety: Stripe refund calls are wrapped (a failure records a `failed` +Refund row and never aborts the surrounding confirmation) and are idempotent +(a payment already past `succeeded` is not refunded twice). +""" +import logging +from datetime import datetime, timezone + +from sqlalchemy.orm import Session +from sqlalchemy.exc import IntegrityError +from sqlalchemy import func + +from app.core.config import settings +from app.core.stripe_client import get_stripe +from app.core.exceptions import NotFoundError, ForbiddenError, BadRequestError +from app.modules.auth.dependencies import AuthContext +from app.modules.booking.models import ( + Booking, BookingStatus, PaymentStatus, BookingSlot, BookingStatusHistory, +) +from app.modules.booking.service import _booking_out +from sqlalchemy.orm import joinedload, selectinload +from app.modules.profile.models import Profile +from app.modules.booking.state_machine import can_transition +from app.modules.venue.models import Venue +from app.modules.payment.models import ( + Payment, Refund, LedgerEntry, PaymentAttemptStatus, RefundStatus, +) +from app.modules.payment.schemas import ( + PaymentIntentResponse, PaymentResponse, RefundResponse, OwnerLedgerStatsResponse, LedgerEntryResponse +) +from app.modules.notification import service as notifications + +logger = logging.getLogger(__name__) + +ADVANCE = "advance" +BALANCE = "balance" + + +# --------------------------------------------------------------------------- # +# Request-path: create a payment intent (advance or balance) +# --------------------------------------------------------------------------- # +def create_payment_intent( + db: Session, current_user_id, booking_id: str, payment_type: str = ADVANCE +) -> PaymentIntentResponse: + booking = db.query(Booking).filter(Booking.id == booking_id).with_for_update().first() + if not booking: + raise NotFoundError("Booking not found") + if str(booking.user_id) != str(current_user_id): + raise ForbiddenError("You do not own this booking") + + venue = db.get(Venue, booking.venue_id) + if not venue: + raise NotFoundError("Venue not found") + + if payment_type == ADVANCE: + if booking.status not in (BookingStatus.owner_accepted, BookingStatus.payment_pending): + raise BadRequestError("Booking is not awaiting payment") + now = datetime.now(timezone.utc) + if booking.status == BookingStatus.owner_accepted: + if not booking.hold_expires_at or booking.hold_expires_at < now: + raise BadRequestError("Payment hold has expired") + elif booking.status == BookingStatus.payment_pending: + if not booking.payment_expires_at or booking.payment_expires_at < now: + raise BadRequestError("Payment hold has expired") + amount_paise = booking.advance_due_paise + if amount_paise <= 0: + raise BadRequestError("Booking has no advance amount due") + idempotency_key = f"booking-{booking.id}-advance" + elif payment_type == BALANCE: + if booking.status != BookingStatus.confirmed or booking.payment_status != PaymentStatus.advance_paid: + raise BadRequestError("Booking is not awaiting a balance payment") + amount_paise = booking.balance_due_paise + if amount_paise <= 0: + raise BadRequestError("Booking has no balance amount due") + idempotency_key = f"booking-{booking.id}-balance" + elif payment_type == "full": + if booking.status not in (BookingStatus.owner_accepted, BookingStatus.payment_pending): + raise BadRequestError("Booking is not awaiting payment") + now = datetime.now(timezone.utc) + if booking.status == BookingStatus.owner_accepted: + if not booking.hold_expires_at or booking.hold_expires_at < now: + raise BadRequestError("Payment hold has expired") + elif booking.status == BookingStatus.payment_pending: + if not booking.payment_expires_at or booking.payment_expires_at < now: + raise BadRequestError("Payment hold has expired") + amount_paise = booking.quoted_price_paise + if amount_paise <= 0: + raise BadRequestError("Booking has no amount due") + idempotency_key = f"booking-{booking.id}-full" + else: + raise BadRequestError("Invalid payment type") + + stripe = get_stripe() + intent = stripe.PaymentIntent.create( + amount=amount_paise, + currency=settings.stripe_currency, + metadata={ + "booking_id": str(booking.id), + "user_id": str(booking.user_id), + "payment_type": payment_type, + }, + idempotency_key=idempotency_key, + ) + + payment = Payment( + booking_id=booking.id, + amount_paise=amount_paise, + currency=settings.stripe_currency, + status=PaymentAttemptStatus.pending, + stripe_payment_intent_id=intent.id, + stripe_client_secret=intent.client_secret, + payment_type=payment_type, + ) + db.add(payment) + if payment_type in (ADVANCE, "full"): + booking.stripe_advance_payment_intent_id = intent.id + booking.payment_status = PaymentStatus.unpaid + else: + booking.stripe_balance_payment_intent_id = intent.id + db.commit() + db.refresh(payment) + + return PaymentIntentResponse( + payment_id=str(payment.id), + booking_id=str(booking.id), + client_secret=intent.client_secret, + amount_paise=amount_paise, + currency=payment.currency, + status=payment.status.value, + ) + + +# --------------------------------------------------------------------------- # +# Webhook-path: confirm / fail (caller commits) +# --------------------------------------------------------------------------- # +def confirm_payment(db: Session, payment_intent_id: str) -> None: + payment = ( + db.query(Payment) + .filter(Payment.stripe_payment_intent_id == payment_intent_id) + .with_for_update() + .first() + ) + if not payment: + logger.warning("confirm_payment: no payment for intent %s", payment_intent_id) + return + booking = db.query(Booking).filter(Booking.id == payment.booking_id).with_for_update().first() + if not booking: + logger.warning("confirm_payment: no booking for payment %s", payment.id) + return + + if payment.payment_type == BALANCE: + confirm_balance_payment(db, payment, booking) + return + + # ---- advance / full payment confirmation ---- + # Idempotent: already confirmed + if payment.status == PaymentAttemptStatus.succeeded and booking.status == BookingStatus.confirmed: + return + + # Booking left the payable state (hold expired / canceled) before the webhook + # arrived — the money is owed back. + if booking.status not in (BookingStatus.owner_accepted, BookingStatus.payment_pending): + logger.warning("confirm_payment: booking %s in %s, refunding stray payment", booking.id, booking.status) + _record_refund(db, payment, booking, payment.amount_paise, "booking_not_payable") + return + + venue = db.get(Venue, booking.venue_id) + + # Claim the slot(s). The GIST exclusion rejects a second blocking range that + # overlaps an existing one on the same venue -> the loser hits IntegrityError. + slots = db.query(BookingSlot).filter(BookingSlot.booking_id == booking.id).all() + for s in slots: + s.is_blocking = True + try: + db.flush() + except IntegrityError: + db.rollback() + logger.info("confirm_payment: booking %s lost the slot race; conflict-canceling", booking.id) + _conflict_cancel_self_and_refund(db, payment_intent_id) + return + + # Won (or no slots yet). Confirm. + if not can_transition(booking.status, BookingStatus.confirmed): + logger.error("confirm_payment: illegal transition %s -> confirmed", booking.status) + return + + old_status = booking.status + payment.status = PaymentAttemptStatus.succeeded + booking.status = BookingStatus.confirmed + booking.confirmed_at = datetime.now(timezone.utc) + booking.amount_paid_paise = (booking.amount_paid_paise or 0) + payment.amount_paise + + if payment.payment_type == "full": + booking.balance_due_paise = 0 + booking.payment_status = PaymentStatus.fully_paid + else: + booking.payment_status = ( + PaymentStatus.fully_paid if booking.balance_due_paise == 0 else PaymentStatus.advance_paid + ) + + if old_status == BookingStatus.payment_pending: + booking.auto_confirmed_at = datetime.now(timezone.utc) + booking.confirmed_by = "SYSTEM" + else: + booking.confirmed_by = "OWNER" + + owner_id = venue.owner_id if venue else booking.user_id + db.add(LedgerEntry( + booking_id=booking.id, venue_id=booking.venue_id, owner_id=owner_id, + user_id=booking.user_id, entry_type="charge", amount_paise=payment.amount_paise, + direction="credit", stripe_pi_ref=payment_intent_id, + )) + + fee_pct = float(venue.platform_commission_pct) if venue else settings.platform_fee_pct + fee = booking.platform_fee_paise or round(payment.amount_paise * fee_pct / 100) + booking.platform_fee_paise = fee + if fee: + db.add(LedgerEntry( + booking_id=booking.id, venue_id=booking.venue_id, owner_id=owner_id, + user_id=booking.user_id, entry_type="platform_fee", amount_paise=fee, + direction="debit", stripe_pi_ref=payment_intent_id, + )) + + reason_str = "full_payment_succeeded" if payment.payment_type == "full" else "token_payment_succeeded" + db.add(BookingStatusHistory( + booking_id=booking.id, old_status=old_status, + new_status=BookingStatus.confirmed, reason=reason_str, + )) + + # Knock out competitors for the same slot and refund any who already paid. + for competitor in _find_competing_bookings(db, booking): + _conflict_cancel(db, competitor, venue) + + venue_name = venue.name if venue else "your venue" + notifications.notify(db, booking.user_id, "payment_confirmed", + context={"venue_name": venue_name}, booking_id=booking.id) + if venue: + notifications.notify(db, venue.owner_id, "payment_confirmed", + context={"venue_name": venue_name}, booking_id=booking.id) + + +def confirm_balance_payment(db: Session, payment: Payment, booking: Booking) -> None: + """Capture the remaining balance on an already-confirmed booking. + + The slot is already reserved (claimed at advance confirmation), so there is + no slot/conflict work here — only the advance_paid -> fully_paid transition. + """ + # Idempotent: already fully paid + if payment.status == PaymentAttemptStatus.succeeded and booking.payment_status == PaymentStatus.fully_paid: + return + + if booking.status != BookingStatus.confirmed: + logger.warning("confirm_balance_payment: booking %s in %s, refunding stray balance", booking.id, booking.status) + _record_refund(db, payment, booking, payment.amount_paise, "balance_not_payable") + return + + venue = db.get(Venue, booking.venue_id) + owner_id = venue.owner_id if venue else booking.user_id + + payment.status = PaymentAttemptStatus.succeeded + booking.amount_paid_paise = (booking.amount_paid_paise or 0) + payment.amount_paise + booking.payment_status = PaymentStatus.fully_paid + booking.balance_overdue_at = None + booking.owner_action_deadline = None + + db.add(LedgerEntry( + booking_id=booking.id, venue_id=booking.venue_id, owner_id=owner_id, + user_id=booking.user_id, entry_type="charge", amount_paise=payment.amount_paise, + direction="credit", stripe_pi_ref=payment.stripe_payment_intent_id, + )) + + venue_name = venue.name if venue else "your venue" + notifications.notify(db, booking.user_id, "balance_paid", + context={"venue_name": venue_name}, booking_id=booking.id) + if venue: + notifications.notify(db, venue.owner_id, "balance_paid", + context={"venue_name": venue_name}, booking_id=booking.id) + + +def fail_payment(db: Session, payment_intent_id: str) -> None: + payment = ( + db.query(Payment) + .filter(Payment.stripe_payment_intent_id == payment_intent_id) + .with_for_update() + .first() + ) + if not payment: + return + payment.status = PaymentAttemptStatus.failed + # Booking stays in its current state; the hold-expiry / balance-overdue jobs + # reclaim it if no retry succeeds. + + +def cancel_payment_intent(payment_intent_id: str | None) -> None: + """Cancel an uncaptured Stripe PaymentIntent (e.g. a pending advance whose + booking is being cancelled before payment succeeds). + + Resilient like _record_refund: a Stripe failure (already-captured, already + -canceled, or network error) is logged and swallowed so it never aborts the + surrounding cancellation transaction. + """ + if not payment_intent_id: + return + try: + get_stripe().PaymentIntent.cancel(payment_intent_id) + except Exception: # noqa: BLE001 — Stripe/network failure must not abort the txn + logger.exception("Stripe cancel failed for payment intent %s", payment_intent_id) + + +# --------------------------------------------------------------------------- # +# Request-path: owner / admin refund (full) +# --------------------------------------------------------------------------- # +def refund_booking(db: Session, booking_id: str, current_user: AuthContext, reason: str | None) -> RefundResponse: + booking = db.query(Booking).filter(Booking.id == booking_id).with_for_update().first() + if not booking: + raise NotFoundError("Booking not found") + + venue = db.get(Venue, booking.venue_id) + is_venue_owner = current_user.is_owner() and venue and str(venue.owner_id) == str(current_user.user_id) + if not current_user.is_admin() and not is_venue_owner: + raise ForbiddenError("Only the venue owner or an admin can refund this booking") + + payments = _succeeded_payments(db, booking.id) + if not payments: + raise BadRequestError("No captured payment to refund") + + # Full refund: every captured payment (advance + balance) is returned. + refunded = 0 + for p in payments: + refunded += _record_refund(db, p, booking, p.amount_paise, reason or "owner_cancellation") + + if refunded > 0: + booking.payment_status = PaymentStatus.refunded + venue_name = venue.name if venue else "your venue" + notifications.notify(db, booking.user_id, "refund_issued", + context={"venue_name": venue_name, "amount_rupees": refunded // 100}, + booking_id=booking.id) + + if booking.status == BookingStatus.confirmed and can_transition(booking.status, BookingStatus.user_cancelled): + booking.status = BookingStatus.user_cancelled + booking.cancelled_at = datetime.now(timezone.utc) + db.add(BookingStatusHistory( + booking_id=booking.id, old_status=BookingStatus.confirmed, + new_status=BookingStatus.user_cancelled, reason=reason or "owner_cancellation", + )) + # release the slots so they can be rebooked + for s in db.query(BookingSlot).filter(BookingSlot.booking_id == booking.id).all(): + s.is_blocking = False + + db.commit() + return RefundResponse( + booking_id=str(booking.id), refunded_paise=refunded, + status="succeeded" if refunded > 0 else "failed", + ) + + +def refund_for_cancellation(db: Session, booking: Booking, amount_paise: int, reason: str) -> int: + """Refund up to `amount_paise` across a booking's captured payments. + + Used by the booking cancellation flows so their computed policy refunds + (full / partial / goodwill) move real money and write ledger entries. + Does NOT commit and does NOT set booking.payment_status — the caller owns + both (the caller knows whether the result is a full or partial refund). + """ + if amount_paise <= 0: + return 0 + remaining = amount_paise + refunded = 0 + for p in _succeeded_payments(db, booking.id): + if remaining <= 0: + break + take = min(remaining, p.amount_paise) + got = _record_refund(db, p, booking, take, reason) + refunded += got + remaining -= got + return refunded + + +def list_payments_for_booking(db: Session, booking_id: str, current_user: AuthContext) -> list[PaymentResponse]: + booking = db.get(Booking, booking_id) + if not booking: + raise NotFoundError("Booking not found") + venue = db.get(Venue, booking.venue_id) + is_owner_of_booking = str(booking.user_id) == str(current_user.user_id) + is_venue_owner = current_user.is_owner() and venue and str(venue.owner_id) == str(current_user.user_id) + if not (is_owner_of_booking or is_venue_owner or current_user.is_admin()): + raise ForbiddenError("Not allowed to view these payments") + rows = db.query(Payment).filter(Payment.booking_id == booking_id).all() + return [ + PaymentResponse( + id=str(p.id), booking_id=str(p.booking_id), amount_paise=p.amount_paise, + currency=p.currency, status=p.status.value, + stripe_payment_intent_id=p.stripe_payment_intent_id, + ) + for p in rows + ] + + +def get_owner_ledger_stats(db: Session, current_user: AuthContext) -> OwnerLedgerStatsResponse: + if not current_user.is_owner(): + raise ForbiddenError("Must be a venue owner") + + entries = db.query( + LedgerEntry.entry_type, + LedgerEntry.direction, + func.sum(LedgerEntry.amount_paise).label("total") + ).filter( + LedgerEntry.owner_id == current_user.user_id + ).group_by( + LedgerEntry.entry_type, + LedgerEntry.direction + ).all() + + gross_volume = 0 + platform_fees = 0 + refunds_issued = 0 + payouts_completed = 0 + + for entry_type, direction, total in entries: + val = int(total or 0) + if entry_type == "charge" and direction == "credit": + gross_volume += val + elif entry_type == "platform_fee" and direction == "debit": + platform_fees += val + elif entry_type == "refund" and direction == "debit": + refunds_issued += val + elif entry_type == "payout" and direction == "debit": + payouts_completed += val + + net_revenue = gross_volume - platform_fees - refunds_issued + available_balance = net_revenue - payouts_completed + + return OwnerLedgerStatsResponse( + gross_volume_paise=gross_volume, + platform_fees_paise=platform_fees, + refunds_issued_paise=refunds_issued, + net_revenue_paise=net_revenue, + payouts_completed_paise=payouts_completed, + available_balance_paise=available_balance + ) + + +def list_owner_ledger_entries(db: Session, current_user: AuthContext, entry_type: str | None = None) -> list[LedgerEntryResponse]: + if not current_user.is_owner(): + raise ForbiddenError("Must be a venue owner") + + query = ( + db.query(LedgerEntry, Venue, Profile) + .outerjoin(Venue, LedgerEntry.venue_id == Venue.id) + .outerjoin(Profile, LedgerEntry.user_id == Profile.id) + .filter(LedgerEntry.owner_id == current_user.user_id) + ) + + if entry_type and entry_type != "all": + query = query.filter(LedgerEntry.entry_type == entry_type) + + query = query.order_by(LedgerEntry.created_at.desc()) + results = query.all() + + responses = [] + for ledger, venue, profile in results: + responses.append( + LedgerEntryResponse( + id=str(ledger.id), + booking_id=str(ledger.booking_id), + venue_id=str(ledger.venue_id), + venue_name=venue.name if venue else None, + user_full_name=profile.full_name if profile else None, + entry_type=ledger.entry_type, + amount_paise=ledger.amount_paise, + direction=ledger.direction, + stripe_pi_ref=ledger.stripe_pi_ref, + created_at=ledger.created_at.isoformat() + ) + ) + return responses + + +# --------------------------------------------------------------------------- # +# Helpers +# --------------------------------------------------------------------------- # +def _succeeded_payments(db: Session, booking_id) -> list[Payment]: + return ( + db.query(Payment) + .filter(Payment.booking_id == booking_id, Payment.status == PaymentAttemptStatus.succeeded) + .order_by(Payment.created_at.asc()) + .all() + ) + + +def _record_refund(db: Session, payment: Payment, booking: Booking, amount_paise: int, reason: str) -> int: + """Issue a Stripe refund and record it (refund row + ledger debit). + + Resilient: a Stripe failure records a `failed` Refund row and returns 0 + rather than raising (so a competitor-refund failure never rolls back the + winner's confirmation). Idempotent: a payment that is not in `succeeded` + state is skipped (prevents double refunds). Does NOT send notifications — + the caller decides which message to send. + """ + if amount_paise <= 0: + return 0 + if payment.status != PaymentAttemptStatus.succeeded: + logger.info("refund skipped: payment %s not refundable (status=%s)", payment.id, payment.status) + return 0 + + stripe = get_stripe() + try: + refund = stripe.Refund.create( + payment_intent=payment.stripe_payment_intent_id, + amount=amount_paise, + metadata={"booking_id": str(booking.id), "reason": reason}, + ) + except Exception: # noqa: BLE001 — Stripe/network failure must not abort the txn + logger.exception("Stripe refund failed for payment %s (booking %s)", payment.id, booking.id) + db.add(Refund( + payment_id=payment.id, booking_id=booking.id, amount_paise=amount_paise, + reason=reason, status=RefundStatus.failed, stripe_refund_id=None, + )) + return 0 + + db.add(Refund( + payment_id=payment.id, booking_id=booking.id, amount_paise=amount_paise, + reason=reason, status=RefundStatus.succeeded, stripe_refund_id=refund.id, + )) + # Mark the attempt refunded only when the whole captured amount is returned. + if amount_paise >= payment.amount_paise: + payment.status = PaymentAttemptStatus.refunded + booking.refund_amount_paise = (booking.refund_amount_paise or 0) + amount_paise + + venue = db.get(Venue, booking.venue_id) + owner_id = venue.owner_id if venue else booking.user_id + db.add(LedgerEntry( + booking_id=booking.id, venue_id=booking.venue_id, owner_id=owner_id, + user_id=booking.user_id, entry_type="refund", amount_paise=amount_paise, + direction="debit", stripe_pi_ref=payment.stripe_payment_intent_id, + )) + return amount_paise + + +def _find_competing_bookings(db: Session, booking: Booking) -> list[Booking]: + """Other active bookings contending for the same slot. + + Overlap rule: same venue, still requested/owner_accepted, whose effective slot + range intersects this booking's effective slot range. + """ + slot = db.query(BookingSlot).filter(BookingSlot.booking_id == booking.id).first() + if slot is None: + return [] + return ( + db.query(Booking) + .join(BookingSlot, BookingSlot.booking_id == Booking.id) + .filter( + Booking.id != booking.id, + Booking.venue_id == booking.venue_id, + Booking.status.in_([BookingStatus.requested, BookingStatus.owner_accepted, BookingStatus.payment_pending]), + BookingSlot.deleted_at.is_(None), + BookingSlot.effective_starts_at < slot.effective_ends_at, + BookingSlot.effective_ends_at > slot.effective_starts_at, + ) + .with_for_update() + .all() + ) + + +def _conflict_cancel(db: Session, competitor: Booking, venue: Venue | None) -> None: + old = competitor.status + if not can_transition(old, BookingStatus.conflict_cancelled): + logger.warning("skip conflict-cancel: illegal %s -> conflict_cancelled for booking %s", old, competitor.id) + return + competitor.status = BookingStatus.conflict_cancelled + competitor.cancelled_at = datetime.now(timezone.utc) + db.add(BookingStatusHistory( + booking_id=competitor.id, old_status=old, new_status=BookingStatus.conflict_cancelled, + reason="slot_confirmed_by_another", + )) + # Refund any captured payment; the conflict_canceled notice (below) already + # tells the user their money was refunded, so no separate refund_issued. + for paid in _succeeded_payments(db, competitor.id): + _record_refund(db, paid, competitor, paid.amount_paise, "conflict_canceled") + venue_name = venue.name if venue else "the venue" + notifications.notify(db, competitor.user_id, "conflict_canceled", + context={"venue_name": venue_name}, booking_id=competitor.id) + + +def _conflict_cancel_self_and_refund(db: Session, payment_intent_id: str) -> None: + """The current booking lost the slot race — cancel it and refund this payment.""" + payment = ( + db.query(Payment) + .filter(Payment.stripe_payment_intent_id == payment_intent_id) + .with_for_update() + .first() + ) + if not payment: + return + booking = db.query(Booking).filter(Booking.id == payment.booking_id).with_for_update().first() + if not booking: + return + venue = db.get(Venue, booking.venue_id) + old = booking.status + if not can_transition(old, BookingStatus.conflict_cancelled): + logger.warning("skip self conflict-cancel: illegal %s -> conflict_cancelled for booking %s", old, booking.id) + return + booking.status = BookingStatus.conflict_cancelled + booking.cancelled_at = datetime.now(timezone.utc) + db.add(BookingStatusHistory( + booking_id=booking.id, old_status=old, new_status=BookingStatus.conflict_cancelled, + reason="lost_slot_race", + )) + # This payment just succeeded but hasn't been marked succeeded yet — do so + # so the refund guard recognises it as refundable. + payment.status = PaymentAttemptStatus.succeeded + _record_refund(db, payment, booking, payment.amount_paise, "lost_slot_race") + venue_name = venue.name if venue else "the venue" + notifications.notify(db, booking.user_id, "conflict_canceled", + context={"venue_name": venue_name}, booking_id=booking.id) diff --git a/apps/api/app/modules/payment/webhooks.py b/apps/api/app/modules/payment/webhooks.py new file mode 100644 index 000000000..7691e4e95 --- /dev/null +++ b/apps/api/app/modules/payment/webhooks.py @@ -0,0 +1,104 @@ +"""Stripe webhook entrypoint. + +Flow: verify signature -> record the event in stripe_events (idempotency guard, +duplicate = no-op) -> dispatch -> stamp processed_at / processing_error. +""" +import json +import logging +from datetime import datetime, timezone + +from fastapi import Request, HTTPException +from sqlalchemy.exc import IntegrityError + +from app.core.config import settings +from app.core.database import SessionLocal +from app.core.stripe_client import get_stripe +from app.modules.payment.models import StripeEvent +from app.modules.payment import service + +logger = logging.getLogger(__name__) + + +async def handle(request: Request): + payload = await request.body() + sig = request.headers.get("stripe-signature") + stripe = get_stripe() + + if settings.stripe_webhook_secret: + try: + event = stripe.Webhook.construct_event(payload, sig, settings.stripe_webhook_secret) + except Exception as e: # signature / parse failure + logger.warning("Invalid Stripe webhook: %s", e) + raise HTTPException(status_code=400, detail="Invalid signature") + else: + # dev fallback when no signing secret is configured + logger.warning("STRIPE_WEBHOOK_SECRET unset — skipping signature verification") + event = json.loads(payload) + + event_id = event["id"] + event_type = event["type"] + + db = SessionLocal() + try: + # Idempotency guard keyed on the Stripe event id. We record the event + # first, but treat it as a true duplicate ONLY once it has been + # *successfully* processed (processed_at set). An event that was seen but + # failed to process (processing_error, processed_at NULL) is re-dispatched + # when Stripe retries — so a transient failure can never strand a paid + # booking in an unconfirmed state. + stored = db.get(StripeEvent, event_id) + if stored is None: + stored = StripeEvent(id=event_id, type=event_type, raw_payload=_json_safe(event)) + db.add(stored) + try: + db.commit() + except IntegrityError: + # Concurrent delivery inserted it first — reload and continue. + db.rollback() + stored = db.get(StripeEvent, event_id) + + if stored is None: + # Extremely unlikely row contention; ask Stripe to retry. + raise HTTPException(status_code=409, detail="Event contention, retry") + if stored.processed_at is not None: + logger.info("Duplicate Stripe event %s ignored (already processed)", event_id) + return {"status": "duplicate"} + + try: + _dispatch(db, event) + stored = db.get(StripeEvent, event_id) + stored.processed_at = datetime.now(timezone.utc) + stored.processing_error = None + db.commit() + except HTTPException: + raise + except Exception as e: + db.rollback() + stored = db.get(StripeEvent, event_id) + if stored: + stored.processing_error = str(e) + db.commit() + logger.exception("Error processing Stripe event %s", event_id) + raise HTTPException(status_code=500, detail="Processing error") + + return {"status": "ok"} + finally: + db.close() + + +def _dispatch(db, event) -> None: + event_type = event["type"] + obj = event["data"]["object"] + if event_type == "payment_intent.succeeded": + service.confirm_payment(db, obj["id"]) + elif event_type == "payment_intent.payment_failed": + service.fail_payment(db, obj["id"]) + else: + logger.info("Unhandled Stripe event type %s", event_type) + + +def _json_safe(event) -> dict | None: + try: + return json.loads(json.dumps(event, default=str)) + except (TypeError, ValueError): + return None diff --git a/apps/api/app/modules/profile/models.py b/apps/api/app/modules/profile/models.py new file mode 100644 index 000000000..1b316589e --- /dev/null +++ b/apps/api/app/modules/profile/models.py @@ -0,0 +1,56 @@ +import enum +import uuid +from datetime import datetime +from sqlalchemy import String, Enum, DateTime, UniqueConstraint, func, ForeignKey +from sqlalchemy.orm import mapped_column, Mapped, relationship +from sqlalchemy.dialects.postgresql import UUID +from app.core.database import Base + + +class ProfileStatus(str, enum.Enum): + active = "active" + suspended = "suspended" + pending = "pending" + rejected = "rejected" + + +class UserRole(str, enum.Enum): + customer = "customer" + venue_owner = "venue_owner" + super_admin = "super_admin" + + +class Profile(Base): + __tablename__ = "profiles" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True) + email: Mapped[str | None] = mapped_column(String, nullable=True, index=True) + full_name: Mapped[str | None] = mapped_column(String, nullable=True) + phone: Mapped[str | None] = mapped_column(String, nullable=True) + avatar_url: Mapped[str | None] = mapped_column(String, nullable=True) + status: Mapped[ProfileStatus] = mapped_column( + Enum(ProfileStatus, name="profile_status", create_constraint=False), + nullable=False, + default=ProfileStatus.active, + ) + deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=func.now(), onupdate=func.now()) + + bookings = relationship( + "Booking", + back_populates="user", + ) + + +class UserRoleAssignment(Base): + __tablename__ = "user_roles" + __table_args__ = (UniqueConstraint("user_id", "role", name="uq_user_roles_user_role"),) + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("profiles.id", ondelete="CASCADE"), nullable=False) + role: Mapped[UserRole] = mapped_column( + Enum(UserRole, name="user_role", create_constraint=False), + nullable=False, + ) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=func.now()) diff --git a/apps/api/app/modules/profile/routes.py b/apps/api/app/modules/profile/routes.py new file mode 100644 index 000000000..c70240e46 --- /dev/null +++ b/apps/api/app/modules/profile/routes.py @@ -0,0 +1,17 @@ +from fastapi import APIRouter +from app.modules.profile.schemas import ProfileResponse, UpdateProfileRequest +from app.modules.auth.dependencies import get_current_user +from fastapi import Depends +from app.modules.profile import service + +router = APIRouter() + + +@router.get("/me", response_model=ProfileResponse) +def get_profile(user=Depends(get_current_user)): + return service.get_profile(user["sub"]) + + +@router.patch("/me", response_model=ProfileResponse) +def update_profile(body: UpdateProfileRequest, user=Depends(get_current_user)): + return service.update_profile(user["sub"], body) diff --git a/apps/api/app/modules/profile/schemas.py b/apps/api/app/modules/profile/schemas.py new file mode 100644 index 000000000..ed1868e16 --- /dev/null +++ b/apps/api/app/modules/profile/schemas.py @@ -0,0 +1,13 @@ +from pydantic import BaseModel, EmailStr +from typing import Optional + + +class ProfileResponse(BaseModel): + id: str + email: EmailStr + full_name: str + role: str + + +class UpdateProfileRequest(BaseModel): + full_name: Optional[str] = None diff --git a/apps/api/app/modules/profile/service.py b/apps/api/app/modules/profile/service.py new file mode 100644 index 000000000..be131d0fc --- /dev/null +++ b/apps/api/app/modules/profile/service.py @@ -0,0 +1,9 @@ +from app.modules.profile.schemas import ProfileResponse, UpdateProfileRequest + + +def get_profile(user_id: str) -> ProfileResponse: + raise NotImplementedError + + +def update_profile(user_id: str, body: UpdateProfileRequest) -> ProfileResponse: + raise NotImplementedError diff --git a/apps/api/app/modules/search/category_intent.py b/apps/api/app/modules/search/category_intent.py new file mode 100644 index 000000000..2383d30ea --- /dev/null +++ b/apps/api/app/modules/search/category_intent.py @@ -0,0 +1,119 @@ +"""Detects which venue-category "intent" a search query implies, so hybrid +search can boost only the categories the query actually seems to be about +instead of always boosting wedding venues regardless of what was searched. + +Deliberately simple: a token-overlap check against three small term sets. +Not a classifier — just enough to stop "rooftop party bangalore" from +getting a wedding-hall boost it has nothing to do with, and (as of this +version) to stop "corporate offsite bangalore" from boosting rooftop/resort +venues instead of the conference/meeting/auditorium venues it actually +means. + +The boost *group names* (GROUP_WEDDING / GROUP_EVENT / GROUP_CORPORATE) are +the same strings stored in VenueCategory.search_boost_group — see +search_metadata_cache.py — so a category's DB tag and a query's detected +intent line up automatically. + +The boost *magnitudes* live in settings so they can be tuned without a +deploy. +""" + +from app.core.config import settings + +# Must match the values used in VenueCategory.search_boost_group. +GROUP_WEDDING = "wedding_hall_banquet_hall" +GROUP_EVENT = "event_space_rooftop_resort_lawn" +GROUP_CORPORATE = ( + "corporate_conference_meeting" # conference_room, meeting_room, auditorium +) + +# Terms that suggest the query is about wedding/marriage-function venues. +_WEDDING_INTENT_TERMS = { + "wedding", + "marriage", + "reception", + "mandap", + "sadya", + "kalyanam", + "kalyana", + "vivaham", + "nikah", + "shaadi", + "shadi", + "vivah", + "engagement", + "muhurtham", + "sangeet", + "mehendi", + "haldi", + "baraat", + "banquet", +} + +# Terms that suggest the query is about party/event-style venues +# (rooftop, club, resort, lawn, event_space). +# NOTE: "corporate"/"conference" used to live here, which meant a corporate +# offsite query was boosting resort/rooftop/lawn venues instead of the +# conference/meeting/auditorium venues it actually meant. Moved to +# _CORPORATE_INTENT_TERMS below. +_EVENT_INTENT_TERMS = { + "party", + "rooftop", + "club", + "nightclub", + "lounge", + "birthday", + "anniversary", + "celebration", + "resort", + "lawn", + "getaway", + "staycation", + "event", + "dj", + "sundowner", +} + +# Terms that suggest the query is about formal corporate/institutional +# venues (conference rooms, meeting rooms, auditoriums). +_CORPORATE_INTENT_TERMS = { + "corporate", + "conference", + "meeting", + "boardroom", + "seminar", + "convocation", + "offsite", + "training", + "workshop", + "interview", + "auditorium", +} + +NO_BOOST = 1.00 + + +def detect_category_intents(query: str) -> dict[str, float]: + """Return the boost multiplier to apply per category group, based on + whether the (normalized) query's tokens overlap with that group's terms. + Groups with no signal in the query get a neutral 1.0 — i.e. no boost. + """ + tokens = set(query.lower().split()) if query else set() + + wedding_boost = ( + settings.search_wedding_boost if tokens & _WEDDING_INTENT_TERMS else NO_BOOST + ) + event_boost = ( + settings.search_event_boost if tokens & _EVENT_INTENT_TERMS else NO_BOOST + ) + corporate_boost = ( + settings.search_corporate_boost + if tokens & _CORPORATE_INTENT_TERMS + else NO_BOOST + ) + + return { + GROUP_WEDDING: wedding_boost, + GROUP_EVENT: event_boost, + GROUP_CORPORATE: corporate_boost, + } diff --git a/apps/api/app/modules/search/indexer.py b/apps/api/app/modules/search/indexer.py new file mode 100644 index 000000000..181429adc --- /dev/null +++ b/apps/api/app/modules/search/indexer.py @@ -0,0 +1,200 @@ +import logging +import uuid +from datetime import datetime, timezone, timedelta +from uuid import UUID + +from sqlalchemy import text +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session, joinedload + +from app.core.config import settings +from app.infrastructure.embeddings.jina import embed_passage, embed_query +from app.modules.search import search_metadata_cache +from app.modules.search.models import SearchIndexJob +from app.modules.venue.models import Venue + +logger = logging.getLogger(__name__) + +# Exponential backoff delays in seconds per retry attempt index. +# Attempt 0 → immediate, 1 → 5 min, 2 → 15 min, 3 → 1 hr, 4 → 6 hr +_BACKOFF_SECONDS = [0, 300, 900, 3600, 21600] +MAX_RETRIES = len(_BACKOFF_SECONDS) # was a hardcoded `5` in two places below + + +def _redis_client(): + from upstash_redis import Redis + + return Redis(url=settings.upstash_redis_url, token=settings.upstash_redis_token) + + +def enqueue_job(db: Session, entity_id: UUID, operation: str) -> None: + """Insert a pending search index job and try to push to Upstash (fire-and-forget). + + If a pending/processing job already exists for this entity the partial unique + index raises IntegrityError — we treat that as a no-op (the outstanding job + will pick up the latest state when it runs). + """ + job = SearchIndexJob( + entity_type="venue", + entity_id=entity_id, + operation=operation, + ) + db.add(job) + try: + db.flush() + except IntegrityError: + db.rollback() + return + + job_id = str(job.id) + + try: + if settings.upstash_redis_url and settings.upstash_redis_token: + _redis_client().lpush(settings.upstash_search_queue_key, job_id) + except Exception: + # Redis push failure is non-fatal — the APScheduler worker will poll the DB. + pass + + +def _build_search_document(venue: Venue) -> str: + """Rich document for better semantic search""" + parts = [venue.name] + + if venue.description and len(venue.description.strip()) > 10: + parts.append(venue.description.strip()) + + parts.append(f"{venue.city} {venue.state} India") + + if venue.category: + slug = venue.category.slug + parts.append(venue.category.label) + + # Was a hardcoded dict of slug -> synonym string; now sourced from + # VenueCategory.search_keywords via the startup-loaded cache, so + # adding/editing a category's synonyms doesn't require a deploy. + keywords = search_metadata_cache.keywords_for(slug) + if keywords: + parts.append(keywords) + elif not search_metadata_cache.is_loaded(): + logger.warning( + "search_metadata_cache not loaded when indexing venue %s — " + "keyword expansion skipped for this document", + venue.id, + ) + + parts.append(f"capacity {venue.max_capacity} pax people guests") + if venue.min_capacity and venue.min_capacity > 0: + parts.append(f"minimum {venue.min_capacity} guests") + + if venue.amenities: + parts.append(" ".join(a.name for a in venue.amenities)) + + return "\n".join(parts) + + +def _update_fts(db: Session, venue_id: UUID, document: str) -> None: + db.execute( + text( + "UPDATE venues SET search_vector = to_tsvector('english', :doc) WHERE id = :id" + ), + {"doc": document, "id": str(venue_id)}, + ) + + +def generate_query_embedding(query: str) -> list[float]: + """Kept as a thin wrapper so search/service.py doesn't need to import the + embeddings client directly — it only ever talks to the search module.""" + return embed_query(query) + + +def process_job(db: Session, job_id: str) -> None: + """Process a single search index job end-to-end.""" + job = ( + db.query(SearchIndexJob).filter(SearchIndexJob.id == uuid.UUID(job_id)).first() + ) + if not job: + logger.warning("search_indexer: job %s not found", job_id) + return + if job.status != "pending": + logger.debug("search_indexer: job %s already %s, skipping", job_id, job.status) + return + + job.status = "processing" + job.started_at = datetime.now(timezone.utc) + db.commit() + + try: + venue = ( + db.query(Venue) + .options(joinedload(Venue.category), joinedload(Venue.amenities)) + .filter(Venue.id == job.entity_id) + .first() + ) + if not venue: + raise ValueError(f"Venue {job.entity_id} not found") + + document = _build_search_document(venue) + + _update_fts(db, venue.id, document) + + if settings.jina_api_key: + embedding = embed_passage(document) + venue.embedding = embedding + venue.embedding_updated_at = datetime.now(timezone.utc) + + job.status = "completed" + job.completed_at = datetime.now(timezone.utc) + db.commit() + logger.info("search_indexer: job %s completed for venue %s", job_id, venue.id) + + except Exception as exc: + db.rollback() + job = ( + db.query(SearchIndexJob) + .filter(SearchIndexJob.id == uuid.UUID(job_id)) + .first() + ) + if job: + job.retry_count += 1 + job.error_message = str(exc) + job.status = ( + "failed" if job.retry_count < MAX_RETRIES else "failed_permanently" + ) + db.commit() + logger.error("search_indexer: job %s failed (%s)", job_id, exc) + + +def retryable_job_ids(db: Session, limit: int = 10) -> list[str]: + """Return job IDs eligible for processing: pending or failed-with-backoff-elapsed.""" + now = datetime.now(timezone.utc) + pending = ( + db.query(SearchIndexJob) + .filter(SearchIndexJob.status == "pending") + .order_by(SearchIndexJob.created_at.asc()) + .limit(limit) + .all() + ) + + results = list(pending) + + if len(results) < limit: + failed = ( + db.query(SearchIndexJob) + .filter( + SearchIndexJob.status == "failed", + SearchIndexJob.retry_count < MAX_RETRIES, + ) + .order_by(SearchIndexJob.created_at.asc()) + .all() + ) + for job in failed: + if len(results) >= limit: + break + delay = _BACKOFF_SECONDS[min(job.retry_count, len(_BACKOFF_SECONDS) - 1)] + eligible_at = job.created_at.replace(tzinfo=timezone.utc) + timedelta( + seconds=delay + ) + if now >= eligible_at: + results.append(job) + + return [str(j.id) for j in results] diff --git a/apps/api/app/modules/search/models.py b/apps/api/app/modules/search/models.py new file mode 100644 index 000000000..6248f2801 --- /dev/null +++ b/apps/api/app/modules/search/models.py @@ -0,0 +1,21 @@ +import uuid +from datetime import datetime +from sqlalchemy import Integer, Text, DateTime, func +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import mapped_column, Mapped +from app.core.database import Base + + +class SearchIndexJob(Base): + __tablename__ = "search_index_jobs" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + entity_type: Mapped[str] = mapped_column(Text, nullable=False) + entity_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False) + operation: Mapped[str] = mapped_column(Text, nullable=False) # create | update | delete | reindex + status: Mapped[str] = mapped_column(Text, nullable=False, server_default="pending") + retry_count: Mapped[int] = mapped_column(Integer, nullable=False, server_default="0") + error_message: Mapped[str | None] = mapped_column(Text, nullable=True) + started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) diff --git a/apps/api/app/modules/search/query_normalizer.py b/apps/api/app/modules/search/query_normalizer.py new file mode 100644 index 000000000..c59cf4e64 --- /dev/null +++ b/apps/api/app/modules/search/query_normalizer.py @@ -0,0 +1,87 @@ +"""Lightweight typo correction for search queries. + +Fuzzy-matches each token in the raw query against a vocabulary of domain +terms and swaps in the closest match when confidence is high. Intentionally +cheap and dependency-light — no ML, no external calls — just a +pre-processing pass before FTS/vector search runs. + +The vocabulary has two parts: + - _STATIC_SYNONYMS: linguistic spelling variants (e.g. "shaadi"/"vivah") + that aren't really DB data — these stay hardcoded since they're + language, not content. + - dynamic vocabulary: city names + category slugs/labels currently in the + DB, injected at startup via set_dynamic_vocabulary() so new cities or + categories don't require a code change. See + search_metadata_cache.build_query_vocabulary() and main.py's lifespan. +""" + +from rapidfuzz import fuzz, process + +from app.core.config import settings + +_STATIC_SYNONYMS = [ + "wedding", + "marriage", + "reception", + "function", + "mandap", + "sadya", + "kalyanam", + "vivaham", + "engagement", + "muhurtham", + "nikah", + "shaadi", + "mehendi", + "sangeet", + "baraat", +] + +# Populated at startup from the DB — see set_dynamic_vocabulary(). +_dynamic_vocabulary: list[str] = [] + + +def set_dynamic_vocabulary(terms: list[str]) -> None: + """Replace the DB-sourced portion of the vocabulary. Call once at + startup (main.py's lifespan) and again whenever categories/cities + change meaningfully, if you want that reflected without a restart.""" + global _dynamic_vocabulary + _dynamic_vocabulary = terms + + +def _vocabulary() -> list[str]: + return _STATIC_SYNONYMS + _dynamic_vocabulary + + +def normalize_query(q: str) -> str: + """Fuzzy-correct likely typos in each token of a search query. + + Falls back to the original token whenever no confident vocabulary match + is found, so this only ever tightens spelling — it never invents terms + that weren't plausibly intended. + """ + if not q or not q.strip(): + return q + + vocabulary = _vocabulary() + if not vocabulary: + # Dynamic vocabulary hasn't loaded yet (e.g. called before startup + # finished) — fall back to the static synonym list only rather than + # silently no-op'ing typo correction entirely. + vocabulary = _STATIC_SYNONYMS + + tokens = q.strip().split() + corrected_tokens = [] + + for token in tokens: + if len(token) < settings.search_normalizer_min_token_len: + corrected_tokens.append(token) + continue + + match = process.extractOne(token.lower(), vocabulary, scorer=fuzz.ratio) + if match and match[1] >= settings.search_normalizer_match_threshold: + corrected_tokens.append(match[0]) + else: + corrected_tokens.append(token) + + return " ".join(corrected_tokens) diff --git a/apps/api/app/modules/search/routes.py b/apps/api/app/modules/search/routes.py new file mode 100644 index 000000000..382caf4b4 --- /dev/null +++ b/apps/api/app/modules/search/routes.py @@ -0,0 +1,51 @@ +from fastapi import APIRouter, Query, Depends +from sqlalchemy.orm import Session +from app.core.database import get_db +from app.modules.search.schemas import SearchParams, SearchResult +from app.modules.search import service +from app.shared.pagination import Page + +router = APIRouter() + + +def _params( + q: str = Query(default=""), + city: str = Query(default=""), + venue_type: str | None = Query(default=None), + capacity: int = Query(default=0), + page: int = Query(default=1, ge=1), + page_size: int = Query(default=20, ge=1, le=100), +) -> SearchParams: + return SearchParams(q=q, city=city, venue_type=venue_type, capacity=capacity, page=page, page_size=page_size) + + +@router.get("/", response_model=Page[SearchResult]) +def search_venues( + params: SearchParams = Depends(_params), + db: Session = Depends(get_db), +): + return service.search(db, params) + + +@router.get("/fts", response_model=Page[SearchResult]) +def search_fts( + params: SearchParams = Depends(_params), + db: Session = Depends(get_db), +): + return service.search_fts(db, params) + + +@router.get("/semantic", response_model=Page[SearchResult]) +def search_semantic( + params: SearchParams = Depends(_params), + db: Session = Depends(get_db), +): + return service.search_semantic(db, params) + + +@router.get("/hybrid", response_model=Page[SearchResult]) +def search_hybrid( + params: SearchParams = Depends(_params), + db: Session = Depends(get_db), +): + return service.search_hybrid(db, params) diff --git a/apps/api/app/modules/search/schemas.py b/apps/api/app/modules/search/schemas.py new file mode 100644 index 000000000..59066fa19 --- /dev/null +++ b/apps/api/app/modules/search/schemas.py @@ -0,0 +1,36 @@ +from pydantic import BaseModel +from typing import Optional +from uuid import UUID +from app.modules.venue.schemas import VenueCategoryResponse + + +class SearchParams(BaseModel): + q: str = "" + city: str = "" + venue_type: Optional[str] = None # slug — kept for URL backward compat + capacity: int = 0 + page: int = 1 + page_size: int = 20 + + +class SearchResult(BaseModel): + id: UUID + name: str + city: str + category: VenueCategoryResponse + capacity: int + pricing_mode: str + starting_price_paise: Optional[int] = None + display_price_min_paise: Optional[int] = None + display_price_max_paise: Optional[int] = None + cover_photo_url: Optional[str] = None + is_liked: bool = False + # Match diagnostics — only populated by search_hybrid (None for plain + # /search, /search/fts, /search/semantic). Lets a caller (e.g. Deep + # Research's citation UI) show which signal(s) actually matched and how + # strongly, without re-deriving it client-side. + match_source: Optional[str] = None # "hybrid" | "semantic" | "keyword" + fts_score: Optional[float] = None + vector_score: Optional[float] = None + category_boost: Optional[float] = None + match_score: Optional[float] = None diff --git a/apps/api/app/modules/search/search_metadata_cache.py b/apps/api/app/modules/search/search_metadata_cache.py new file mode 100644 index 000000000..29b5941bd --- /dev/null +++ b/apps/api/app/modules/search/search_metadata_cache.py @@ -0,0 +1,107 @@ +"""In-memory cache of search-relevant metadata, populated from the DB at +app startup (and reloadable on demand from the admin routes). + +This exists to kill three copies of the same information that used to live +as hardcoded Python literals: + - indexer.py's per-slug keyword-expansion dict + - service.py's SQL boost CASE slug lists + - query_normalizer.py's hardcoded city/category vocabulary + +Now there's one source of truth: VenueCategory.search_boost_group and +VenueCategory.search_keywords columns (admin-editable), plus the distinct +city/slug values already in the venues/venue_categories tables. + +Requires two new columns on VenueCategory — see the accompanying migration +`XXXX_add_search_metadata_to_venue_categories.py`: + search_boost_group: str | None # e.g. "wedding_hall_banquet_hall" + search_keywords: str | None # space-separated synonym string +""" + +import logging +from threading import Lock + +from sqlalchemy.orm import Session + +from app.modules.venue.models import Venue, VenueCategory + +logger = logging.getLogger(__name__) + +_lock = Lock() +_boost_groups: dict[str, str] = {} +_keywords: dict[str, str] = {} +_loaded = False + + +def load_category_metadata(db: Session) -> None: + """Populate the boost-group / keyword cache from the DB. Call once at + startup, and again whenever a category's search metadata is edited via + the admin routes (call reload from there rather than waiting for a + restart).""" + global _boost_groups, _keywords, _loaded + + categories = db.query(VenueCategory).all() + boost_groups = { + c.slug: c.search_boost_group for c in categories if c.search_boost_group + } + keywords = {c.slug: c.search_keywords for c in categories if c.search_keywords} + + with _lock: + _boost_groups = boost_groups + _keywords = keywords + _loaded = True + + logger.info( + "search_metadata_cache: loaded %d categories (%d with boost group, %d with keywords)", + len(categories), + len(boost_groups), + len(keywords), + ) + + +def boost_group_for(slug: str | None) -> str | None: + if not slug: + return None + return _boost_groups.get(slug) + + +def keywords_for(slug: str | None) -> str | None: + if not slug: + return None + return _keywords.get(slug) + + +def slugs_in_group(group: str) -> list[str]: + """Slugs tagged with a given boost group, e.g. all slugs tagged + 'wedding_hall_banquet_hall'. Used to build the SQL boost CASE at query + time instead of a hardcoded IN (...) list.""" + return [slug for slug, g in _boost_groups.items() if g == group] + + +def is_loaded() -> bool: + return _loaded + + +def build_query_vocabulary(db: Session) -> list[str]: + """Distinct city names + category labels/slugs currently in the DB, + for the query normalizer's fuzzy-match vocabulary. Combine this with a + small static list of linguistic synonyms (spelling variants like + "shaadi"/"vivah") that aren't really DB data — that part stays in + query_normalizer.py. + """ + cities = [ + row[0] + for row in db.query(Venue.city).distinct().filter(Venue.city.isnot(None)).all() + if row[0] + ] + categories = db.query(VenueCategory).all() + category_terms: list[str] = [] + for c in categories: + category_terms.append(c.slug.replace("_", " ")) + if c.label: + category_terms.append(c.label.lower()) + + vocabulary = sorted({t.lower() for t in cities + category_terms if t}) + logger.info( + "search_metadata_cache: built %d dynamic vocabulary terms", len(vocabulary) + ) + return vocabulary diff --git a/apps/api/app/modules/search/service.py b/apps/api/app/modules/search/service.py new file mode 100644 index 000000000..1a91f7be4 --- /dev/null +++ b/apps/api/app/modules/search/service.py @@ -0,0 +1,532 @@ +import logging +from uuid import UUID + +import numpy as np +from sqlalchemy import func as sa_func, text, or_ +from sqlalchemy.orm import Session, joinedload + +from app.modules.search import search_metadata_cache +from app.modules.search.category_intent import ( + GROUP_CORPORATE, + GROUP_EVENT, + GROUP_WEDDING, + detect_category_intents, +) +from app.modules.search.query_normalizer import normalize_query +from app.modules.search.schemas import SearchParams, SearchResult +from app.modules.venue.models import Venue, VenueCategory, VenueStatus, VenuePhoto +from app.shared.pagination import Page +from app.core.config import settings + +logger = logging.getLogger(__name__) + + +def _base_query(db: Session, params: SearchParams): + """Base approved/active venue query with city, type, and capacity filters applied.""" + query = ( + db.query(Venue) + .options(joinedload(Venue.category)) + .filter( + Venue.status == VenueStatus.approved, + Venue.is_active == True, + Venue.deleted_at.is_(None), + ) + ) + if params.city: + query = query.filter(Venue.city.ilike(f"%{params.city}%")) + if params.venue_type: + query = query.join(VenueCategory, Venue.category_id == VenueCategory.id).filter( + VenueCategory.slug == params.venue_type + ) + if params.capacity > 0: + query = query.filter(Venue.max_capacity >= params.capacity) + return query + + +def _cover_photos(db: Session, venue_ids: list) -> dict: + if not venue_ids: + return {} + photos = ( + db.query(VenuePhoto) + .filter( + VenuePhoto.venue_id.in_(venue_ids), + VenuePhoto.is_cover == True, + VenuePhoto.deleted_at.is_(None), + ) + .all() + ) + return {p.venue_id: p.image_url for p in photos} + + +def _match_source(fts_matched: bool, vector_matched: bool) -> str: + if fts_matched and vector_matched: + return "hybrid" + if vector_matched: + return "semantic" + return "keyword" + + +def _to_results( + venues: list[Venue], cover_photos: dict, scores: dict | None = None +) -> list[SearchResult]: + scores = scores or {} + results = [] + for v in venues: + starting_price = v.starting_price_paise if v.pricing_mode in ('flat', 'mixed') else v.hourly_rate_paise + row_scores = scores.get(v.id) + results.append(SearchResult( + id=v.id, + name=v.name, + city=v.city, + category=v.category, + capacity=v.max_capacity, + pricing_mode=v.pricing_mode, + starting_price_paise=starting_price, + display_price_min_paise=v.display_price_min_paise, + display_price_max_paise=v.display_price_max_paise, + cover_photo_url=cover_photos.get(v.id), + match_source=( + _match_source(row_scores["fts_matched"], row_scores["vector_matched"]) + if row_scores + else None + ), + fts_score=row_scores["fts_score"] if row_scores else None, + vector_score=row_scores["vector_score"] if row_scores else None, + category_boost=row_scores["boost"] if row_scores else None, + match_score=row_scores["hybrid_score"] if row_scores else None, + )) + return results + + +def search(db: Session, params: SearchParams) -> Page[SearchResult]: + query = ( + db.query(Venue) + .options(joinedload(Venue.category)) + .filter( + Venue.status == VenueStatus.approved, + Venue.is_active == True, + Venue.deleted_at.is_(None), + ) + ) + + if params.q: + search_term = f"%{params.q}%" + query = query.filter( + or_( + Venue.name.ilike(search_term), + Venue.description.ilike(search_term), + Venue.city.ilike(search_term), + Venue.state.ilike(search_term), + ) + ) + + if params.city: + query = query.filter(Venue.city.ilike(f"%{params.city}%")) + + if params.venue_type: + query = query.join(VenueCategory, Venue.category_id == VenueCategory.id).filter( + VenueCategory.slug == params.venue_type + ) + + if params.capacity > 0: + query = query.filter(Venue.max_capacity >= params.capacity) + + total_count = query.count() + + offset = (params.page - 1) * params.page_size + venues = ( + query.order_by(Venue.created_at.desc()) + .offset(offset) + .limit(params.page_size) + .all() + ) + + venue_ids = [v.id for v in venues] + cover_photos = {} + if venue_ids: + photos = db.query(VenuePhoto).filter( + VenuePhoto.venue_id.in_(venue_ids), + VenuePhoto.is_cover == True, + VenuePhoto.deleted_at.is_(None), + ).all() + cover_photos = {p.venue_id: p.image_url for p in photos} + + results = [] + for v in venues: + starting_price = v.starting_price_paise if v.pricing_mode in ('flat', 'mixed') else v.hourly_rate_paise + results.append(SearchResult( + id=v.id, + name=v.name, + city=v.city, + category=v.category, + capacity=v.max_capacity, + pricing_mode=v.pricing_mode, + starting_price_paise=starting_price, + display_price_min_paise=v.display_price_min_paise, + display_price_max_paise=v.display_price_max_paise, + cover_photo_url=cover_photos.get(v.id), + )) + + return Page( + items=_to_results(venues, covers), + total=total_count, + page=params.page, + page_size=params.page_size, + ) + + +# ── FTS search ──────────────────────────────────────────────────────────────── + + +def search_fts(db: Session, params: SearchParams) -> Page[SearchResult]: + """Full-text search ranked by ts_rank. Requires search_vector to be populated.""" + query = _base_query(db, params) + + if params.q: + ts_query = sa_func.plainto_tsquery("english", params.q) + query = query.filter( + Venue.search_vector.op("@@")(ts_query), + ).order_by(sa_func.ts_rank(Venue.search_vector, ts_query).desc()) + else: + query = query.order_by(Venue.created_at.desc()) + + total_count = query.count() + offset = (params.page - 1) * params.page_size + venues = query.offset(offset).limit(params.page_size).all() + covers = _cover_photos(db, [v.id for v in venues]) + return Page( + items=_to_results(venues, covers), + total=total_count, + page=params.page, + page_size=params.page_size, + ) + + +# ── Semantic search ─────────────────────────────────────────────────────────── + + +def search_semantic(db: Session, params: SearchParams) -> Page[SearchResult]: + """Vector similarity search using Jina embeddings. Requires embedding to be populated.""" + from app.modules.search.indexer import generate_query_embedding + + query = _base_query(db, params).filter(Venue.embedding.isnot(None)) + + if params.q: + try: + query_vec = generate_query_embedding(params.q) + query = query.order_by(Venue.embedding.op("<=>")(query_vec).asc()) + except Exception as exc: + logger.warning( + "search_semantic: embedding generation failed (%s), falling back to FTS", + exc, + ) + return search_fts(db, params) + else: + query = query.order_by(Venue.created_at.desc()) + + total_count = query.count() + offset = (params.page - 1) * params.page_size + venues = query.offset(offset).limit(params.page_size).all() + covers = _cover_photos(db, [v.id for v in venues]) + return Page( + items=_to_results(venues, covers), + total=total_count, + page=params.page, + page_size=params.page_size, + ) + + +# ── Hybrid search ───────────────────────────────────────────────────────────── + + +def _has_any_embeddings(db: Session) -> bool: + """Cheap existence check — was `.limit(1).count()`, which still runs a + count aggregate. `.exists()` short-circuits at the first matching row.""" + exists_query = ( + db.query(Venue.id) + .filter( + Venue.status == VenueStatus.approved, + Venue.is_active == True, + Venue.deleted_at.is_(None), + Venue.embedding.isnot(None), + ) + .exists() + ) + return db.query(exists_query).scalar() + + +def _log_hybrid_diagnostics( + db: Session, + raw_query: str, + normalized_q: str, + query_vec: list[float], + intents: dict, +) -> None: + """Debug diagnostics comparing the FTS-only and vector-only result sets. + + Runs 3 extra queries — only call this when settings.search_diagnostics_enabled + is on (dev/staging), not unconditionally on every production request. + """ + try: + vec_norm = float(np.linalg.norm(np.array(query_vec, dtype=np.float32))) + + common_where = """ + v.status = 'approved' AND v.is_active = true AND v.deleted_at IS NULL + """ + + fts_count_sql = text(f""" + SELECT COUNT(*) FROM venues v + WHERE {common_where} + AND v.search_vector @@ plainto_tsquery('english', :q) + """) + fts_matches = db.execute(fts_count_sql, {"q": normalized_q}).scalar() or 0 + + fts_top_sql = text(f""" + SELECT v.id FROM venues v + WHERE {common_where} + AND v.search_vector @@ plainto_tsquery('english', :q) + ORDER BY ts_rank(v.search_vector, plainto_tsquery('english', :q)) DESC + LIMIT 10 + """) + fts_top_ids = [ + str(r.id) for r in db.execute(fts_top_sql, {"q": normalized_q}).fetchall() + ] + + vec_top_sql = text(f""" + SELECT v.id FROM venues v + WHERE {common_where} + AND v.embedding IS NOT NULL + ORDER BY v.embedding <=> CAST(:qvec AS vector) ASC + LIMIT 10 + """) + vec_top_ids = [ + str(r.id) + for r in db.execute(vec_top_sql, {"qvec": str(query_vec)}).fetchall() + ] + + overlap = len(set(fts_top_ids) & set(vec_top_ids)) + + logger.info( + "search_hybrid diagnostics | " + "raw_query=%r normalized_query=%r fts_matches=%d " + "query_vec_norm=%.4f vector_threshold=%.2f " + "fts_top10=%s vector_top10=%s overlap=%d " + "wedding_boost=%.2f event_boost=%.2f", + raw_query, + normalized_q, + fts_matches, + vec_norm, + settings.search_min_vector_similarity, + fts_top_ids, + vec_top_ids, + overlap, + intents.get(GROUP_WEDDING, 1.0), + intents.get(GROUP_EVENT, 1.0), + ) + except Exception: + logger.exception("search_hybrid: diagnostics logging failed") + + +def _log_hybrid_result_scores(rows, limit: int = 20) -> None: + """Log the FTS/vector/boost/hybrid score breakdown for top results.""" + try: + lines = [ + f" #{i+1:>2} id={row.id} cat={row.category_slug or '-':<14} " + f"fts={row.fts_score:.4f} vec={row.vector_score:.4f} " + f"boost={row.boost:.2f} hybrid={row.hybrid_score:.4f} name={row.name!r}" + for i, row in enumerate(rows[:limit]) + ] + logger.info( + "search_hybrid result scores (top %s):\n%s", len(lines), "\n".join(lines) + ) + except Exception: + logger.exception("search_hybrid: result score logging failed") + + +def search_hybrid(db: Session, params: SearchParams) -> Page[SearchResult]: + """Hybrid Search using Full-Text Search + Vector Search + Category Boost""" + + if not params.q: + return search_fts(db, params) + + raw_query = params.q + normalized_q = normalize_query(raw_query) + + if not _has_any_embeddings(db): + return search_fts(db, params) + + from app.modules.search.indexer import generate_query_embedding + + try: + query_vec = generate_query_embedding(normalized_q) + except Exception as exc: + logger.warning("Embedding generation failed: %s", exc) + return search_fts(db, params) + + intents = detect_category_intents(normalized_q) + + if settings.search_diagnostics_enabled: + _log_hybrid_diagnostics(db, raw_query, normalized_q, query_vec, intents) + + # Slug lists per boost group now come from the DB-backed cache instead + # of being hardcoded here — see search_metadata_cache.py. Passed as + # array bind params (`= ANY(:param)`), not string-interpolated, so a + # category added via the admin panel doesn't need a redeploy. + wedding_slugs = search_metadata_cache.slugs_in_group(GROUP_WEDDING) + event_slugs = search_metadata_cache.slugs_in_group(GROUP_EVENT) + corporate_slugs = search_metadata_cache.slugs_in_group(GROUP_CORPORATE) + + base_filters = [ + "v.status = 'approved'", + "v.is_active = true", + "v.deleted_at IS NULL", + """ + ( + v.search_vector @@ plainto_tsquery('english', :q) + + OR + + ( + v.embedding IS NOT NULL + AND NOT ( + v.search_vector @@ plainto_tsquery('english', :q) + ) + AND ( + 1 - ( + v.embedding <=> CAST(:qvec AS vector) + ) + ) >= :min_vector_score + ) + ) + """, + ] + + query_params = { + "q": normalized_q, + "qvec": str(query_vec), + "min_vector_score": settings.search_min_vector_similarity, + # Boost values and slug lists are now bind params rather than + # f-string-interpolated literals: the SQL *text* is identical across + # calls regardless of which intent was detected, so Postgres can + # reuse a cached plan instead of re-planning every query. + "wedding_slugs": wedding_slugs, + "event_slugs": event_slugs, + "corporate_slugs": corporate_slugs, + "wedding_boost": intents.get(GROUP_WEDDING, 1.0), + "event_boost": intents.get(GROUP_EVENT, 1.0), + "corporate_boost": intents.get(GROUP_CORPORATE, 1.0), + "fts_weight": settings.search_fts_weight, + "vector_weight": settings.search_vector_weight, + } + + if params.city: + base_filters.append("v.city ILIKE :city") + query_params["city"] = f"%{params.city}%" + + if params.capacity > 0: + base_filters.append("v.max_capacity >= :capacity") + query_params["capacity"] = params.capacity + + if params.venue_type: + base_filters.append("vc.slug = :venue_type") + query_params["venue_type"] = params.venue_type + + where_clause = " AND ".join(base_filters) + + query_params["limit"] = params.page_size + query_params["offset"] = (params.page - 1) * params.page_size + + # Boost/score computed once in a CTE (was duplicated inline in both the + # `boost` column and `hybrid_score` expression) and total count comes + # from a window function on the same query instead of a separate + # COUNT(*) round trip over the whole WHERE clause. + rows_sql = text(f""" + WITH scored AS ( + SELECT + v.id, + v.name, + vc.slug AS category_slug, + COALESCE( + ts_rank(v.search_vector, plainto_tsquery('english', :q)), + 0 + ) AS fts_score, + COALESCE( + 1 - (v.embedding <=> CAST(:qvec AS vector)), + 0 + ) AS vector_score, + ( + v.search_vector @@ plainto_tsquery('english', :q) + ) AS fts_matched, + ( + v.embedding IS NOT NULL + AND (1 - (v.embedding <=> CAST(:qvec AS vector))) >= :min_vector_score + ) AS vector_matched, + CASE + WHEN vc.slug = ANY(:wedding_slugs) THEN :wedding_boost + WHEN vc.slug = ANY(:event_slugs) THEN :event_boost + WHEN vc.slug = ANY(:corporate_slugs) THEN :corporate_boost + ELSE 1.00 + END AS boost + FROM venues v + LEFT JOIN venue_categories vc ON vc.id = v.category_id + WHERE {where_clause} + ) + SELECT + id, + name, + category_slug, + fts_score, + vector_score, + fts_matched, + vector_matched, + boost, + (:fts_weight * fts_score + :vector_weight * vector_score) * boost AS hybrid_score, + COUNT(*) OVER() AS total_count + FROM scored + ORDER BY hybrid_score DESC + LIMIT :limit + OFFSET :offset + """) + + rows = db.execute(rows_sql, query_params).fetchall() + + if settings.search_diagnostics_enabled: + _log_hybrid_result_scores(rows) + + if not rows: + return Page(items=[], total=0, page=params.page, page_size=params.page_size) + + total = rows[0].total_count + venue_ids: list[UUID] = [row.id for row in rows] + scores = { + row.id: { + "fts_score": row.fts_score, + "vector_score": row.vector_score, + "fts_matched": row.fts_matched, + "vector_matched": row.vector_matched, + "boost": row.boost, + "hybrid_score": row.hybrid_score, + } + for row in rows + } + + # Fetch full venue objects with relationships + venues_by_id = { + venue.id: venue + for venue in ( + db.query(Venue) + .options(joinedload(Venue.category)) + .filter(Venue.id.in_(venue_ids)) + .all() + ) + } + + venues = [venues_by_id[vid] for vid in venue_ids if vid in venues_by_id] + covers = _cover_photos(db, venue_ids) + + return Page( + items=_to_results(venues, covers, scores), + total=total, + page=params.page, + page_size=params.page_size, + ) diff --git a/apps/api/app/modules/venue/models.py b/apps/api/app/modules/venue/models.py new file mode 100644 index 000000000..3c5956f8e --- /dev/null +++ b/apps/api/app/modules/venue/models.py @@ -0,0 +1,381 @@ +import enum +import uuid +from datetime import datetime, date, time +from typing import Any +from sqlalchemy import ( + Integer, Boolean, Numeric, BigInteger, Text, Time, DateTime, Date, + ForeignKey, CheckConstraint, Index, func, Enum, text +) +from sqlalchemy.dialects.postgresql import UUID, ARRAY, TSVECTOR +from sqlalchemy.orm import mapped_column, Mapped, relationship +from pgvector.sqlalchemy import Vector +from app.core.database import Base + + +class VenueStatus(str, enum.Enum): + draft = "draft" + pending_approval = "pending_approval" + approved = "approved" + rejected = "rejected" + suspended = "suspended" + + +class BookingMode(str, enum.Enum): + MANUAL = "MANUAL" + INSTANT = "INSTANT" + + + +class VenueCategory(Base): + __tablename__ = "venue_categories" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + slug: Mapped[str] = mapped_column(Text, nullable=False) + label: Mapped[str] = mapped_column(Text, nullable=False) + icon: Mapped[str | None] = mapped_column(Text, nullable=True) + banner_image: Mapped[str | None] = mapped_column(Text, nullable=True) + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="true") + sort_order: Mapped[int] = mapped_column(Integer, nullable=False, server_default="0") + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + search_boost_group: Mapped[str | None] = mapped_column(Text, nullable=True) + search_keywords: Mapped[str | None] = mapped_column(Text, nullable=True) + + __table_args__ = ( + Index("uq_venue_categories_slug_active", "slug", unique=True, postgresql_where=text("deleted_at IS NULL")), + ) + + venues: Mapped[list["Venue"]] = relationship(back_populates="category") + + +class Venue(Base): + __tablename__ = "venues" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + owner_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("profiles.id"), nullable=False) + + name: Mapped[str] = mapped_column(Text, nullable=False) + slug: Mapped[str | None] = mapped_column(Text, unique=True, nullable=True) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + category_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("venue_categories.id"), nullable=False) + + address_line1: Mapped[str] = mapped_column(Text, nullable=False) + address_line2: Mapped[str | None] = mapped_column(Text, nullable=True) + city: Mapped[str] = mapped_column(Text, nullable=False) + state: Mapped[str] = mapped_column(Text, nullable=False) + country: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'India'")) + postal_code: Mapped[str | None] = mapped_column(Text, nullable=True) + latitude: Mapped[float | None] = mapped_column(Numeric(10, 7), nullable=True) + longitude: Mapped[float | None] = mapped_column(Numeric(10, 7), nullable=True) + timezone: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'Asia/Kolkata'")) + + min_capacity: Mapped[int | None] = mapped_column(Integer, nullable=True) + max_capacity: Mapped[int] = mapped_column(Integer, nullable=False) + + open_time: Mapped[time] = mapped_column(Time, nullable=False) + close_time: Mapped[time] = mapped_column(Time, nullable=False) + spans_next_day: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false") + + allowed_booking_types: Mapped[list[str]] = mapped_column(ARRAY(Text), nullable=False, default=lambda: ["full_day", "time_slot"], server_default=text("ARRAY['full_day','time_slot']")) + min_booking_duration_minutes: Mapped[int] = mapped_column(Integer, nullable=False, server_default="60") + max_booking_duration_minutes: Mapped[int] = mapped_column(Integer, nullable=False, server_default="1440") + slot_interval_minutes: Mapped[int] = mapped_column(Integer, nullable=False, server_default="30") + + pre_buffer_minutes: Mapped[int] = mapped_column(Integer, nullable=False, server_default="0") + post_buffer_minutes: Mapped[int] = mapped_column(Integer, nullable=False, server_default="0") + + pricing_mode: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'flat'")) + starting_price_paise: Mapped[int | None] = mapped_column(BigInteger, nullable=True) + hourly_rate_paise: Mapped[int | None] = mapped_column(BigInteger, nullable=True) + + platform_commission_pct: Mapped[float] = mapped_column(Numeric(5, 2), nullable=False, server_default="10.00") + advance_pct: Mapped[float] = mapped_column(Numeric(5, 2), nullable=False, server_default="30.00") + + min_price_pct: Mapped[float] = mapped_column(Numeric(5, 2), nullable=False, server_default="50.00") + max_price_pct: Mapped[float] = mapped_column(Numeric(5, 2), nullable=False, server_default="200.00") + display_price_min_paise: Mapped[int | None] = mapped_column(BigInteger, nullable=True) + display_price_max_paise: Mapped[int | None] = mapped_column(BigInteger, nullable=True) + + balance_due_days_before_event: Mapped[int] = mapped_column(Integer, nullable=False, server_default="7") + owner_action_window_hours: Mapped[int] = mapped_column(Integer, nullable=False, server_default="48") + overdue_advance_refund_pct: Mapped[float] = mapped_column(Numeric(5, 2), nullable=False, server_default="0.00") + + status: Mapped[VenueStatus] = mapped_column( + Enum(VenueStatus, name="venue_status"), + nullable=False, + server_default=text("'draft'") + ) + booking_mode: Mapped[str] = mapped_column( + Text, + nullable=False, + server_default=text("'MANUAL'") + ) + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="true") + last_completed_step: Mapped[int] = mapped_column(Integer, nullable=False, server_default="0") + + search_vector: Mapped[Any | None] = mapped_column(TSVECTOR, nullable=True) + embedding: Mapped[list[float] | None] = mapped_column(Vector(1024), nullable=True) + embedding_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()) + + # Relationships + category: Mapped["VenueCategory"] = relationship(back_populates="venues") + photos: Mapped[list["VenuePhoto"]] = relationship( + back_populates="venue", + cascade="all, delete-orphan", + primaryjoin="and_(Venue.id==VenuePhoto.venue_id, VenuePhoto.deleted_at.is_(None))", + order_by="VenuePhoto.sort_order" + ) + amenities: Mapped[list["Amenity"]] = relationship( + secondary="venue_amenities", + back_populates="venues", + secondaryjoin="and_(VenueAmenity.amenity_id==Amenity.id, Amenity.deleted_at.is_(None))" + ) + availability: Mapped[list["VenueAvailability"]] = relationship( + back_populates="venue", + cascade="all, delete-orphan", + primaryjoin="and_(Venue.id==VenueAvailability.venue_id, VenueAvailability.deleted_at.is_(None))" + ) + blocked_dates: Mapped[list["VenueBlockedDate"]] = relationship( + back_populates="venue", + cascade="all, delete-orphan", + primaryjoin="and_(Venue.id==VenueBlockedDate.venue_id, VenueBlockedDate.deleted_at.is_(None))" + ) + cancellation_policy: Mapped["VenueCancellationPolicy"] = relationship(back_populates="venue", uselist=False, cascade="all, delete-orphan") + + __table_args__ = ( + CheckConstraint("min_capacity IS NULL OR min_capacity <= max_capacity", name="ck_venues_capacity"), + CheckConstraint("min_capacity > 0", name="ck_venues_min_capacity"), + CheckConstraint("max_capacity > 0", name="ck_venues_max_capacity"), + CheckConstraint("min_booking_duration_minutes > 0", name="ck_venues_min_duration"), + CheckConstraint("max_booking_duration_minutes > 0", name="ck_venues_max_duration"), + CheckConstraint("slot_interval_minutes > 0", name="ck_venues_slot_interval"), + CheckConstraint("min_booking_duration_minutes <= max_booking_duration_minutes", name="ck_venues_duration_range"), + CheckConstraint("pre_buffer_minutes >= 0", name="ck_venues_pre_buffer"), + CheckConstraint("post_buffer_minutes >= 0", name="ck_venues_post_buffer"), + CheckConstraint("pricing_mode IN ('flat', 'hourly', 'mixed')", name="ck_venues_pricing_mode"), + CheckConstraint("starting_price_paise >= 0", name="ck_venues_base_price"), + CheckConstraint("hourly_rate_paise >= 0", name="ck_venues_hourly_rate"), + CheckConstraint("platform_commission_pct >= 0 AND platform_commission_pct <= 100", name="ck_venues_commission"), + CheckConstraint("advance_pct > 0 AND advance_pct <= 100", name="ck_venues_advance_pct"), + CheckConstraint("balance_due_days_before_event > 0", name="ck_venues_balance_days"), + CheckConstraint("owner_action_window_hours BETWEEN 24 AND 72", name="ck_venues_action_window"), + CheckConstraint("overdue_advance_refund_pct BETWEEN 0 AND 100", name="ck_venues_overdue_refund_pct"), + CheckConstraint("min_price_pct > 0 AND min_price_pct <= 100", name="ck_venues_min_price_pct"), + CheckConstraint("max_price_pct >= 100 AND max_price_pct <= 500", name="ck_venues_max_price_pct"), + CheckConstraint("min_price_pct <= max_price_pct", name="ck_venues_price_pct_range"), + Index("idx_venues_search", "city", "category_id", "status", "is_active", postgresql_where=text("deleted_at IS NULL")), + ) + + bookings = relationship( + "Booking", + back_populates="venue", + ) + + pricing_rules: Mapped[list["VenuePricingRule"]] = relationship( + back_populates="venue", + cascade="all, delete-orphan", + primaryjoin="and_(Venue.id==VenuePricingRule.venue_id, VenuePricingRule.deleted_at.is_(None))", + order_by="VenuePricingRule.priority.desc()", + ) + + +class VenuePhoto(Base): + __tablename__ = "venue_photos" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + venue_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("venues.id"), nullable=False) + image_url: Mapped[str] = mapped_column(Text, nullable=False) + sort_order: Mapped[int] = mapped_column(Integer, nullable=False, server_default="0") + is_cover: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false") + + deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + + # Relationships + venue: Mapped["Venue"] = relationship(back_populates="photos") + + __table_args__ = ( + Index("venue_photos_one_cover", "venue_id", unique=True, postgresql_where=text("is_cover = true AND deleted_at IS NULL")), + ) + + +class Amenity(Base): + __tablename__ = "amenities" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + name: Mapped[str] = mapped_column(Text, nullable=False) + icon: Mapped[str | None] = mapped_column(Text, nullable=True) + + deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + + __table_args__ = ( + Index("uq_amenities_name_active", "name", unique=True, postgresql_where=text("deleted_at IS NULL")), + ) + + # Relationships + venues: Mapped[list["Venue"]] = relationship(secondary="venue_amenities", back_populates="amenities") + + +class VenueAmenity(Base): + __tablename__ = "venue_amenities" + + venue_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("venues.id"), primary_key=True) + amenity_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("amenities.id"), primary_key=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + + +class VenueAvailability(Base): + __tablename__ = "venue_availability" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + venue_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("venues.id"), nullable=False) + day_of_week: Mapped[int] = mapped_column(Integer, nullable=False) + + is_available: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="true") + opens_at: Mapped[time | None] = mapped_column(Time, nullable=True) + closes_at: Mapped[time | None] = mapped_column(Time, nullable=True) + spans_next_day: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false") + + deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()) + + # Relationships + venue: Mapped["Venue"] = relationship(back_populates="availability") + + __table_args__ = ( + CheckConstraint("day_of_week BETWEEN 0 AND 6", name="ck_venue_availability_day"), + CheckConstraint("is_available = false OR (opens_at IS NOT NULL AND closes_at IS NOT NULL)", name="ck_venue_availability_times"), + Index("venue_availability_unique_day", "venue_id", "day_of_week", unique=True, postgresql_where=text("deleted_at IS NULL")), + ) + + +class VenueBlockedDate(Base): + __tablename__ = "venue_blocked_dates" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + venue_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("venues.id"), nullable=False) + starts_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + ends_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + reason: Mapped[str | None] = mapped_column(Text, nullable=True) + blocked_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("profiles.id"), nullable=False) + + deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + + # Relationships + venue: Mapped["Venue"] = relationship(back_populates="blocked_dates") + + __table_args__ = ( + CheckConstraint("ends_at > starts_at", name="ck_venue_blocked_dates_order"), + Index("idx_venue_blocked_dates_venue", "venue_id", postgresql_where=text("deleted_at IS NULL")), + ) + + +class VenuePricingRule(Base): + __tablename__ = "venue_pricing_rules" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + venue_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("venues.id", ondelete="CASCADE"), nullable=False) + + name: Mapped[str] = mapped_column(Text, nullable=False) + + days_of_week: Mapped[list[int] | None] = mapped_column(ARRAY(Integer), nullable=True) + start_date: Mapped[date | None] = mapped_column(Date, nullable=True) + end_date: Mapped[date | None] = mapped_column(Date, nullable=True) + start_time: Mapped[time | None] = mapped_column(Time, nullable=True) + end_time: Mapped[time | None] = mapped_column(Time, nullable=True) + + adjustment_type: Mapped[str] = mapped_column(Text, nullable=False) + multiplier: Mapped[float | None] = mapped_column(Numeric(5, 2), nullable=True) + amount_paise: Mapped[int | None] = mapped_column(BigInteger, nullable=True) + + applies_to: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'both'")) + priority: Mapped[int] = mapped_column(Integer, nullable=False, server_default="0") + source: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'owner'")) + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="true") + + deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()) + + # Relationships + venue: Mapped["Venue"] = relationship(back_populates="pricing_rules") + + __table_args__ = ( + CheckConstraint("adjustment_type IN ('multiplier', 'fixed_delta', 'override')", name="ck_venue_pricing_rules_adjustment_type"), + CheckConstraint( + "(adjustment_type = 'multiplier' AND multiplier IS NOT NULL AND multiplier > 0 AND amount_paise IS NULL) OR " + "(adjustment_type = 'fixed_delta' AND amount_paise IS NOT NULL AND multiplier IS NULL) OR " + "(adjustment_type = 'override' AND amount_paise IS NOT NULL AND amount_paise >= 0 AND multiplier IS NULL)", + name="ck_venue_pricing_rules_adjustment_payload", + ), + CheckConstraint("applies_to IN ('full_day', 'time_slot', 'both')", name="ck_venue_pricing_rules_applies_to"), + CheckConstraint("source IN ('owner', 'system')", name="ck_venue_pricing_rules_source"), + CheckConstraint("start_date IS NULL OR end_date IS NULL OR start_date <= end_date", name="ck_venue_pricing_rules_date_range"), + Index( + "idx_venue_pricing_rules_priority", + "venue_id", "priority", + postgresql_where=text("deleted_at IS NULL AND is_active = true"), + ), + ) + + +class VenueCancellationPolicy(Base): + __tablename__ = "venue_cancellation_policies" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + venue_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("venues.id"), nullable=False, unique=True) + + tier_1_hours: Mapped[int | None] = mapped_column(Integer, nullable=True) + tier_1_refund_pct: Mapped[float | None] = mapped_column(Numeric(5, 2), nullable=True) + tier_2_hours: Mapped[int | None] = mapped_column(Integer, nullable=True) + tier_2_refund_pct: Mapped[float | None] = mapped_column(Numeric(5, 2), nullable=True) + tier_3_hours: Mapped[int | None] = mapped_column(Integer, nullable=True) + tier_3_refund_pct: Mapped[float | None] = mapped_column(Numeric(5, 2), nullable=True) + + no_show_refund_pct: Mapped[float] = mapped_column(Numeric(5, 2), nullable=False, server_default="0") + platform_fee_refundable: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false") + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()) + + # Relationships + venue: Mapped["Venue"] = relationship(back_populates="cancellation_policy") + + __table_args__ = ( + CheckConstraint("tier_1_hours > 0", name="ck_vcp_tier_1_hours"), + CheckConstraint("tier_1_refund_pct BETWEEN 0 AND 100", name="ck_vcp_tier_1_pct"), + CheckConstraint("tier_2_hours > 0", name="ck_vcp_tier_2_hours"), + CheckConstraint("tier_2_refund_pct BETWEEN 0 AND 100", name="ck_vcp_tier_2_pct"), + CheckConstraint("tier_3_hours > 0", name="ck_vcp_tier_3_hours"), + CheckConstraint("tier_3_refund_pct BETWEEN 0 AND 100", name="ck_vcp_tier_3_pct"), + CheckConstraint("no_show_refund_pct BETWEEN 0 AND 100", name="ck_vcp_no_show_pct"), + CheckConstraint( + "(tier_1_hours IS NULL OR tier_2_hours IS NULL OR tier_1_hours > tier_2_hours) AND " + "(tier_2_hours IS NULL OR tier_3_hours IS NULL OR tier_2_hours > tier_3_hours)", + name="tiers_descending" + ), + CheckConstraint("(tier_1_hours IS NULL) = (tier_1_refund_pct IS NULL)", name="tier_1_paired"), + CheckConstraint("(tier_2_hours IS NULL) = (tier_2_refund_pct IS NULL)", name="tier_2_paired"), + CheckConstraint("(tier_3_hours IS NULL) = (tier_3_refund_pct IS NULL)", name="tier_3_paired"), + ) + + +class VenueLike(Base): + __tablename__ = "venue_likes" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("profiles.id", ondelete="CASCADE"), nullable=False) + venue_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("venues.id", ondelete="CASCADE"), nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + + __table_args__ = ( + Index("uq_venue_likes_user_venue", "user_id", "venue_id", unique=True), + ) diff --git a/apps/api/app/modules/venue/pricing_engine.py b/apps/api/app/modules/venue/pricing_engine.py new file mode 100644 index 000000000..57c7b906b --- /dev/null +++ b/apps/api/app/modules/venue/pricing_engine.py @@ -0,0 +1,114 @@ +from dataclasses import dataclass +from datetime import date, time +from decimal import Decimal, ROUND_HALF_EVEN +from typing import Sequence +from uuid import UUID + +from app.modules.venue.models import VenuePricingRule + + +def _banker_round(value: Decimal) -> int: + """Banker's rounding to nearest integer for financial precision.""" + return int(value.quantize(Decimal("1"), rounding=ROUND_HALF_EVEN)) + + +@dataclass +class PricedUnit: + base_paise: int + final_paise: int + applied_rule_id: UUID | None + applied_rule_name: str | None + clamped: bool + + +def _rule_matches_date(rule: VenuePricingRule, target_date: date) -> bool: + if rule.days_of_week is not None and target_date.weekday() not in rule.days_of_week: + return False + if rule.start_date is not None and target_date < rule.start_date: + return False + if rule.end_date is not None and target_date > rule.end_date: + return False + return True + + +def _rule_matches_time(rule: VenuePricingRule, target_time: time | None) -> bool: + if rule.start_time is None and rule.end_time is None: + return True + if target_time is None: + return False + if rule.start_time is not None and target_time < rule.start_time: + return False + if rule.end_time is not None and target_time >= rule.end_time: + return False + return True + + +def resolve_rule( + rules: Sequence[VenuePricingRule], + *, + target_date: date, + target_time: time | None, + applies_to: str, +) -> VenuePricingRule | None: + """Highest-priority active rule matching the given date/time. No stacking.""" + candidates = [ + r for r in rules + if r.is_active + and r.deleted_at is None + and r.applies_to in (applies_to, "both") + and _rule_matches_date(r, target_date) + and _rule_matches_time(r, target_time) + ] + if not candidates: + return None + candidates.sort(key=lambda r: (r.priority, r.created_at), reverse=True) + return candidates[0] + + +def apply_adjustment(base_paise: int, rule: VenuePricingRule | None) -> int: + if rule is None: + return base_paise + if rule.adjustment_type == "multiplier": + return _banker_round(Decimal(str(base_paise)) * Decimal(str(rule.multiplier))) + if rule.adjustment_type == "fixed_delta": + return max(0, base_paise + rule.amount_paise) + if rule.adjustment_type == "override": + return rule.amount_paise + return base_paise + + +def clamp_price( + computed_paise: int, + base_paise: int, + min_price_pct: Decimal, + max_price_pct: Decimal, +) -> tuple[int, bool]: + floor_paise = _banker_round(Decimal(str(base_paise)) * min_price_pct / Decimal("100")) + ceiling_paise = _banker_round(Decimal(str(base_paise)) * max_price_pct / Decimal("100")) + if computed_paise < floor_paise: + return floor_paise, True + if computed_paise > ceiling_paise: + return ceiling_paise, True + return computed_paise, False + + +def price_unit( + rules: Sequence[VenuePricingRule], + *, + base_paise: int, + target_date: date, + target_time: time | None, + applies_to: str, + min_price_pct: Decimal, + max_price_pct: Decimal, +) -> PricedUnit: + rule = resolve_rule(rules, target_date=target_date, target_time=target_time, applies_to=applies_to) + computed_paise = apply_adjustment(base_paise, rule) + final_paise, clamped = clamp_price(computed_paise, base_paise, min_price_pct, max_price_pct) + return PricedUnit( + base_paise=base_paise, + final_paise=final_paise, + applied_rule_id=rule.id if rule else None, + applied_rule_name=rule.name if rule else None, + clamped=clamped, + ) diff --git a/apps/api/app/modules/venue/routes.py b/apps/api/app/modules/venue/routes.py new file mode 100644 index 000000000..175052528 --- /dev/null +++ b/apps/api/app/modules/venue/routes.py @@ -0,0 +1,361 @@ +from uuid import UUID +from datetime import datetime +from fastapi import APIRouter, Depends, Query, HTTPException, UploadFile, File +from sqlalchemy.orm import Session + +from app.core.database import get_db +from app.modules.auth.dependencies import require_owner, require_auth, get_current_user_optional, AuthContext +from app.modules.venue.schemas import ( + VenueResponse, + VenueListResponse, + VenueStatsResponse, + VenueCategoryResponse, + CreateVenueRequest, + UpdateVenueRequest, + PricingPreviewResponse, + DeleteResponse, + VenueAvailabilityResponse, + BulkUpdateAvailabilityRequest, + VenueBlockedDateResponse, + CreateBlockedDateRequest, + CancellationPolicyResponse, + UpdateCancellationPolicyRequest, + AmenityResponse, + UpdateVenueAmenitiesRequest, + BookingType, + PublicVenueBlockedDateResponse, + VenuePhotoResponse, + BulkUpdateVenuePhotosRequest, + VenuePricingRuleResponse, + CreatePricingRuleRequest, + UpdatePricingRuleRequest, +) +from app.modules.venue import service +from app.modules.booking import service as booking_service +from app.modules.booking.schemas import BookingOut +from app.shared.utils import parse_timezone_datetime + +router = APIRouter() + + + +# Owner routes + +@router.get( + "/my/venues", + response_model=list[VenueListResponse] +) +def list_my_venues( + auth: AuthContext = Depends(require_owner), + db: Session = Depends(get_db), +): + + return service.list_owner_venues(db, owner_id=auth.user_id) + + +@router.get("/my/venues/{venue_id}", response_model=VenueResponse) +def get_my_venue( + venue_id: UUID, + auth: AuthContext = Depends(require_owner), + db: Session = Depends(get_db), +): + return service.get_owner_venue(db, venue_id=venue_id, owner_id=auth.user_id) + + +@router.get("/my/venues/{venue_id}/stats", response_model=VenueStatsResponse) +def get_my_venue_stats( + venue_id: UUID, + auth: AuthContext = Depends(require_owner), + db: Session = Depends(get_db), +): + return service.get_venue_stats_this_month(db, venue_id=venue_id, owner_id=auth.user_id) + + +@router.post("/", response_model=VenueResponse, status_code=201) +def create_venue( + body: CreateVenueRequest, + auth: AuthContext = Depends(require_owner), + db: Session = Depends(get_db), +): + + return service.create_venue(db, owner_id=auth.user_id, body=body) + + +@router.patch("/{venue_id}", response_model=VenueResponse) +def update_venue( + venue_id: UUID, + body: UpdateVenueRequest, + auth: AuthContext = Depends(require_owner), + db: Session = Depends(get_db), +): + + return service.update_venue(db, venue_id=venue_id, owner_id=auth.user_id, body=body) + + +@router.delete("/{venue_id}", response_model=DeleteResponse, status_code=200) +def delete_venue( + venue_id: UUID, + auth: AuthContext = Depends(require_owner), + db: Session = Depends(get_db), +): + + service.delete_venue(db, venue_id=venue_id, owner_id=auth.user_id) + return DeleteResponse(id=venue_id) + + +@router.post("/{venue_id}/submit", response_model=VenueResponse) +def submit_venue( + venue_id: UUID, + auth: AuthContext = Depends(require_owner), + db: Session = Depends(get_db), +): + return service.submit_venue(db, venue_id=venue_id, owner_id=auth.user_id) + + +@router.put("/{venue_id}/availability", response_model=list[VenueAvailabilityResponse]) +def bulk_update_availability( + venue_id: UUID, + body: BulkUpdateAvailabilityRequest, + auth: AuthContext = Depends(require_owner), + db: Session = Depends(get_db), +): + return service.bulk_update_availability(db, venue_id, auth.user_id, body.availabilities) + + +@router.post("/{venue_id}/blocked-dates", response_model=VenueBlockedDateResponse, status_code=201) +def create_blocked_date( + venue_id: UUID, + body: CreateBlockedDateRequest, + auth: AuthContext = Depends(require_owner), + db: Session = Depends(get_db), +): + return service.create_blocked_date(db, venue_id, auth.user_id, body) + + +@router.delete("/{venue_id}/blocked-dates/{blocked_id}", response_model=DeleteResponse, status_code=200) +def delete_blocked_date( + venue_id: UUID, + blocked_id: UUID, + auth: AuthContext = Depends(require_owner), + db: Session = Depends(get_db), +): + service.delete_blocked_date(db, venue_id, blocked_id, auth.user_id) + return DeleteResponse(id=blocked_id, message="Blocked date removed successfully") + + +@router.get("/{venue_id}/pricing-rules", response_model=list[VenuePricingRuleResponse]) +def list_pricing_rules( + venue_id: UUID, + auth: AuthContext = Depends(require_owner), + db: Session = Depends(get_db), +): + return service.list_pricing_rules(db, venue_id, auth.user_id) + + +@router.post("/{venue_id}/pricing-rules", response_model=VenuePricingRuleResponse, status_code=201) +def create_pricing_rule( + venue_id: UUID, + body: CreatePricingRuleRequest, + auth: AuthContext = Depends(require_owner), + db: Session = Depends(get_db), +): + return service.create_pricing_rule(db, venue_id, auth.user_id, body) + + +@router.patch("/{venue_id}/pricing-rules/{rule_id}", response_model=VenuePricingRuleResponse) +def update_pricing_rule( + venue_id: UUID, + rule_id: UUID, + body: UpdatePricingRuleRequest, + auth: AuthContext = Depends(require_owner), + db: Session = Depends(get_db), +): + return service.update_pricing_rule(db, venue_id, rule_id, auth.user_id, body) + + +@router.delete("/{venue_id}/pricing-rules/{rule_id}", response_model=DeleteResponse, status_code=200) +def delete_pricing_rule( + venue_id: UUID, + rule_id: UUID, + auth: AuthContext = Depends(require_owner), + db: Session = Depends(get_db), +): + service.delete_pricing_rule(db, venue_id, rule_id, auth.user_id) + return DeleteResponse(id=rule_id, message="Pricing rule removed successfully") + + +@router.get("/{venue_id}/pricing-preview", response_model=PricingPreviewResponse) +def get_owner_pricing_preview( + venue_id: UUID, + starts_at: str = Query(..., description="ISO 8601 datetime with timezone offset"), + ends_at: str = Query(..., description="ISO 8601 datetime with timezone offset"), + booking_type: BookingType = Query(..., description="full_day or time_slot"), + auth: AuthContext = Depends(require_owner), + db: Session = Depends(get_db), +): + starts_dt = parse_timezone_datetime(starts_at, "starts_at") + ends_dt = parse_timezone_datetime(ends_at, "ends_at") + return service.get_owner_pricing_preview(db, venue_id, auth.user_id, starts_dt, ends_dt, booking_type) + + +@router.put("/{venue_id}/cancellation-policy", response_model=CancellationPolicyResponse) +def put_venue_cancellation_policy( + venue_id: UUID, + body: UpdateCancellationPolicyRequest, + auth: AuthContext = Depends(require_owner), + db: Session = Depends(get_db), +): + return service.put_venue_cancellation_policy(db, venue_id, auth.user_id, body) + + +@router.put("/{venue_id}/amenities", response_model=list[AmenityResponse]) +def update_venue_amenities( + venue_id: UUID, + body: UpdateVenueAmenitiesRequest, + auth: AuthContext = Depends(require_owner), + db: Session = Depends(get_db), +): + return service.update_venue_amenities(db, venue_id, auth.user_id, body) + + +@router.post("/{venue_id}/photos", response_model=VenuePhotoResponse, status_code=201) +async def add_venue_photo( + venue_id: UUID, + file: UploadFile = File(...), + auth: AuthContext = Depends(require_owner), + db: Session = Depends(get_db), +): + if not file.content_type.startswith("image/"): + raise HTTPException(status_code=400, detail="File must be an image") + + file_bytes = await file.read() + return service.add_venue_photo(db, venue_id, auth.user_id, file_bytes) + + +@router.put("/{venue_id}/photos/bulk-update", response_model=list[VenuePhotoResponse]) +def bulk_update_venue_photos( + venue_id: UUID, + body: BulkUpdateVenuePhotosRequest, + auth: AuthContext = Depends(require_owner), + db: Session = Depends(get_db), +): + return service.bulk_update_venue_photos(db, venue_id, auth.user_id, body) + + +@router.delete("/{venue_id}/photos/{photo_id}", response_model=DeleteResponse, status_code=200) +def delete_venue_photo( + venue_id: UUID, + photo_id: UUID, + auth: AuthContext = Depends(require_owner), + db: Session = Depends(get_db), +): + service.delete_venue_photo(db, venue_id, photo_id, auth.user_id) + return DeleteResponse(id=photo_id, message="Photo deleted successfully") + + +@router.get("/{venue_id}/bookings", response_model=list[BookingOut]) +def list_venue_bookings( + venue_id: UUID, + auth: AuthContext = Depends(require_owner), + db: Session = Depends(get_db), +): + return booking_service.list_venue_bookings(db, venue_id, auth.user_id) + + +@router.get("/{venue_id}/bookings/pending", response_model=list[BookingOut]) +def list_pending_venue_bookings( + venue_id: UUID, + auth: AuthContext = Depends(require_owner), + db: Session = Depends(get_db), +): + return booking_service.list_venue_bookings( + db, + venue_id, + auth.user_id, + pending_only=True, + ) + + + + + + + +# User routes + +@router.get("/likes", response_model=list[UUID]) +def get_liked_venue_ids( + auth: AuthContext = Depends(require_auth), + db: Session = Depends(get_db), +): + return service.get_liked_venue_ids(db, auth.user_id) + + +@router.post("/{venue_id}/like", response_model=dict) +def toggle_venue_like( + venue_id: UUID, + auth: AuthContext = Depends(require_auth), + db: Session = Depends(get_db), +): + is_liked = service.toggle_venue_like(db, venue_id, auth.user_id) + return {"is_liked": is_liked} + + + +# Public routes + +@router.get("/categories", response_model=list[VenueCategoryResponse]) +def get_venue_categories(db: Session = Depends(get_db)): + return service.get_venue_categories(db) + + +@router.get("/amenities", response_model=list[AmenityResponse]) +def get_platform_amenities(db: Session = Depends(get_db)): + return service.get_platform_amenities(db) + + +@router.get("/{identifier}", response_model=VenueResponse) +def get_venue( + identifier: str, + auth: AuthContext | None = Depends(get_current_user_optional), + db: Session = Depends(get_db), +): + + return service.get_venue(db, identifier, user_id=auth.user_id if auth else None) + + +@router.get("/{venue_id}/pricing", response_model=PricingPreviewResponse) +def get_pricing_preview( + venue_id: UUID, + starts_at: str = Query(..., description="ISO 8601 datetime with timezone offset"), + ends_at: str = Query(..., description="ISO 8601 datetime with timezone offset"), + booking_type: BookingType = Query(..., description="full_day or time_slot"), + db: Session = Depends(get_db), +): + starts_dt = parse_timezone_datetime(starts_at, "starts_at") + ends_dt = parse_timezone_datetime(ends_at, "ends_at") + return service.get_pricing_preview(db, venue_id, starts_dt, ends_dt, booking_type) + + +@router.get("/{venue_id}/availability", response_model=list[VenueAvailabilityResponse]) +def get_venue_availability( + venue_id: UUID, + db: Session = Depends(get_db), +): + return service.get_venue_availability(db, venue_id) + + +@router.get("/{venue_id}/blocked-dates", response_model=list[PublicVenueBlockedDateResponse]) +def get_venue_blocked_dates( + venue_id: UUID, + db: Session = Depends(get_db), +): + return service.get_venue_blocked_dates(db, venue_id) + + +@router.get("/{venue_id}/cancellation-policy", response_model=CancellationPolicyResponse) +def get_venue_cancellation_policy( + venue_id: UUID, + db: Session = Depends(get_db), +): + return service.get_venue_cancellation_policy(db, venue_id) diff --git a/apps/api/app/modules/venue/schemas.py b/apps/api/app/modules/venue/schemas.py new file mode 100644 index 000000000..852eb8a08 --- /dev/null +++ b/apps/api/app/modules/venue/schemas.py @@ -0,0 +1,703 @@ +from pydantic import BaseModel, field_validator, Field +from uuid import UUID +from datetime import datetime, date, time +from typing import Optional +from decimal import Decimal +from enum import Enum + + + +class BookingType(str, Enum): + full_day = "full_day" + time_slot = "time_slot" + + +class PricingMode(str, Enum): + flat = "flat" + hourly = "hourly" + mixed = "mixed" + + +class VenueStatus(str, Enum): + draft = "draft" + pending_approval = "pending_approval" + approved = "approved" + rejected = "rejected" + suspended = "suspended" + + +class BookingMode(str, Enum): + MANUAL = "MANUAL" + INSTANT = "INSTANT" + + + +class VenueCategoryResponse(BaseModel): + id: UUID + slug: str + label: str + icon: Optional[str] = None + banner_image: Optional[str] = None + is_active: bool + sort_order: int + + model_config = {"from_attributes": True} + + +class VenuePhotoResponse(BaseModel): + id: UUID + venue_id: UUID + image_url: str + sort_order: int + is_cover: bool + created_at: datetime + + model_config = {"from_attributes": True} + + +class AmenityResponse(BaseModel): + id: UUID + name: str + icon: Optional[str] = None + + model_config = {"from_attributes": True} + + +class CancellationPolicyResponse(BaseModel): + tier_1_hours: Optional[int] = None + tier_1_refund_pct: Optional[Decimal] = None + tier_2_hours: Optional[int] = None + tier_2_refund_pct: Optional[Decimal] = None + tier_3_hours: Optional[int] = None + tier_3_refund_pct: Optional[Decimal] = None + no_show_refund_pct: Decimal + platform_fee_refundable: bool + notes: Optional[str] = None + + model_config = {"from_attributes": True} + +class UpdateCancellationPolicyRequest(BaseModel): + tier_1_hours: Optional[int] = Field(default=None, gt=0) + tier_1_refund_pct: Optional[Decimal] = Field(default=None, ge=0, le=100) + tier_2_hours: Optional[int] = Field(default=None, gt=0) + tier_2_refund_pct: Optional[Decimal] = Field(default=None, ge=0, le=100) + tier_3_hours: Optional[int] = Field(default=None, gt=0) + tier_3_refund_pct: Optional[Decimal] = Field(default=None, ge=0, le=100) + no_show_refund_pct: Decimal = Field(default=Decimal("0.00"), ge=0, le=100) + platform_fee_refundable: bool = False + notes: Optional[str] = None + + def model_post_init(self, __context) -> None: + if (self.tier_1_hours is None) != (self.tier_1_refund_pct is None): + raise ValueError("tier_1_hours and tier_1_refund_pct must be both set or both null") + if (self.tier_2_hours is None) != (self.tier_2_refund_pct is None): + raise ValueError("tier_2_hours and tier_2_refund_pct must be both set or both null") + if (self.tier_3_hours is None) != (self.tier_3_refund_pct is None): + raise ValueError("tier_3_hours and tier_3_refund_pct must be both set or both null") + + if self.tier_1_hours is not None and self.tier_2_hours is not None: + if self.tier_1_hours <= self.tier_2_hours: + raise ValueError("tier_1_hours must be strictly greater than tier_2_hours") + if self.tier_2_hours is not None and self.tier_3_hours is not None: + if self.tier_2_hours <= self.tier_3_hours: + raise ValueError("tier_2_hours must be strictly greater than tier_3_hours") + + +class UpdateVenueAmenitiesRequest(BaseModel): + amenity_ids: list[UUID] + + +class UpdateVenuePhotoItem(BaseModel): + photo_id: UUID + sort_order: int + is_cover: bool + +class BulkUpdateVenuePhotosRequest(BaseModel): + photos: list[UpdateVenuePhotoItem] + + +from typing import Any +from pydantic import model_validator + +class VenueListResponse(BaseModel): + id: UUID + name: str + slug: Optional[str] = None + city: str + max_capacity: int + status: VenueStatus + is_active: bool + category_name: str + cover_photo_url: Optional[str] = None + last_completed_step: Optional[int] = Field(default=0, ge=0) + + @model_validator(mode="before") + @classmethod + def flatten_nested(cls, data: Any) -> Any: + if isinstance(data, dict): + return data + + result = { + "id": data.id, + "name": data.name, + "slug": data.slug, + "city": data.city, + "max_capacity": data.max_capacity, + "status": data.status, + "is_active": data.is_active, + "last_completed_step": data.last_completed_step, + } + + category = getattr(data, 'category', None) + result["category_name"] = category.label if category else "Uncategorized" + + photos = getattr(data, 'photos', []) + if photos: + cover = next((p for p in photos if p.is_cover), photos[0]) + result["cover_photo_url"] = cover.image_url + else: + result["cover_photo_url"] = None + + return result + +class VenueStatsResponse(BaseModel): + active_bookings: int + revenue_this_month_paise: int + +class VenueResponse(BaseModel): + id: UUID + owner_id: UUID + + + name: str + slug: Optional[str] = None + description: Optional[str] = None + category: VenueCategoryResponse + + + address_line1: str + address_line2: Optional[str] = None + city: str + state: str + country: str + postal_code: Optional[str] = None + latitude: Optional[Decimal] = None + longitude: Optional[Decimal] = None + timezone: str + + + min_capacity: Optional[int] = None + max_capacity: int + + + open_time: time + close_time: time + spans_next_day: bool + + + allowed_booking_types: list[BookingType] + min_booking_duration_minutes: int + max_booking_duration_minutes: int + slot_interval_minutes: int + + + pre_buffer_minutes: int + post_buffer_minutes: int + + + pricing_mode: PricingMode + starting_price_paise: Optional[int] = None + hourly_rate_paise: Optional[int] = None + + + platform_commission_pct: Decimal + + + advance_pct: Decimal + balance_due_days_before_event: int + owner_action_window_hours: int + overdue_advance_refund_pct: Decimal + + min_price_pct: Decimal + max_price_pct: Decimal + display_price_min_paise: Optional[int] = None + display_price_max_paise: Optional[int] = None + + + status: VenueStatus + booking_mode: BookingMode = BookingMode.MANUAL + is_active: bool + + + created_at: datetime + updated_at: datetime + + last_completed_step: int + + + photos: list[VenuePhotoResponse] = Field(default_factory=list) + amenities: list[AmenityResponse] = Field(default_factory=list) + cancellation_policy: Optional[CancellationPolicyResponse] = None + is_liked: bool = False + + model_config = {"from_attributes": True} + + +class DeleteResponse(BaseModel): + id: UUID + deleted: bool = True + message: str = "Venue deleted successfully" + + +class CreateVenueRequest(BaseModel): + + name: str + description: Optional[str] = None + category_id: UUID + + + address_line1: str + address_line2: Optional[str] = None + city: str + state: str + country: str = "India" + postal_code: Optional[str] = None + latitude: Optional[Decimal] = None + longitude: Optional[Decimal] = None + timezone: str = "Asia/Kolkata" + + + min_capacity: Optional[int] = Field(default=None, gt=0) + max_capacity: int = Field(gt=0) + + + open_time: time + close_time: time + spans_next_day: bool = False + + + allowed_booking_types: list[BookingType] = Field(default_factory=lambda: [BookingType.full_day, BookingType.time_slot]) + min_booking_duration_minutes: int = Field(default=60, gt=0) + max_booking_duration_minutes: int = Field(default=1440, gt=0) + slot_interval_minutes: int = Field(default=30, gt=0) + + + pre_buffer_minutes: int = Field(default=0, ge=0) + post_buffer_minutes: int = Field(default=0, ge=0) + + + pricing_mode: PricingMode = PricingMode.flat + starting_price_paise: Optional[int] = Field(default=None, ge=0) + hourly_rate_paise: Optional[int] = Field(default=None, ge=0) + + + advance_pct: Decimal = Field(default=Decimal("30.00"), gt=0, le=100) + balance_due_days_before_event: int = Field(default=7, gt=0) + owner_action_window_hours: int = Field(default=48, ge=24, le=72) + overdue_advance_refund_pct: Decimal = Field(default=Decimal("0.00"), ge=0, le=100) + + min_price_pct: Decimal = Field(default=Decimal("50.00"), gt=0, le=100) + max_price_pct: Decimal = Field(default=Decimal("200.00"), ge=100, le=500) + + cancellation_policy: Optional[UpdateCancellationPolicyRequest] = None + amenity_ids: Optional[list[UUID]] = None + + last_completed_step: Optional[int] = Field(default=0, ge=0) + + @field_validator("allowed_booking_types") + @classmethod + def validate_booking_types(cls, v: list[BookingType]) -> list[BookingType]: + if not v: + raise ValueError("allowed_booking_types cannot be empty") + if len(v) != len(set(v)): + raise ValueError("Duplicate booking types are not allowed") + return v + + def model_post_init(self, __context) -> None: + has_full_day = BookingType.full_day in self.allowed_booking_types + has_time_slot = BookingType.time_slot in self.allowed_booking_types + + if has_full_day and has_time_slot: + if self.pricing_mode != PricingMode.mixed: + raise ValueError("pricing_mode must be 'mixed' when both full_day and time_slot are allowed") + elif has_full_day: + if self.pricing_mode != PricingMode.flat: + raise ValueError("pricing_mode must be 'flat' when only full_day is allowed") + elif has_time_slot: + if self.pricing_mode != PricingMode.hourly: + raise ValueError("pricing_mode must be 'hourly' when only time_slot is allowed") + + if self.pricing_mode == PricingMode.flat: + if self.starting_price_paise is None: + raise ValueError("starting_price_paise is required when pricing_mode is 'flat'") + if self.hourly_rate_paise is not None: + raise ValueError("hourly_rate_paise must be null when pricing_mode is 'flat'") + elif self.pricing_mode == PricingMode.hourly: + if self.hourly_rate_paise is None: + raise ValueError("hourly_rate_paise is required when pricing_mode is 'hourly'") + if self.starting_price_paise is not None: + raise ValueError("starting_price_paise must be null when pricing_mode is 'hourly'") + elif self.pricing_mode == PricingMode.mixed: + if self.starting_price_paise is None or self.hourly_rate_paise is None: + raise ValueError("Both starting_price_paise and hourly_rate_paise are required when pricing_mode is 'mixed'") + + + if ( + self.min_capacity is not None + and self.min_capacity > self.max_capacity + ): + raise ValueError("min_capacity cannot exceed max_capacity") + + + if self.min_booking_duration_minutes > self.max_booking_duration_minutes: + raise ValueError( + "min_booking_duration_minutes cannot exceed max_booking_duration_minutes" + ) + + if not self.spans_next_day and self.close_time <= self.open_time: + raise ValueError("close_time must be after open_time unless spans_next_day is true") + + if self.min_price_pct > self.max_price_pct: + raise ValueError("min_price_pct cannot exceed max_price_pct") + + +class UpdateVenueRequest(BaseModel): + name: Optional[str] = None + description: Optional[str] = None + category_id: Optional[UUID] = None + + address_line1: Optional[str] = None + address_line2: Optional[str] = None + city: Optional[str] = None + state: Optional[str] = None + country: Optional[str] = None + postal_code: Optional[str] = None + latitude: Optional[Decimal] = None + longitude: Optional[Decimal] = None + timezone: Optional[str] = None + + min_capacity: Optional[int] = Field(default=None, gt=0) + max_capacity: Optional[int] = Field(default=None, gt=0) + + open_time: Optional[time] = None + close_time: Optional[time] = None + spans_next_day: Optional[bool] = None + + allowed_booking_types: Optional[list[BookingType]] = None + min_booking_duration_minutes: Optional[int] = Field(default=None, gt=0) + max_booking_duration_minutes: Optional[int] = Field(default=None, gt=0) + slot_interval_minutes: Optional[int] = Field(default=None, gt=0) + + pre_buffer_minutes: Optional[int] = Field(default=None, ge=0) + post_buffer_minutes: Optional[int] = Field(default=None, ge=0) + + pricing_mode: Optional[PricingMode] = None + starting_price_paise: Optional[int] = Field(default=None, ge=0) + hourly_rate_paise: Optional[int] = Field(default=None, ge=0) + + advance_pct: Optional[Decimal] = Field(default=None, gt=0, le=100) + balance_due_days_before_event: Optional[int] = Field(default=None, gt=0) + owner_action_window_hours: Optional[int] = Field(default=None, ge=24, le=72) + overdue_advance_refund_pct: Optional[Decimal] = Field(default=None, ge=0, le=100) + + min_price_pct: Optional[Decimal] = Field(default=None, gt=0, le=100) + max_price_pct: Optional[Decimal] = Field(default=None, ge=100, le=500) + + last_completed_step: Optional[int] = None + + @field_validator("allowed_booking_types") + @classmethod + def validate_booking_types(cls, v: Optional[list[BookingType]]) -> Optional[list[BookingType]]: + if v is not None: + if not v: + raise ValueError("allowed_booking_types cannot be empty if provided") + if len(v) != len(set(v)): + raise ValueError("Duplicate booking types are not allowed") + return v + + def model_post_init(self, __context) -> None: + if self.allowed_booking_types is not None and self.pricing_mode is not None: + has_full_day = BookingType.full_day in self.allowed_booking_types + has_time_slot = BookingType.time_slot in self.allowed_booking_types + + if has_full_day and has_time_slot: + if self.pricing_mode != PricingMode.mixed: + raise ValueError("pricing_mode must be 'mixed' when both full_day and time_slot are allowed") + elif has_full_day: + if self.pricing_mode != PricingMode.flat: + raise ValueError("pricing_mode must be 'flat' when only full_day is allowed") + elif has_time_slot: + if self.pricing_mode != PricingMode.hourly: + raise ValueError("pricing_mode must be 'hourly' when only time_slot is allowed") + + if self.pricing_mode == PricingMode.flat and self.hourly_rate_paise is not None: + raise ValueError("hourly_rate_paise must be null when pricing_mode is 'flat'") + if self.pricing_mode == PricingMode.hourly and self.starting_price_paise is not None: + raise ValueError("starting_price_paise must be null when pricing_mode is 'hourly'") + + if ( + self.min_capacity is not None + and self.max_capacity is not None + and self.min_capacity > self.max_capacity + ): + raise ValueError("min_capacity cannot exceed max_capacity") + + if ( + self.min_booking_duration_minutes is not None + and self.max_booking_duration_minutes is not None + and self.min_booking_duration_minutes > self.max_booking_duration_minutes + ): + raise ValueError( + "min_booking_duration_minutes cannot exceed max_booking_duration_minutes" + ) + + if self.open_time is not None and self.close_time is not None: + spans_next = self.spans_next_day if self.spans_next_day is not None else False + if not spans_next and self.close_time <= self.open_time: + raise ValueError("close_time must be after open_time unless spans_next_day is true") + + if ( + self.min_price_pct is not None + and self.max_price_pct is not None + and self.min_price_pct > self.max_price_pct + ): + raise ValueError("min_price_pct cannot exceed max_price_pct") + + +class PricingDisplay(BaseModel): + quoted_price: str + advance_due: str + balance_due: str + platform_fee: str + owner_payout: str + + +class PricingBreakdownItem(BaseModel): + period_date: date + start_time: Optional[time] = None + end_time: Optional[time] = None + base_paise: int + applied_rule_id: Optional[UUID] = None + applied_rule_name: Optional[str] = None + clamped: bool + final_paise: int + + +class PricingPreviewResponse(BaseModel): + pricing_mode: PricingMode + quoted_price_paise: int + platform_commission_pct: float + platform_fee_paise: int + owner_payout_paise: int + advance_pct: float + advance_due_paise: int + balance_due_paise: int + display: PricingDisplay + breakdown: list[PricingBreakdownItem] = Field(default_factory=list) + clamped: bool = False + + +# ─── Search Result (used by search module) ──────────────────────────────────── + +class VenueSearchResult(BaseModel): + id: UUID + name: str + slug: Optional[str] = None + category: VenueCategoryResponse + city: str + state: str + max_capacity: int + pricing_mode: PricingMode + starting_price_paise: Optional[int] = None + hourly_rate_paise: Optional[int] = None + cover_photo_url: Optional[str] = None + status: VenueStatus + is_liked: bool = False + + model_config = {"from_attributes": True} + + +class VenueAvailabilityResponse(BaseModel): + day_of_week: int = Field(ge=0, le=6) + is_available: bool + opens_at: Optional[time] = None + closes_at: Optional[time] = None + spans_next_day: bool + + model_config = {"from_attributes": True} + +class VenueAvailabilityUpdate(BaseModel): + day_of_week: int = Field(ge=0, le=6) + is_available: bool + opens_at: Optional[time] = None + closes_at: Optional[time] = None + spans_next_day: bool = False + + def model_post_init(self, __context) -> None: + if self.is_available: + if self.opens_at is None or self.closes_at is None: + raise ValueError("opens_at and closes_at are required when is_available is true") + if not self.spans_next_day and self.closes_at <= self.opens_at: + raise ValueError("closes_at must be after opens_at unless spans_next_day is true") + +class BulkUpdateAvailabilityRequest(BaseModel): + availabilities: list[VenueAvailabilityUpdate] + + @field_validator("availabilities") + @classmethod + def validate_unique_days(cls, v: list[VenueAvailabilityUpdate]) -> list[VenueAvailabilityUpdate]: + days = [item.day_of_week for item in v] + if len(days) != len(set(days)): + raise ValueError("Duplicate day_of_week entries are not allowed") + return v + + +class PublicVenueBlockedDateResponse(BaseModel): + id: UUID + venue_id: UUID + starts_at: datetime + ends_at: datetime + + model_config = {"from_attributes": True} + + +class VenueBlockedDateResponse(BaseModel): + id: UUID + venue_id: UUID + starts_at: datetime + ends_at: datetime + reason: Optional[str] = None + blocked_by: UUID + created_at: datetime + + model_config = {"from_attributes": True} + +class CreateBlockedDateRequest(BaseModel): + starts_at: datetime + ends_at: datetime + reason: Optional[str] = None + + def model_post_init(self, __context) -> None: + if self.ends_at <= self.starts_at: + raise ValueError("ends_at must be strictly after starts_at") + + +class PricingRuleAdjustmentType(str, Enum): + multiplier = "multiplier" + fixed_delta = "fixed_delta" + override = "override" + + +class PricingRuleAppliesTo(str, Enum): + full_day = "full_day" + time_slot = "time_slot" + both = "both" + + +MAX_ACTIVE_PRICING_RULES_PER_VENUE = 20 + +MIN_VENUE_PHOTOS = 3 + + +class VenuePricingRuleResponse(BaseModel): + id: UUID + venue_id: UUID + name: str + days_of_week: Optional[list[int]] = None + start_date: Optional[date] = None + end_date: Optional[date] = None + start_time: Optional[time] = None + end_time: Optional[time] = None + adjustment_type: PricingRuleAdjustmentType + multiplier: Optional[Decimal] = None + amount_paise: Optional[int] = None + applies_to: PricingRuleAppliesTo + priority: int + source: str + is_active: bool + created_at: datetime + updated_at: datetime + exceeds_bounds: bool = False + + model_config = {"from_attributes": True} + + +class CreatePricingRuleRequest(BaseModel): + name: str = Field(min_length=1) + days_of_week: Optional[list[int]] = None + start_date: Optional[date] = None + end_date: Optional[date] = None + start_time: Optional[time] = None + end_time: Optional[time] = None + adjustment_type: PricingRuleAdjustmentType = PricingRuleAdjustmentType.multiplier + multiplier: Optional[Decimal] = Field(default=None, gt=0) + amount_paise: Optional[int] = None + applies_to: PricingRuleAppliesTo = PricingRuleAppliesTo.both + priority: int = 0 + is_active: bool = True + + def model_post_init(self, __context) -> None: + if self.days_of_week is not None: + if not self.days_of_week: + raise ValueError("days_of_week cannot be empty if provided") + if any(d < 0 or d > 6 for d in self.days_of_week): + raise ValueError("days_of_week values must be between 0 (Mon) and 6 (Sun)") + + if self.start_date is not None and self.end_date is not None and self.start_date > self.end_date: + raise ValueError("start_date cannot be after end_date") + + if self.adjustment_type == PricingRuleAdjustmentType.multiplier: + if self.multiplier is None: + raise ValueError("multiplier is required when adjustment_type is 'multiplier'") + if self.amount_paise is not None: + raise ValueError("amount_paise must be null when adjustment_type is 'multiplier'") + else: + if self.amount_paise is None: + raise ValueError("amount_paise is required when adjustment_type is 'fixed_delta' or 'override'") + if self.multiplier is not None: + raise ValueError("multiplier must be null when adjustment_type is not 'multiplier'") + if self.adjustment_type == PricingRuleAdjustmentType.override and self.amount_paise < 0: + raise ValueError("amount_paise must be >= 0 when adjustment_type is 'override'") + + +class UpdatePricingRuleRequest(BaseModel): + name: Optional[str] = Field(default=None, min_length=1) + days_of_week: Optional[list[int]] = None + start_date: Optional[date] = None + end_date: Optional[date] = None + start_time: Optional[time] = None + end_time: Optional[time] = None + adjustment_type: Optional[PricingRuleAdjustmentType] = None + multiplier: Optional[Decimal] = Field(default=None, gt=0) + amount_paise: Optional[int] = None + applies_to: Optional[PricingRuleAppliesTo] = None + priority: Optional[int] = None + is_active: Optional[bool] = None + + def model_post_init(self, __context) -> None: + if self.days_of_week is not None: + if not self.days_of_week: + raise ValueError("days_of_week cannot be empty if provided") + if any(d < 0 or d > 6 for d in self.days_of_week): + raise ValueError("days_of_week values must be between 0 (Mon) and 6 (Sun)") + + if self.start_date is not None and self.end_date is not None and self.start_date > self.end_date: + raise ValueError("start_date cannot be after end_date") + + +class PricingQuote(BaseModel): + quoted_price_paise: int + + platform_commission_pct: float + platform_fee_paise: int + + owner_payout_paise: int + + advance_pct: float + advance_due_paise: int + balance_due_paise: int + + pricing_mode: str + + breakdown: list[PricingBreakdownItem] = Field(default_factory=list) + clamped: bool = False diff --git a/apps/api/app/modules/venue/service.py b/apps/api/app/modules/venue/service.py new file mode 100644 index 000000000..069c9677f --- /dev/null +++ b/apps/api/app/modules/venue/service.py @@ -0,0 +1,1166 @@ +import re +import uuid +from datetime import datetime, timedelta, timezone +from decimal import Decimal, ROUND_HALF_EVEN +from uuid import UUID + +from sqlalchemy.orm import Session, joinedload, selectinload + +from app.core.exceptions import NotFoundError, ForbiddenError, ConflictError +from app.modules.venue.models import Venue, VenueCategory, VenueStatus, VenueAvailability, VenueBlockedDate, VenueCancellationPolicy, VenueAmenity, Amenity, VenuePhoto, VenuePricingRule, VenueLike +from app.modules.venue.schemas import ( + CreateVenueRequest, + UpdateVenueRequest, + PricingPreviewResponse, + PricingDisplay, + PricingBreakdownItem, + PricingQuote, + VenueAvailabilityUpdate, + CreateBlockedDateRequest, + UpdateCancellationPolicyRequest, + UpdateVenueAmenitiesRequest, + BulkUpdateVenuePhotosRequest, + VenuePricingRuleResponse, + CreatePricingRuleRequest, + UpdatePricingRuleRequest, + MAX_ACTIVE_PRICING_RULES_PER_VENUE, + MIN_VENUE_PHOTOS, +) +from app.modules.venue import pricing_engine +from app.modules.booking.models import BookingType, Booking, BookingStatus +from app.modules.payment.models import LedgerEntry +from sqlalchemy import func +from app.core.storage import upload_image_to_cloudinary, delete_image_from_cloudinary + + +# Default platform commission + +DEFAULT_PLATFORM_COMMISSION_PCT = Decimal("10.00") + + +# Internal helpers + +def _get_venue_or_404(db: Session, venue_id: str | UUID) -> Venue: + try: + vid = UUID(str(venue_id)) + venue = db.query(Venue).filter( + Venue.id == vid, + Venue.deleted_at.is_(None), + ).first() + except ValueError: + venue = db.query(Venue).filter( + Venue.slug == str(venue_id), + Venue.deleted_at.is_(None), + ).first() + + if not venue: + raise NotFoundError("Venue not found") + return venue + +# Acquire exclusive write lock on Venue to serialize slot check and creation +def _get_active_venue_or_404( + db: Session, + venue_id: str | UUID, + *, + for_update: bool = False, +) -> Venue: + query = db.query(Venue).filter( + Venue.status == VenueStatus.approved, + Venue.is_active.is_(True), + Venue.deleted_at.is_(None), + ) + + try: + vid = UUID(str(venue_id)) + query = query.filter(Venue.id == vid) + except ValueError: + query = query.filter(Venue.slug == str(venue_id)) + + if for_update: + query = query.with_for_update() + + venue = query.first() + + if not venue: + raise NotFoundError("Venue not found") + + return venue + + +def _assert_owner(venue: Venue, owner_id: UUID) -> None: + if venue.owner_id != owner_id: + raise ForbiddenError("You do not own this venue") + + +def _banker_round(value: Decimal) -> int: + """Banker's rounding to nearest integer for financial precision.""" + return int(value.quantize(Decimal('1'), rounding=ROUND_HALF_EVEN)) + + +def _format_inr(paise: int) -> str: + rupees = paise / 100 + return f"₹{rupees:,.0f}" + + +def _generate_slug(db: Session, name: str) -> str: + base_slug = re.sub(r'[^a-z0-9]+', '-', name.lower()).strip('-') + if not base_slug: + base_slug = "venue" + + slug = base_slug + counter = 1 + while db.query(Venue).filter(Venue.slug == slug).first(): + slug = f"{base_slug}-{counter}" + counter += 1 + return slug + + +# Public service functions + +def get_venue_categories(db: Session) -> list[VenueCategory]: + return ( + db.query(VenueCategory) + .filter(VenueCategory.is_active.is_(True), VenueCategory.deleted_at.is_(None)) + .order_by(VenueCategory.sort_order.asc(), VenueCategory.label.asc()) + .all() + ) + + +def get_platform_amenities(db: Session) -> list[Amenity]: + return db.query(Amenity).filter(Amenity.deleted_at.is_(None)).order_by(Amenity.name.asc()).all() + +def get_venue(db: Session, identifier: str, user_id: UUID | None = None) -> Venue: + try: + venue_id = UUID(identifier) + venue = _get_active_venue_or_404(db, venue_id) + except ValueError: + venue = db.query(Venue).filter( + Venue.slug == identifier, + Venue.status == VenueStatus.approved, + Venue.is_active.is_(True), + Venue.deleted_at.is_(None), + ).first() + if not venue: + raise NotFoundError("Venue not found") + return venue + + +def toggle_venue_like(db: Session, venue_id: UUID, user_id: UUID) -> bool: + venue = _get_active_venue_or_404(db, venue_id) + like = db.query(VenueLike).filter(VenueLike.user_id == user_id, VenueLike.venue_id == venue.id).first() + + if like: + db.delete(like) + db.commit() + return False + else: + new_like = VenueLike(user_id=user_id, venue_id=venue.id) + db.add(new_like) + db.commit() + return True + + +def get_liked_venue_ids(db: Session, user_id: UUID) -> list[UUID]: + likes = db.query(VenueLike).filter(VenueLike.user_id == user_id).all() + return [like.venue_id for like in likes] + + +def get_pricing_preview( + db: Session, + venue_id: UUID, + starts_at: datetime, + ends_at: datetime, + booking_type: BookingType, +) -> PricingPreviewResponse: + venue = _get_active_venue_or_404(db, venue_id) + return _compute_pricing_preview(db, venue, starts_at, ends_at, booking_type) + + +def get_owner_pricing_preview( + db: Session, + venue_id: UUID, + owner_id: UUID, + starts_at: datetime, + ends_at: datetime, + booking_type: BookingType, +) -> PricingPreviewResponse: + """Owner-facing dry run: same engine as the public quote, but usable on + draft/pending venues too (ownership-gated instead of approval-gated).""" + venue = _get_venue_or_404(db, venue_id) + _assert_owner(venue, owner_id) + return _compute_pricing_preview(db, venue, starts_at, ends_at, booking_type) + + +def _compute_pricing_preview( + db: Session, + venue: Venue, + starts_at: datetime, + ends_at: datetime, + booking_type: BookingType, +) -> PricingPreviewResponse: + if ends_at <= starts_at: + raise ConflictError("ends_at must be after starts_at") + + active_rules = [ + r for r in db.query(VenuePricingRule).filter( + VenuePricingRule.venue_id == venue.id, + VenuePricingRule.deleted_at.is_(None), + VenuePricingRule.is_active.is_(True), + ).all() + ] + applies_to = "full_day" if booking_type == BookingType.full_day else "time_slot" + has_matching_rules = any(r.applies_to in (applies_to, "both") for r in active_rules) + + breakdown: list[PricingBreakdownItem] = [] + any_clamped = False + + # ── Pricing Logic ───────────────────────────────────── + if booking_type == BookingType.full_day: + base = venue.starting_price_paise or 0 + + if not has_matching_rules: + days = (ends_at.date() - starts_at.date()).days + 1 + quoted_price_paise = base * days + else: + quoted_price_paise = 0 + current_date = starts_at.date() + end_date = ends_at.date() + while current_date <= end_date: + unit = pricing_engine.price_unit( + active_rules, + base_paise=base, + target_date=current_date, + target_time=None, + applies_to="full_day", + min_price_pct=Decimal(str(venue.min_price_pct)), + max_price_pct=Decimal(str(venue.max_price_pct)), + ) + quoted_price_paise += unit.final_paise + any_clamped = any_clamped or unit.clamped + breakdown.append(PricingBreakdownItem( + period_date=current_date, + base_paise=unit.base_paise, + applied_rule_id=unit.applied_rule_id, + applied_rule_name=unit.applied_rule_name, + clamped=unit.clamped, + final_paise=unit.final_paise, + )) + current_date += timedelta(days=1) + used_mode = "flat" # full_day always uses base price (per day) + + elif booking_type == BookingType.time_slot: + hourly_rate = venue.hourly_rate_paise or 0 + + if not has_matching_rules: + duration_seconds = (ends_at - starts_at).total_seconds() + duration_hours = Decimal(str(duration_seconds)) / Decimal("3600") + quoted_price_paise = _banker_round(Decimal(str(hourly_rate)) * duration_hours) + else: + interval_minutes = venue.slot_interval_minutes or 30 + quoted_price_paise = 0 + cursor = starts_at + while cursor < ends_at: + segment_end = min(cursor + timedelta(minutes=interval_minutes), ends_at) + segment_hours = Decimal(str((segment_end - cursor).total_seconds())) / Decimal("3600") + segment_base = _banker_round(Decimal(str(hourly_rate)) * segment_hours) + unit = pricing_engine.price_unit( + active_rules, + base_paise=segment_base, + target_date=cursor.date(), + target_time=cursor.time(), + applies_to="time_slot", + min_price_pct=Decimal(str(venue.min_price_pct)), + max_price_pct=Decimal(str(venue.max_price_pct)), + ) + quoted_price_paise += unit.final_paise + any_clamped = any_clamped or unit.clamped + breakdown.append(PricingBreakdownItem( + period_date=cursor.date(), + start_time=cursor.time(), + end_time=segment_end.time(), + base_paise=unit.base_paise, + applied_rule_id=unit.applied_rule_id, + applied_rule_name=unit.applied_rule_name, + clamped=unit.clamped, + final_paise=unit.final_paise, + )) + cursor = segment_end + used_mode = "hourly" + + else: + raise ConflictError(f"Invalid booking_type: {booking_type}") + + platform_fee_paise = _banker_round( + Decimal(str(quoted_price_paise)) + * Decimal(str(venue.platform_commission_pct)) + / Decimal("100") + ) + + owner_payout_paise = quoted_price_paise - platform_fee_paise + + advance_due_paise = _banker_round( + Decimal(str(quoted_price_paise)) + * Decimal(str(venue.advance_pct)) + / Decimal("100") + ) + + balance_due_paise = quoted_price_paise - advance_due_paise + + return PricingPreviewResponse( + pricing_mode=used_mode, + quoted_price_paise=quoted_price_paise, + platform_commission_pct=float(venue.platform_commission_pct), + platform_fee_paise=platform_fee_paise, + owner_payout_paise=owner_payout_paise, + advance_pct=float(venue.advance_pct), + advance_due_paise=advance_due_paise, + balance_due_paise=balance_due_paise, + display=PricingDisplay( + quoted_price=_format_inr(quoted_price_paise), + advance_due=_format_inr(advance_due_paise), + balance_due=_format_inr(balance_due_paise), + platform_fee=_format_inr(platform_fee_paise), + owner_payout=_format_inr(owner_payout_paise), + ), + breakdown=breakdown, + clamped=any_clamped, + ) + + +# Owner service functions + +def list_owner_venues(db: Session, owner_id: UUID) -> list[Venue]: + + return ( + db.query(Venue) + .options( + joinedload(Venue.category), + selectinload(Venue.photos), + selectinload(Venue.amenities), + joinedload(Venue.cancellation_policy) + ) + .filter( + Venue.owner_id == owner_id, + Venue.deleted_at.is_(None), + ) + .order_by(Venue.created_at.desc()) + .all() + ) + + +def get_owner_venue(db: Session, venue_id: UUID, owner_id: UUID) -> Venue: + venue = ( + db.query(Venue) + .options( + joinedload(Venue.category), + selectinload(Venue.photos), + selectinload(Venue.amenities), + joinedload(Venue.cancellation_policy) + ) + .filter( + Venue.id == venue_id, + Venue.owner_id == owner_id, + Venue.deleted_at.is_(None), + ) + .first() + ) + if not venue: + raise NotFoundError("Venue not found") + return venue + + +def _get_category_or_400(db: Session, category_id: UUID) -> VenueCategory: + cat = db.query(VenueCategory).filter( + VenueCategory.id == category_id, + VenueCategory.is_active.is_(True), + VenueCategory.deleted_at.is_(None), + ).first() + if not cat: + raise ConflictError("Invalid or inactive category") + return cat + + +def create_venue(db: Session, owner_id: UUID, body: CreateVenueRequest) -> Venue: + + _get_category_or_400(db, body.category_id) + + venue = Venue( + id=uuid.uuid4(), + owner_id=owner_id, + + name=body.name, + slug=_generate_slug(db, body.name), + description=body.description, + category_id=body.category_id, + + + address_line1=body.address_line1, + address_line2=body.address_line2, + city=body.city, + state=body.state, + country=body.country, + postal_code=body.postal_code, + latitude=body.latitude, + longitude=body.longitude, + timezone=body.timezone, + + + min_capacity=body.min_capacity, + max_capacity=body.max_capacity, + + + open_time=body.open_time, + close_time=body.close_time, + spans_next_day=body.spans_next_day, + + + allowed_booking_types=[bt.value for bt in body.allowed_booking_types], + min_booking_duration_minutes=body.min_booking_duration_minutes, + max_booking_duration_minutes=body.max_booking_duration_minutes, + slot_interval_minutes=body.slot_interval_minutes, + + + pre_buffer_minutes=body.pre_buffer_minutes, + post_buffer_minutes=body.post_buffer_minutes, + + + pricing_mode=body.pricing_mode.value, + starting_price_paise=body.starting_price_paise, + hourly_rate_paise=body.hourly_rate_paise, + + + platform_commission_pct=DEFAULT_PLATFORM_COMMISSION_PCT, + + + advance_pct=body.advance_pct, + balance_due_days_before_event=body.balance_due_days_before_event, + owner_action_window_hours=body.owner_action_window_hours, + overdue_advance_refund_pct=body.overdue_advance_refund_pct, + + min_price_pct=body.min_price_pct, + max_price_pct=body.max_price_pct, + + + status=VenueStatus.draft, + + last_completed_step=body.last_completed_step, + + is_active=True, + ) + + db.add(venue) + db.flush() + + if body.cancellation_policy: + policy = VenueCancellationPolicy( + id=uuid.uuid4(), + venue_id=venue.id, + tier_1_hours=body.cancellation_policy.tier_1_hours, + tier_1_refund_pct=body.cancellation_policy.tier_1_refund_pct, + tier_2_hours=body.cancellation_policy.tier_2_hours, + tier_2_refund_pct=body.cancellation_policy.tier_2_refund_pct, + tier_3_hours=body.cancellation_policy.tier_3_hours, + tier_3_refund_pct=body.cancellation_policy.tier_3_refund_pct, + no_show_refund_pct=body.cancellation_policy.no_show_refund_pct, + platform_fee_refundable=body.cancellation_policy.platform_fee_refundable, + notes=body.cancellation_policy.notes, + ) + db.add(policy) + + if body.amenity_ids: + valid_amenities = db.query(Amenity).filter( + Amenity.id.in_(body.amenity_ids), + Amenity.deleted_at.is_(None), + ).all() + if len(valid_amenities) != len(set(body.amenity_ids)): + raise ConflictError("One or more amenity IDs provided do not exist in the platform.") + + new_links = [VenueAmenity(venue_id=venue.id, amenity_id=am_id) for am_id in set(body.amenity_ids)] + db.add_all(new_links) + + db.commit() + db.refresh(venue) + + from app.modules.search.indexer import enqueue_job + enqueue_job(db, venue.id, "create") + + return venue + + +def update_venue( + db: Session, + venue_id: UUID, + owner_id: UUID, + body: UpdateVenueRequest, +) -> Venue: + + venue = _get_venue_or_404(db, venue_id) + _assert_owner(venue, owner_id) + + update_data = body.model_dump(exclude_unset=True) + + if "category_id" in update_data and update_data["category_id"] is not None: + _get_category_or_400(db, update_data["category_id"]) + + for field, value in update_data.items(): + + if hasattr(value, "value"): + value = value.value + + if isinstance(value, list) and value and hasattr(value[0], "value"): + value = [item.value for item in value] + setattr(venue, field, value) + + if venue.pricing_mode == "flat": + if venue.starting_price_paise is None: + raise ConflictError("starting_price_paise is required when pricing_mode is 'flat'") + if venue.hourly_rate_paise is not None: + raise ConflictError("hourly_rate_paise must be null when pricing_mode is 'flat'") + elif venue.pricing_mode == "hourly": + if venue.hourly_rate_paise is None: + raise ConflictError("hourly_rate_paise is required when pricing_mode is 'hourly'") + if venue.starting_price_paise is not None: + raise ConflictError("starting_price_paise must be null when pricing_mode is 'hourly'") + elif venue.pricing_mode == "mixed": + if venue.starting_price_paise is None or venue.hourly_rate_paise is None: + raise ConflictError("Both starting_price_paise and hourly_rate_paise are required when pricing_mode is 'mixed'") + + if venue.min_capacity is not None and venue.min_capacity > venue.max_capacity: + raise ConflictError("min_capacity cannot exceed max_capacity") + if venue.min_booking_duration_minutes > venue.max_booking_duration_minutes: + raise ConflictError("min_booking_duration_minutes cannot exceed max_booking_duration_minutes") + if venue.min_price_pct > venue.max_price_pct: + raise ConflictError("min_price_pct cannot exceed max_price_pct") + + _recompute_display_price_range(venue) + + db.commit() + db.refresh(venue) + + from app.modules.search.indexer import enqueue_job + enqueue_job(db, venue.id, "update") + + return venue + + +def delete_venue(db: Session, venue_id: UUID, owner_id: UUID) -> None: + + venue = _get_venue_or_404(db, venue_id) + _assert_owner(venue, owner_id) + + if venue.status not in (VenueStatus.draft, VenueStatus.rejected, VenueStatus.suspended): + raise ConflictError( + f"Venue cannot be deleted in status '{venue.status.value}'. " + "Only draft, rejected, or suspended venues can be deleted." + ) + + venue.deleted_at = datetime.now(timezone.utc) + db.commit() + + +def submit_venue(db: Session, venue_id: UUID, owner_id: UUID) -> Venue: + + venue = _get_venue_or_404(db, venue_id) + _assert_owner(venue, owner_id) + + allowed_from = (VenueStatus.draft, VenueStatus.rejected) + if venue.status not in allowed_from: + raise ConflictError( + f"Cannot submit venue in status '{venue.status.value}'. " + "Only draft or rejected venues can be submitted for review." + ) + + if len(venue.photos) < MIN_VENUE_PHOTOS: + raise ConflictError( + f"At least {MIN_VENUE_PHOTOS} photos are required before submitting " + f"for review. This venue currently has {len(venue.photos)}." + ) + + venue.status = VenueStatus.pending_approval + db.commit() + db.refresh(venue) + return venue + + +def get_venue_availability(db: Session, venue_id: UUID) -> list[VenueAvailability]: + _get_active_venue_or_404(db, venue_id) + return ( + db.query(VenueAvailability) + .filter( + VenueAvailability.venue_id == venue_id, + VenueAvailability.deleted_at.is_(None), + ) + .order_by(VenueAvailability.day_of_week.asc()) + .all() + ) + + +def bulk_update_availability( + db: Session, + venue_id: UUID, + owner_id: UUID, + availabilities: list[VenueAvailabilityUpdate], +) -> list[VenueAvailability]: + + venue = _get_venue_or_404(db, venue_id) + _assert_owner(venue, owner_id) + + now = datetime.now(timezone.utc) + db.query(VenueAvailability).filter( + VenueAvailability.venue_id == venue_id, + VenueAvailability.deleted_at.is_(None) + ).update({"deleted_at": now}, synchronize_session=False) + + new_records = [] + for item in availabilities: + new_record = VenueAvailability( + id=uuid.uuid4(), + venue_id=venue_id, + day_of_week=item.day_of_week, + is_available=item.is_available, + opens_at=item.opens_at, + closes_at=item.closes_at, + spans_next_day=item.spans_next_day, + ) + db.add(new_record) + new_records.append(new_record) + + db.commit() + for rec in new_records: + db.refresh(rec) + return sorted(new_records, key=lambda x: x.day_of_week) + + +def get_venue_blocked_dates(db: Session, venue_id: UUID) -> list[VenueBlockedDate]: + _get_active_venue_or_404(db, venue_id) + now = datetime.now(timezone.utc) + return ( + db.query(VenueBlockedDate) + .filter( + VenueBlockedDate.venue_id == venue_id, + VenueBlockedDate.deleted_at.is_(None), + VenueBlockedDate.ends_at > now, + ) + .order_by(VenueBlockedDate.starts_at.asc()) + .all() + ) + + +def create_blocked_date( + db: Session, + venue_id: UUID, + owner_id: UUID, + body: CreateBlockedDateRequest, +) -> VenueBlockedDate: + venue = _get_venue_or_404(db, venue_id) + _assert_owner(venue, owner_id) + + new_block = VenueBlockedDate( + id=uuid.uuid4(), + venue_id=venue_id, + starts_at=body.starts_at, + ends_at=body.ends_at, + reason=body.reason, + blocked_by=owner_id, + ) + db.add(new_block) + db.commit() + db.refresh(new_block) + return new_block + + +def delete_blocked_date(db: Session, venue_id: UUID, blocked_id: UUID, owner_id: UUID) -> None: + venue = _get_venue_or_404(db, venue_id) + _assert_owner(venue, owner_id) + + blocked_date = db.query(VenueBlockedDate).filter( + VenueBlockedDate.id == blocked_id, + VenueBlockedDate.venue_id == venue_id, + VenueBlockedDate.deleted_at.is_(None), + ).first() + + if not blocked_date: + raise NotFoundError("Blocked date not found") + + blocked_date.deleted_at = datetime.now(timezone.utc) + db.commit() + + +# Pricing rules + +def _rule_exceeds_bounds(rule: VenuePricingRule, venue: Venue) -> bool: + if rule.adjustment_type != "multiplier" or rule.multiplier is None: + return False + rule_pct = Decimal(str(rule.multiplier)) * Decimal("100") + return rule_pct < Decimal(str(venue.min_price_pct)) or rule_pct > Decimal(str(venue.max_price_pct)) + + +def _rule_to_response(rule: VenuePricingRule, venue: Venue) -> VenuePricingRuleResponse: + return VenuePricingRuleResponse.model_validate( + rule, from_attributes=True + ).model_copy(update={"exceeds_bounds": _rule_exceeds_bounds(rule, venue)}) + + +def _recompute_display_price_range(venue: Venue) -> None: + """Recompute the venue's listing display range from its active multiplier rules. + Called transactionally on every pricing-rule / bounds write (PRD §6.6): no worker, + no cache invalidation -- just a cheap read-time-equivalent computation stored for + index-only listing queries. + """ + base = venue.starting_price_paise if venue.pricing_mode in ("flat", "mixed") else venue.hourly_rate_paise + + active_multiplier_rules = [ + r for r in venue.pricing_rules + if r.is_active and r.adjustment_type == "multiplier" and r.multiplier is not None + ] + + if base is None or not active_multiplier_rules: + venue.display_price_min_paise = None + venue.display_price_max_paise = None + return + + multipliers = [Decimal(str(r.multiplier)) for r in active_multiplier_rules] + low_multiplier = min(Decimal("1.0"), min(multipliers)) + high_multiplier = max(Decimal("1.0"), max(multipliers)) + + min_pct = Decimal(str(venue.min_price_pct)) + max_pct = Decimal(str(venue.max_price_pct)) + + display_min, _ = pricing_engine.clamp_price( + _banker_round(Decimal(str(base)) * low_multiplier), base, min_pct, max_pct + ) + display_max, _ = pricing_engine.clamp_price( + _banker_round(Decimal(str(base)) * high_multiplier), base, min_pct, max_pct + ) + venue.display_price_min_paise = display_min + venue.display_price_max_paise = display_max + + +def list_pricing_rules(db: Session, venue_id: UUID, owner_id: UUID) -> list[VenuePricingRuleResponse]: + venue = _get_venue_or_404(db, venue_id) + _assert_owner(venue, owner_id) + + rules = ( + db.query(VenuePricingRule) + .filter(VenuePricingRule.venue_id == venue_id, VenuePricingRule.deleted_at.is_(None)) + .order_by(VenuePricingRule.priority.desc(), VenuePricingRule.created_at.desc()) + .all() + ) + return [_rule_to_response(r, venue) for r in rules] + + +def _get_pricing_rule_or_404(db: Session, venue_id: UUID, rule_id: UUID) -> VenuePricingRule: + rule = db.query(VenuePricingRule).filter( + VenuePricingRule.id == rule_id, + VenuePricingRule.venue_id == venue_id, + VenuePricingRule.deleted_at.is_(None), + ).first() + if not rule: + raise NotFoundError("Pricing rule not found") + return rule + + +def create_pricing_rule( + db: Session, + venue_id: UUID, + owner_id: UUID, + body: CreatePricingRuleRequest, +) -> VenuePricingRuleResponse: + venue = _get_venue_or_404(db, venue_id) + _assert_owner(venue, owner_id) + + if body.is_active: + active_count = db.query(VenuePricingRule).filter( + VenuePricingRule.venue_id == venue_id, + VenuePricingRule.deleted_at.is_(None), + VenuePricingRule.is_active.is_(True), + ).count() + if active_count >= MAX_ACTIVE_PRICING_RULES_PER_VENUE: + raise ConflictError( + f"Venue already has the maximum of {MAX_ACTIVE_PRICING_RULES_PER_VENUE} active pricing rules" + ) + + rule = VenuePricingRule( + id=uuid.uuid4(), + venue_id=venue_id, + name=body.name, + days_of_week=body.days_of_week, + start_date=body.start_date, + end_date=body.end_date, + start_time=body.start_time, + end_time=body.end_time, + adjustment_type=body.adjustment_type.value, + multiplier=body.multiplier, + amount_paise=body.amount_paise, + applies_to=body.applies_to.value, + priority=body.priority, + source="owner", + is_active=body.is_active, + ) + db.add(rule) + db.flush() + db.refresh(venue) + + _recompute_display_price_range(venue) + db.commit() + db.refresh(rule) + return _rule_to_response(rule, venue) + + +def update_pricing_rule( + db: Session, + venue_id: UUID, + rule_id: UUID, + owner_id: UUID, + body: UpdatePricingRuleRequest, +) -> VenuePricingRuleResponse: + venue = _get_venue_or_404(db, venue_id) + _assert_owner(venue, owner_id) + rule = _get_pricing_rule_or_404(db, venue_id, rule_id) + + if body.is_active and not rule.is_active: + active_count = db.query(VenuePricingRule).filter( + VenuePricingRule.venue_id == venue_id, + VenuePricingRule.deleted_at.is_(None), + VenuePricingRule.is_active.is_(True), + ).count() + if active_count >= MAX_ACTIVE_PRICING_RULES_PER_VENUE: + raise ConflictError( + f"Venue already has the maximum of {MAX_ACTIVE_PRICING_RULES_PER_VENUE} active pricing rules" + ) + + update_data = body.model_dump(exclude_unset=True) + for field, value in update_data.items(): + if hasattr(value, "value"): + value = value.value + setattr(rule, field, value) + + if rule.adjustment_type == "multiplier": + if rule.multiplier is None: + raise ConflictError("multiplier is required when adjustment_type is 'multiplier'") + if rule.amount_paise is not None: + raise ConflictError("amount_paise must be null when adjustment_type is 'multiplier'") + else: + if rule.amount_paise is None: + raise ConflictError("amount_paise is required when adjustment_type is 'fixed_delta' or 'override'") + if rule.multiplier is not None: + raise ConflictError("multiplier must be null when adjustment_type is not 'multiplier'") + if rule.adjustment_type == "override" and rule.amount_paise < 0: + raise ConflictError("amount_paise must be >= 0 when adjustment_type is 'override'") + + if rule.start_date is not None and rule.end_date is not None and rule.start_date > rule.end_date: + raise ConflictError("start_date cannot be after end_date") + + db.flush() + db.refresh(venue) + _recompute_display_price_range(venue) + db.commit() + db.refresh(rule) + return _rule_to_response(rule, venue) + + +def delete_pricing_rule(db: Session, venue_id: UUID, rule_id: UUID, owner_id: UUID) -> None: + venue = _get_venue_or_404(db, venue_id) + _assert_owner(venue, owner_id) + rule = _get_pricing_rule_or_404(db, venue_id, rule_id) + + rule.deleted_at = datetime.now(timezone.utc) + db.flush() + db.refresh(venue) + _recompute_display_price_range(venue) + db.commit() + + +def get_venue_cancellation_policy(db: Session, venue_id: UUID) -> VenueCancellationPolicy: + _get_active_venue_or_404(db, venue_id) + policy = db.query(VenueCancellationPolicy).filter( + VenueCancellationPolicy.venue_id == venue_id + ).first() + + if not policy: + raise NotFoundError("Cancellation policy not found for this venue") + + return policy + + +def put_venue_cancellation_policy( + db: Session, + venue_id: UUID, + owner_id: UUID, + body: UpdateCancellationPolicyRequest, +) -> VenueCancellationPolicy: + venue = _get_venue_or_404(db, venue_id) + _assert_owner(venue, owner_id) + + policy = db.query(VenueCancellationPolicy).filter( + VenueCancellationPolicy.venue_id == venue_id + ).first() + + if not policy: + policy = VenueCancellationPolicy( + id=uuid.uuid4(), + venue_id=venue_id, + ) + db.add(policy) + + policy.tier_1_hours = body.tier_1_hours + policy.tier_1_refund_pct = body.tier_1_refund_pct + policy.tier_2_hours = body.tier_2_hours + policy.tier_2_refund_pct = body.tier_2_refund_pct + policy.tier_3_hours = body.tier_3_hours + policy.tier_3_refund_pct = body.tier_3_refund_pct + policy.no_show_refund_pct = body.no_show_refund_pct + policy.platform_fee_refundable = body.platform_fee_refundable + policy.notes = body.notes + + db.commit() + db.refresh(policy) + return policy + + +def update_venue_amenities( + db: Session, + venue_id: UUID, + owner_id: UUID, + body: UpdateVenueAmenitiesRequest, +) -> list[Amenity]: + venue = _get_venue_or_404(db, venue_id) + _assert_owner(venue, owner_id) + + if body.amenity_ids: + valid_amenities = db.query(Amenity).filter( + Amenity.id.in_(body.amenity_ids), + Amenity.deleted_at.is_(None), + ).all() + if len(valid_amenities) != len(set(body.amenity_ids)): + raise ConflictError("One or more amenity IDs provided do not exist in the platform.") + + db.query(VenueAmenity).filter(VenueAmenity.venue_id == venue_id).delete(synchronize_session=False) + + new_links = [] + for am_id in set(body.amenity_ids): + new_links.append(VenueAmenity(venue_id=venue_id, amenity_id=am_id)) + + if new_links: + db.add_all(new_links) + + db.commit() + db.refresh(venue) + return venue.amenities + + +def add_venue_photo( + db: Session, + venue_id: UUID, + owner_id: UUID, + file_bytes: bytes, +) -> VenuePhoto: + venue = _get_venue_or_404(db, venue_id) + _assert_owner(venue, owner_id) + + + image_url = upload_image_to_cloudinary(file_bytes, folder=f"venues/{venue_id}") + + + existing_photos = db.query(VenuePhoto).filter( + VenuePhoto.venue_id == venue_id, + VenuePhoto.deleted_at.is_(None) + ).all() + + sort_order = len(existing_photos) + is_cover = True if sort_order == 0 else False + + photo = VenuePhoto( + id=uuid.uuid4(), + venue_id=venue_id, + image_url=image_url, + sort_order=sort_order, + is_cover=is_cover + ) + db.add(photo) + db.commit() + db.refresh(photo) + return photo + + +def bulk_update_venue_photos( + db: Session, + venue_id: UUID, + owner_id: UUID, + body: BulkUpdateVenuePhotosRequest, +) -> list[VenuePhoto]: + venue = _get_venue_or_404(db, venue_id) + _assert_owner(venue, owner_id) + + cover_count = sum(1 for p in body.photos if p.is_cover) + if cover_count != 1: + raise ConflictError("Exactly one photo must be marked as the cover photo.") + + photo_ids_in_request = {p.photo_id for p in body.photos} + + + existing_photos = db.query(VenuePhoto).filter( + VenuePhoto.venue_id == venue_id, + VenuePhoto.deleted_at.is_(None) + ).all() + + existing_photo_map = {p.id: p for p in existing_photos} + + if len(photo_ids_in_request) != len(existing_photos): + raise ConflictError("The request must include all active photos for this venue.") + + for p_id in photo_ids_in_request: + if p_id not in existing_photo_map: + raise NotFoundError(f"Photo with ID {p_id} not found in this venue") + + + + for photo in existing_photos: + photo.is_cover = False + db.flush() + + + for item in body.photos: + photo = existing_photo_map[item.photo_id] + photo.sort_order = item.sort_order + photo.is_cover = item.is_cover + + db.commit() + + return sorted(existing_photos, key=lambda p: p.sort_order) + + +def delete_venue_photo(db: Session, venue_id: UUID, photo_id: UUID, owner_id: UUID) -> None: + venue = _get_venue_or_404(db, venue_id) + _assert_owner(venue, owner_id) + + photo = db.query(VenuePhoto).filter( + VenuePhoto.id == photo_id, + VenuePhoto.venue_id == venue_id, + VenuePhoto.deleted_at.is_(None) + ).first() + + if not photo: + raise NotFoundError("Photo not found") + + photo.deleted_at = datetime.now(timezone.utc) + + if photo.is_cover: + photo.is_cover = False + next_photo = db.query(VenuePhoto).filter( + VenuePhoto.venue_id == venue_id, + VenuePhoto.id != photo_id, + VenuePhoto.deleted_at.is_(None) + ).order_by(VenuePhoto.sort_order.asc()).first() + + if next_photo: + next_photo.is_cover = True + db.add(next_photo) + + db.commit() + + +def get_pricing_quote( + db: Session, + venue_id: UUID, + starts_at: datetime, + ends_at: datetime, + booking_type: BookingType, +) -> PricingQuote: + + preview = get_pricing_preview( + db=db, + venue_id=venue_id, + starts_at=starts_at, + ends_at=ends_at, + booking_type=booking_type, + ) + + return PricingQuote( + quoted_price_paise=preview.quoted_price_paise, + platform_commission_pct=preview.platform_commission_pct, + platform_fee_paise=preview.platform_fee_paise, + owner_payout_paise=preview.owner_payout_paise, + advance_pct=preview.advance_pct, + advance_due_paise=preview.advance_due_paise, + balance_due_paise=preview.balance_due_paise, + pricing_mode=preview.pricing_mode, + breakdown=preview.breakdown, + clamped=preview.clamped, + ) + + +def get_pricing_quote_for_slot( + db: Session, + venue_id: UUID, + starts_at: datetime, + ends_at: datetime, + booking_type: str, +) -> PricingQuote: + """ + Get pricing quote for a booking slot. + For use by availability and booking modules. + """ + + return get_pricing_quote( + db=db, + venue_id=venue_id, + starts_at=starts_at, + ends_at=ends_at, + booking_type=BookingType(booking_type), + ) + +def get_venue_stats_this_month(db: Session, venue_id: UUID, owner_id: UUID) -> dict: + venue = _get_venue_or_404(db, venue_id) + if venue.owner_id != owner_id: + raise ForbiddenError("Not your venue") + + now = datetime.now(timezone.utc) + start_of_month = datetime(now.year, now.month, 1, tzinfo=timezone.utc) + + from app.modules.booking.models import BookingSlot + + active_bookings = ( + db.query(func.count(Booking.id)) + .join(Booking.slot) + .filter( + Booking.venue_id == venue_id, + BookingSlot.ends_at >= now, + Booking.status == BookingStatus.confirmed + ) + .scalar() or 0 + ) + + ledger_rows = ( + db.query( + LedgerEntry.entry_type, + LedgerEntry.direction, + func.sum(LedgerEntry.amount_paise).label("total"), + ) + .filter( + LedgerEntry.venue_id == venue_id, + LedgerEntry.created_at >= start_of_month, + ) + .group_by(LedgerEntry.entry_type, LedgerEntry.direction) + .all() + ) + + gross_volume = 0 + platform_fees = 0 + refunds_issued = 0 + + for entry_type, direction, total in ledger_rows: + val = int(total or 0) + if entry_type == "charge" and direction == "credit": + gross_volume += val + elif entry_type == "platform_fee" and direction == "debit": + platform_fees += val + elif entry_type == "refund" and direction == "debit": + refunds_issued += val + + net_revenue = gross_volume - platform_fees - refunds_issued + + return { + "active_bookings": active_bookings, + "revenue_this_month_paise": net_revenue + } diff --git a/apps/api/app/shared/models.py b/apps/api/app/shared/models.py new file mode 100644 index 000000000..b4965f996 --- /dev/null +++ b/apps/api/app/shared/models.py @@ -0,0 +1,12 @@ +from datetime import datetime +from sqlalchemy import DateTime, func +from sqlalchemy.orm import mapped_column, Mapped + + +class TimestampMixin: + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=func.now(), nullable=False + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=func.now(), onupdate=func.now(), nullable=False + ) diff --git a/apps/api/app/shared/pagination.py b/apps/api/app/shared/pagination.py new file mode 100644 index 000000000..681413202 --- /dev/null +++ b/apps/api/app/shared/pagination.py @@ -0,0 +1,16 @@ +from pydantic import BaseModel +from typing import Generic, TypeVar, List + +T = TypeVar("T") + + +class PaginationParams(BaseModel): + page: int = 1 + page_size: int = 20 + + +class Page(BaseModel, Generic[T]): + items: List[T] + total: int + page: int + page_size: int diff --git a/apps/api/app/shared/utils.py b/apps/api/app/shared/utils.py new file mode 100644 index 000000000..cb94204fb --- /dev/null +++ b/apps/api/app/shared/utils.py @@ -0,0 +1,30 @@ +import uuid +from datetime import datetime +from fastapi import HTTPException + + +def generate_id() -> str: + return str(uuid.uuid4()) + + +# Helper to parse datetime query parameters that might have '+' decoded as space +def parse_timezone_datetime(val: str, field_name: str) -> datetime: + processed_val = val + if " " in val: + parts = val.split(" ") + if len(parts) == 2 and (":" in parts[1] or parts[1].isdigit()): + processed_val = "+".join(parts) + try: + return datetime.fromisoformat(processed_val) + except ValueError as e: + raise HTTPException( + status_code=422, + detail=[ + { + "type": "datetime_from_date_parsing", + "loc": ["query", field_name], + "msg": f"Input should be a valid datetime or date, unexpected extra characters at the end of the input: {str(e)}", + "input": val, + } + ], + ) diff --git a/apps/api/app/tests/test_availability_service.py b/apps/api/app/tests/test_availability_service.py new file mode 100644 index 000000000..315115c4c --- /dev/null +++ b/apps/api/app/tests/test_availability_service.py @@ -0,0 +1,88 @@ +from datetime import date, datetime, time, timezone, timedelta +from zoneinfo import ZoneInfo + +from app.modules.availability.service import ( + resolve_operating_window, + expand_full_day_slot, + compute_effective_range, +) +from app.modules.booking.models import BookingType +from app.modules.venue.models import Venue +import app.modules.venue.service as venue_service + + +def make_venue(tz="Asia/Kolkata"): + v = Venue() + v.timezone = tz + v.open_time = time(9, 0) + v.close_time = time(18, 0) + v.spans_next_day = False + v.pricing_mode = "hourly" + v.hourly_rate_paise = 10000 + v.platform_commission_pct = 10.00 + v.advance_pct = 30.00 + return v + + +def test_resolve_and_expand_basic(): + v = make_venue() + op = resolve_operating_window(v, date(2026, 6, 7)) + assert op.is_available is True + assert op.opens_at == time(9, 0) + + starts_utc, ends_utc = expand_full_day_slot(v, date(2026, 6, 7)) + # Asia/Kolkata is UTC+5:30 + assert starts_utc.tzinfo is not None + assert ends_utc.tzinfo is not None + assert (ends_utc - starts_utc).seconds == (18 - 9) * 3600 + + +def test_compute_effective_range(): + tz = ZoneInfo("Asia/Kolkata") + starts = datetime(2026, 6, 7, 9, 0, tzinfo=tz) + ends = datetime(2026, 6, 7, 18, 0, tzinfo=tz) + es, ee = compute_effective_range(starts, ends, 15, 20) + assert es == starts - timedelta(minutes=15) + assert ee == ends + timedelta(minutes=20) + + +def test_pricing_quote_hourly(monkeypatch): + v = make_venue() + tz = ZoneInfo(v.timezone) + starts = datetime(2026, 6, 7, 10, 0, tzinfo=tz).astimezone(timezone.utc) + ends = datetime(2026, 6, 7, 12, 30, tzinfo=tz).astimezone(timezone.utc) + + # Pricing now resolves the venue from the DB; stub that lookup so we can + # exercise the (DB-free) pricing math on our in-memory venue. + monkeypatch.setattr(venue_service, "_get_active_venue_or_404", lambda db, venue_id: v) + q = venue_service.get_pricing_quote( + db=None, venue_id=None, starts_at=starts, ends_at=ends, + booking_type=BookingType.time_slot, + ) + # duration 2.5 hours * 10000 paise = 25000 paise + assert q.quoted_price_paise == 25000 + assert q.advance_due_paise == int(25000 * 0.30) + + +def test_expand_full_day_handles_dst_forward(): + # London DST starts 2026-03-29 (clocks forward 1 hour) -> day length 23 hours + v = make_venue(tz="Europe/London") + v.open_time = time(0, 0) + v.close_time = time(0, 0) + v.spans_next_day = True + + starts_utc, ends_utc = expand_full_day_slot(v, date(2026, 3, 29)) + delta = ends_utc - starts_utc + assert delta.total_seconds() == 23 * 3600 + + +def test_expand_full_day_handles_dst_backward(): + # London DST ends 2026-10-25 (clocks back 1 hour) -> day length 25 hours + v = make_venue(tz="Europe/London") + v.open_time = time(0, 0) + v.close_time = time(0, 0) + v.spans_next_day = True + + starts_utc, ends_utc = expand_full_day_slot(v, date(2026, 10, 25)) + delta = ends_utc - starts_utc + assert delta.total_seconds() == 25 * 3600 diff --git a/apps/api/pyproject.toml b/apps/api/pyproject.toml new file mode 100644 index 000000000..9d5ad3175 --- /dev/null +++ b/apps/api/pyproject.toml @@ -0,0 +1,51 @@ +[project] +name = "venue404-api" +version = "0.0.1" +requires-python = ">=3.12" +dependencies = [ + "fastapi>=0.111.0", + "uvicorn[standard]>=0.30.0", + "sqlalchemy>=2.0.0", + "alembic>=1.13.0", + "psycopg2-binary>=2.9.0", + "pydantic-settings>=2.3.0", + "email-validator>=2.2.0", + "passlib[bcrypt]>=1.7.4", + "python-jose[cryptography]>=3.3.0", + "stripe>=9.0.0", + "apscheduler>=3.10.0", + "resend>=2.0.0", + "cloudinary>=1.40.0", + "pillow>=10.0.0", + "python-multipart>=0.0.9", + "pgvector>=0.3.0", + "upstash-redis>=1.0.0", + "httpx>=0.27.0", + "rapidfuzz>=3.14.5", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0.0", + "pytest-asyncio>=0.23.0", + "pytest-dotenv>=0.5.2", + "ruff>=0.5.0", +] + +[tool.pytest.ini_options] +env_files = [".env.test"] + +[tool.ruff] +target-version = "py312" +line-length = 100 + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "UP"] + +[build-system] +requires = ["setuptools>=69", "wheel"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.packages.find] +include = ["app*"] +exclude = ["tests*", "alembic*"] diff --git a/apps/api/scripts/reindex_all_venues.py b/apps/api/scripts/reindex_all_venues.py new file mode 100644 index 000000000..5ce84515f --- /dev/null +++ b/apps/api/scripts/reindex_all_venues.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +""" +Re-enqueue ALL venues for search indexing. +""" + +import logging +import sys + +from app.core.database import SessionLocal +from app.modules.search.indexer import enqueue_job +from app.modules.venue.models import Venue, VenueStatus +from app.modules.booking.models import Booking +from app.modules.profile.models import Profile + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +def reindex_all_venues(batch_size: int = 200): + db = SessionLocal() + processed = 0 + total = 0 + + try: + # Count total venues + total = db.query(Venue).filter(Venue.deleted_at.is_(None)).count() + logger.info(f"Found {total} venues to reindex.") + + offset = 0 + + while True: + venues = ( + db.query(Venue) + .filter(Venue.deleted_at.is_(None)) + # .filter(Venue.status == VenueStatus.approved) # Uncomment if needed + # .filter(Venue.is_active == True) + .order_by(Venue.id) + .offset(offset) + .limit(batch_size) + .all() + ) + + if not venues: + break + + for venue in venues: + try: + enqueue_job(db, venue.id, "reindex") + processed += 1 + if processed % 50 == 0: + logger.info(f"Progress: {processed}/{total} venues enqueued") + except Exception as e: + logger.error(f"Failed to enqueue venue {venue.id}: {e}") + + offset += batch_size + db.commit() # Commit periodically + + logger.info(f"✅ Successfully enqueued {processed} out of {total} venues.") + + except Exception as exc: + logger.exception("Reindexing failed") + sys.exit(1) + finally: + db.close() + + +if __name__ == "__main__": + reindex_all_venues() diff --git a/apps/api/scripts/run_job.py b/apps/api/scripts/run_job.py new file mode 100644 index 000000000..6d03a9b8b --- /dev/null +++ b/apps/api/scripts/run_job.py @@ -0,0 +1,59 @@ +import logging +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) + +from app.core.logging import setup_logging + +setup_logging() + +logger = logging.getLogger(__name__) + +import app.modules.venue.models # noqa: F401 +import app.modules.profile.models # noqa: F401 +import app.modules.booking.models # noqa: F401 + +from app.jobs.runner import JOBS, run_job as _run_job + + +def run_job(job_name: str) -> bool: + if job_name not in JOBS: + print(f"Unknown job: {job_name}") + return False + + print(f"\n🚀 Running {job_name}") + + try: + processed = _run_job(job_name) + print(f"✅ {job_name} completed. Processed: {processed}") + return True + + except Exception: + logger.exception("%s failed", job_name) + print(f"❌ {job_name} failed") + return False + + +def run_all(): + print("Running ALL jobs...\n") + + success = 0 + + for job_name in JOBS: + if run_job(job_name): + success += 1 + + print(f"\nCompleted {success}/{len(JOBS)} jobs.") + + +if __name__ == "__main__": + if len(sys.argv) > 1 and sys.argv[1] == "all": + run_all() + elif len(sys.argv) > 1: + run_job(sys.argv[1]) + else: + print("Usage:") + print(" python run_job.py ") + print(" python run_job.py all") + print("\nAvailable jobs:", list(JOBS.keys())) diff --git a/apps/api/scripts/text_indexer.py b/apps/api/scripts/text_indexer.py new file mode 100644 index 000000000..d6da03025 --- /dev/null +++ b/apps/api/scripts/text_indexer.py @@ -0,0 +1,30 @@ +from pprint import pprint + +from sqlalchemy.orm import joinedload + +from app.core.database import SessionLocal +from app.modules.booking.models import Booking +from app.modules.venue.models import Venue +from app.modules.profile.models import Profile +from app.modules.search.indexer import _build_search_document + +db = SessionLocal() + +try: + venue = ( + db.query(Venue) + .options( + joinedload(Venue.category), + joinedload(Venue.amenities), + ) + .first() + ) + + if venue is None: + raise RuntimeError("No venue found") + + document = _build_search_document(venue) + pprint(document) + +finally: + db.close() diff --git a/apps/api/scripts/trigger_confirm_payment.py b/apps/api/scripts/trigger_confirm_payment.py new file mode 100644 index 000000000..6c3b5f8e0 --- /dev/null +++ b/apps/api/scripts/trigger_confirm_payment.py @@ -0,0 +1,44 @@ +"""Manual trigger for confirm_payment - Docker friendly""" + +import sys +from pathlib import Path + +# Add correct path for Docker +sys.path.insert(0, "/app") + +from app.core.database import SessionLocal +from app.modules.payment import service +from app.modules.booking.models import Booking + + +def main(): + payment_intent_id = "pi_3TkRDgGhjSGtBU6K1jQuJXAE" # ← Change if needed + + print(f"🔄 [Docker] Triggering confirm_payment for: {payment_intent_id}") + + db = SessionLocal() + try: + service.confirm_payment(db, payment_intent_id) + db.commit() + print("✅ confirm_payment executed successfully!") + + # Check result + booking = ( + db.query(Booking) + .filter_by(id="bddb9e73-8931-483c-8fe4-00662144d4f6") + .first() + ) + if booking: + print( + f"📊 Final Status → Booking: {booking.status} | Payment: {booking.payment_status}" + ) + + except Exception as e: + db.rollback() + print(f"❌ Error: {e}") + finally: + db.close() + + +if __name__ == "__main__": + main() diff --git a/apps/api/tests/conftest.py b/apps/api/tests/conftest.py new file mode 100644 index 000000000..0ccd1bcf9 --- /dev/null +++ b/apps/api/tests/conftest.py @@ -0,0 +1,142 @@ +import time +from datetime import date, timedelta +from datetime import time as dt_time +from uuid import UUID, uuid4 + +import pytest +from fastapi.testclient import TestClient +from jose import jwt +from sqlalchemy import text +from sqlalchemy.orm import Session + +from app.core.config import settings + +# Hard stop if the DATABASE_URL looks like a real/production database. +# .env.test should always point to a local throwaway DB. +_db_url = settings.database_url +assert "supabase.co" not in _db_url, ( + "Tests must not run against Supabase. Set DATABASE_URL in apps/api/.env.test." +) +assert any(h in _db_url for h in ("localhost", "127.0.0.1", "0.0.0.0")), ( + "Tests must use a local database. Set DATABASE_URL in apps/api/.env.test." +) +from app.core.database import SessionLocal +from app.main import app +from app.modules.profile.models import Profile, ProfileStatus, UserRole, UserRoleAssignment +from app.modules.venue.models import Venue, VenueCategory, VenueStatus + + +# ── Session-scoped: seed one category for the whole run ────────────────────── + +@pytest.fixture(scope="session") +def category_id() -> UUID: + db = SessionLocal() + try: + db.execute(text("TRUNCATE venue_categories CASCADE")) + db.commit() + cat = VenueCategory(slug="test-hall", label="Test Hall", is_active=True, sort_order=0) + db.add(cat) + db.commit() + db.refresh(cat) + return cat.id + finally: + db.close() + + +# ── Function-scoped: fresh DB state per test ───────────────────────────────── + +@pytest.fixture +def db() -> Session: + session = SessionLocal() + yield session + session.rollback() + session.execute(text("TRUNCATE profiles CASCADE")) + session.commit() + session.close() + + +@pytest.fixture +def client() -> TestClient: + return TestClient(app) + + +# ── Helpers (plain functions, not fixtures) ─────────────────────────────────── + +def make_token(user_id: UUID, email: str) -> str: + now = int(time.time()) + return jwt.encode( + { + "sub": str(user_id), + "email": email, + "aud": "authenticated", + "iat": now, + "exp": now + 3600, + }, + settings.supabase_jwt_secret, + algorithm="HS256", + ) + + +def seed_user(db: Session, role: str) -> tuple[UUID, str]: + """Insert a profile + role row. Returns (user_id, bearer_token).""" + user_id = uuid4() + email = f"{role}-{user_id.hex[:6]}@test.com" + db.add(Profile(id=user_id, email=email, status=ProfileStatus.active)) + db.add(UserRoleAssignment(user_id=user_id, role=UserRole(role))) + db.commit() + return user_id, make_token(user_id, email) + + +def create_booking(client, token: str, venue_id: UUID, booking_date: date | None = None): + """POST /api/bookings/ and return the raw response.""" + return client.post( + "/api/bookings/", + json={ + "venue_id": str(venue_id), + "venue_name": "Test Venue", + "venue_cover_image": None, + "booking_type": "full_day", + "booking_date": (booking_date or date.today() + timedelta(days=30)).isoformat(), + "guest_count": 10, + "event_type": "corporate", + "user_notes": "test", + }, + headers={"Authorization": f"Bearer {token}"}, + ) + + +def seed_approved_venue(db: Session, owner_id: UUID, category_id: UUID) -> UUID: + """Insert a fully approved, active venue ready to accept bookings.""" + venue_id = uuid4() + db.add(Venue( + id=venue_id, + owner_id=owner_id, + category_id=category_id, + name="Test Venue", + slug=f"test-venue-{venue_id.hex[:6]}", + address_line1="123 Test St", + city="Mumbai", + state="Maharashtra", + country="India", + timezone="Asia/Kolkata", + max_capacity=100, + open_time=dt_time(9, 0), + close_time=dt_time(21, 0), + allowed_booking_types=["full_day"], + min_booking_duration_minutes=60, + max_booking_duration_minutes=1440, + slot_interval_minutes=30, + pre_buffer_minutes=0, + post_buffer_minutes=0, + pricing_mode="flat", + starting_price_paise=1_000_000, + platform_commission_pct=10, + advance_pct=30, + balance_due_days_before_event=7, + owner_action_window_hours=48, + overdue_advance_refund_pct=0, + status=VenueStatus.approved, + is_active=True, + )) + db.commit() + return venue_id diff --git a/apps/api/tests/test_admin_immutability.py b/apps/api/tests/test_admin_immutability.py new file mode 100644 index 000000000..cb8298359 --- /dev/null +++ b/apps/api/tests/test_admin_immutability.py @@ -0,0 +1,118 @@ +""" +Admin action immutability tests. + +Business rule (CLAUDE.md): + Admin actions are append-only. Never update. Never delete. + +Tests verify: + 1. The DB FK has ON DELETE RESTRICT — deleting an admin's profile is blocked + 2. AdminAction rows have no cascade delete from the ORM side + 3. No DELETE or PUT routes exist for admin_actions +""" +import pytest +from sqlalchemy.exc import IntegrityError +from uuid import uuid4 + +from app.modules.admin.models import AdminAction +from app.modules.profile.models import Profile +from tests.conftest import seed_user + + +# ── DB-level constraint ─────────────────────────────────────────────────────── + +def test_cannot_delete_profile_with_admin_actions(db): + admin_id, _ = seed_user(db, "super_admin") + + db.add(AdminAction( + id=uuid4(), + admin_id=admin_id, + action_type="venue_approved", + target_type="venue", + target_id=uuid4(), + reason="test", + )) + db.commit() + + with pytest.raises(IntegrityError): + db.query(Profile).filter(Profile.id == admin_id).delete() + db.commit() + + db.rollback() + + +def test_admin_action_row_persists_after_failed_delete(db): + admin_id, _ = seed_user(db, "super_admin") + action_id = uuid4() + + db.add(AdminAction( + id=action_id, + admin_id=admin_id, + action_type="venue_suspended", + target_type="venue", + target_id=uuid4(), + )) + db.commit() + + try: + db.query(Profile).filter(Profile.id == admin_id).delete() + db.commit() + except IntegrityError: + db.rollback() + + row = db.query(AdminAction).filter(AdminAction.id == action_id).first() + assert row is not None + + +# ── ORM-level: no cascade delete ───────────────────────────────────────────── + +def test_admin_action_model_has_no_cascade(): + """The AdminAction model must not define a cascade that enables deletes.""" + mapper = AdminAction.__mapper__ + for rel in mapper.relationships: + assert "delete" not in (rel.cascade or ""), ( + f"Relationship '{rel.key}' on AdminAction must not cascade deletes" + ) + + +# ── Route-level: no mutating routes for audit log ──────────────────────────── + +def test_no_delete_route_for_admin_actions(client, db): + admin_id, admin_token = seed_user(db, "super_admin") + action_id = uuid4() + + db.add(AdminAction( + id=action_id, + admin_id=admin_id, + action_type="venue_approved", + target_type="venue", + target_id=uuid4(), + )) + db.commit() + + resp = client.delete( + f"/api/admin/actions/{action_id}", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + # 404 (route doesn't exist) or 405 (method not allowed) — either is correct + assert resp.status_code in (404, 405) + + +def test_no_update_route_for_admin_actions(client, db): + admin_id, admin_token = seed_user(db, "super_admin") + action_id = uuid4() + + db.add(AdminAction( + id=action_id, + admin_id=admin_id, + action_type="venue_approved", + target_type="venue", + target_id=uuid4(), + )) + db.commit() + + resp = client.patch( + f"/api/admin/actions/{action_id}", + json={"reason": "overwritten"}, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert resp.status_code in (404, 405) diff --git a/apps/api/tests/test_auth.py b/apps/api/tests/test_auth.py new file mode 100644 index 000000000..c56e797d0 --- /dev/null +++ b/apps/api/tests/test_auth.py @@ -0,0 +1,31 @@ +from tests.conftest import seed_user + + +def test_missing_auth_header_returns_422(client): + # FastAPI rejects missing required headers before reaching auth logic + resp = client.get("/api/bookings/") + assert resp.status_code == 422 + + +def test_malformed_auth_header_returns_401(client): + # Header present but not in "Bearer " format + resp = client.get("/api/bookings/", headers={"Authorization": "notbearer"}) + assert resp.status_code == 401 + + +def test_invalid_jwt_returns_401(client): + resp = client.get("/api/bookings/", headers={"Authorization": "Bearer bad.jwt.value"}) + assert resp.status_code == 401 + + +def test_customer_on_owner_route_returns_403(client, db): + _, token = seed_user(db, "customer") + resp = client.get("/api/bookings/owner", headers={"Authorization": f"Bearer {token}"}) + assert resp.status_code == 403 + + +def test_customer_can_access_auth_required_route(client, db): + _, token = seed_user(db, "customer") + resp = client.get("/api/bookings/", headers={"Authorization": f"Bearer {token}"}) + # 200 with empty list — not 401/403 + assert resp.status_code == 200 diff --git a/apps/api/tests/test_booking.py b/apps/api/tests/test_booking.py new file mode 100644 index 000000000..cbf3d2ab4 --- /dev/null +++ b/apps/api/tests/test_booking.py @@ -0,0 +1,153 @@ +import pytest +from unittest.mock import MagicMock +from datetime import datetime, timedelta, date, timezone +from uuid import uuid4 +from fastapi import HTTPException + +from app.modules.booking.models import Booking, BookingStatus, PaymentStatus, BookingSlot +from app.modules.booking.state_machine import can_transition +from app.modules.booking.cancellation import _compute_refund +from app.modules.booking.service import ( + owner_accept_booking, + owner_extend_deadline, +) +from app.modules.booking.schemas import ExtendDeadlineIn +from app.modules.venue.models import VenueCancellationPolicy + + +def test_status_transitions(): + # Test valid transitions + assert can_transition(BookingStatus.requested, BookingStatus.owner_accepted) is True + assert can_transition(BookingStatus.requested, BookingStatus.owner_rejected) is True + assert can_transition(BookingStatus.owner_accepted, BookingStatus.confirmed) is True + assert can_transition(BookingStatus.confirmed, BookingStatus.completed) is True + + # Test invalid transitions + assert can_transition(BookingStatus.completed, BookingStatus.requested) is False + assert can_transition(BookingStatus.user_cancelled, BookingStatus.confirmed) is False + assert can_transition(BookingStatus.owner_rejected, BookingStatus.owner_accepted) is False + + +def test_refund_computation_no_policy(): + booking = Booking( + amount_paid_paise=100000, # INR 1000 + platform_fee_paise=10000, # INR 100 + slot=BookingSlot(starts_at=datetime.now(timezone.utc) + timedelta(days=2)) + ) + # Without policy, refund should default to 0.0% (and match no_show or None tier) + result = _compute_refund(booking, None) + assert result.refund_amount_paise == 0 + assert result.penalty_amount_paise == 100000 + assert result.refund_pct_applied == 0.0 + + +def test_refund_computation_policy_fee_refundable(): + policy = VenueCancellationPolicy( + tier_1_hours=48, + tier_1_refund_pct=100.0, + tier_2_hours=24, + tier_2_refund_pct=50.0, + tier_3_hours=12, + tier_3_refund_pct=25.0, + no_show_refund_pct=10.0, + platform_fee_refundable=True + ) + + starts_at = datetime.now(timezone.utc) + timedelta(days=3) + booking = Booking( + amount_paid_paise=100000, # INR 1000 + platform_fee_paise=10000, # INR 100 + slot=BookingSlot(starts_at=starts_at) + ) + + # Case 1: > 48 hours notice (Tier 1 -> 100% refund) + result = _compute_refund(booking, policy, cancelled_at=datetime.now(timezone.utc)) + assert result.refund_amount_paise == 100000 + assert result.refund_pct_applied == 100.0 + assert result.tier_matched == "tier_1" + + # Case 2: 30 hours notice (Tier 2 -> 50% refund of total 1000 = 500) + result = _compute_refund(booking, policy, cancelled_at=starts_at - timedelta(hours=30)) + assert result.refund_amount_paise == 50000 + assert result.refund_pct_applied == 50.0 + assert result.tier_matched == "tier_2" + + # Case 3: 5 hours notice (No show -> 10% refund of total 1000 = 100) + result = _compute_refund(booking, policy, cancelled_at=starts_at - timedelta(hours=5)) + assert result.refund_amount_paise == 10000 + assert result.refund_pct_applied == 10.0 + assert result.tier_matched == "no_show" + + +def test_refund_computation_policy_fee_non_refundable(): + policy = VenueCancellationPolicy( + tier_1_hours=48, + tier_1_refund_pct=100.0, + tier_2_hours=24, + tier_2_refund_pct=50.0, + no_show_refund_pct=0.0, + platform_fee_refundable=False + ) + + starts_at = datetime.now(timezone.utc) + timedelta(days=3) + booking = Booking( + amount_paid_paise=100000, # INR 1000 + platform_fee_paise=10000, # INR 100 + slot=BookingSlot(starts_at=starts_at) + ) + + # Case 1: > 48 hours notice (Tier 1 -> 100% refund of owner share (900) = 900) + result = _compute_refund(booking, policy, cancelled_at=datetime.now(timezone.utc)) + assert result.refund_amount_paise == 90000 + assert result.refund_pct_applied == 100.0 + assert result.tier_matched == "tier_1" + + # Case 2: 30 hours notice (Tier 2 -> 50% refund of owner share (900) = 450) + result = _compute_refund(booking, policy, cancelled_at=starts_at - timedelta(hours=30)) + assert result.refund_amount_paise == 45000 + assert result.refund_pct_applied == 50.0 + assert result.tier_matched == "tier_2" + + +def test_owner_accept_booking_idempotency(monkeypatch): + import app.modules.booking.service as booking_service + + db = MagicMock() + booking = MagicMock() + booking.status = BookingStatus.owner_accepted + booking.venue.owner_id = uuid4() + + db.query().filter().with_for_update().first.return_value = booking + + # _booking_out serializes a real Booking to a pydantic model; stub it so the + # test focuses on the idempotency control flow, not response serialization. + sentinel = object() + monkeypatch.setattr(booking_service, "_booking_out", lambda b: sentinel) + + # Calling accept on an already accepted booking should return current state + # and NOT recreate intents or flush. + result = owner_accept_booking(db, uuid4(), booking.venue.owner_id) + assert result is sentinel + db.flush.assert_not_called() + + +def test_owner_extend_deadline_validation(): + db = MagicMock() + booking = MagicMock() + booking.status = BookingStatus.confirmed + booking.payment_status = PaymentStatus.advance_paid + booking.balance_overdue_at = datetime.now(timezone.utc) + booking.deadline_extension_count = 0 + booking.venue.owner_id = uuid4() + + # Slot starts in the past relative to execution + booking.slot.starts_at = datetime.now(timezone.utc) - timedelta(hours=2) + + db.query().filter().with_for_update().first.return_value = booking + + # Extension on already started event should fail + body = ExtendDeadlineIn(new_due_date=date.today() + timedelta(days=2)) + with pytest.raises(HTTPException) as exc_info: + owner_extend_deadline(db, uuid4(), booking.venue.owner_id, body) + assert exc_info.value.status_code == 400 + assert "Cannot extend deadline for a past or ongoing event" in exc_info.value.detail diff --git a/apps/api/tests/test_booking_integration.py b/apps/api/tests/test_booking_integration.py new file mode 100644 index 000000000..72a4bca9c --- /dev/null +++ b/apps/api/tests/test_booking_integration.py @@ -0,0 +1,132 @@ +from datetime import date, timedelta + +from app.modules.booking.models import Booking, BookingStatus +from tests.conftest import seed_approved_venue, seed_user + + +def _booking_date() -> str: + # Use a date 30 days out so it always passes the "must be future" check + return (date.today() + timedelta(days=30)).isoformat() + + +def _create_booking(client, customer_token: str, venue_id: str, venue_name: str) -> dict: + resp = client.post( + "/api/bookings/", + json={ + "venue_id": venue_id, + "venue_name": venue_name, + "venue_cover_image": None, + "booking_type": "full_day", + "booking_date": _booking_date(), + "guest_count": 10, + "event_type": "corporate", + "user_notes": "integration test booking", + }, + headers={"Authorization": f"Bearer {customer_token}"}, + ) + return resp + + +# ── Auth guards ─────────────────────────────────────────────────────────────── + +def test_create_booking_unauthenticated_returns_422(client): + resp = client.post("/api/bookings/", json={}) + assert resp.status_code == 422 + + +def test_accept_booking_as_customer_returns_403(client, db, category_id): + owner_id, owner_token = seed_user(db, "venue_owner") + customer_id, customer_token = seed_user(db, "customer") + venue_id = seed_approved_venue(db, owner_id, category_id) + + create_resp = _create_booking(client, customer_token, str(venue_id), "Test Venue") + assert create_resp.status_code == 201 + booking_id = create_resp.json()["id"] + + # Customer trying to accept their own booking should be forbidden + resp = client.post( + f"/api/bookings/{booking_id}/accept", + headers={"Authorization": f"Bearer {customer_token}"}, + ) + assert resp.status_code == 403 + + +# ── Status transitions ──────────────────────────────────────────────────────── + +def test_create_booking_status_is_requested(client, db, category_id): + owner_id, _ = seed_user(db, "venue_owner") + _, customer_token = seed_user(db, "customer") + venue_id = seed_approved_venue(db, owner_id, category_id) + + resp = _create_booking(client, customer_token, str(venue_id), "Test Venue") + + assert resp.status_code == 201 + assert resp.json()["status"] == "requested" + + +def test_create_booking_row_in_db(client, db, category_id): + owner_id, _ = seed_user(db, "venue_owner") + _, customer_token = seed_user(db, "customer") + venue_id = seed_approved_venue(db, owner_id, category_id) + + resp = _create_booking(client, customer_token, str(venue_id), "Test Venue") + assert resp.status_code == 201 + + booking_id = resp.json()["id"] + row = db.query(Booking).filter(Booking.id == booking_id).first() + assert row is not None + assert row.status == BookingStatus.requested + + +def test_owner_accept_transitions_to_owner_accepted(client, db, category_id): + owner_id, owner_token = seed_user(db, "venue_owner") + _, customer_token = seed_user(db, "customer") + venue_id = seed_approved_venue(db, owner_id, category_id) + + create_resp = _create_booking(client, customer_token, str(venue_id), "Test Venue") + assert create_resp.status_code == 201 + booking_id = create_resp.json()["id"] + + accept_resp = client.post( + f"/api/bookings/{booking_id}/accept", + headers={"Authorization": f"Bearer {owner_token}"}, + ) + + assert accept_resp.status_code == 200 + assert accept_resp.json()["status"] == "owner_accepted" + + +def test_owner_accept_sets_hold_expiry_in_db(client, db, category_id): + owner_id, owner_token = seed_user(db, "venue_owner") + _, customer_token = seed_user(db, "customer") + venue_id = seed_approved_venue(db, owner_id, category_id) + + create_resp = _create_booking(client, customer_token, str(venue_id), "Test Venue") + booking_id = create_resp.json()["id"] + + client.post( + f"/api/bookings/{booking_id}/accept", + headers={"Authorization": f"Bearer {owner_token}"}, + ) + + db.expire_all() + row = db.query(Booking).filter(Booking.id == booking_id).first() + assert row.status == BookingStatus.owner_accepted + assert row.hold_expires_at is not None + + +def test_wrong_owner_cannot_accept_booking(client, db, category_id): + owner_id, _ = seed_user(db, "venue_owner") + other_owner_id, other_owner_token = seed_user(db, "venue_owner") + _, customer_token = seed_user(db, "customer") + venue_id = seed_approved_venue(db, owner_id, category_id) + + create_resp = _create_booking(client, customer_token, str(venue_id), "Test Venue") + booking_id = create_resp.json()["id"] + + # A different owner trying to accept someone else's venue booking + resp = client.post( + f"/api/bookings/{booking_id}/accept", + headers={"Authorization": f"Bearer {other_owner_token}"}, + ) + assert resp.status_code in (403, 404) diff --git a/apps/api/tests/test_conflict_flow.py b/apps/api/tests/test_conflict_flow.py new file mode 100644 index 000000000..a45f83185 --- /dev/null +++ b/apps/api/tests/test_conflict_flow.py @@ -0,0 +1,85 @@ +""" +Conflict flow tests. + +Business rules under test (from CLAUDE.md): +- Multiple users may request the same slot +- Requesting a slot does NOT reserve it +- Acceptance starts a 24-hour hold but does NOT block competing requests +- Only one confirmed booking can exist per slot +- After acceptance, NEW requests for the same slot are rejected (slot is blocking) +""" +from app.modules.booking.models import Booking, BookingStatus +from app.modules.booking.state_machine import can_transition +from tests.conftest import create_booking, seed_approved_venue, seed_user + + +# ── State machine (unit) ────────────────────────────────────────────────────── + +def test_requested_can_transition_to_conflict_cancelled(): + assert can_transition(BookingStatus.requested, BookingStatus.conflict_cancelled) is True + + +def test_conflict_cancelled_is_terminal(): + for target in BookingStatus: + assert can_transition(BookingStatus.conflict_cancelled, target) is False + + +def test_hold_expired_is_terminal(): + for target in BookingStatus: + assert can_transition(BookingStatus.hold_expired, target) is False + + +# ── Multiple requests for same slot (integration) ───────────────────────────── + +def test_two_customers_can_request_same_slot(client, db, category_id): + owner_id, _ = seed_user(db, "venue_owner") + _, token1 = seed_user(db, "customer") + _, token2 = seed_user(db, "customer") + venue_id = seed_approved_venue(db, owner_id, category_id) + + resp1 = create_booking(client, token1, venue_id) + resp2 = create_booking(client, token2, venue_id) + + assert resp1.status_code == 201 + assert resp2.status_code == 201 + assert resp1.json()["id"] != resp2.json()["id"] + + +def test_competing_request_stays_requested_after_acceptance(client, db, category_id): + owner_id, owner_token = seed_user(db, "venue_owner") + _, token1 = seed_user(db, "customer") + _, token2 = seed_user(db, "customer") + venue_id = seed_approved_venue(db, owner_id, category_id) + + b1 = create_booking(client, token1, venue_id).json() + b2 = create_booking(client, token2, venue_id).json() + + # Owner accepts booking 1 + resp = client.post( + f"/api/bookings/{b1['id']}/accept", + headers={"Authorization": f"Bearer {owner_token}"}, + ) + assert resp.status_code == 200 + + # Booking 2 stays "requested" — conflict_cancelled only happens at payment confirmation + db.expire_all() + row = db.query(Booking).filter(Booking.id == b2["id"]).first() + assert row.status == BookingStatus.requested + + +def test_new_request_blocked_after_acceptance(client, db, category_id): + owner_id, owner_token = seed_user(db, "venue_owner") + _, token1 = seed_user(db, "customer") + _, token3 = seed_user(db, "customer") + venue_id = seed_approved_venue(db, owner_id, category_id) + + b1 = create_booking(client, token1, venue_id).json() + + client.post( + f"/api/bookings/{b1['id']}/accept", + headers={"Authorization": f"Bearer {owner_token}"}, + ) + + # A brand-new request for the same slot after acceptance should be blocked + resp = create_booking(client, token3, venue_id) + assert resp.status_code == 409 diff --git a/apps/api/tests/test_email.py b/apps/api/tests/test_email.py new file mode 100644 index 000000000..b435efbd6 --- /dev/null +++ b/apps/api/tests/test_email.py @@ -0,0 +1,63 @@ +import smtplib + +from app.core import email as email_mod +from app.core.config import settings + + +def test_no_transport_configured_is_noop(monkeypatch): + monkeypatch.setattr(settings, "resend_api_key", "") + monkeypatch.setattr(settings, "smtp_host", "") + assert email_mod.send_email("a@b.com", "Subject", "

x

") is False + + +def test_smtp_fallback_is_used(monkeypatch): + monkeypatch.setattr(settings, "resend_api_key", "") + monkeypatch.setattr(settings, "smtp_host", "smtp.example.com") + monkeypatch.setattr(settings, "smtp_user", "") + + captured = {} + + class FakeSMTP: + def __init__(self, host, port): + captured["host"] = host + captured["port"] = port + + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def starttls(self): + captured["starttls"] = True + + def login(self, user, password): + captured["login"] = (user, password) + + def send_message(self, msg): + captured["to"] = msg["To"] + + monkeypatch.setattr(smtplib, "SMTP", FakeSMTP) + + assert email_mod.send_email("guest@example.com", "Hi", "

hi

") is True + assert captured["host"] == "smtp.example.com" + assert captured["to"] == "guest@example.com" + assert captured.get("starttls") is True + + +def test_resend_is_preferred_when_key_set(monkeypatch): + monkeypatch.setattr(settings, "resend_api_key", "re_test") + calls = {} + + def fake_resend(to, s, h): + calls["resend"] = (to, s) + return True + + def fake_smtp(*a): + calls["smtp"] = True + return True + + monkeypatch.setattr(email_mod, "_send_via_resend", fake_resend) + monkeypatch.setattr(email_mod, "_send_via_smtp", fake_smtp) + assert email_mod.send_email("x@y.com", "S", "

b

") is True + assert "resend" in calls and "smtp" not in calls diff --git a/apps/api/tests/test_hold_expiry.py b/apps/api/tests/test_hold_expiry.py new file mode 100644 index 000000000..53d311adf --- /dev/null +++ b/apps/api/tests/test_hold_expiry.py @@ -0,0 +1,94 @@ +""" +Hold expiry job tests. + +Business rule (CLAUDE.md): + If token payment is not completed within 24 hours: + accepted -> hold_expired + The slot must be unblocked so the owner can accept another requester. +""" +from datetime import datetime, timedelta, timezone + +from app.jobs.hold_expiry import run as run_hold_expiry +from app.modules.booking.models import Booking, BookingSlot, BookingStatus +from tests.conftest import create_booking, seed_approved_venue, seed_user + + +def _accept_booking(client, owner_token: str, booking_id: str): + return client.post( + f"/api/bookings/{booking_id}/accept", + headers={"Authorization": f"Bearer {owner_token}"}, + ) + + +def _expire_hold(db, booking_id: str): + """Force hold_expires_at into the past to simulate expiry.""" + db.query(Booking).filter(Booking.id == booking_id).update( + {"hold_expires_at": datetime.now(timezone.utc) - timedelta(hours=25)} + ) + db.commit() + + +def test_expired_hold_transitions_to_hold_expired(client, db, category_id): + owner_id, owner_token = seed_user(db, "venue_owner") + _, customer_token = seed_user(db, "customer") + venue_id = seed_approved_venue(db, owner_id, category_id) + + booking = create_booking(client, customer_token, venue_id).json() + _accept_booking(client, owner_token, booking["id"]) + _expire_hold(db, booking["id"]) + + processed = run_hold_expiry() + + assert processed == 1 + db.expire_all() + row = db.query(Booking).filter(Booking.id == booking["id"]).first() + assert row.status == BookingStatus.hold_expired + + +def test_expired_hold_unblocks_slot(client, db, category_id): + owner_id, owner_token = seed_user(db, "venue_owner") + _, customer_token = seed_user(db, "customer") + venue_id = seed_approved_venue(db, owner_id, category_id) + + booking = create_booking(client, customer_token, venue_id).json() + _accept_booking(client, owner_token, booking["id"]) + _expire_hold(db, booking["id"]) + + run_hold_expiry() + + db.expire_all() + slot = db.query(BookingSlot).filter(BookingSlot.booking_id == booking["id"]).first() + assert slot.is_blocking is False + + +def test_non_expired_hold_is_not_affected(client, db, category_id): + owner_id, owner_token = seed_user(db, "venue_owner") + _, customer_token = seed_user(db, "customer") + venue_id = seed_approved_venue(db, owner_id, category_id) + + booking = create_booking(client, customer_token, venue_id).json() + _accept_booking(client, owner_token, booking["id"]) + # Do NOT expire the hold — it should still be in the future + + processed = run_hold_expiry() + + assert processed == 0 + db.expire_all() + row = db.query(Booking).filter(Booking.id == booking["id"]).first() + assert row.status == BookingStatus.owner_accepted + + +def test_only_accepted_bookings_are_expired(client, db, category_id): + owner_id, _ = seed_user(db, "venue_owner") + _, customer_token = seed_user(db, "customer") + venue_id = seed_approved_venue(db, owner_id, category_id) + + # A plain "requested" booking — never accepted, no hold + booking = create_booking(client, customer_token, venue_id).json() + + processed = run_hold_expiry() + + assert processed == 0 + db.expire_all() + row = db.query(Booking).filter(Booking.id == booking["id"]).first() + assert row.status == BookingStatus.requested diff --git a/apps/api/tests/test_notification_templates.py b/apps/api/tests/test_notification_templates.py new file mode 100644 index 000000000..27c45f609 --- /dev/null +++ b/apps/api/tests/test_notification_templates.py @@ -0,0 +1,28 @@ +from app.modules.notification.templates import render_notification + + +def test_known_type_renders_title_body_and_link(): + title, body, html = render_notification( + "payment_confirmed", {"venue_name": "Hall A"}, booking_id="b-123" + ) + assert "confirmed" in title.lower() + assert "Hall A" in body + assert "/bookings/b-123" in html + + +def test_unknown_type_is_safe(): + title, body, html = render_notification("does_not_exist", {}) + assert title == "Notification" + assert body + + +def test_missing_context_key_does_not_crash(): + # refund_issued references {amount_rupees}; omitting it must not raise + title, body, html = render_notification("refund_issued", {"venue_name": "Hall A"}) + assert title + assert body + + +def test_no_booking_id_means_no_link(): + _, _, html = render_notification("hold_expired", {"venue_name": "Hall A"}) + assert "/bookings/" not in html diff --git a/apps/api/tests/test_pricing_rules.py b/apps/api/tests/test_pricing_rules.py new file mode 100644 index 000000000..31bc9a59c --- /dev/null +++ b/apps/api/tests/test_pricing_rules.py @@ -0,0 +1,273 @@ +from datetime import date, timedelta + +from app.modules.booking.models import Booking +from app.modules.venue.models import VenuePricingRule +from tests.conftest import seed_approved_venue, seed_user + + +def _next_weekday(target_weekday: int, min_days_out: int = 30) -> date: + """Next date (at least `min_days_out` away) matching `target_weekday` (0=Mon..6=Sun).""" + d = date.today() + timedelta(days=min_days_out) + while d.weekday() != target_weekday: + d += timedelta(days=1) + return d + + +def _full_day_window(d: date) -> tuple[str, str]: + return f"{d.isoformat()}T09:00:00+05:30", f"{d.isoformat()}T21:00:00+05:30" + + +def _create_rule(client, token: str, venue_id, **overrides) -> dict: + body = { + "name": "Weekend rate", + "days_of_week": [5, 6], + "adjustment_type": "multiplier", + "multiplier": 1.5, + "applies_to": "full_day", + "priority": 50, + "is_active": True, + } + body.update(overrides) + resp = client.post( + f"/api/venues/{venue_id}/pricing-rules", + json=body, + headers={"Authorization": f"Bearer {token}"}, + ) + return resp + + +# ── Owner CRUD ──────────────────────────────────────────────────────────────── + +def test_create_pricing_rule_as_owner_returns_201(client, db, category_id): + owner_id, token = seed_user(db, "venue_owner") + venue_id = seed_approved_venue(db, owner_id, category_id) + + resp = _create_rule(client, token, venue_id) + + assert resp.status_code == 201 + data = resp.json() + assert data["name"] == "Weekend rate" + assert float(data["multiplier"]) == 1.5 + assert data["exceeds_bounds"] is False + + +def test_create_pricing_rule_as_customer_returns_403(client, db, category_id): + owner_id, _ = seed_user(db, "venue_owner") + _, customer_token = seed_user(db, "customer") + venue_id = seed_approved_venue(db, owner_id, category_id) + + resp = _create_rule(client, customer_token, venue_id) + + assert resp.status_code == 403 + + +def test_list_pricing_rules_after_create(client, db, category_id): + owner_id, token = seed_user(db, "venue_owner") + venue_id = seed_approved_venue(db, owner_id, category_id) + created = _create_rule(client, token, venue_id).json() + + resp = client.get( + f"/api/venues/{venue_id}/pricing-rules", + headers={"Authorization": f"Bearer {token}"}, + ) + + assert resp.status_code == 200 + ids = [r["id"] for r in resp.json()] + assert created["id"] in ids + + +def test_update_pricing_rule(client, db, category_id): + owner_id, token = seed_user(db, "venue_owner") + venue_id = seed_approved_venue(db, owner_id, category_id) + rule = _create_rule(client, token, venue_id).json() + + resp = client.patch( + f"/api/venues/{venue_id}/pricing-rules/{rule['id']}", + json={"priority": 90, "is_active": False}, + headers={"Authorization": f"Bearer {token}"}, + ) + + assert resp.status_code == 200 + data = resp.json() + assert data["priority"] == 90 + assert data["is_active"] is False + + +def test_delete_pricing_rule_soft_deletes(client, db, category_id): + owner_id, token = seed_user(db, "venue_owner") + venue_id = seed_approved_venue(db, owner_id, category_id) + rule = _create_rule(client, token, venue_id).json() + + resp = client.delete( + f"/api/venues/{venue_id}/pricing-rules/{rule['id']}", + headers={"Authorization": f"Bearer {token}"}, + ) + assert resp.status_code == 200 + + listed = client.get( + f"/api/venues/{venue_id}/pricing-rules", + headers={"Authorization": f"Bearer {token}"}, + ).json() + assert rule["id"] not in [r["id"] for r in listed] + + row = db.query(VenuePricingRule).filter(VenuePricingRule.id == rule["id"]).first() + assert row is not None + assert row.deleted_at is not None + + +def test_create_rule_exceeding_bounds_flags_exceeds_bounds(client, db, category_id): + owner_id, token = seed_user(db, "venue_owner") + venue_id = seed_approved_venue(db, owner_id, category_id) + + resp = _create_rule(client, token, venue_id, name="Huge surge", multiplier=3.0) + + assert resp.status_code == 201 + assert resp.json()["exceeds_bounds"] is True # 300% > default max_price_pct (200%) + + +# ── Rule resolution via the public quote endpoint ────────────────────────────── + +def test_zero_rules_prices_identically_to_static(client, db, category_id): + owner_id, token = seed_user(db, "venue_owner") + venue_id = seed_approved_venue(db, owner_id, category_id) + target = _next_weekday(2) # any weekday, no rules exist + starts_at, ends_at = _full_day_window(target) + + resp = client.get( + f"/api/venues/{venue_id}/pricing", + params={"starts_at": starts_at, "ends_at": ends_at, "booking_type": "full_day"}, + ) + + assert resp.status_code == 200 + data = resp.json() + assert data["quoted_price_paise"] == 1_000_000 # base price, untouched + assert data["breakdown"] == [] + assert data["clamped"] is False + + +def test_weekend_rule_adjusts_quoted_price(client, db, category_id): + owner_id, token = seed_user(db, "venue_owner") + venue_id = seed_approved_venue(db, owner_id, category_id) + _create_rule(client, token, venue_id) # +50% Sat/Sun + + saturday = _next_weekday(5) + starts_at, ends_at = _full_day_window(saturday) + + resp = client.get( + f"/api/venues/{venue_id}/pricing", + params={"starts_at": starts_at, "ends_at": ends_at, "booking_type": "full_day"}, + ) + + assert resp.status_code == 200 + data = resp.json() + assert data["quoted_price_paise"] == 1_500_000 # 1,000,000 * 1.5 + assert data["clamped"] is False + assert len(data["breakdown"]) == 1 + assert data["breakdown"][0]["applied_rule_name"] == "Weekend rate" + + +def test_rule_beyond_bounds_is_clamped(client, db, category_id): + owner_id, token = seed_user(db, "venue_owner") + venue_id = seed_approved_venue(db, owner_id, category_id) + _create_rule(client, token, venue_id, name="Huge surge", multiplier=3.0) + + saturday = _next_weekday(5) + starts_at, ends_at = _full_day_window(saturday) + + resp = client.get( + f"/api/venues/{venue_id}/pricing", + params={"starts_at": starts_at, "ends_at": ends_at, "booking_type": "full_day"}, + ) + + assert resp.status_code == 200 + data = resp.json() + assert data["quoted_price_paise"] == 2_000_000 # capped at max_price_pct (200%) of base + assert data["clamped"] is True + assert data["breakdown"][0]["clamped"] is True + + +def test_weekday_unaffected_by_weekend_rule(client, db, category_id): + owner_id, token = seed_user(db, "venue_owner") + venue_id = seed_approved_venue(db, owner_id, category_id) + _create_rule(client, token, venue_id) # Sat/Sun only + + monday = _next_weekday(0) + starts_at, ends_at = _full_day_window(monday) + + resp = client.get( + f"/api/venues/{venue_id}/pricing", + params={"starts_at": starts_at, "ends_at": ends_at, "booking_type": "full_day"}, + ) + + assert resp.status_code == 200 + assert resp.json()["quoted_price_paise"] == 1_000_000 + + +# ── Booking snapshot integration ─────────────────────────────────────────────── + +def test_booking_snapshots_rule_adjusted_price_and_breakdown(client, db, category_id): + owner_id, owner_token = seed_user(db, "venue_owner") + customer_id, customer_token = seed_user(db, "customer") + venue_id = seed_approved_venue(db, owner_id, category_id) + _create_rule(client, owner_token, venue_id) # +50% Sat/Sun + + saturday = _next_weekday(5) + starts_at, ends_at = _full_day_window(saturday) + + resp = client.post( + "/api/bookings/", + json={ + "venue_id": str(venue_id), + "venue_name": "Test Venue", + "venue_cover_image": None, + "booking_type": "full_day", + "starts_at": starts_at, + "ends_at": ends_at, + "guest_count": 10, + "event_type": "corporate", + "user_notes": "pricing rule test", + }, + headers={"Authorization": f"Bearer {customer_token}"}, + ) + + assert resp.status_code == 201 + data = resp.json() + assert data["quoted_price_paise"] == 1_500_000 + + row = db.query(Booking).filter(Booking.id == data["id"]).first() + assert row is not None + assert row.quoted_price_paise == 1_500_000 + assert row.pricing_breakdown is not None + assert row.pricing_breakdown[0]["applied_rule_name"] == "Weekend rate" + + +def test_booking_creation_rejects_stale_price_with_409(client, db, category_id): + owner_id, owner_token = seed_user(db, "venue_owner") + customer_id, customer_token = seed_user(db, "customer") + venue_id = seed_approved_venue(db, owner_id, category_id) + _create_rule(client, owner_token, venue_id) # +50% Sat/Sun + + saturday = _next_weekday(5) + starts_at, ends_at = _full_day_window(saturday) + + resp = client.post( + "/api/bookings/", + json={ + "venue_id": str(venue_id), + "venue_name": "Test Venue", + "venue_cover_image": None, + "booking_type": "full_day", + "starts_at": starts_at, + "ends_at": ends_at, + "guest_count": 10, + "event_type": "corporate", + "user_notes": "stale quote test", + "expected_total_paise": 1_000_000, # stale: doesn't account for the rule + }, + headers={"Authorization": f"Bearer {customer_token}"}, + ) + + assert resp.status_code == 409 + detail = resp.json()["detail"] + assert detail["error"] == "PRICE_CHANGED" + assert detail["quoted_price_paise"] == 1_500_000 diff --git a/apps/api/tests/test_state_machine.py b/apps/api/tests/test_state_machine.py new file mode 100644 index 000000000..37da7ae6a --- /dev/null +++ b/apps/api/tests/test_state_machine.py @@ -0,0 +1,41 @@ +from app.modules.booking.state_machine import can_transition, VALID_TRANSITIONS +from app.modules.booking.models import BookingStatus + +TERMINAL = [ + BookingStatus.completed, + BookingStatus.hold_expired, + BookingStatus.request_expired, + BookingStatus.conflict_cancelled, + BookingStatus.user_cancelled, + BookingStatus.admin_cancelled, + BookingStatus.owner_rejected, + BookingStatus.balance_overdue_cancelled, +] + + +def test_status_count(): + assert len(list(BookingStatus)) == 11 + + +def test_happy_path_request_accept_confirm_complete(): + assert can_transition(BookingStatus.requested, BookingStatus.owner_accepted) + assert can_transition(BookingStatus.owner_accepted, BookingStatus.confirmed) + assert can_transition(BookingStatus.confirmed, BookingStatus.completed) + + +def test_payment_required_before_confirm(): + # cannot jump straight from requested to confirmed + assert not can_transition(BookingStatus.requested, BookingStatus.confirmed) + + +def test_hold_and_conflict_paths(): + assert can_transition(BookingStatus.owner_accepted, BookingStatus.hold_expired) + # conflict cancellation only applies to still-pending (requested) bookings + assert can_transition(BookingStatus.requested, BookingStatus.conflict_cancelled) + assert not can_transition(BookingStatus.owner_accepted, BookingStatus.conflict_cancelled) + + +def test_terminal_states_have_no_exits(): + for s in TERMINAL: + assert VALID_TRANSITIONS[s] == set() + assert not can_transition(s, BookingStatus.requested) diff --git a/apps/api/tests/test_suspended_user.py b/apps/api/tests/test_suspended_user.py new file mode 100644 index 000000000..bb12fc665 --- /dev/null +++ b/apps/api/tests/test_suspended_user.py @@ -0,0 +1,48 @@ +""" +Suspended user access tests. + +Business rule: a suspended user must be denied access to every protected route +even when they hold a valid JWT. +""" +from uuid import uuid4 + +from app.modules.profile.models import Profile, ProfileStatus, UserRole, UserRoleAssignment +from tests.conftest import make_token + + +def _seed_suspended(db, role: str): + user_id = uuid4() + email = f"suspended-{user_id.hex[:6]}@test.com" + db.add(Profile(id=user_id, email=email, status=ProfileStatus.suspended)) + db.add(UserRoleAssignment(user_id=user_id, role=UserRole(role))) + db.commit() + return make_token(user_id, email) + + +def test_suspended_customer_cannot_list_bookings(client, db): + token = _seed_suspended(db, "customer") + resp = client.get("/api/bookings/", headers={"Authorization": f"Bearer {token}"}) + assert resp.status_code == 403 + + +def test_suspended_owner_cannot_list_venues(client, db): + token = _seed_suspended(db, "venue_owner") + resp = client.get("/api/venues/my/venues", headers={"Authorization": f"Bearer {token}"}) + assert resp.status_code == 403 + + +def test_suspended_user_cannot_create_booking(client, db, category_id): + from tests.conftest import create_booking, seed_approved_venue, seed_user + + owner_id, _ = seed_user(db, "venue_owner") + venue_id = seed_approved_venue(db, owner_id, category_id) + suspended_token = _seed_suspended(db, "customer") + + resp = create_booking(client, suspended_token, venue_id) + assert resp.status_code == 403 + + +def test_suspended_admin_cannot_access_admin_routes(client, db): + token = _seed_suspended(db, "super_admin") + resp = client.get("/api/admin/venues", headers={"Authorization": f"Bearer {token}"}) + assert resp.status_code == 403 diff --git a/apps/api/tests/test_venue.py b/apps/api/tests/test_venue.py new file mode 100644 index 000000000..3cbb89046 --- /dev/null +++ b/apps/api/tests/test_venue.py @@ -0,0 +1,78 @@ +from tests.conftest import seed_user + + +VENUE_BODY = { + "name": "Integration Test Venue", + "address_line1": "1 Main Street", + "city": "Mumbai", + "state": "Maharashtra", + "country": "India", + "timezone": "Asia/Kolkata", + "max_capacity": 50, + "open_time": "09:00:00", + "close_time": "21:00:00", + "allowed_booking_types": ["full_day"], + "pricing_mode": "flat", + "starting_price_paise": 500000, + "min_booking_duration_minutes": 60, + "max_booking_duration_minutes": 1440, + "slot_interval_minutes": 30, + "advance_pct": "30.00", + "balance_due_days_before_event": 7, + "owner_action_window_hours": 48, + "overdue_advance_refund_pct": "0.00", +} + + +def test_create_venue_as_owner_returns_201(client, db, category_id): + _, token = seed_user(db, "venue_owner") + + body = {**VENUE_BODY, "category_id": str(category_id)} + resp = client.post( + "/api/venues/", + json=body, + headers={"Authorization": f"Bearer {token}"}, + ) + + assert resp.status_code == 201 + data = resp.json() + assert data["name"] == body["name"] + assert data["city"] == body["city"] + assert data["status"] == "draft" + + +def test_create_venue_row_exists_in_db(client, db, category_id): + from app.modules.venue.models import Venue + + owner_id, token = seed_user(db, "venue_owner") + body = {**VENUE_BODY, "category_id": str(category_id)} + + resp = client.post( + "/api/venues/", + json=body, + headers={"Authorization": f"Bearer {token}"}, + ) + assert resp.status_code == 201 + + venue_id = resp.json()["id"] + row = db.query(Venue).filter(Venue.id == venue_id).first() + assert row is not None + assert str(row.owner_id) == str(owner_id) + + +def test_create_venue_as_customer_returns_403(client, db, category_id): + _, token = seed_user(db, "customer") + + body = {**VENUE_BODY, "category_id": str(category_id)} + resp = client.post( + "/api/venues/", + json=body, + headers={"Authorization": f"Bearer {token}"}, + ) + assert resp.status_code == 403 + + +def test_create_venue_unauthenticated_returns_422(client, category_id): + body = {**VENUE_BODY, "category_id": str(category_id)} + resp = client.post("/api/venues/", json=body) + assert resp.status_code == 422 diff --git a/apps/api/tests/test_venue_visibility.py b/apps/api/tests/test_venue_visibility.py new file mode 100644 index 000000000..b20c49399 --- /dev/null +++ b/apps/api/tests/test_venue_visibility.py @@ -0,0 +1,132 @@ +""" +Venue visibility tests. + +Business rule (CLAUDE.md): a venue is visible to customers only when + status = approved AND active = true AND deleted_at IS NULL + +Every other status must be invisible in public search results. +""" +from datetime import datetime, timezone +from uuid import uuid4 + +from app.modules.venue.models import Venue, VenueStatus +from tests.conftest import seed_approved_venue, seed_user + + +def _seed_venue_with_status(db, owner_id, category_id, status: VenueStatus, is_active: bool = True): + from datetime import time as dt_time + + venue_id = uuid4() + db.add(Venue( + id=venue_id, + owner_id=owner_id, + category_id=category_id, + name=f"Venue {status.value}", + slug=f"venue-{status.value}-{venue_id.hex[:6]}", + address_line1="1 Test St", + city="Mumbai", + state="Maharashtra", + country="India", + timezone="Asia/Kolkata", + max_capacity=50, + open_time=dt_time(9, 0), + close_time=dt_time(21, 0), + allowed_booking_types=["full_day"], + min_booking_duration_minutes=60, + max_booking_duration_minutes=1440, + slot_interval_minutes=30, + pre_buffer_minutes=0, + post_buffer_minutes=0, + pricing_mode="flat", + starting_price_paise=500_000, + platform_commission_pct=10, + advance_pct=30, + balance_due_days_before_event=7, + owner_action_window_hours=48, + overdue_advance_refund_pct=0, + status=status, + is_active=is_active, + )) + db.commit() + return venue_id + + +# ── Search endpoint visibility ──────────────────────────────────────────────── + +def test_approved_active_venue_appears_in_search(client, db, category_id): + owner_id, _ = seed_user(db, "venue_owner") + seed_approved_venue(db, owner_id, category_id) + + resp = client.get("/api/search/") + assert resp.status_code == 200 + assert len(resp.json()["items"]) == 1 + + +def test_draft_venue_hidden_from_search(client, db, category_id): + owner_id, _ = seed_user(db, "venue_owner") + _seed_venue_with_status(db, owner_id, category_id, VenueStatus.draft) + + resp = client.get("/api/search/") + assert resp.status_code == 200 + assert len(resp.json()["items"]) == 0 + + +def test_pending_approval_venue_hidden_from_search(client, db, category_id): + owner_id, _ = seed_user(db, "venue_owner") + _seed_venue_with_status(db, owner_id, category_id, VenueStatus.pending_approval) + + resp = client.get("/api/search/") + assert resp.status_code == 200 + assert len(resp.json()["items"]) == 0 + + +def test_rejected_venue_hidden_from_search(client, db, category_id): + owner_id, _ = seed_user(db, "venue_owner") + _seed_venue_with_status(db, owner_id, category_id, VenueStatus.rejected) + + resp = client.get("/api/search/") + assert resp.status_code == 200 + assert len(resp.json()["items"]) == 0 + + +def test_suspended_venue_hidden_from_search(client, db, category_id): + owner_id, _ = seed_user(db, "venue_owner") + _seed_venue_with_status(db, owner_id, category_id, VenueStatus.suspended) + + resp = client.get("/api/search/") + assert resp.status_code == 200 + assert len(resp.json()["items"]) == 0 + + +def test_approved_but_inactive_venue_hidden_from_search(client, db, category_id): + owner_id, _ = seed_user(db, "venue_owner") + _seed_venue_with_status(db, owner_id, category_id, VenueStatus.approved, is_active=False) + + resp = client.get("/api/search/") + assert resp.status_code == 200 + assert len(resp.json()["items"]) == 0 + + +def test_soft_deleted_venue_hidden_from_search(client, db, category_id): + owner_id, _ = seed_user(db, "venue_owner") + venue_id = seed_approved_venue(db, owner_id, category_id) + + db.query(Venue).filter(Venue.id == venue_id).update( + {"deleted_at": datetime.now(timezone.utc)} + ) + db.commit() + + resp = client.get("/api/search/") + assert resp.status_code == 200 + assert len(resp.json()["items"]) == 0 + + +def test_only_approved_venues_appear_among_mixed(client, db, category_id): + owner_id, _ = seed_user(db, "venue_owner") + seed_approved_venue(db, owner_id, category_id) + _seed_venue_with_status(db, owner_id, category_id, VenueStatus.draft) + _seed_venue_with_status(db, owner_id, category_id, VenueStatus.pending_approval) + + resp = client.get("/api/search/") + assert resp.status_code == 200 + assert len(resp.json()["items"]) == 1 diff --git a/apps/api/uv.lock b/apps/api/uv.lock new file mode 100644 index 000000000..6a7accb78 --- /dev/null +++ b/apps/api/uv.lock @@ -0,0 +1,1603 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "alembic" +version = "1.18.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mako" }, + { name = "sqlalchemy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/94/13/8b084e0f2efb0275a1d534838844926f798bd766566b1375174e2448cd31/alembic-1.18.4.tar.gz", hash = "sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc", size = 2056725, upload-time = "2026-02-10T16:00:47.195Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/29/6533c317b74f707ea28f8d633734dbda2119bbadfc61b2f3640ba835d0f7/alembic-1.18.4-py3-none-any.whl", hash = "sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a", size = 263893, upload-time = "2026-02-10T16:00:49.997Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, +] + +[[package]] +name = "apscheduler" +version = "3.11.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzlocal" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/12/3e4389e5920b4c1763390c6d371162f3784f86f85cd6d6c1bfe68eef14e2/apscheduler-3.11.2.tar.gz", hash = "sha256:2a9966b052ec805f020c8c4c3ae6e6a06e24b1bf19f2e11d91d8cca0473eef41", size = 108683, upload-time = "2025-12-22T00:39:34.884Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/64/2e54428beba8d9992aa478bb8f6de9e4ecaa5f8f513bcfd567ed7fb0262d/apscheduler-3.11.2-py3-none-any.whl", hash = "sha256:ce005177f741409db4e4dd40a7431b76feb856b9dd69d57e0da49d6715bfd26d", size = 64439, upload-time = "2025-12-22T00:39:33.303Z" }, +] + +[[package]] +name = "bcrypt" +version = "5.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd", size = 25386, upload-time = "2025-09-25T19:50:47.829Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/85/3e65e01985fddf25b64ca67275bb5bdb4040bd1a53b66d355c6c37c8a680/bcrypt-5.0.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f3c08197f3039bec79cee59a606d62b96b16669cff3949f21e74796b6e3cd2be", size = 481806, upload-time = "2025-09-25T19:49:05.102Z" }, + { url = "https://files.pythonhosted.org/packages/44/dc/01eb79f12b177017a726cbf78330eb0eb442fae0e7b3dfd84ea2849552f3/bcrypt-5.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:200af71bc25f22006f4069060c88ed36f8aa4ff7f53e67ff04d2ab3f1e79a5b2", size = 268626, upload-time = "2025-09-25T19:49:06.723Z" }, + { url = "https://files.pythonhosted.org/packages/8c/cf/e82388ad5959c40d6afd94fb4743cc077129d45b952d46bdc3180310e2df/bcrypt-5.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:baade0a5657654c2984468efb7d6c110db87ea63ef5a4b54732e7e337253e44f", size = 271853, upload-time = "2025-09-25T19:49:08.028Z" }, + { url = "https://files.pythonhosted.org/packages/ec/86/7134b9dae7cf0efa85671651341f6afa695857fae172615e960fb6a466fa/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:c58b56cdfb03202b3bcc9fd8daee8e8e9b6d7e3163aa97c631dfcfcc24d36c86", size = 269793, upload-time = "2025-09-25T19:49:09.727Z" }, + { url = "https://files.pythonhosted.org/packages/cc/82/6296688ac1b9e503d034e7d0614d56e80c5d1a08402ff856a4549cb59207/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bfd2a34de661f34d0bda43c3e4e79df586e4716ef401fe31ea39d69d581ef23", size = 289930, upload-time = "2025-09-25T19:49:11.204Z" }, + { url = "https://files.pythonhosted.org/packages/d1/18/884a44aa47f2a3b88dd09bc05a1e40b57878ecd111d17e5bba6f09f8bb77/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:ed2e1365e31fc73f1825fa830f1c8f8917ca1b3ca6185773b349c20fd606cec2", size = 272194, upload-time = "2025-09-25T19:49:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/0e/8f/371a3ab33c6982070b674f1788e05b656cfbf5685894acbfef0c65483a59/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:83e787d7a84dbbfba6f250dd7a5efd689e935f03dd83b0f919d39349e1f23f83", size = 269381, upload-time = "2025-09-25T19:49:14.308Z" }, + { url = "https://files.pythonhosted.org/packages/b1/34/7e4e6abb7a8778db6422e88b1f06eb07c47682313997ee8a8f9352e5a6f1/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:137c5156524328a24b9fac1cb5db0ba618bc97d11970b39184c1d87dc4bf1746", size = 271750, upload-time = "2025-09-25T19:49:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/c0/1b/54f416be2499bd72123c70d98d36c6cd61a4e33d9b89562c22481c81bb30/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:38cac74101777a6a7d3b3e3cfefa57089b5ada650dce2baf0cbdd9d65db22a9e", size = 303757, upload-time = "2025-09-25T19:49:17.244Z" }, + { url = "https://files.pythonhosted.org/packages/13/62/062c24c7bcf9d2826a1a843d0d605c65a755bc98002923d01fd61270705a/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:d8d65b564ec849643d9f7ea05c6d9f0cd7ca23bdd4ac0c2dbef1104ab504543d", size = 306740, upload-time = "2025-09-25T19:49:18.693Z" }, + { url = "https://files.pythonhosted.org/packages/d5/c8/1fdbfc8c0f20875b6b4020f3c7dc447b8de60aa0be5faaf009d24242aec9/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:741449132f64b3524e95cd30e5cd3343006ce146088f074f31ab26b94e6c75ba", size = 334197, upload-time = "2025-09-25T19:49:20.523Z" }, + { url = "https://files.pythonhosted.org/packages/a6/c1/8b84545382d75bef226fbc6588af0f7b7d095f7cd6a670b42a86243183cd/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:212139484ab3207b1f0c00633d3be92fef3c5f0af17cad155679d03ff2ee1e41", size = 352974, upload-time = "2025-09-25T19:49:22.254Z" }, + { url = "https://files.pythonhosted.org/packages/10/a6/ffb49d4254ed085e62e3e5dd05982b4393e32fe1e49bb1130186617c29cd/bcrypt-5.0.0-cp313-cp313t-win32.whl", hash = "sha256:9d52ed507c2488eddd6a95bccee4e808d3234fa78dd370e24bac65a21212b861", size = 148498, upload-time = "2025-09-25T19:49:24.134Z" }, + { url = "https://files.pythonhosted.org/packages/48/a9/259559edc85258b6d5fc5471a62a3299a6aa37a6611a169756bf4689323c/bcrypt-5.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f6984a24db30548fd39a44360532898c33528b74aedf81c26cf29c51ee47057e", size = 145853, upload-time = "2025-09-25T19:49:25.702Z" }, + { url = "https://files.pythonhosted.org/packages/2d/df/9714173403c7e8b245acf8e4be8876aac64a209d1b392af457c79e60492e/bcrypt-5.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9fffdb387abe6aa775af36ef16f55e318dcda4194ddbf82007a6f21da29de8f5", size = 139626, upload-time = "2025-09-25T19:49:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/f8/14/c18006f91816606a4abe294ccc5d1e6f0e42304df5a33710e9e8e95416e1/bcrypt-5.0.0-cp314-cp314t-macosx_10_12_universal2.whl", hash = "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef", size = 481862, upload-time = "2025-09-25T19:49:28.365Z" }, + { url = "https://files.pythonhosted.org/packages/67/49/dd074d831f00e589537e07a0725cf0e220d1f0d5d8e85ad5bbff251c45aa/bcrypt-5.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4", size = 268544, upload-time = "2025-09-25T19:49:30.39Z" }, + { url = "https://files.pythonhosted.org/packages/f5/91/50ccba088b8c474545b034a1424d05195d9fcbaaf802ab8bfe2be5a4e0d7/bcrypt-5.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf", size = 271787, upload-time = "2025-09-25T19:49:32.144Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e7/d7dba133e02abcda3b52087a7eea8c0d4f64d3e593b4fffc10c31b7061f3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da", size = 269753, upload-time = "2025-09-25T19:49:33.885Z" }, + { url = "https://files.pythonhosted.org/packages/33/fc/5b145673c4b8d01018307b5c2c1fc87a6f5a436f0ad56607aee389de8ee3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9", size = 289587, upload-time = "2025-09-25T19:49:35.144Z" }, + { url = "https://files.pythonhosted.org/packages/27/d7/1ff22703ec6d4f90e62f1a5654b8867ef96bafb8e8102c2288333e1a6ca6/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f", size = 272178, upload-time = "2025-09-25T19:49:36.793Z" }, + { url = "https://files.pythonhosted.org/packages/c8/88/815b6d558a1e4d40ece04a2f84865b0fef233513bd85fd0e40c294272d62/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493", size = 269295, upload-time = "2025-09-25T19:49:38.164Z" }, + { url = "https://files.pythonhosted.org/packages/51/8c/e0db387c79ab4931fc89827d37608c31cc57b6edc08ccd2386139028dc0d/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b", size = 271700, upload-time = "2025-09-25T19:49:39.917Z" }, + { url = "https://files.pythonhosted.org/packages/06/83/1570edddd150f572dbe9fc00f6203a89fc7d4226821f67328a85c330f239/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c", size = 334034, upload-time = "2025-09-25T19:49:41.227Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f2/ea64e51a65e56ae7a8a4ec236c2bfbdd4b23008abd50ac33fbb2d1d15424/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4", size = 352766, upload-time = "2025-09-25T19:49:43.08Z" }, + { url = "https://files.pythonhosted.org/packages/d7/d4/1a388d21ee66876f27d1a1f41287897d0c0f1712ef97d395d708ba93004c/bcrypt-5.0.0-cp314-cp314t-win32.whl", hash = "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e", size = 152449, upload-time = "2025-09-25T19:49:44.971Z" }, + { url = "https://files.pythonhosted.org/packages/3f/61/3291c2243ae0229e5bca5d19f4032cecad5dfb05a2557169d3a69dc0ba91/bcrypt-5.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d", size = 149310, upload-time = "2025-09-25T19:49:46.162Z" }, + { url = "https://files.pythonhosted.org/packages/3e/89/4b01c52ae0c1a681d4021e5dd3e45b111a8fb47254a274fa9a378d8d834b/bcrypt-5.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993", size = 143761, upload-time = "2025-09-25T19:49:47.345Z" }, + { url = "https://files.pythonhosted.org/packages/84/29/6237f151fbfe295fe3e074ecc6d44228faa1e842a81f6d34a02937ee1736/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b", size = 494553, upload-time = "2025-09-25T19:49:49.006Z" }, + { url = "https://files.pythonhosted.org/packages/45/b6/4c1205dde5e464ea3bd88e8742e19f899c16fa8916fb8510a851fae985b5/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb", size = 275009, upload-time = "2025-09-25T19:49:50.581Z" }, + { url = "https://files.pythonhosted.org/packages/3b/71/427945e6ead72ccffe77894b2655b695ccf14ae1866cd977e185d606dd2f/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef", size = 278029, upload-time = "2025-09-25T19:49:52.533Z" }, + { url = "https://files.pythonhosted.org/packages/17/72/c344825e3b83c5389a369c8a8e58ffe1480b8a699f46c127c34580c4666b/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd", size = 275907, upload-time = "2025-09-25T19:49:54.709Z" }, + { url = "https://files.pythonhosted.org/packages/0b/7e/d4e47d2df1641a36d1212e5c0514f5291e1a956a7749f1e595c07a972038/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd", size = 296500, upload-time = "2025-09-25T19:49:56.013Z" }, + { url = "https://files.pythonhosted.org/packages/0f/c3/0ae57a68be2039287ec28bc463b82e4b8dc23f9d12c0be331f4782e19108/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464", size = 278412, upload-time = "2025-09-25T19:49:57.356Z" }, + { url = "https://files.pythonhosted.org/packages/45/2b/77424511adb11e6a99e3a00dcc7745034bee89036ad7d7e255a7e47be7d8/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75", size = 275486, upload-time = "2025-09-25T19:49:59.116Z" }, + { url = "https://files.pythonhosted.org/packages/43/0a/405c753f6158e0f3f14b00b462d8bca31296f7ecfc8fc8bc7919c0c7d73a/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff", size = 277940, upload-time = "2025-09-25T19:50:00.869Z" }, + { url = "https://files.pythonhosted.org/packages/62/83/b3efc285d4aadc1fa83db385ec64dcfa1707e890eb42f03b127d66ac1b7b/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4", size = 310776, upload-time = "2025-09-25T19:50:02.393Z" }, + { url = "https://files.pythonhosted.org/packages/95/7d/47ee337dacecde6d234890fe929936cb03ebc4c3a7460854bbd9c97780b8/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb", size = 312922, upload-time = "2025-09-25T19:50:04.232Z" }, + { url = "https://files.pythonhosted.org/packages/d6/3a/43d494dfb728f55f4e1cf8fd435d50c16a2d75493225b54c8d06122523c6/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c", size = 341367, upload-time = "2025-09-25T19:50:05.559Z" }, + { url = "https://files.pythonhosted.org/packages/55/ab/a0727a4547e383e2e22a630e0f908113db37904f58719dc48d4622139b5c/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb", size = 359187, upload-time = "2025-09-25T19:50:06.916Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bb/461f352fdca663524b4643d8b09e8435b4990f17fbf4fea6bc2a90aa0cc7/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538", size = 153752, upload-time = "2025-09-25T19:50:08.515Z" }, + { url = "https://files.pythonhosted.org/packages/41/aa/4190e60921927b7056820291f56fc57d00d04757c8b316b2d3c0d1d6da2c/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9", size = 150881, upload-time = "2025-09-25T19:50:09.742Z" }, + { url = "https://files.pythonhosted.org/packages/54/12/cd77221719d0b39ac0b55dbd39358db1cd1246e0282e104366ebbfb8266a/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980", size = 144931, upload-time = "2025-09-25T19:50:11.016Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ba/2af136406e1c3839aea9ecadc2f6be2bcd1eff255bd451dd39bcf302c47a/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a", size = 495313, upload-time = "2025-09-25T19:50:12.309Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ee/2f4985dbad090ace5ad1f7dd8ff94477fe089b5fab2040bd784a3d5f187b/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191", size = 275290, upload-time = "2025-09-25T19:50:13.673Z" }, + { url = "https://files.pythonhosted.org/packages/e4/6e/b77ade812672d15cf50842e167eead80ac3514f3beacac8902915417f8b7/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254", size = 278253, upload-time = "2025-09-25T19:50:15.089Z" }, + { url = "https://files.pythonhosted.org/packages/36/c4/ed00ed32f1040f7990dac7115f82273e3c03da1e1a1587a778d8cea496d8/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db", size = 276084, upload-time = "2025-09-25T19:50:16.699Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c4/fa6e16145e145e87f1fa351bbd54b429354fd72145cd3d4e0c5157cf4c70/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac", size = 297185, upload-time = "2025-09-25T19:50:18.525Z" }, + { url = "https://files.pythonhosted.org/packages/24/b4/11f8a31d8b67cca3371e046db49baa7c0594d71eb40ac8121e2fc0888db0/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822", size = 278656, upload-time = "2025-09-25T19:50:19.809Z" }, + { url = "https://files.pythonhosted.org/packages/ac/31/79f11865f8078e192847d2cb526e3fa27c200933c982c5b2869720fa5fce/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8", size = 275662, upload-time = "2025-09-25T19:50:21.567Z" }, + { url = "https://files.pythonhosted.org/packages/d4/8d/5e43d9584b3b3591a6f9b68f755a4da879a59712981ef5ad2a0ac1379f7a/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a", size = 278240, upload-time = "2025-09-25T19:50:23.305Z" }, + { url = "https://files.pythonhosted.org/packages/89/48/44590e3fc158620f680a978aafe8f87a4c4320da81ed11552f0323aa9a57/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1", size = 311152, upload-time = "2025-09-25T19:50:24.597Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/e4fbfc46f14f47b0d20493669a625da5827d07e8a88ee460af6cd9768b44/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42", size = 313284, upload-time = "2025-09-25T19:50:26.268Z" }, + { url = "https://files.pythonhosted.org/packages/25/ae/479f81d3f4594456a01ea2f05b132a519eff9ab5768a70430fa1132384b1/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10", size = 341643, upload-time = "2025-09-25T19:50:28.02Z" }, + { url = "https://files.pythonhosted.org/packages/df/d2/36a086dee1473b14276cd6ea7f61aef3b2648710b5d7f1c9e032c29b859f/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172", size = 359698, upload-time = "2025-09-25T19:50:31.347Z" }, + { url = "https://files.pythonhosted.org/packages/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683", size = 153725, upload-time = "2025-09-25T19:50:34.384Z" }, + { url = "https://files.pythonhosted.org/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2", size = 150912, upload-time = "2025-09-25T19:50:35.69Z" }, + { url = "https://files.pythonhosted.org/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953, upload-time = "2025-09-25T19:50:37.32Z" }, +] + +[[package]] +name = "certifi" +version = "2026.5.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + +[[package]] +name = "click" +version = "8.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" }, +] + +[[package]] +name = "cloudinary" +version = "1.44.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "six" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0c/1b/ae48dbbe3c2ff81b94816dd6645af2d609e135cea4fcd65b1a8a61672cea/cloudinary-1.44.2.tar.gz", hash = "sha256:bc9139c68f2ef6996ba62a5d0300e63c711d4f6564644c0c82aa31bf641ef3c7", size = 188389, upload-time = "2026-04-16T08:18:19.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/8b/0da00a22362c89ef53f9d64af29546d24aba8533ffdb7bbcb19631c7f636/cloudinary-1.44.2-py3-none-any.whl", hash = "sha256:6624d06014c33f50c0c9e740fde1dfe4579d03311025d8bb7183824b7443aa09", size = 147805, upload-time = "2026-04-16T08:18:18.84Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "cryptography" +version = "48.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/a9/db8f313fdcd85d767d4973515e1db101f9c71f95fced83233de224673757/cryptography-48.0.0.tar.gz", hash = "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920", size = 832984, upload-time = "2026-05-04T22:59:38.133Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/3d/01f6dd9190170a5a241e0e98c2d04be3664a9e6f5b9b872cde63aff1c3dd/cryptography-48.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6", size = 8001587, upload-time = "2026-05-04T22:57:36.803Z" }, + { url = "https://files.pythonhosted.org/packages/b2/6e/e90527eef33f309beb811cf7c982c3aeffcce8e3edb178baa4ca3ae4a6fa/cryptography-48.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c", size = 4690433, upload-time = "2026-05-04T22:57:40.373Z" }, + { url = "https://files.pythonhosted.org/packages/90/04/673510ed51ddff56575f306cf1617d80411ee76831ccd3097599140efdfe/cryptography-48.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3", size = 4710620, upload-time = "2026-05-04T22:57:42.935Z" }, + { url = "https://files.pythonhosted.org/packages/14/d5/e9c4ef932c8d800490c34d8bd589d64a31d5890e27ec9e9ad532be893294/cryptography-48.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5", size = 4696283, upload-time = "2026-05-04T22:57:45.294Z" }, + { url = "https://files.pythonhosted.org/packages/0c/29/174b9dfb60b12d59ecfc6cfa04bc88c21b42a54f01b8aae09bb6e51e4c7f/cryptography-48.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c", size = 5296573, upload-time = "2026-05-04T22:57:47.933Z" }, + { url = "https://files.pythonhosted.org/packages/95/38/0d29a6fd7d0d1373f0c0c88a04ba20e359b257753ac497564cd660fc1d55/cryptography-48.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f", size = 4743677, upload-time = "2026-05-04T22:57:50.067Z" }, + { url = "https://files.pythonhosted.org/packages/30/be/eef653013d5c63b6a490529e0316f9ac14a37602965d4903efed1399f32b/cryptography-48.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25", size = 4330808, upload-time = "2026-05-04T22:57:52.301Z" }, + { url = "https://files.pythonhosted.org/packages/84/9e/500463e87abb7a0a0f9f256ec21123ecde0a7b5541a15e840ea54551fd81/cryptography-48.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602", size = 4695941, upload-time = "2026-05-04T22:57:54.603Z" }, + { url = "https://files.pythonhosted.org/packages/e3/dc/7303087450c2ec9e7fbb750e17c2abfbc658f23cbd0e54009509b7cc4091/cryptography-48.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c", size = 5252579, upload-time = "2026-05-04T22:57:57.207Z" }, + { url = "https://files.pythonhosted.org/packages/d0/c0/7101d3b7215edcdc90c45da544961fd8ed2d6448f77577460fa75a8443f7/cryptography-48.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5", size = 4743326, upload-time = "2026-05-04T22:57:59.535Z" }, + { url = "https://files.pythonhosted.org/packages/ac/d8/5b833bad13016f562ab9d063d68199a4bd121d18458e439515601d3357ec/cryptography-48.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321", size = 4826672, upload-time = "2026-05-04T22:58:01.996Z" }, + { url = "https://files.pythonhosted.org/packages/98/e1/7074eb8bf3c135558c73fc2bcf0f5633f912e6fb87e868a55c454080ef09/cryptography-48.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74", size = 4972574, upload-time = "2026-05-04T22:58:03.968Z" }, + { url = "https://files.pythonhosted.org/packages/04/70/e5a1b41d325f797f39427aa44ef8baf0be500065ab6d8e10369d850d4a4f/cryptography-48.0.0-cp311-abi3-win32.whl", hash = "sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4", size = 3294868, upload-time = "2026-05-04T22:58:06.467Z" }, + { url = "https://files.pythonhosted.org/packages/f4/ac/8ac51b4a5fc5932eb7ee5c517ba7dc8cd834f0048962b6b352f00f41ebf9/cryptography-48.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7", size = 3817107, upload-time = "2026-05-04T22:58:08.845Z" }, + { url = "https://files.pythonhosted.org/packages/6b/84/70e3feea9feea87fd7cbe77efb2712ae1e3e6edf10749dc6e95f4e60e455/cryptography-48.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:3cb07a3ed6431663cd321ea8a000a1314c74211f823e4177fefa2255e057d1ec", size = 7986556, upload-time = "2026-05-04T22:58:11.172Z" }, + { url = "https://files.pythonhosted.org/packages/89/6e/18e07a618bb5442ba10cf4df16e99c071365528aa570dfcb8c02e25a303b/cryptography-48.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18", size = 4684776, upload-time = "2026-05-04T22:58:13.712Z" }, + { url = "https://files.pythonhosted.org/packages/be/6a/4ea3b4c6c6759794d5ee2103c304a5076dc4b19ae1f9fe47dba439e159e9/cryptography-48.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20", size = 4698121, upload-time = "2026-05-04T22:58:16.448Z" }, + { url = "https://files.pythonhosted.org/packages/2f/59/6ff6ad6cae03bb887da2a5860b2c9805f8dac969ef01ce563336c49bd1d1/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff", size = 4690042, upload-time = "2026-05-04T22:58:18.544Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b4/fc334ed8cfd705aca282fe4d8f5ae64a8e0f74932e9feecb344610cf6e4d/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:55b7718303bf06a5753dcdccf2f3945cf18ad7bffde41b61226e4db31ab89a9c", size = 5282526, upload-time = "2026-05-04T22:58:20.75Z" }, + { url = "https://files.pythonhosted.org/packages/11/08/9f8c5386cc4cd90d8255c7cdd0f5baf459a08502a09de30dc51f553d38dc/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db", size = 4733116, upload-time = "2026-05-04T22:58:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/b8/77/99307d7574045699f8805aa500fa0fb83422d115b5400a064ddd306d7750/cryptography-48.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741", size = 4316030, upload-time = "2026-05-04T22:58:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/fd/36/a608b98337af3cb2aff4818e406649d30572b7031918b04c87d979495348/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166", size = 4689640, upload-time = "2026-05-04T22:58:27.747Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a6/825010a291b4438aecc1f568bc428189fc1175515223632477c07dc0a6df/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:906cbf0670286c6e0044156bc7d4af9cbb0ef6db9f73e52c3ec56ba6bdde5336", size = 5237657, upload-time = "2026-05-04T22:58:29.848Z" }, + { url = "https://files.pythonhosted.org/packages/b9/09/4e76a09b4caa29aad535ddc806f5d4c5d01885bd978bd984fbc6ca032cae/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057", size = 4732362, upload-time = "2026-05-04T22:58:32.009Z" }, + { url = "https://files.pythonhosted.org/packages/18/78/444fa04a77d0cb95f417dda20d450e13c56ba8e5220fc892a1658f44f882/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae", size = 4819580, upload-time = "2026-05-04T22:58:34.254Z" }, + { url = "https://files.pythonhosted.org/packages/38/85/ea67067c70a1fd4be2c63d35eeed82658023021affccc7b17705f8527dd2/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c", size = 4963283, upload-time = "2026-05-04T22:58:36.376Z" }, + { url = "https://files.pythonhosted.org/packages/75/54/cc6d0f3deac3e81c7f847e8a189a12b6cdd65059b43dad25d4316abd849a/cryptography-48.0.0-cp314-cp314t-win32.whl", hash = "sha256:c17dfe85494deaeddc5ce251aebd1d60bbe6afc8b62071bb0b469431a000124f", size = 3270954, upload-time = "2026-05-04T22:58:38.791Z" }, + { url = "https://files.pythonhosted.org/packages/49/67/cc947e288c0758a4e5473d1dcb743037ab7785541265a969240b8885441a/cryptography-48.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27241b1dc9962e056062a8eef1991d02c3a24569c95975bd2322a8a52c6e5e12", size = 3797313, upload-time = "2026-05-04T22:58:40.746Z" }, + { url = "https://files.pythonhosted.org/packages/f2/63/61d4a4e1c6b6bab6ce1e213cd36a24c415d90e76d78c5eb8577c5541d2e8/cryptography-48.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86", size = 7983482, upload-time = "2026-05-04T22:58:43.769Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ac/f5b5995b87770c693e2596559ffafe195b4033a57f14a82268a2842953f3/cryptography-48.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e", size = 4683266, upload-time = "2026-05-04T22:58:46.064Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c6/8b14f67e18338fbc4adb76f66c001f5c3610b3e2d1837f268f47a347dbbb/cryptography-48.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f", size = 4696228, upload-time = "2026-05-04T22:58:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/ea/73/f808fbae9514bd91b47875b003f13e284c8c6bdfd904b7944e803937eec1/cryptography-48.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7", size = 4689097, upload-time = "2026-05-04T22:58:50.9Z" }, + { url = "https://files.pythonhosted.org/packages/93/01/d86632d7d28db8ae83221995752eeb6639ffb374c2d22955648cf8d52797/cryptography-48.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832", size = 5283582, upload-time = "2026-05-04T22:58:53.017Z" }, + { url = "https://files.pythonhosted.org/packages/02/e1/50edc7a50334807cc4791fc4a0ce7468b4a1416d9138eab358bfc9a3d70b/cryptography-48.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c", size = 4730479, upload-time = "2026-05-04T22:58:55.611Z" }, + { url = "https://files.pythonhosted.org/packages/6f/af/99a582b1b1641ff5911ac559beb45097cf79efd4ead4657f578ef1af2d47/cryptography-48.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a", size = 4326481, upload-time = "2026-05-04T22:58:57.607Z" }, + { url = "https://files.pythonhosted.org/packages/90/ee/89aa26a06ef0a7d7611788ffd571a7c50e368cc6a4d5eef8b4884e866edb/cryptography-48.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a", size = 4688713, upload-time = "2026-05-04T22:59:00.077Z" }, + { url = "https://files.pythonhosted.org/packages/70/ba/bcb1b0bb7a33d4c7c0c4d4c7874b4a62ae4f56113a5f4baefa362dfb1f0f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a", size = 5238165, upload-time = "2026-05-04T22:59:02.317Z" }, + { url = "https://files.pythonhosted.org/packages/c9/70/ca4003b1ce5ca3dc3186ada51908c8a9b9ff7d5cab83cc0d43ee14ec144f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239", size = 4729947, upload-time = "2026-05-04T22:59:05.255Z" }, + { url = "https://files.pythonhosted.org/packages/44/a0/4ec7cf774207905aef1a8d11c3750d5a1db805eb380ee4e16df317870128/cryptography-48.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c", size = 4822059, upload-time = "2026-05-04T22:59:07.802Z" }, + { url = "https://files.pythonhosted.org/packages/1e/75/a2e55f99c16fcac7b5d6c1eb19ad8e00799854d6be5ca845f9259eae1681/cryptography-48.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4", size = 4960575, upload-time = "2026-05-04T22:59:09.851Z" }, + { url = "https://files.pythonhosted.org/packages/b8/23/6e6f32143ab5d8b36ca848a502c4bcd477ae75b9e1677e3530d669062578/cryptography-48.0.0-cp39-abi3-win32.whl", hash = "sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd", size = 3279117, upload-time = "2026-05-04T22:59:12.019Z" }, + { url = "https://files.pythonhosted.org/packages/9d/9a/0fea98a70cf1749d41d738836f6349d97945f7c89433a259a6c2642eefeb/cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8", size = 3792100, upload-time = "2026-05-04T22:59:14.884Z" }, +] + +[[package]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, +] + +[[package]] +name = "ecdsa" +version = "0.19.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/25/ca/8de7744cb3bc966c85430ca2d0fcaeea872507c6a4cf6e007f7fe269ed9d/ecdsa-0.19.2.tar.gz", hash = "sha256:62635b0ac1ca2e027f82122b5b81cb706edc38cd91c63dda28e4f3455a2bf930", size = 202432, upload-time = "2026-03-26T09:58:17.675Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/79/119091c98e2bf49e24ed9f3ae69f816d715d2904aefa6a2baa039a2ba0b0/ecdsa-0.19.2-py2.py3-none-any.whl", hash = "sha256:840f5dc5e375c68f36c1a7a5b9caad28f95daa65185c9253c0c08dd952bb7399", size = 150818, upload-time = "2026-03-26T09:58:15.808Z" }, +] + +[[package]] +name = "email-validator" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, +] + +[[package]] +name = "fastapi" +version = "0.136.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/2d/ff8d91d7b564d464629a0fd50a4489c97fcb836ac230bf3a7269232a9b1f/fastapi-0.136.3.tar.gz", hash = "sha256:e487fae93ad408e6f47641ee4dfe389864fd7bec92e547ea8498fc13f43e83ab", size = 396410, upload-time = "2026-05-23T18:53:15.192Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/82/45359b62a067409bd929ae8a56b8ed13e5a8c8a61194b3c236920999ab83/fastapi-0.136.3-py3-none-any.whl", hash = "sha256:3d2a69bdf04b7e9f3afa292c3bc7a98816bbfafa10bc9b45f3f3700d2f761620", size = 117481, upload-time = "2026-05-23T18:53:16.924Z" }, +] + +[[package]] +name = "greenlet" +version = "3.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/6e/802acd792aebb2256fbbee8cacf2727faaeb6f240ac11008f09eae4414bc/greenlet-3.5.1.tar.gz", hash = "sha256:5a56aeb7d5d9cc4b3a735efb5095bd4b4f6f0e4f93e5ca876d0e2315137b7829", size = 197356, upload-time = "2026-05-20T15:05:03.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/37/4549f149c9797c21b32c2683c33522af22522099de128b2406672526d005/greenlet-3.5.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:fa4f98af3a528f0c3fd592a26df7f376f93329c8f4d987f6bb979057af8bf5e2", size = 286220, upload-time = "2026-05-20T13:07:28.463Z" }, + { url = "https://files.pythonhosted.org/packages/38/ff/a4f436709716965eaab9f36ea7b906c8a927fbe32fb1372a2071d964f6b1/greenlet-3.5.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ffea73584b216150eab159b6d12348fb253e68757974de1e2c40d8a318ac89ed", size = 601585, upload-time = "2026-05-20T14:00:06.141Z" }, + { url = "https://files.pythonhosted.org/packages/65/ad/54bc3fcee3ad368a61b19b67d88117f7a8c29727bf71fffdeda81fbd946e/greenlet-3.5.1-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1072b4f9edcc1e192d9283a66a3e68d6b84c561de33a83d7858beb9ba1effe10", size = 614215, upload-time = "2026-05-20T14:05:42.675Z" }, + { url = "https://files.pythonhosted.org/packages/40/69/b91cda0647df839483201545913514c2827ebea5e5ccdf931842763bc127/greenlet-3.5.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:add5217d68b31130f0beca584d7fef4878327d2e31642b66618a14eef312b63b", size = 611358, upload-time = "2026-05-20T13:14:26.37Z" }, + { url = "https://files.pythonhosted.org/packages/59/90/3cf77e080350cd02fa307bb2abf05df48f4482c240275bbd2c203ba8bb1c/greenlet-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a5ea42a752d47a145eae922b605cd1634665ac3d5ec1e72402d5048e8d60d207", size = 1570475, upload-time = "2026-05-20T14:02:25.29Z" }, + { url = "https://files.pythonhosted.org/packages/65/2c/18cece62045e74598c3c393f70dce4a63f56222015ba29a5d4eeb04f764c/greenlet-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c5551170cf4f5ff5623e9af81323751979fee2c731e2287b61f73cd27257b823", size = 1635625, upload-time = "2026-05-20T13:14:34.027Z" }, + { url = "https://files.pythonhosted.org/packages/30/f5/310d104ddf41eb5a70f4c268d22508dfb0c3c8e86fec152be34d0d2ed819/greenlet-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3c8bb982ad117d29478ef8f5533e97df21f1e2befd17a299257b0c96d1371c0b", size = 238791, upload-time = "2026-05-20T13:10:39.018Z" }, + { url = "https://files.pythonhosted.org/packages/62/90/ceca11f504cd23a8047a3dea31919adc48df9b626dd0c13f0d858734fdfd/greenlet-3.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:80eb4b04dadc4e67df3fae179a32c4706a3f495bc7f22fc8a81115d5f5512188", size = 235580, upload-time = "2026-05-20T13:08:45.056Z" }, + { url = "https://files.pythonhosted.org/packages/27/69/7f7e5372d998b81001899b1c0823c957aa413ba0f2662e65821611cc31e4/greenlet-3.5.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:51518ff74664078fc51bffcc6fc529b0df5ae58da192691cee765d45ce944a2b", size = 285060, upload-time = "2026-05-20T13:08:51.899Z" }, + { url = "https://files.pythonhosted.org/packages/b1/bf/387f9b6b865fd2ae0d0be09e0004827295a01b71be76ed350dd1e28a91a4/greenlet-3.5.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ffdb3c0bb002c99cd8f298957e046c3dbf6006b5b7cdf11a4e19194624a0a0a", size = 604370, upload-time = "2026-05-20T14:00:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/32/f5/169ce3d4e4c67291bd18f8cbe0299c9f3e45102c7f1fb3c14780c93e4532/greenlet-3.5.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7715a5a2c3378ba602c3a440558261e13a820bb53a82693aacd7b7f6d964e283", size = 616987, upload-time = "2026-05-20T14:05:44.237Z" }, + { url = "https://files.pythonhosted.org/packages/ee/e5/7f2e41d5273be07e77560d61ea4e56485b4d6c316d2a84518c62d1364061/greenlet-3.5.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc71ff466927a201b08305acac451ebe1aedfcea002f62f1f2f2ac2ac1e6a135", size = 613911, upload-time = "2026-05-20T13:14:27.539Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a4/fbdc67579b73615a1f91615e814303cc71e06128f7baaba87be79b8fb90c/greenlet-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cd443683db272ebaaca03af98c0b063ab30db70ea8a31a1559f35e3f7b744ccd", size = 1570689, upload-time = "2026-05-20T14:02:27.225Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b4/77abbe35078be39718a46cd49caf16bceb35662f97a34101dca28aa98e47/greenlet-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:089fff7a6ce8d9316d1f65ebc00273a56be258c1725b32b94de90a3a979557e1", size = 1635602, upload-time = "2026-05-20T13:14:36.344Z" }, + { url = "https://files.pythonhosted.org/packages/37/f7/129f27ca700845b8ee8ca88ce7f43435a1239c2eddb7677fc938822762cf/greenlet-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:110a1ca7b49b014b097f6078272c3f4ed31af45b254de5228b79adba879f6af9", size = 238683, upload-time = "2026-05-20T13:11:50.57Z" }, + { url = "https://files.pythonhosted.org/packages/6d/5c/a485a36e87df8d8fd0632ee01511244f5156a20ed3746cc6599340326395/greenlet-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:f16ba1efc0715b680a18b8123d90dad887c6112ae3555b4b5c32c149540c6b4e", size = 235499, upload-time = "2026-05-20T13:12:42.028Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cb/c62454606daf5640369c94d8a9dd540599b1bfc090e2d2180cb77f4038d2/greenlet-3.5.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d8ab31c9de8651a2facdd5c5bb0011f2380dd1a7af78ce2adf4b56095294fc07", size = 285579, upload-time = "2026-05-20T13:08:56.396Z" }, + { url = "https://files.pythonhosted.org/packages/ec/71/c4270398c2eba968a6071af1dfbdcaeee6ec1c24bc8b435b8cc452700da6/greenlet-3.5.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e300185139abc337ade480c327183adf42a875ac7181bfe66d7d4efea31fbea", size = 651106, upload-time = "2026-05-20T14:00:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/1a/ab/71e34b78a44ec271fb5f550c17bc46d301ddc5953890d935f270b0dcdb5a/greenlet-3.5.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7ffdb990dcaa0234cf9845aead5df2e3c3a8b6507d409274dd87e0d5ab05ffc2", size = 663478, upload-time = "2026-05-20T14:05:45.88Z" }, + { url = "https://files.pythonhosted.org/packages/77/96/4efd6fa5c62c85426a0c19077a586258ebc3a2a146ff2493e4312a697a22/greenlet-3.5.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f82b3597e9d83b63408affed0b48fd0f54935edac4302237b9a837be0dae33c", size = 660800, upload-time = "2026-05-20T13:14:29.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/e0/6c71401a25cac7000261304e866a2f2cc04dc74810d40e2f118aa4799495/greenlet-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c0141e37414c10164e702b8fb1473304221ad98f71600850c6ef7ff4880feba0", size = 1617518, upload-time = "2026-05-20T14:02:28.662Z" }, + { url = "https://files.pythonhosted.org/packages/41/26/c5c06643e8c0af9e7bf18e16cb51d0ab7625155f0392e1c9015d66d556cd/greenlet-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:50ae25a67bea74ea41fb14b960bc532df73eb713417b2d61892dced82fe8d3bc", size = 1681593, upload-time = "2026-05-20T13:14:39.417Z" }, + { url = "https://files.pythonhosted.org/packages/8a/bd/e11a108317485075e68af9d23039619b86b28130c3b50d227d42edece64b/greenlet-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:8a17c42330e261299766b75ac1ea32caa437a9453c8f65d16a13140db378ecd3", size = 239800, upload-time = "2026-05-20T13:09:30.128Z" }, + { url = "https://files.pythonhosted.org/packages/47/f8/8e8e8417b7bf28639a5a56356ef934d0375e1d0c70a57e04d7701e870ffe/greenlet-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:7b5f5fae05b8ac6d176a61b60c394a8cbdc2b5b91b81793066e68745cf165e54", size = 236862, upload-time = "2026-05-20T13:09:10.498Z" }, + { url = "https://files.pythonhosted.org/packages/90/12/41bf27fde4d3605d3773ae57751eda182b8be2f5398011c041173b1d9534/greenlet-3.5.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:ea8da1e900d758d078810d4255d8c6aa572181896a31ec79d779eb79c3adc9ad", size = 293637, upload-time = "2026-05-20T13:12:35.529Z" }, + { url = "https://files.pythonhosted.org/packages/44/44/ba14b23e9757707050c2f397d305bbcae62e5d7cad122f8b6baec5ae4a1f/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a19570c52a21420dcbc94e661994bc325c0b5b11304540fed514586da5dc8f2e", size = 650840, upload-time = "2026-05-20T14:00:11.079Z" }, + { url = "https://files.pythonhosted.org/packages/a8/37/5ddc2b686a6844f91abecef43411842426da2e1573f60b49ecf2547f4ae1/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3d955c89b75eeca4723d7cc14135f393cd47c32e2a6cb4a8e4c6e760a26b0986", size = 656416, upload-time = "2026-05-20T14:05:47.118Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f0/d17510297c35a2992712f0bf84de3779749999f7d3d63aa1f09db7c62dbe/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2daaaebd1a5aa88c49045b6baf9310b3263796bd88db713edf37cf53e7bb4e", size = 654397, upload-time = "2026-05-20T13:14:30.696Z" }, + { url = "https://files.pythonhosted.org/packages/37/eb/147387705bb89092645b012586e7273cb5ed3c90ef7eaf3a69173eaf0209/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bfbd69cc349e43bf3a8ae1c85548ff0718efc887615c2db16c3833d7b0b072d", size = 1614469, upload-time = "2026-05-20T14:02:30.192Z" }, + { url = "https://files.pythonhosted.org/packages/a6/4e/37ee0da7732b7aa9896f17e15579a9df34b9fcb9dd494f0adfa749af6623/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4378720dd888136c27215a0214d32a4d37c3852765d45bc37aad0623423cfd78", size = 1675115, upload-time = "2026-05-20T13:14:40.972Z" }, + { url = "https://files.pythonhosted.org/packages/57/f3/97dfcf4a6eb5077f8a672234216fb5923eb89f2cab7081cb10b2cf75b605/greenlet-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:45718441607f9325d948db98cbc691276059316d0358c188c246da4e1d4d23d2", size = 245246, upload-time = "2026-05-20T13:12:22.646Z" }, + { url = "https://files.pythonhosted.org/packages/5d/73/d7f72e34b582f694f4a9b248162db7b09cc458a259ba8f0c0bfa1a34ea7d/greenlet-3.5.1-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:2baee5ca02031757ffe8cc3d69f0cc0aec7065ce362622da74f32d3bcab1c541", size = 285575, upload-time = "2026-05-20T13:12:07.043Z" }, + { url = "https://files.pythonhosted.org/packages/df/59/fa9c6e87dc8ad27a95dabe2f29f372b733d05a8a67470f6c901ed9975655/greenlet-3.5.1-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b1ec3274918a81d3ea778b9e75b56b72b33f300edb6cf7f3a7fe1dae56683de", size = 656428, upload-time = "2026-05-20T14:00:12.556Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f9/e753408871eaa61dfe35e619cfc67512b036fde99893685d50eea9e07146/greenlet-3.5.1-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:111e2390ffffc47d5840b01711dd7fac07d4c09283d0283e7f3264b14e284c64", size = 667064, upload-time = "2026-05-20T14:05:48.662Z" }, + { url = "https://files.pythonhosted.org/packages/96/27/5565b5b40389f1c7753003a07e21892fda8660926787036d5bc0308b8113/greenlet-3.5.1-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e630136e905fe5ff43e86945ae41220b6d1470956a39220e708110ac48d01ea5", size = 665697, upload-time = "2026-05-20T13:14:32.943Z" }, + { url = "https://files.pythonhosted.org/packages/cf/82/e7de4178c0c2d1c9a5a3be3cc0b33e46a85b3ee4a77c071bf7ad8600e079/greenlet-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:975eac34b44a7077ca4d421348455b94f0f518246a7f14bc6d2fdcfe5b584368", size = 1621256, upload-time = "2026-05-20T14:02:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/00/10/f2dddcf7dacac17dfc68691809589adad06135eb28930429cf58a6467a2f/greenlet-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:9ab3c3a0b2ae6198e67c898dad5215a49f9ae0d0081b3c3ec59f333e39eeca26", size = 1685956, upload-time = "2026-05-20T13:14:42.55Z" }, + { url = "https://files.pythonhosted.org/packages/22/17/4a232b32133230ada52f70e9d7f5b65b0caef8772f01849bd8d149e7e4ca/greenlet-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:cbfc69be86e10dcfef5b1e6269d1d6926552aa89ee39e1de3353360c1b6989ab", size = 239802, upload-time = "2026-05-20T13:13:15.481Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ae/4e623a7e6d4d2a5f4cb8e4c82de4169fc637942caae68d6e676b8a128ac5/greenlet-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:92fd6d44ac5e5a887c8a5dc4a8ba0ba908527c31c12f78c6bc7dcfe8aab279f6", size = 236853, upload-time = "2026-05-20T13:15:37.301Z" }, + { url = "https://files.pythonhosted.org/packages/7a/57/816d9cff29119da3505b3d6a5e14a8af89006ac36f47f891ff293ee05af1/greenlet-3.5.1-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:a6fdf2433a5441ef9a95464f7c3e674775da1c8c1177fff311cee1acad4626ed", size = 293877, upload-time = "2026-05-20T13:10:19.078Z" }, + { url = "https://files.pythonhosted.org/packages/23/a1/59b0a7c7d140ff1a75626680b9a9899b79a9176cab298b394968fb023295/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7546556f0d649f99f6a361098a55f761181bb2ea12ff150bb16d26092ad88244", size = 655333, upload-time = "2026-05-20T14:00:14.758Z" }, + { url = "https://files.pythonhosted.org/packages/72/1b/5efe127597625042218939d01855109f352779050768b670b52edcc16a6c/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5ee3ea898009fa898f85f9982255d35278c477bebe185beca249cab42d4526c", size = 659443, upload-time = "2026-05-20T14:05:50.159Z" }, + { url = "https://files.pythonhosted.org/packages/6c/6d/c404246ea4d22d097a7426d0efb5b781bd7eb67715f09e79001bd552ab18/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5c81f74d204d3edd136ebfd50dce53acbb776995d721a0fe801626cfc93b8cd", size = 658356, upload-time = "2026-05-20T13:14:35.091Z" }, + { url = "https://files.pythonhosted.org/packages/51/02/f8ee37fb6d2219329f350af241c27fcf12df57e723d11f6fc6d3bacdadaa/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:2c18ef16bf6d4dd410e4dd52996888ea1497be26892fe5bbc73580aba4287b8e", size = 1619216, upload-time = "2026-05-20T14:02:33.403Z" }, + { url = "https://files.pythonhosted.org/packages/93/c5/3dc9475ace2c7a3680da12372cddd7f1ac874eb410a1ac48d3e9dab83782/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:17d86354f0ae6b61bf9be5148d0dd34e06c3cb7c602c671f79f29ac3b150e659", size = 1678427, upload-time = "2026-05-20T13:14:43.71Z" }, + { url = "https://files.pythonhosted.org/packages/df/4e/750c15c317a41ffb36f0bf40b933e3d744a7dede61889f74443ea69690cf/greenlet-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:e7516cf6ae6b8a582c2770a0caed47b8a48373ed732c33d69a72913ae6ac923e", size = 245225, upload-time = "2026-05-20T13:13:59.366Z" }, + { url = "https://files.pythonhosted.org/packages/4f/fd/d3baea2eeb7b617efd47e87ca06e2ec2c6118d303aa9e918e0ce16eadc10/greenlet-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:5028648bf2253ec4745add746129d3904121fa7fe871a76bed23c5720573ce0a", size = 239590, upload-time = "2026-05-20T13:13:37.382Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httptools" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/88/1d21a36da8f5cb0fa49eafd4b169eba5608d57e75bbcf61845cbc6243216/httptools-0.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d", size = 208247, upload-time = "2026-05-25T22:17:07.843Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/cc4feea2945cb3051038f090c9b36bd5b8a9d7f5a894a506a8983e33fd1c/httptools-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5", size = 113064, upload-time = "2026-05-25T22:17:09.136Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a6/febbb8b8db0f58b38e44ad6cb946e6a255ae49b55f2e8543408fb7501ccd/httptools-0.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2", size = 523851, upload-time = "2026-05-25T22:17:10.106Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e4/f90a0df0b83beff265b7e3b65f2a4cefd95792d4be0ac3e16049f2acd3c2/httptools-0.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09", size = 518842, upload-time = "2026-05-25T22:17:11.218Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2d/0c9ac76dd2c893841fbf6498d6acec4f2442e1b7067f6e3e316a80e494e8/httptools-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a", size = 501238, upload-time = "2026-05-25T22:17:12.728Z" }, + { url = "https://files.pythonhosted.org/packages/ca/42/906adc91ae3a5fa9c59c0a2f21c139725bd7e5b41ae6acd485cd14123ebf/httptools-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745", size = 509567, upload-time = "2026-05-25T22:17:13.842Z" }, + { url = "https://files.pythonhosted.org/packages/05/0b/4240efeb672751ee5b9b380cb0e3fdc050bc05f68adc7a8aefc4fcd9a69a/httptools-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150", size = 90918, upload-time = "2026-05-25T22:17:15.155Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e5/8cfcabc5546e8022f168be28bcdaa128a240a0befdd03b59d558b4f18bd6/httptools-0.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8", size = 205148, upload-time = "2026-05-25T22:17:16.333Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0e/0fb14848c19a686c8062ff9067c1a48793e3224b47bc5b201535b6036fce/httptools-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c", size = 111368, upload-time = "2026-05-25T22:17:17.586Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/46f1cecf06b9bbde8e4b8c88034ac7908989e5ff7a3a388ef38392949c1f/httptools-0.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7", size = 486447, upload-time = "2026-05-25T22:17:18.564Z" }, + { url = "https://files.pythonhosted.org/packages/77/00/258bfc0837221f81d9725c45f9b948a6a6b2994a147a4fb66e85100c668f/httptools-0.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d", size = 482448, upload-time = "2026-05-25T22:17:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/04/ab/d1cef3b5523f4d272a70f42a776c3169a2dddfe3a54de4b2ce4a36341528/httptools-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681", size = 464460, upload-time = "2026-05-25T22:17:20.882Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/5d1d072442277bb2b3434e0e60690b8e8c23840ef7de8b6ea54040a536d3/httptools-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683", size = 471312, upload-time = "2026-05-25T22:17:22.085Z" }, + { url = "https://files.pythonhosted.org/packages/0d/66/b96623b27e51a68199ef4efdda0613cced9233fe3062ac74e50749c5ad37/httptools-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1", size = 90117, upload-time = "2026-05-25T22:17:23.074Z" }, + { url = "https://files.pythonhosted.org/packages/1a/12/fa3fbf5f9517b273edea2dc982aa82a8c634091e67c590792b729017bc6f/httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6", size = 206183, upload-time = "2026-05-25T22:17:24.004Z" }, + { url = "https://files.pythonhosted.org/packages/30/fc/5e7c4cb443370f2090a3aba0453a07384d29ff66b7435bb90e77e1037599/httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b", size = 112079, upload-time = "2026-05-25T22:17:25.216Z" }, + { url = "https://files.pythonhosted.org/packages/ba/53/771bd891eb0f236f32145d6a1775777ec85745f3cc983a1f23d1a3b8ddfe/httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0", size = 481596, upload-time = "2026-05-25T22:17:26.186Z" }, + { url = "https://files.pythonhosted.org/packages/62/42/94e15bc68ce3d423243c45d7f1b0c7561f13844f97dc52ae23182fb65628/httptools-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e", size = 480865, upload-time = "2026-05-25T22:17:27.542Z" }, + { url = "https://files.pythonhosted.org/packages/1c/7c/fe2980fc03723272e30f135b62360b075f513dfe7cc73aef36c7f04012bd/httptools-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b", size = 463189, upload-time = "2026-05-25T22:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/47fc5fff68acd1bfa20b4734059c9a06cadb88119dcd5258b5b0d21d91c8/httptools-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0", size = 466610, upload-time = "2026-05-25T22:17:29.816Z" }, + { url = "https://files.pythonhosted.org/packages/60/bd/07b13c93ffd9bec9546e0d43f8e19378dd696dbd278511406bc07371ef1f/httptools-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527", size = 92705, upload-time = "2026-05-25T22:17:31.133Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c4/121648f68ce066d7bd762d6b6d97e620847642d38d54f3d90ff11d947629/httptools-0.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568", size = 215023, upload-time = "2026-05-25T22:17:32.401Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b0/312a062ae741ae3e8baa8c8bf20be81b2e67337b259ab4349bebc7b6142e/httptools-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b", size = 117405, upload-time = "2026-05-25T22:17:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/fc/37/fccd705f795386bb05bf413012fecff2a33e5aa8c2f069096de3e9fd8702/httptools-0.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca", size = 558497, upload-time = "2026-05-25T22:17:34.732Z" }, + { url = "https://files.pythonhosted.org/packages/bd/39/f172e8003576de35f5ba77ff417cf0e34429d35dc014deef15afa337a72c/httptools-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f", size = 571585, upload-time = "2026-05-25T22:17:35.813Z" }, + { url = "https://files.pythonhosted.org/packages/3e/b9/f5564760af99f3dbbf3f9104dc00e5da27e96cf433c6bdcf77617f70bf3f/httptools-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d", size = 543297, upload-time = "2026-05-25T22:17:37.08Z" }, + { url = "https://files.pythonhosted.org/packages/99/67/8d9f2c313618e161b82f3873188e7196126da1d6e29688df40eb3997c77a/httptools-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081", size = 539535, upload-time = "2026-05-25T22:17:38.032Z" }, + { url = "https://files.pythonhosted.org/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77", size = 108209, upload-time = "2026-05-25T22:17:39.473Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "mako" +version = "1.3.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/62/791b31e69ae182791ec67f04850f2f062716bbd205483d63a215f3e062d3/mako-1.3.12.tar.gz", hash = "sha256:9f778e93289bd410bb35daadeb4fc66d95a746f0b75777b942088b7fd7af550a", size = 400219, upload-time = "2026-04-28T19:01:08.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/b1/a0ec7a5a9db730a08daef1fdfb8090435b82465abbf758a596f0ea88727e/mako-1.3.12-py3-none-any.whl", hash = "sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9", size = 78521, upload-time = "2026-04-28T19:01:10.393Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "numpy" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/05/3d27272d30698dc0ecb7fdfaa41ad70303b444f81722bb99bce1d818638a/numpy-2.5.0.tar.gz", hash = "sha256:5a129578019311b6e56bdd714250f19b518f7dceeeb8d1af5490f4942d3f891c", size = 20652461, upload-time = "2026-06-21T20:57:51.95Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/0a/11486d02add7b1384dff7374d124b1cfbb0ee864dcc9f6a2c0380638cf84/numpy-2.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:489780423903667933b4ed6197b6ec3b75ea5dd17d1d8f0f38d798feb6921561", size = 16789987, upload-time = "2026-06-21T20:56:16.657Z" }, + { url = "https://files.pythonhosted.org/packages/55/b2/285f48640a181947b4587a3766d21ec1eaa7fea833d4b49957e09da467a2/numpy-2.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ece55976ced6bca95a03ae2839e2e5ccffe8eb6a3e7022415645eb154a81e4e6", size = 11760322, upload-time = "2026-06-21T20:56:19.813Z" }, + { url = "https://files.pythonhosted.org/packages/dd/67/b032db1eb03ca30d16eda3b0c22aaa615338b9263c2fd559d0f29451aca4/numpy-2.5.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:c83b664b0e6eee9594fa920cf0639d8af796606d3fad6cc70180c87e4b97c7be", size = 5319605, upload-time = "2026-06-21T20:56:22.173Z" }, + { url = "https://files.pythonhosted.org/packages/b9/83/03fc7300c7c6b6c84c487b1dc80d322817b95fbd1f4dd57a85e23b7198de/numpy-2.5.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:bf80333980bf37f523341ddd72c783f39d6829ec7736b9eb99086388a2d52cc2", size = 6653628, upload-time = "2026-06-21T20:56:23.914Z" }, + { url = "https://files.pythonhosted.org/packages/82/49/2ec21730bc63ccfda829323f7040a8ed4715b3852ce658689cf74ee96a8c/numpy-2.5.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1a4874217b36d5ac8fc876f52e39df56f8182c88463e9e2dceabf7ca8b7efb8", size = 15153691, upload-time = "2026-06-21T20:56:25.631Z" }, + { url = "https://files.pythonhosted.org/packages/bb/6b/f4a3d0637692c49da8ef99d72d52526f92e0a8d6ac4f0ca9f31441b9d9ea/numpy-2.5.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aaa760137137e8d3c920d27927748215b56014f92667dc9b6c27dfc61249255a", size = 16660066, upload-time = "2026-06-21T20:56:28.009Z" }, + { url = "https://files.pythonhosted.org/packages/3a/2f/c354ec86d1f3f5c19649463b0d39652e160736e5b0a4cd18dff0576715c4/numpy-2.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7174ce8265fc7f7417d171c9ea8fe905220748893ea67a2a7abe726ec331c4b0", size = 16514638, upload-time = "2026-06-21T20:56:30.26Z" }, + { url = "https://files.pythonhosted.org/packages/06/34/43efdcb319988648580f93c11f1ae82cf7e2faa74925e98e454ae3aa95f8/numpy-2.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b8c3daaf99de52415d20b42f8e8155c78642cb04207d02f9d317a0dcf1b3fb54", size = 18419647, upload-time = "2026-06-21T20:56:32.41Z" }, + { url = "https://files.pythonhosted.org/packages/71/e2/f5d1676b1d7fb682eb5e9a1641e7ebd2414b3216c370661d1029778908b4/numpy-2.5.0-cp312-cp312-win32.whl", hash = "sha256:6206db0af545d73d068add6d992279145f158428d1da6cc49adc4b630c5d6ee5", size = 6056688, upload-time = "2026-06-21T20:56:34.657Z" }, + { url = "https://files.pythonhosted.org/packages/8f/7c/48f115d1c58a34032facebcd51fdf2d02df2c51d4a46a81dd1197bb2ea6b/numpy-2.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:6f2d6873e2940c860a309d21e25b1e69af6aaffdd80aa056b04c16380db1c4f2", size = 12419237, upload-time = "2026-06-21T20:56:36.24Z" }, + { url = "https://files.pythonhosted.org/packages/86/26/2e0882f4044d1b1a1b63e875151fb2393389032022a8b7f5657a7996d3b2/numpy-2.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:a55e1eb2bca2cfd17a16b213c99dfc8502d47b0d494224d2122277d0400935ca", size = 10339912, upload-time = "2026-06-21T20:56:38.733Z" }, + { url = "https://files.pythonhosted.org/packages/8a/33/07675aaad7f26ea013d5e884d9a0d784b79c6bd7566c333f5a52fa3c610b/numpy-2.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:520e6b8be0a4b65840ac8090d4f51cef4bed66e2b0894d5a520f099adc24a9b2", size = 16784890, upload-time = "2026-06-21T20:56:40.799Z" }, + { url = "https://files.pythonhosted.org/packages/85/4b/953118a730ee3b35e28645e0eb4cf9beec5bdbb954e1ac2f5fcefba6bbc3/numpy-2.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:146b81cdd3967fdb6beca8ba25f00c58741d8f3cbd797f55af0fbe0bfec3469c", size = 11754584, upload-time = "2026-06-21T20:56:43.094Z" }, + { url = "https://files.pythonhosted.org/packages/44/9b/56dd530c367c74ae17411027cea4135ca57e1e0583bf5594cee18bd83217/numpy-2.5.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:126b88d95e8ff9b00c9e717aa540469f21d6180162f84c0caec51b16215d49cd", size = 5313904, upload-time = "2026-06-21T20:56:45.503Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b0/bcd672edad27ecca7da1f7bb0ce72cd1706a4f2d79ae94990afc97c13e1c/numpy-2.5.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d4313cef1594c5ce46c31b6e54e918338f63f16ee9322304e8c9114d6d81c8bd", size = 6648504, upload-time = "2026-06-21T20:56:47.567Z" }, + { url = "https://files.pythonhosted.org/packages/80/9e/15cdfcbd30a1544a46c9e487a00df331c4672450216538705a9e51fa6710/numpy-2.5.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:750fb097caf26fa878746d9d119f6f9da12dedcbff1eea966c3e3447647c4a9e", size = 15150086, upload-time = "2026-06-21T20:56:49.352Z" }, + { url = "https://files.pythonhosted.org/packages/32/4e/8d7656ccaab3e81e97258b8a9bc5f0c8502513a92fb4ceb0a2cbfebc17bf/numpy-2.5.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3893adc2dc7c0412ba76777db55a049215d99c9aa3113003be8f49f4f1290ab9", size = 16647250, upload-time = "2026-06-21T20:56:51.542Z" }, + { url = "https://files.pythonhosted.org/packages/3c/81/97060281b602ed07f21b12f4ec409eac1f75a2f91fbc829ed8b2becf3ad4/numpy-2.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:835e454dd99b238cdc5a3f63bce2371296f5ebc53ca1e0f8e6ddbb6d92a29aab", size = 16512864, upload-time = "2026-06-21T20:56:55.401Z" }, + { url = "https://files.pythonhosted.org/packages/33/ab/4496208146911f8d8ddb54f68a972aafa6c8d44babcb2ea03b0e5cc87c9d/numpy-2.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6f9836778081a0a3c02a6a21493f3e9f5b311f8d2541934f31f05583dc999ea4", size = 18408407, upload-time = "2026-06-21T20:56:57.75Z" }, + { url = "https://files.pythonhosted.org/packages/d4/9f/a4df67c181e4ee8b467aa3332dc2db10fd5c515136831302f3ca48bc0a01/numpy-2.5.0-cp313-cp313-win32.whl", hash = "sha256:0b525be4744b60bb0557ac872d53ef07d085b5f39622bc579c98d3809d05b988", size = 6054431, upload-time = "2026-06-21T20:57:00.016Z" }, + { url = "https://files.pythonhosted.org/packages/30/53/491e1c47c55b62ccc6a63c1c5b8635c73fc2258dddeb9bda27cae4a0ae96/numpy-2.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:44353e2878930039db472b99dc353d749826e4010bd4d2a7f835e94a97a5c748", size = 12414420, upload-time = "2026-06-21T20:57:01.815Z" }, + { url = "https://files.pythonhosted.org/packages/eb/4a/25c2906f541e9d9f4c5769764db732e6627be91a13f4724fa10634d77db4/numpy-2.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:48f54b00711f83a5f796b70c518e8c2b3c5848dda03a54911f23eb68519b9b60", size = 10339533, upload-time = "2026-06-21T20:57:03.961Z" }, + { url = "https://files.pythonhosted.org/packages/86/ad/abc44aaceaf7b17ee1edde2bbb4458da591bc79574cffff50c4bb35f00d1/numpy-2.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f27582c55ba4c750b7c58c8faf021d2cd9324a662b466229db8a417b41368af9", size = 16783807, upload-time = "2026-06-21T20:57:06.253Z" }, + { url = "https://files.pythonhosted.org/packages/5d/39/b72e168daf9c00fb20c9fc996d00437ccecdef3102387775d29d7a62576d/numpy-2.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:28e7137057d551e4a83c4ae414e3451f50568409db7569aacc7f9811ee06a446", size = 11765215, upload-time = "2026-06-21T20:57:08.547Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a0/8400a9c0e3625182347593f5e1f57da9a617a534794805c8df5518154ddc/numpy-2.5.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:e1da54b53e75cd9fcfc23efcc7edab2c6aecf97b6037566d8a0fe804af8ec57c", size = 5324493, upload-time = "2026-06-21T20:57:11.012Z" }, + { url = "https://files.pythonhosted.org/packages/f6/8c/0d104deaa0401c93395a629ec902891618a2eff76d19229139cb5a887bfc/numpy-2.5.0-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:694d8f74e156f7fd01179f1aa8faa2f648ab6ae0f70b6c3fe57a03249aea2303", size = 6645211, upload-time = "2026-06-21T20:57:12.919Z" }, + { url = "https://files.pythonhosted.org/packages/6a/d9/4a4a628c812750363786afc3d33492709a5cd64b215469c16b0f6c7bb811/numpy-2.5.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1a7569a7b53c77716f036bb28cb1c91f166a26ec7d9502cd1e4bdfe502fdec22", size = 15166004, upload-time = "2026-06-21T20:57:14.717Z" }, + { url = "https://files.pythonhosted.org/packages/a0/5e/2a902317d7fc4aa93236e80c932662dadfc459b323d758329e01775125e1/numpy-2.5.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:39a0433bd4086ebd462960cf375e19195bb07b53dc1d87dd5fcf47ad78576f03", size = 16650797, upload-time = "2026-06-21T20:57:16.906Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a0/a0090e6329f4ca5992c07847bb579c5259a19953dc57255bb08793142ffb/numpy-2.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:929f0c79ac38bcbd7154fe631dc907abfeddbcc5027a896bd1f7767323271e7a", size = 16524647, upload-time = "2026-06-21T20:57:19.165Z" }, + { url = "https://files.pythonhosted.org/packages/5e/7d/6caf27734c42b65837e7461ed0dbbd6b6fc835060c9714ec59d673bb383a/numpy-2.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cc4f247a47bbf070bfd70be53ccdcf47b800af563535e7bbe172322197c30e21", size = 18411841, upload-time = "2026-06-21T20:57:21.638Z" }, + { url = "https://files.pythonhosted.org/packages/13/dc/26edadbd812536769a82c2e9e002234e33feb5da43061d47a044f6d309b7/numpy-2.5.0-cp314-cp314-win32.whl", hash = "sha256:5dc71423499fab3f46f7a7201155ade1669ea101f2f429d332df9e72f8161731", size = 6106361, upload-time = "2026-06-21T20:57:23.844Z" }, + { url = "https://files.pythonhosted.org/packages/f2/9e/4dd1459282229a72d92dece2ae9138e5cac94a72263a7ceb48f37434c925/numpy-2.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:ebb81d9d5443e0309d6c54894c3fbed74ad7da0714352a67b6d773cd189eae73", size = 12551749, upload-time = "2026-06-21T20:57:25.945Z" }, + { url = "https://files.pythonhosted.org/packages/05/a7/6bc6384c080b86c7f6c85c5bc5b540b24f4f679cd144791d99574e90d462/numpy-2.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:3b94d0d0deceebfad3e67ae5c0e5eb87371e8f7a0581cd04a779928c2450cf1e", size = 10617072, upload-time = "2026-06-21T20:57:28.175Z" }, + { url = "https://files.pythonhosted.org/packages/86/6b/4a2b71d66ada5608ae02b63f150dfad520f6940721cb7f029ad270befc0e/numpy-2.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:22f3d43e362d650bc39db1f17851302874a148ca95ba6981c1dfb5fa6862f35b", size = 11881067, upload-time = "2026-06-21T20:57:30.104Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b2/d365eb40a20efb49d67e9feb90494ed8511282ee1f5fa16006675c65397d/numpy-2.5.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:243563efb4cd7528a264567e9fd206c87826457322521d06206a00bfa316c927", size = 5440290, upload-time = "2026-06-21T20:57:32.193Z" }, + { url = "https://files.pythonhosted.org/packages/fa/5e/e9c03188de5f9b767e46a8fe988bcfd3efad066a4a3fda8b9cb11a93f895/numpy-2.5.0-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:84881d825ca75249b189bbee875fcfe3238aa5c479e6100893cda566e8e86826", size = 6748371, upload-time = "2026-06-21T20:57:33.933Z" }, + { url = "https://files.pythonhosted.org/packages/fd/1d/68c186a38a5027bae2c4ddd5ea681fdaf8b4d30fb7301def6d8ad270390f/numpy-2.5.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cda12aa4779d42b8771180aba759c96f527d43446d8f380ab59e2b35e8489efd", size = 15214643, upload-time = "2026-06-21T20:57:35.677Z" }, + { url = "https://files.pythonhosted.org/packages/8c/67/73f67b7c7e20635baae9c4c3ead4ae7326a005900297a6110971abd62eb5/numpy-2.5.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c0121101093d2bd74981b10f8837d78e794a8ff57834eb27179f49e1ba11ac6", size = 16690128, upload-time = "2026-06-21T20:57:38.159Z" }, + { url = "https://files.pythonhosted.org/packages/eb/05/d4c1fb0c46d02a27d6b2b8b319a78c90937acec8631c1641874670b31e6f/numpy-2.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d371c92cfa09da00022f501ab67fafaea813d752eb30ac44336d45b1e5b0268a", size = 16577902, upload-time = "2026-06-21T20:57:40.447Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1d/771c797d50fa26e4888989cccf1d50ee51f530d4e455ad2692dcb64fa711/numpy-2.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9990713e9c38154c6861e7547f1e3fc7a87e75ff09bab24ef1cc81d81c2835e9", size = 18452814, upload-time = "2026-06-21T20:57:42.875Z" }, + { url = "https://files.pythonhosted.org/packages/e8/46/52fc0d2a68d7643f0f149eeea5a5d8ea2a3507056ac8afa83c9212606e8b/numpy-2.5.0-cp314-cp314t-win32.whl", hash = "sha256:edadfbd4794b1086c0d822f81863e8a68fc129d132fd0bb9e31e955d7fbbbdb7", size = 6253168, upload-time = "2026-06-21T20:57:45.101Z" }, + { url = "https://files.pythonhosted.org/packages/2a/be/6c8d1118b5f13b2881dc095d5b345de19c6638b8959c17409b6eff84c8aa/numpy-2.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f7e5fa4382967ae6548bd2f174219afb908e294b0d5f625af01166edd5f7d9aa", size = 12736286, upload-time = "2026-06-21T20:57:46.935Z" }, + { url = "https://files.pythonhosted.org/packages/fd/6a/d3a169aaf8536cf228d56a09e04bcb713a2fe4410d4e2105b9419b5a9c89/numpy-2.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:016623417bb330d719d579daf2d6b9a01ddc52e41a9ed61a47f39fde46dcd865", size = 10686451, upload-time = "2026-06-21T20:57:49.313Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "passlib" +version = "1.7.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/06/9da9ee59a67fae7761aab3ccc84fa4f3f33f125b370f1ccdb915bf967c11/passlib-1.7.4.tar.gz", hash = "sha256:defd50f72b65c5402ab2c573830a6978e5f202ad0d984793c8dde2c4152ebe04", size = 689844, upload-time = "2020-10-08T19:00:52.121Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/a4/ab6b7589382ca3df236e03faa71deac88cae040af60c071a78d254a62172/passlib-1.7.4-py2.py3-none-any.whl", hash = "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1", size = 525554, upload-time = "2020-10-08T19:00:49.856Z" }, +] + +[package.optional-dependencies] +bcrypt = [ + { name = "bcrypt" }, +] + +[[package]] +name = "pgvector" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/25/6c/6d8b4b03b958c02fa8687ec6063c49d952a189f8c91ebbe51e877dfab8f7/pgvector-0.4.2.tar.gz", hash = "sha256:322cac0c1dc5d41c9ecf782bd9991b7966685dee3a00bc873631391ed949513a", size = 31354, upload-time = "2025-12-05T01:07:17.87Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/26/6cee8a1ce8c43625ec561aff19df07f9776b7525d9002c86bceb3e0ac970/pgvector-0.4.2-py3-none-any.whl", hash = "sha256:549d45f7a18593783d5eec609ea1684a724ba8405c4cb182a0b2b08aeff04e08", size = 27441, upload-time = "2025-12-05T01:07:16.536Z" }, +] + +[[package]] +name = "pillow" +version = "12.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" }, + { url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" }, + { url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" }, + { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" }, + { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" }, + { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" }, + { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" }, + { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" }, + { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" }, + { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" }, + { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" }, + { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" }, + { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" }, + { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" }, + { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" }, + { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" }, + { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" }, + { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" }, + { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" }, + { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" }, + { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" }, + { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" }, + { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" }, + { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" }, + { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" }, + { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" }, + { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" }, + { url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" }, + { url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" }, + { url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" }, + { url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" }, + { url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" }, + { url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" }, + { url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" }, + { url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" }, + { url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" }, + { url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" }, + { url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" }, + { url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" }, + { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "psycopg2-binary" +version = "2.9.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2a/60/a3624f79acea344c16fbef3a94d28b89a8042ddfb8f3e4ca83f538671409/psycopg2_binary-2.9.12.tar.gz", hash = "sha256:5ac9444edc768c02a6b6a591f070b8aae28ff3a99be57560ac996001580f294c", size = 379686, upload-time = "2026-04-21T09:40:34.304Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/9f/ef4ef3c8e15083df90ca35265cfd1a081a2f0cc07bb229c6314c6af817f4/psycopg2_binary-2.9.12-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5cdc05117180c5fa9c40eea8ea559ce64d73824c39d928b7da9fb5f6a9392433", size = 3712459, upload-time = "2026-04-20T23:34:30.549Z" }, + { url = "https://files.pythonhosted.org/packages/b5/01/3dd14e46ba48c1e1a6ec58ee599fa1b5efa00c246d5046cd903d0eeb1af1/psycopg2_binary-2.9.12-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d3227a3bc228c10d21011a99245edca923e4e8bf461857e869a507d9a41fe9f6", size = 3822936, upload-time = "2026-04-20T23:34:32.77Z" }, + { url = "https://files.pythonhosted.org/packages/a6/f7/0640e4901119d8a9f7a1784b927f494e2198e213ceb593753d1f2c8b1b30/psycopg2_binary-2.9.12-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:995ce929eede89db6254b50827e2b7fd61e50d11f0b116b29fffe4a2e53c4580", size = 4578676, upload-time = "2026-04-20T23:34:35.18Z" }, + { url = "https://files.pythonhosted.org/packages/b0/55/44df3965b5f297c50cc0b1b594a31c67d6127a9d133045b8a66611b14dfb/psycopg2_binary-2.9.12-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9fe06d93e72f1c048e731a2e3e7854a5bfaa58fc736068df90b352cefe66f03f", size = 4274917, upload-time = "2026-04-20T23:34:37.982Z" }, + { url = "https://files.pythonhosted.org/packages/b0/4b/74535248b1eac0c9336862e8617c765ac94dac76f9e25d7c4a79588c8907/psycopg2_binary-2.9.12-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40e7b28b63aaf737cb3a1edc3a9bbc9a9f4ad3dcb7152e8c1130e4050eddcb7d", size = 5894843, upload-time = "2026-04-20T23:34:40.856Z" }, + { url = "https://files.pythonhosted.org/packages/f2/ba/f1bf8d2ae71868ad800b661099086ee52bc0f8d9f05be1acd8ebb06757cc/psycopg2_binary-2.9.12-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:89d19a9f7899e8eb0656a2b3a08e0da04c720a06db6e0033eab5928aabe60fa9", size = 4110556, upload-time = "2026-04-20T23:34:44.016Z" }, + { url = "https://files.pythonhosted.org/packages/45/46/c15706c338403b7c420bcc0c2905aad116cc064545686d8bf85f1999ea00/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:612b965daee295ae2da8f8218ce1d274645dc76ef3f1abf6a0a94fd57eff876d", size = 3655714, upload-time = "2026-04-20T23:34:46.233Z" }, + { url = "https://files.pythonhosted.org/packages/b3/7c/a2d5dc09b64a4564db242a0fe418fde7d33f6f8259dd2c5b9d7def00fb5a/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b9a339b79d37c1b45f3235265f07cdeb0cb5ad7acd2ac7720a5920989c17c24e", size = 3301154, upload-time = "2026-04-20T23:34:49.528Z" }, + { url = "https://files.pythonhosted.org/packages/c0/e8/cc8c9a4ce71461f9ec548d38cadc41dc184b34c73e6455450775a9334ccd/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:3471336e1acfd9c7fe507b8bad5af9317b6a89294f9eb37bd9a030bb7bebcdc6", size = 3048882, upload-time = "2026-04-20T23:34:51.86Z" }, + { url = "https://files.pythonhosted.org/packages/19/6a/31e2296bc0787c5ab75d3d118e40b239db8151b5192b90b77c72bc9256e9/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7af18183109e23502c8b2ae7f6926c0882766f35b5175a4cd737ad825e4d7a1b", size = 3351298, upload-time = "2026-04-20T23:34:54.124Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a8/75f4e3e11203b590150abed2cf7794b9c9c9f7eceddae955191138b44dde/psycopg2_binary-2.9.12-cp312-cp312-win_amd64.whl", hash = "sha256:398fcd4db988c7d7d3713e2b8e18939776fd3fb447052daae4f24fa39daede4c", size = 2757230, upload-time = "2026-04-20T23:34:56.242Z" }, + { url = "https://files.pythonhosted.org/packages/91/bb/4608c96f970f6e0c56572e87027ef4404f709382a3503e9934526d7ba051/psycopg2_binary-2.9.12-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7c729a73c7b1b84de3582f73cdd27d905121dc2c531f3d9a3c32a3011033b965", size = 3712419, upload-time = "2026-04-20T23:34:58.754Z" }, + { url = "https://files.pythonhosted.org/packages/5e/af/48f76af9d50d61cf390f8cd657b503168b089e2e9298e48465d029fcc713/psycopg2_binary-2.9.12-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4413d0caef93c5cf50b96863df4c2efe8c269bf2267df353225595e7e15e8df7", size = 3822990, upload-time = "2026-04-20T23:35:00.821Z" }, + { url = "https://files.pythonhosted.org/packages/7a/df/aba0f99397cd811d32e06fc0cc781f1f3ce98bc0e729cb423925085d781a/psycopg2_binary-2.9.12-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4dfcf8e45ebb0c663be34a3442f65e17311f3367089cd4e5e3a3e8e62c978777", size = 4578696, upload-time = "2026-04-20T23:35:03.409Z" }, + { url = "https://files.pythonhosted.org/packages/95/9c/eaa74021ac4e4d5c2f83d82fc6615a63f4fe6c94dc4e94c3990427053f67/psycopg2_binary-2.9.12-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c41321a14dd74aceb6a9a643b9253a334521babfa763fa873e33d89cfa122fb5", size = 4274982, upload-time = "2026-04-20T23:35:05.583Z" }, + { url = "https://files.pythonhosted.org/packages/35/ed/c25deff98bd26187ba48b3b250a3ffc3037c46c5b89362534a15d200e0db/psycopg2_binary-2.9.12-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83946ba43979ebfdc99a3cd0ee775c89f221df026984ba19d46133d8d75d3cd9", size = 5894867, upload-time = "2026-04-20T23:35:07.902Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/8d0e21ca77373c6c9589e5c4528f6e8f0c08c62cafc76fb0bddb7a2cee22/psycopg2_binary-2.9.12-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:411e85815652d13560fbe731878daa5d92378c4995a22302071890ec3397d019", size = 4110578, upload-time = "2026-04-20T23:35:10.149Z" }, + { url = "https://files.pythonhosted.org/packages/00/fc/f481e2435bd8f742d0123309174aae4165160ad3ef17c1b99c3622c241d2/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c8ad4c08e00f7679559eaed7aff1edfffc60c086b976f93972f686384a95e2c", size = 3655816, upload-time = "2026-04-20T23:35:12.56Z" }, + { url = "https://files.pythonhosted.org/packages/53/79/b9f46466bdbe9f239c96cde8be33c1aace4842f06013b47b730dc9759187/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:00814e40fa23c2b37ef0a1e3c749d89982c73a9cb5046137f0752a22d432e82f", size = 3301307, upload-time = "2026-04-20T23:35:15.029Z" }, + { url = "https://files.pythonhosted.org/packages/3f/19/7dc003b32fe35024df89b658104f7c8538a8b2dcbde7a4e746ce929742e7/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:98062447aebc20ed20add1f547a364fd0ef8933640d5372ff1873f8deb9b61be", size = 3048968, upload-time = "2026-04-20T23:35:16.757Z" }, + { url = "https://files.pythonhosted.org/packages/91/58/2dbd7db5c604d45f4950d988506aae672a14126ec22998ced5021cbb76bb/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:66a7685d7e548f10fb4ce32fb01a7b7f4aa702134de92a292c7bd9e0d3dbd290", size = 3351369, upload-time = "2026-04-20T23:35:18.933Z" }, + { url = "https://files.pythonhosted.org/packages/42/ee/dee8dcaad07f735824de3d6563bc67119fa6c28257b17977a8d624f02fab/psycopg2_binary-2.9.12-cp313-cp313-win_amd64.whl", hash = "sha256:b6937f5fe4e180aeee87de907a2fa982ded6f7f15d7218f78a083e4e1d68f2a0", size = 2757347, upload-time = "2026-04-20T23:35:21.283Z" }, + { url = "https://files.pythonhosted.org/packages/13/1b/708c0dca874acfad6d65314271859899a79007686f3a1f74e82a2ed4b645/psycopg2_binary-2.9.12-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6f3b3de8a74ef8db215f22edffb19e32dc6fa41340456de7ec99efdc8a7b3ec2", size = 3712428, upload-time = "2026-04-20T23:35:23.453Z" }, + { url = "https://files.pythonhosted.org/packages/d6/39/ddbea9d4b4de6aca9431b6ed253f530f8a02d3b8f9bcfd0dbfe2b3de6fe4/psycopg2_binary-2.9.12-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1006fb62f0f0bc5ce256a832356c6262e91be43f5e4eb15b5eaf38079464caf2", size = 3823184, upload-time = "2026-04-20T23:35:25.92Z" }, + { url = "https://files.pythonhosted.org/packages/bf/a0/bc2fef74b106fa345567122a0659e6d94512ed7dc0131ec44c9e5aba3725/psycopg2_binary-2.9.12-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:840066105706cd2eb29b9a1c2329620056582a4bf3e8169dec5c447042d0869f", size = 4579157, upload-time = "2026-04-20T23:35:28.542Z" }, + { url = "https://files.pythonhosted.org/packages/57/d7/d4e3b2005d3de607ca4fbb0e8742e248056e52184a6b94ebda3c1c2c329b/psycopg2_binary-2.9.12-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:863f5d12241ebe1c76a72a04c2113b6dc905f90b9cef0e9be0efd994affd9354", size = 4274970, upload-time = "2026-04-20T23:35:30.418Z" }, + { url = "https://files.pythonhosted.org/packages/2e/42/c9853f8db3967fe08bcde11f53d53b85d351750cae726ce001cb68afa9c1/psycopg2_binary-2.9.12-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a99eaab34a9010f1a086b126de467466620a750634d114d20455f3a824aae033", size = 5895175, upload-time = "2026-04-20T23:35:33.584Z" }, + { url = "https://files.pythonhosted.org/packages/eb/fd/b82b5601a97630308bef079f545ffec481bbbc795c2ba5ec416a01d03f60/psycopg2_binary-2.9.12-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ffdd7dc5463ccd61845ac37b7012d0f35a1548df9febe14f8dd549be4a0bc81e", size = 4110658, upload-time = "2026-04-20T23:35:35.638Z" }, + { url = "https://files.pythonhosted.org/packages/62/8c/32ca69b0389ef25dd22937bf9e8fbe2ce27aea20b05ded48c4ce4cb42475/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:54a0dfecab1b48731f934e06139dfe11e24219fb6d0ceb32177cf0375f14c7b5", size = 3656251, upload-time = "2026-04-20T23:35:37.854Z" }, + { url = "https://files.pythonhosted.org/packages/c4/29/96992a2b59e3b9d730fcf9612d0a387305025dc867a9fc490a9e496e074e/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:96937c9c5d891f772430f418a7a8b4691a90c3e6b93cf72b5bd7cad8cbca32a5", size = 3301810, upload-time = "2026-04-20T23:35:39.927Z" }, + { url = "https://files.pythonhosted.org/packages/56/ad/44b06659949b243ae10112cd3b20a197f9bf3e81d5651379b9eb889bfaad/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:77b348775efd4cdab410ec6609d81ccecd1139c90265fa583a7255c8064bc03d", size = 3048977, upload-time = "2026-04-20T23:35:41.806Z" }, + { url = "https://files.pythonhosted.org/packages/1d/f2/10a1bcebadb6aa55e280e1f58975c36a7b560ea525184c7aa4064c466633/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:527e6342b3e44c2f0544f6b8e927d60de7f163f5723b8f1dfa7d2a84298738cd", size = 3351466, upload-time = "2026-04-20T23:35:43.993Z" }, + { url = "https://files.pythonhosted.org/packages/20/be/b732c8418ffa5bcfda002890f5dc4c869fc17db66ff11f53b17cfe44afc0/psycopg2_binary-2.9.12-cp314-cp314-win_amd64.whl", hash = "sha256:f12ae41fcafadb39b2785e64a40f9db05d6de2ac114077457e0e7c597f3af980", size = 2848762, upload-time = "2026-04-20T23:35:46.421Z" }, +] + +[[package]] +name = "pyasn1" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/60/1d1e59c9c90d54591469ada7d268251f71c24bdb765f1a8a832cee8c6653/pydantic_settings-2.14.1.tar.gz", hash = "sha256:e874d3bec7e787b0c9958277956ed9b4dd5de6a80e162188fdaff7c5e26fd5fa", size = 235551, upload-time = "2026-05-08T13:40:06.542Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/8d/f1af3832f5e6eb13ba94ee809e72b8ecb5eef226d27ee0bef7d963d943c7/pydantic_settings-2.14.1-py3-none-any.whl", hash = "sha256:6e3c7edfd8277687cdc598f56e5cff0e9bfff0910a3749deaa8d4401c3a2b9de", size = 60964, upload-time = "2026-05-08T13:40:04.958Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "pytest-dotenv" +version = "0.5.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "python-dotenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/b0/cafee9c627c1bae228eb07c9977f679b3a7cb111b488307ab9594ba9e4da/pytest-dotenv-0.5.2.tar.gz", hash = "sha256:2dc6c3ac6d8764c71c6d2804e902d0ff810fa19692e95fe138aefc9b1aa73732", size = 3782, upload-time = "2020-06-16T12:38:03.4Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/da/9da67c67b3d0963160e3d2cbc7c38b6fae342670cc8e6d5936644b2cf944/pytest_dotenv-0.5.2-py3-none-any.whl", hash = "sha256:40a2cece120a213898afaa5407673f6bd924b1fa7eafce6bda0e8abffe2f710f", size = 3993, upload-time = "2020-06-16T12:38:01.139Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "python-jose" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ecdsa" }, + { name = "pyasn1" }, + { name = "rsa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/77/3a1c9039db7124eb039772b935f2244fbb73fc8ee65b9acf2375da1c07bf/python_jose-3.5.0.tar.gz", hash = "sha256:fb4eaa44dbeb1c26dcc69e4bd7ec54a1cb8dd64d3b4d81ef08d90ff453f2b01b", size = 92726, upload-time = "2025-05-28T17:31:54.288Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/c3/0bd11992072e6a1c513b16500a5d07f91a24017c5909b02c72c62d7ad024/python_jose-3.5.0-py2.py3-none-any.whl", hash = "sha256:abd1202f23d34dfad2c3d28cb8617b90acf34132c7afd60abd0b0b7d3cb55771", size = 34624, upload-time = "2025-05-28T17:31:52.802Z" }, +] + +[package.optional-dependencies] +cryptography = [ + { name = "cryptography" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "rapidfuzz" +version = "3.14.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/21/ef6157213316e85790041254259907eb722e00b03480256c0545d98acd33/rapidfuzz-3.14.5.tar.gz", hash = "sha256:ba10ac57884ce82112f7ed910b67e7fb6072d8ef2c06e30dc63c0f604a112e0e", size = 57901753, upload-time = "2026-04-07T11:16:31.931Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/e3/574435c6aafb80254c191ef40d7aca2cb2bb97a095ec9395e9fa59ac307a/rapidfuzz-3.14.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0d3378f471ef440473a396ce2f8e97ee12f89a78b495540e0a5617bbfe895638", size = 1944601, upload-time = "2026-04-07T11:14:18.771Z" }, + { url = "https://files.pythonhosted.org/packages/d0/1f/fbad3102a255ecc112ce9a7e779bacab7fd14398217be8868dc9082ba363/rapidfuzz-3.14.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e910eebca9fd0eba245c0555e764597e8a0cccb673a92da2dc2397050725f48", size = 1164293, upload-time = "2026-04-07T11:14:20.534Z" }, + { url = "https://files.pythonhosted.org/packages/88/37/a3eb7ff6121ed3a5f199a8c38cc86c8e481816f879cb0e0b738b078c9a7e/rapidfuzz-3.14.5-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:01550fe5f60fd176aa66b7611289d46dc4aa4b1b904874c7b6d1d54e581c5ec1", size = 1371999, upload-time = "2026-04-07T11:14:22.63Z" }, + { url = "https://files.pythonhosted.org/packages/79/72/97a9728c711c7c1b06e107d3f0623880fb4ef90e147ed13c551a1730e7cc/rapidfuzz-3.14.5-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:48bee0b91bebfaec41e1081e351000659ab7570cc4598d617aa04d5bf827f9e6", size = 3145715, upload-time = "2026-04-07T11:14:24.508Z" }, + { url = "https://files.pythonhosted.org/packages/ed/54/d5caabbea233ac90c286c87c260e49d7641467e87438a18d858e41c82e91/rapidfuzz-3.14.5-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:7e580cb04ad849ae9b786fa21383c6b994b6e6c1444ad1cb9f22392759d72741", size = 1456304, upload-time = "2026-04-07T11:14:26.515Z" }, + { url = "https://files.pythonhosted.org/packages/fc/a7/2d1a81250ac8c01a0100c026018e76f0e7a097ff63e4c553e02a6938c6fb/rapidfuzz-3.14.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:09d6c9ba091854f07817055d795d604179c12a8f308ba4c7d56f3719dfea1646", size = 2389089, upload-time = "2026-04-07T11:14:28.635Z" }, + { url = "https://files.pythonhosted.org/packages/65/0d/c47c3872203ae88e6506997c0b576ad731f5261daa25d559be09c9756658/rapidfuzz-3.14.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1e989f86113be66574113b9c7bdf4793f3f863d248e47d911b355e05ca6b6b10", size = 2493404, upload-time = "2026-04-07T11:14:30.577Z" }, + { url = "https://files.pythonhosted.org/packages/8f/2f/71e0a5a3130792146c8a200a2dd1e52aa16f7c1074012e17f2601eea9a90/rapidfuzz-3.14.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0ebd1a18e2e47bc0b292a07e6ed9c3642f8aaa672d12253885f599b50807a4f9", size = 4251709, upload-time = "2026-04-07T11:14:32.451Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/d39874901abacef325adb5b34ae416817c8486dfb4fb87c7a9b74ec5b072/rapidfuzz-3.14.5-cp312-cp312-win32.whl", hash = "sha256:9981d38a703b86f0e315a3cd229fd1906fe1d91c989ed121fb975b3c849f89f5", size = 1710069, upload-time = "2026-04-07T11:14:34.37Z" }, + { url = "https://files.pythonhosted.org/packages/85/0b/f65572c53de8a1c704bda707f63a447b67bdbe95d7cdc70d18885e191df5/rapidfuzz-3.14.5-cp312-cp312-win_amd64.whl", hash = "sha256:d8375e3da319593389727c3187ccaf3e0e84199accc530866b8e0f2b79af05e9", size = 1540630, upload-time = "2026-04-07T11:14:36.287Z" }, + { url = "https://files.pythonhosted.org/packages/5e/c3/143be3a578f989758cae516f3270d5cbb49783a7bfdf57cc27a670e00456/rapidfuzz-3.14.5-cp312-cp312-win_arm64.whl", hash = "sha256:478b59bb018a6780d73f33e38d0b3ec5e968a6c1ed42876b993dd456b7aa20e8", size = 813137, upload-time = "2026-04-07T11:14:38.289Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/252803f2010ba699618cdc048b6e1f7cc1f433c08b4a9a17579b92ab0142/rapidfuzz-3.14.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ebd8fd343bf8492a1e60bcb6dc99f90f74f65d98d8241a6b3e1fed225b76ecd6", size = 1940205, upload-time = "2026-04-07T11:14:40.319Z" }, + { url = "https://files.pythonhosted.org/packages/ea/59/b2afd98e41af9cd54554a4c1c423d84cdd60e6b1c0a09496f033b55f60ec/rapidfuzz-3.14.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6737b35d5af7479c5bf9710f7b17edd9d2c43128d974d25fb4ea653e42c64609", size = 1159639, upload-time = "2026-04-07T11:14:42.52Z" }, + { url = "https://files.pythonhosted.org/packages/a3/31/7aa7e62c4c516a7af322ed0c4f0774208b72d457d0cfec808bad0df12f4a/rapidfuzz-3.14.5-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b002c7994cc9f2bc9d9856f0fbaee6e8072c983873846c92f25cefba5b2a925f", size = 1367194, upload-time = "2026-04-07T11:14:44.25Z" }, + { url = "https://files.pythonhosted.org/packages/90/79/2fc252a63bc91d3c3b234d0a3a6ad4ebc460037a23cdcdaf9285f986e6c9/rapidfuzz-3.14.5-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:17a34330cd2a538c1ce5d400b61ba358c5b72c654b928ff87b362e88f8b864c7", size = 3151805, upload-time = "2026-04-07T11:14:46.21Z" }, + { url = "https://files.pythonhosted.org/packages/17/54/0c83508f2683ea70e2d05f8527eb07328acf7bb1e9d97a3bece5702378e7/rapidfuzz-3.14.5-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:95d937e74c1a7a1287dfb03b62a827be08ede10a155cf1af73bbf47f2b73ee6e", size = 1455667, upload-time = "2026-04-07T11:14:47.991Z" }, + { url = "https://files.pythonhosted.org/packages/71/1b/070175e873177814d58850a01ebe80e20ae11e93eb4da894d563988660fa/rapidfuzz-3.14.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:46b92a9970dcc34f0096901c792644094cab49554ac3547f35e3aebbdf0a3610", size = 2388246, upload-time = "2026-04-07T11:14:50.098Z" }, + { url = "https://files.pythonhosted.org/packages/c9/dd/77caf7aaf9c2be050ad1f128d7c24ff0f59079aa62c5f62f9df41c0af45e/rapidfuzz-3.14.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e012177c8e8a8a0754ae0d6027d63042aa5ff036d9f40f07cb3466a6082e21b8", size = 2494333, upload-time = "2026-04-07T11:14:52.303Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/dd7e1f2aa31a8fbbfc16b0610af1d770ffaf1287490f3c8c5b1c52da264f/rapidfuzz-3.14.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a2ae6f53f99c9a0eca7a0afc5b4e45fc73bc1dd4ac74c00509031d76df80ed98", size = 4258579, upload-time = "2026-04-07T11:14:54.538Z" }, + { url = "https://files.pythonhosted.org/packages/9c/0a/ac99e1ba347ba0e85e0bb60b74231d55fb93c0eff43f2920ccb413d0be08/rapidfuzz-3.14.5-cp313-cp313-win32.whl", hash = "sha256:4a60f0057231188e3bd30216f7b4e0f279b11fa4ec818bb6c1d9f014d1562fbc", size = 1709231, upload-time = "2026-04-07T11:14:56.524Z" }, + { url = "https://files.pythonhosted.org/packages/cf/cb/0e251d731b3166378644238e8f0cf9e89858c024e19f75ca9f7e3ae83fd5/rapidfuzz-3.14.5-cp313-cp313-win_amd64.whl", hash = "sha256:11bfc2ed8fbe4ab86bd516fadefab126f90e6dcadffa761739fcb304707dfd35", size = 1538519, upload-time = "2026-04-07T11:14:58.635Z" }, + { url = "https://files.pythonhosted.org/packages/30/6f/4548132acc947db6d5346a248e44a8b3a22d608ef30e770fb578caaf2d00/rapidfuzz-3.14.5-cp313-cp313-win_arm64.whl", hash = "sha256:b486b5218808f6f4dc471b114b1054e63553db69705c97da0271f47bd706aedd", size = 812628, upload-time = "2026-04-07T11:15:00.552Z" }, + { url = "https://files.pythonhosted.org/packages/00/60/69b177577290c5eab892c6f75fe89c3aff3f9ae80298a78d9372b1cecb9a/rapidfuzz-3.14.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:39ef8658aaf67d51667e7bdaf7096f432333377d8302ac43c70b5df8a4cf89b8", size = 1970231, upload-time = "2026-04-07T11:15:02.603Z" }, + { url = "https://files.pythonhosted.org/packages/48/38/2fd790052659cc4e2907b63c25433f0987864b445c1aeec1a302ef5ad948/rapidfuzz-3.14.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:9ad37a0be705b544af6296da8edddc260d10a8ae5462530fc9991f66498bb1f9", size = 1194394, upload-time = "2026-04-07T11:15:04.572Z" }, + { url = "https://files.pythonhosted.org/packages/80/f4/28430ad8472fc3536e8ebd51a864a226e979cfe924c6e3f83d111373aa74/rapidfuzz-3.14.5-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d45e06f60729e07d9b20c205f7e5cff90b6ef2584e852eecf46e045aea69627d", size = 1377051, upload-time = "2026-04-07T11:15:06.728Z" }, + { url = "https://files.pythonhosted.org/packages/77/7e/9aeacabcfd1e77397968362e5b98fe14248b8307011136b17daf99752a8e/rapidfuzz-3.14.5-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e52da10236aa6212de71b9e170bace65b64b129c0dea7fc243d6c9ce976f5074", size = 3160565, upload-time = "2026-04-07T11:15:08.667Z" }, + { url = "https://files.pythonhosted.org/packages/56/f4/db4dd7be0cd2f2022117ac5407d905f435d60e48baaea313a567ad27e865/rapidfuzz-3.14.5-cp313-cp313t-manylinux_2_39_riscv64.whl", hash = "sha256:440d30faaf682ca496170a7f0cc5453ec942e3e079f0fd802c9a7f938dfb50a3", size = 1442113, upload-time = "2026-04-07T11:15:11.138Z" }, + { url = "https://files.pythonhosted.org/packages/a4/99/0e9f6aa57f3e32a767216f797e56dc96b720fcecfb9d8ee907ecc82f8d66/rapidfuzz-3.14.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:56227a61fd3d17b0cd9793132431f3a3d07c8654be96794ba9f89fe0fc8b2d09", size = 2396618, upload-time = "2026-04-07T11:15:13.154Z" }, + { url = "https://files.pythonhosted.org/packages/60/94/44a78e39ffce17cbdd3e2b53b696acc751d5d153be0f499d052b07a4d904/rapidfuzz-3.14.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:2e83cd2e25bb4edd97b689d9979d9c3acccdaaf26ceac08212ceece202febcfa", size = 2478220, upload-time = "2026-04-07T11:15:15.193Z" }, + { url = "https://files.pythonhosted.org/packages/dd/df/454311469a09a507e9d784a35796742bec22e4cebe75551e2da4e0e290fd/rapidfuzz-3.14.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:af3b859726cd3374287e405e14b9634563c078c5531a4f62375508addebddad1", size = 4265027, upload-time = "2026-04-07T11:15:17.28Z" }, + { url = "https://files.pythonhosted.org/packages/fc/01/175465a9ab3e3b70ba669058372f009d1d49c1746e2dcd56b69df188d3a5/rapidfuzz-3.14.5-cp313-cp313t-win32.whl", hash = "sha256:8ce1d850b3c0178440efde9e884d98421b5e87ff925f364d6d79e23910d7593f", size = 1766814, upload-time = "2026-04-07T11:15:19.687Z" }, + { url = "https://files.pythonhosted.org/packages/1b/a0/a9b84a47af06ebed94a1439eb2f02adebfb8628bcd30af1fe3e02f5ef56c/rapidfuzz-3.14.5-cp313-cp313t-win_amd64.whl", hash = "sha256:c84af70bcf34e99aee894e46a0f1ac77f17d0ef828179c387407642e2466d28a", size = 1582448, upload-time = "2026-04-07T11:15:21.98Z" }, + { url = "https://files.pythonhosted.org/packages/1e/f1/5937800238b3f8248e70860d79f69ba8f73e764fff47e36bc9e2f26dbcc6/rapidfuzz-3.14.5-cp313-cp313t-win_arm64.whl", hash = "sha256:aac0ad28c686a5e72b81668b906c030ee28050b244544b8af68e12fb32543895", size = 832932, upload-time = "2026-04-07T11:15:24.358Z" }, + { url = "https://files.pythonhosted.org/packages/81/41/aa3ffb3355e62e1bf91f6599b3092e866bc88487a07c524004943c7676df/rapidfuzz-3.14.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1a31cc6d7d03e7318a0974c038959c59e19c752b81115f2e9138b3331cd64d45", size = 1943327, upload-time = "2026-04-07T11:15:26.266Z" }, + { url = "https://files.pythonhosted.org/packages/2d/e1/c2141f1840a41e07ad2db6f724945f8f8ff3065463899a22939152dd6e09/rapidfuzz-3.14.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0298d357e2bc59d572da4db0bc631009b6f8f6c9bc8c11e99a12b833f16b6575", size = 1161755, upload-time = "2026-04-07T11:15:28.659Z" }, + { url = "https://files.pythonhosted.org/packages/ca/07/66e753eeaa353161d1d331b7dd517bb349b0bacfebe8496d7b26be26f81f/rapidfuzz-3.14.5-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:59b3dba758661a318995655435c6ab20a04ade79fa51e75bc8dc107cac8df280", size = 1376571, upload-time = "2026-04-07T11:15:31.225Z" }, + { url = "https://files.pythonhosted.org/packages/c8/85/9535df0b78ba51f478c9ce7eb6d1f85535cc31fe356773b48fd9d3e563ca/rapidfuzz-3.14.5-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4900143d82071bdda533b00300c40b14b963ff826b3642cc463b6dd0f036585e", size = 3156468, upload-time = "2026-04-07T11:15:33.428Z" }, + { url = "https://files.pythonhosted.org/packages/81/ee/b667eb93bba6dc4e0de658edd778e1619dc4d6aab68fa5e5c7f075152735/rapidfuzz-3.14.5-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:feedf219672eef83ea6be6f3bb093bba396a8560fc75be85ba225f082903df0a", size = 1458311, upload-time = "2026-04-07T11:15:35.557Z" }, + { url = "https://files.pythonhosted.org/packages/7d/ce/479074f5624364a48df3403c538797ef22d3ac49c19dc76c3f79fcdcc70c/rapidfuzz-3.14.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:419e4397a36e2665ec992d8d64c20ba4b2a42500c76ecadeca78a4f19cb9cc32", size = 2398228, upload-time = "2026-04-07T11:15:37.669Z" }, + { url = "https://files.pythonhosted.org/packages/0b/15/a8982f649150fffbdcd6f17565974501f6ab33b2795267bffbd4a7ba905b/rapidfuzz-3.14.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:97131ab2be39043054ee28d99e09efe316e6d53449b7e962dfcf3c2de8b2b246", size = 2497226, upload-time = "2026-04-07T11:15:39.857Z" }, + { url = "https://files.pythonhosted.org/packages/19/52/5267c03ef6759831b7d4625a0c9c06e87baa2fae084b61ac9c388858317b/rapidfuzz-3.14.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:593c00dac4e30231c35bf3b4f1da8ec0998762e9e94425586a5d636fcd57f9d0", size = 4262283, upload-time = "2026-04-07T11:15:42.279Z" }, + { url = "https://files.pythonhosted.org/packages/71/c0/2579f343a97f5254c43bb5853baccc01488357dcb64a27bcb869b7888a4a/rapidfuzz-3.14.5-cp314-cp314-win32.whl", hash = "sha256:0084b687b02b4e569b46d8d6d4ad25659528e6081cd6d067ca453a69035f07e4", size = 1744614, upload-time = "2026-04-07T11:15:44.498Z" }, + { url = "https://files.pythonhosted.org/packages/17/eb/8edfed1e80119dc9c35b11df4bc701eea85622ad681fff0263b6961d3224/rapidfuzz-3.14.5-cp314-cp314-win_amd64.whl", hash = "sha256:5dfa89d78f22cd773054caff44827b846161a29f2dcf7e78b8f90d086621e502", size = 1588971, upload-time = "2026-04-07T11:15:46.86Z" }, + { url = "https://files.pythonhosted.org/packages/f6/04/5676df93c85cfa57a3045d8047318df9f3cd58c7b8a99340dd95f874795e/rapidfuzz-3.14.5-cp314-cp314-win_arm64.whl", hash = "sha256:67f3f9d2b444268ab53e47d31bab89954888d23c04c6789f2c727e51fe4b1d13", size = 834985, upload-time = "2026-04-07T11:15:49.411Z" }, + { url = "https://files.pythonhosted.org/packages/f7/0d/4a8988cea658fe335048ddef8c876addff1b6daa3c9ca8ad65a5a2196e69/rapidfuzz-3.14.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:77eac0526899b3c3ad1454bb2b03cdb491d67358ec8ef0c9c48bd61b632b431d", size = 1972517, upload-time = "2026-04-07T11:15:51.819Z" }, + { url = "https://files.pythonhosted.org/packages/1c/a3/f5cfd9965a9d9a9e32249159797c47b5d6299ea6d1629f9126b25f1c10a3/rapidfuzz-3.14.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b9c6bd754d11f6e78ac54e3d86b4b11dc1ba2f13e5fc958899574532897f5a99", size = 1196056, upload-time = "2026-04-07T11:15:54.292Z" }, + { url = "https://files.pythonhosted.org/packages/64/07/561c2e40cfd10e6630a7b0ac5a2a813aef50d944bcd1f3d260319d659d5b/rapidfuzz-3.14.5-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:738c96944d076deeaff70e92b65696ab4f7ecb8081d7791c5403a3257dfaf8ff", size = 1374732, upload-time = "2026-04-07T11:15:56.584Z" }, + { url = "https://files.pythonhosted.org/packages/c2/39/123bb94fee40e2fb3b7c49b80827c7ef42d838e18def3fc2fef5a3cf817a/rapidfuzz-3.14.5-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4c1bca487a17fe4226b4ffb2d30e799d2b274d692cffa76bd0746f56235fca3", size = 3166902, upload-time = "2026-04-07T11:15:58.768Z" }, + { url = "https://files.pythonhosted.org/packages/75/0a/45716fafc9fd2e028cf20b5ac5bc704887081cd312f84edb0e325599414b/rapidfuzz-3.14.5-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:af6a90a4ed2a48fa1a2d17e9d824e6c7c950bea5bad0b707c77fd55751e6bfef", size = 1452130, upload-time = "2026-04-07T11:16:01.453Z" }, + { url = "https://files.pythonhosted.org/packages/ca/49/4e96c413114398481c0a5b0086af32c364a18613c9a2ea578d17c4bea4ee/rapidfuzz-3.14.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bf5018938208d4597b2e679a4f8cff9fd252f1df53583130ae56281a21801b64", size = 2396308, upload-time = "2026-04-07T11:16:03.588Z" }, + { url = "https://files.pythonhosted.org/packages/89/b7/49fea9fc6878d59bd259d01dd1972d9b86117992b1c66d9b16f0a65273c3/rapidfuzz-3.14.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c0919d1f89ddf91129906705723118ea09754171e4116f5a5dbc667c7bc9b261", size = 2488210, upload-time = "2026-04-07T11:16:05.871Z" }, + { url = "https://files.pythonhosted.org/packages/0c/44/a1f732b93ffacbdad077b7c801149549b2938e1bece6addb5ad85ed74df8/rapidfuzz-3.14.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:93d8da883a35116d6813432177f35e570db5b0a5e30ecb0cbd7cb39c815735df", size = 4270621, upload-time = "2026-04-07T11:16:08.483Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ce/ff942d19fce5385054650bb71a58495ddda299d94661ccc4e6e7fa44868b/rapidfuzz-3.14.5-cp314-cp314t-win32.whl", hash = "sha256:0f23e37019ec07712d58976b1ab2b889f8649a7f7c2f626a2f34ea9139e79279", size = 1803950, upload-time = "2026-04-07T11:16:10.873Z" }, + { url = "https://files.pythonhosted.org/packages/5c/0f/9aafc63f9661222b819b391c187eed29fc90ad5935f9690e5ecc2d2047a4/rapidfuzz-3.14.5-cp314-cp314t-win_amd64.whl", hash = "sha256:7d5ca9c7832e6879a707296d1463685f7c243a27846227044504741640caec66", size = 1632357, upload-time = "2026-04-07T11:16:13.1Z" }, + { url = "https://files.pythonhosted.org/packages/70/a6/51fc1b0e61e3326e1c68a61cfd0c6b3c34c843681c4b1eefbf0596f59162/rapidfuzz-3.14.5-cp314-cp314t-win_arm64.whl", hash = "sha256:3e91dcd2549b8f8d843f98ba03a17e01f3d8b72ce942adbbb6761bc58ffce813", size = 855409, upload-time = "2026-04-07T11:16:15.787Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "resend" +version = "2.32.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dc/d5/2c46334104cce931170d756c7478263744b3eb168179c329d90a065f9b6b/resend-2.32.2.tar.gz", hash = "sha256:448001810f32e7aea39bab27318263fdc69f5764be16b7c6241ed7714dc07ea3", size = 46202, upload-time = "2026-06-17T19:47:28.243Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/e9/64b7f15e4d2b9bc363e1a6e0abc518f7aea4b8dfeab8d15a25e508c31fb2/resend-2.32.2-py2.py3-none-any.whl", hash = "sha256:1a8df5ef54b3a5cb7bb18b745f85fda07f1fa8f79b83dd9a25f6e37fbd625ef2", size = 72664, upload-time = "2026-06-17T19:47:26.915Z" }, +] + +[[package]] +name = "rsa" +version = "4.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/dc/35b341fc554ba02f217fc10da57d1a75168cfbcf75b0ef2202176d4c4f2d/ruff-0.15.20.tar.gz", hash = "sha256:1416eb04349192646b54de98f146c4f59afe37d0decfc02c3cbbf396f3a28566", size = 4755489, upload-time = "2026-06-25T17:20:37.578Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/d9/2d5014f0253ba541d2061d9fa7193f48e941c8b21bb88a7ff9bbe0bd0596/ruff-0.15.20-py3-none-linux_armv6l.whl", hash = "sha256:00e188c53e499c3c1637f73c91dcf2fb56d576cab76ce1be50a27c4e80e37078", size = 10839665, upload-time = "2026-06-25T17:19:44.702Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d3/ac1798ba64f670698867fcfc591d50e7e421bef137db564858f619a30fcf/ruff-0.15.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9ebd1fd9b9c95fc0bd7b2761aebec1f030013d2e193a2901b224af68fe47251b", size = 11208649, upload-time = "2026-06-25T17:19:48.787Z" }, + { url = "https://files.pythonhosted.org/packages/47/47/d3ac899991202095dfcf3d5176be4272642be3cf981a2f1a30f72a2afb95/ruff-0.15.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5b16cdd67ca108185cd36dce98c576350c03b1660a751de725fb049193a0632", size = 10622638, upload-time = "2026-06-25T17:19:51.354Z" }, + { url = "https://files.pythonhosted.org/packages/33/13/4e043fe30aa94d4ff5213a9881fc296d12960f5971b234a5263fdc225312/ruff-0.15.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3413bb3c3d2ca6a8208f1f4809cd2dca3c6de6d0b491c0e70847672bde6e6efd", size = 10984227, upload-time = "2026-06-25T17:19:54.044Z" }, + { url = "https://files.pythonhosted.org/packages/76/e6/92e7bf40388bc5800073b96564f56264f7e48bfd1a498f5ced6ae6d5a769/ruff-0.15.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd7ec42b3bb3da066488db093308a69c4ac5ee6d2af333a86ba6e2eb2e7dd44b", size = 10622882, upload-time = "2026-06-25T17:19:57.037Z" }, + { url = "https://files.pythonhosted.org/packages/13/7a/43460be3f24495a3aa46d4b16873e2c4941b3b5f0b00cf88c03b7b94b339/ruff-0.15.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1a36ad0eb77fba9aabfb69ede54de6f376d04ac18ebea022847046d340a8267", size = 11474808, upload-time = "2026-06-25T17:20:00.357Z" }, + { url = "https://files.pythonhosted.org/packages/27/a0/f37077884873221c6b33b4ab49eb18f9f88e54a16a25a5bca59bef46dd66/ruff-0.15.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6df3b1e4610432f0386dba04d853b5f08cbbc903410c6fcc02f620f05aff53c", size = 12293094, upload-time = "2026-06-25T17:20:03.446Z" }, + { url = "https://files.pythonhosted.org/packages/a6/74/165545b60256a9704c21ac0ec4a0d07933b320812f9584836c9f4aca4292/ruff-0.15.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e89f198a1ea6ef0d727c1cf16088bc91a6cb0ab947dedc966715691647186eae", size = 11526176, upload-time = "2026-06-25T17:20:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/86/b1/a976a136d40ade83ce743578399865f57001003a409acadc0ecbb3051082/ruff-0.15.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309809086c2acb67624950a3c8133e80f32d0d3e27106c0cd60ff26657c9f24b", size = 11520767, upload-time = "2026-06-25T17:20:09.191Z" }, + { url = "https://files.pythonhosted.org/packages/19/0f/f032696cb01c9b54c0263fa393474d7758f1cdc021a01b04e3cbc2500999/ruff-0.15.20-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2d2374caa2f2c2f9e2b7da0a50802cfb8b79f55a9b5e49379f564544fbf56487", size = 11500132, upload-time = "2026-06-25T17:20:13.602Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f4/51b1a14bc69e8c224b15dab9cce8e99b425e0455d462caa2b3c9be2b6a8e/ruff-0.15.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a1ed17b65293e0c2f22fc387bc13198a5de94bf4429589b0ff6946b0feaf21a3", size = 10943828, upload-time = "2026-06-25T17:20:16.635Z" }, + { url = "https://files.pythonhosted.org/packages/71/4b/fe267640783cd02bf6c5cc290b1df1051be2ec294c678b5c15fe19e52343/ruff-0.15.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f701305e66b38ea6c91882490eb73459796808e4c6362a1b765255e0cdcd4053", size = 10645418, upload-time = "2026-06-25T17:20:19.4Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c0/a65aa4ec2f5e87a1df32dc3ec1fede434fe3dfd5cbcf3b503cafc676ab54/ruff-0.15.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b9c0c367ad8e5d0d5b5b8537864c469a0a0e55417aadfbeca41fa61333be9f4", size = 11211770, upload-time = "2026-06-25T17:20:22.033Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a4/0caa331d954ae2723d729d351c989cb4ca8b6077d5c6c2cb6de75e98c041/ruff-0.15.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:01cc00dd58f0df339d0e902219dd53990ea99996a0344e5d9cc8d45d5307e460", size = 11618698, upload-time = "2026-06-25T17:20:25.259Z" }, + { url = "https://files.pythonhosted.org/packages/10/9b/5f14927848d2fd4aa891fd88d883788c5a7baba561c7874732364045708c/ruff-0.15.20-py3-none-win32.whl", hash = "sha256:ed65ef510e43a137207e0f01cfcf998aeddb1aeeda5c9d35023e910284d7cf21", size = 10857322, upload-time = "2026-06-25T17:20:28.612Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f0/fe47c501f9dea92a26d788ff98bb5d92ed4cb4c88792c5c88af6b697dc8e/ruff-0.15.20-py3-none-win_amd64.whl", hash = "sha256:a525c81c70fb0380344dd1d8745d8cc1c890b7fc94a58d5a07bd8eb9557b8415", size = 11993274, upload-time = "2026-06-25T17:20:31.871Z" }, + { url = "https://files.pythonhosted.org/packages/d7/2b/9555445e1201d92b3195f45cdb153a0b68f24e0a4273f6e3d5ab46e212bb/ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca", size = 11343498, upload-time = "2026-06-25T17:20:35.03Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.50" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/da/6fbf010c8ebb347679d0d100b22fe9ba5e13fd04046c5df7280d2f0bf706/sqlalchemy-2.0.50.tar.gz", hash = "sha256:af5607d11ef90fd6a5c0549fe0045dce1663d427426bcfb506dcb5346a85a3b9", size = 9907424, upload-time = "2026-05-24T19:20:04.018Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/b0/a9d19b43f38f878b1278bca5b00b909f7540d41494396dd2561f9ad0956d/sqlalchemy-2.0.50-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:23ae23d8b9d344d30d0a92f06d45825024a5790f1c1dd4cf452636a50d3e58cb", size = 2159807, upload-time = "2026-05-24T19:27:53.086Z" }, + { url = "https://files.pythonhosted.org/packages/f5/2c/191dd58a248fd2cfd4780fa82c375c505e4ad98c8b522fa69ec492130d77/sqlalchemy-2.0.50-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:47b71b933e7b4ebad407c8fdfd70d2c4f08b78b3238bb30eebdd6eb32ca51b89", size = 3343358, upload-time = "2026-05-24T20:09:29.279Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2b/514fce8a7df81cf5bad7ff7865de7ac0c5776a38cc043475c4703eb7fe8b/sqlalchemy-2.0.50-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:110fdac56ace278949f00de805edacbd6141e382d992f9ba28238b3a0827a600", size = 3357994, upload-time = "2026-05-24T20:17:13.495Z" }, + { url = "https://files.pythonhosted.org/packages/35/a6/a0e283f5494f92b0d77e319ff77e437b1ffe4a051ba67c81d53234825475/sqlalchemy-2.0.50-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0f5e4ac70e9e757f6b3e87c0491ff034442ecd8dfd36d041a50564c322dafc0e", size = 3289399, upload-time = "2026-05-24T20:09:32.239Z" }, + { url = "https://files.pythonhosted.org/packages/b7/96/1b07325ba71752d6a028b77d07bed1483ad545f794e8b1dc89b3ba3b3c68/sqlalchemy-2.0.50-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:724f3dcbe53dd0151e3cb5e7ec4ba4c620bede579caacd16275dc35ce06e8615", size = 3321216, upload-time = "2026-05-24T20:17:15.581Z" }, + { url = "https://files.pythonhosted.org/packages/ed/8e/bad6ed253e8a99edfc99af02f7173ec48a1d3ed1b9b35a1b8bc1700900cc/sqlalchemy-2.0.50-cp312-cp312-win32.whl", hash = "sha256:1208050441471d003b7c8cb4054fb084f185cf35ac3f0ea270803865bca9939a", size = 2119194, upload-time = "2026-05-24T19:50:04.943Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2d/314a6690dda4b9cfc571eab1a63cf6fe6e1470aa3759ccda6aa016ee0f5a/sqlalchemy-2.0.50-cp312-cp312-win_amd64.whl", hash = "sha256:9d1af51558029a156a70986b7df88f042b3d158d7c8d8fb5072912d4b32d89c7", size = 2146186, upload-time = "2026-05-24T19:50:06.74Z" }, + { url = "https://files.pythonhosted.org/packages/0b/c4/c42356b527296e9862f67990efce31ef78b4cf69cd3f80873a528a060320/sqlalchemy-2.0.50-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:06a9210bdc5f4298cff0781087e2ff45683922252dacc452846373a58761f093", size = 2156697, upload-time = "2026-05-24T19:27:54.764Z" }, + { url = "https://files.pythonhosted.org/packages/60/a1/b1a70e3c4365ac7fe9e347f3710f19b562c866fb96d45e3c891588789a7b/sqlalchemy-2.0.50-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b53784972ade4f8174b9aa661f31a06f8a936d2cfdd602913ff3c6dd40ae873", size = 3284260, upload-time = "2026-05-24T20:09:34.195Z" }, + { url = "https://files.pythonhosted.org/packages/3f/4a/f3ac3caa19f263d57b0a47f8c91bbf56583dc2d3fc63acfbf644abb24fe0/sqlalchemy-2.0.50-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:31648fa14460537e768a7303b078e4344d208e0d23e06867c1f376a227ed82db", size = 3302280, upload-time = "2026-05-24T20:17:17.825Z" }, + { url = "https://files.pythonhosted.org/packages/66/55/ccada3e3d62254587819749a0bc69f41173eb48a6e385d10e66d32a9c88e/sqlalchemy-2.0.50-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:03f4323c980ad0e918cc9e5369b015f759f4e534db5bbaf4dc36832c10d05064", size = 3231580, upload-time = "2026-05-24T20:09:36.406Z" }, + { url = "https://files.pythonhosted.org/packages/05/f6/6809349130a2de0e109e7f00fd7d431da9565b9b2868b32ee684754f672b/sqlalchemy-2.0.50-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2b9dcc43afef8ac157cd92fce96985d6b8b0cfbd3df4d666f66b4d55a75d202f", size = 3269375, upload-time = "2026-05-24T20:17:20.34Z" }, + { url = "https://files.pythonhosted.org/packages/48/84/278a811ef4e07be9c89dc5cdd7be833268509a66a68c4897cf585e67428f/sqlalchemy-2.0.50-cp313-cp313-win32.whl", hash = "sha256:60922d6599065ddca2c6f376b9aa2f41a6b85a271725e0909490bbc50b1998a5", size = 2117229, upload-time = "2026-05-24T19:50:08.215Z" }, + { url = "https://files.pythonhosted.org/packages/f6/1c/067cc6187ed32d2ec222fe6d2643acc1659a6d0659f8a7cbc5ad3ae83280/sqlalchemy-2.0.50-cp313-cp313-win_amd64.whl", hash = "sha256:287086e67275a212c4582d166a6fb03a65ccc5551d80866270ce0dd9f34eccd3", size = 2143126, upload-time = "2026-05-24T19:50:09.691Z" }, + { url = "https://files.pythonhosted.org/packages/df/32/10ac51b4be7cdecd7e93d069251c86dfbf70b7adbd7c67b48ccea6c49e1c/sqlalchemy-2.0.50-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c966932507a4d7d0a37314927dbfcd89720e3f37d2a1e3352e7ae7939fa8e8a0", size = 2158519, upload-time = "2026-05-24T19:27:56.472Z" }, + { url = "https://files.pythonhosted.org/packages/5a/76/e703d2f7681d7d66c4c891af3f07c7ccf4c76ad7f18351de035b5eda007a/sqlalchemy-2.0.50-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:faffef4bcc20a1892e65e155293d99d60855bbbc79250ab712819cfd56a8e6bb", size = 3282063, upload-time = "2026-05-24T20:09:38.57Z" }, + { url = "https://files.pythonhosted.org/packages/31/26/ef168b184a25701f9995e8fb7e503fafd7a99c1c77cda1bc1a26ea2ed486/sqlalchemy-2.0.50-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c206aec519a2e7bd08abbfb33436e325fd22c632d9c21a9047e376ce241646e", size = 3287069, upload-time = "2026-05-24T20:17:21.942Z" }, + { url = "https://files.pythonhosted.org/packages/c2/15/765acc2bc693bccc43ca4a95d5b69750da8aaf6db1b5c616536e087f8920/sqlalchemy-2.0.50-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bef4ac756363227ef6402a75fee025a4bc690f92328e825868939b3b3a446a6d", size = 3230453, upload-time = "2026-05-24T20:09:40.398Z" }, + { url = "https://files.pythonhosted.org/packages/63/61/08e03c3adbf5db0087a0b6816746fec8f3032fb2f7fc899a9bb9b2a48ce4/sqlalchemy-2.0.50-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:96fbee6b19c19cd1556c8bf9419447cf2ec149ffcab7ab64348c23e54ef8547f", size = 3252413, upload-time = "2026-05-24T20:17:24.067Z" }, + { url = "https://files.pythonhosted.org/packages/03/0c/370a1f2db38436c615e10134c8a37de3688e74084792380695f3f5083860/sqlalchemy-2.0.50-cp314-cp314-win32.whl", hash = "sha256:8f00e3eb43ba30eb1b238ee03a8a62309486d1321eda3328bb611e0340033ad8", size = 2120063, upload-time = "2026-05-24T19:50:11.08Z" }, + { url = "https://files.pythonhosted.org/packages/7f/a0/fe92bb9817863bc13ba093bda931979a26cc2ca69f8e8f26d07add3d7c6f/sqlalchemy-2.0.50-cp314-cp314-win_amd64.whl", hash = "sha256:15708c613cd5005b7dffe1f66ee6a63ee8f5e46799f71c70ebad74178c676a39", size = 2145830, upload-time = "2026-05-24T19:50:12.452Z" }, + { url = "https://files.pythonhosted.org/packages/cc/ff/e5640a98a0b2f491eb8fde10fb6c773621a2e44340de231fafcc9370f4a9/sqlalchemy-2.0.50-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3699dac4be410e97049a1658e9480da9cde956594aa0f3aebc60b88f21c5ba70", size = 2178435, upload-time = "2026-05-24T19:42:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/b7/85/337116e186f1236375b5fb70c21cfac98e8e8ab0d3a47be838dc47a59e08/sqlalchemy-2.0.50-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f96233858e3df43932ac11589e22520da6e8aeb624b03fedfeebb0e8ea213086", size = 3566059, upload-time = "2026-05-24T20:01:20.848Z" }, + { url = "https://files.pythonhosted.org/packages/96/34/bb0e190e161c3c2c24314a65add57218be14a4a9486886b7f5047c1ff7c8/sqlalchemy-2.0.50-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c4e70c46fad30c3bcc6a4708bc0130a3173e11a5b25f0ea4a9d8911b450f1f52", size = 3535366, upload-time = "2026-05-24T20:03:56.768Z" }, + { url = "https://files.pythonhosted.org/packages/df/5a/a7f759f97e4fd499c5d4e4488c760d5a7fbecf3028b465a04274fcd52384/sqlalchemy-2.0.50-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1918a3cf564d16d95bca7301005f41ab2ad50b07cd3b9da50d3ed986db148d6a", size = 3474879, upload-time = "2026-05-24T20:01:23.058Z" }, + { url = "https://files.pythonhosted.org/packages/9d/d9/2907ea38eb60687d297bf9c39e5ee58053c87b57fe8a9cae97090cecbf10/sqlalchemy-2.0.50-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b00098cdbdbd38c7be3d568b0c9c3122b8c0ec62b911b57cd5e6e0254d60a76d", size = 3486117, upload-time = "2026-05-24T20:03:59.052Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e3/5aa06f167559f8c0bdae487e297d23ba548150ab016a3418265d617a4985/sqlalchemy-2.0.50-cp314-cp314t-win32.whl", hash = "sha256:1fbd55a969d7ac44a98e3dec75016074f809fa08f871585ace58dde110d1bf3e", size = 2150823, upload-time = "2026-05-24T20:08:58.644Z" }, + { url = "https://files.pythonhosted.org/packages/65/9b/112fb8f977582d7489d036e409e3723948bcf5320b3ac465f3c481bbe8f9/sqlalchemy-2.0.50-cp314-cp314t-win_amd64.whl", hash = "sha256:c5c3cdb753a9004183e1ccb634b41611654c989e61bc68617ce878e46d6f1e51", size = 2185794, upload-time = "2026-05-24T20:09:00.319Z" }, + { url = "https://files.pythonhosted.org/packages/d0/10/f7220e9b784d295d241c86ed99aeb537f92afcd469a64861f2717e9bb077/sqlalchemy-2.0.50-py3-none-any.whl", hash = "sha256:92064363517a3ff8212b5a93b8c62876579d8dfd1ca5b561335f30152d884fa9", size = 1943861, upload-time = "2026-05-24T19:59:01.119Z" }, +] + +[[package]] +name = "starlette" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/25/44/ec35f1b6e83094b997da438a02c8c9b0ade2b1e84cfc48bd4656780760a6/starlette-1.2.1.tar.gz", hash = "sha256:9b9b5ebb992e67d6093741e63c2f59e4f6fff986f81163c087867bd7b924b3f6", size = 2701854, upload-time = "2026-05-31T01:07:51.847Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/54/196d0c1db10af76baa4f64894448505d60d3cdf70ef92cbb35f46a4e4c71/starlette-1.2.1-py3-none-any.whl", hash = "sha256:4de0082d08c8f6764a85a54cf1120d6939507a19905c7768acad2a9f875d2b89", size = 73350, upload-time = "2026-05-31T01:07:50.09Z" }, +] + +[[package]] +name = "stripe" +version = "15.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d0/28/c3550f39d9e258c21bcba81a89ef3ca7af352ad2cb14fe16b80248841644/stripe-15.2.0.tar.gz", hash = "sha256:f795d8a8ff27433e9ab03099afc0f5c4e992a8a6f92b9f839dc0048bb12f76f6", size = 1520207, upload-time = "2026-05-27T20:34:44.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/2b/697003ba6349058b65a5cb6b0bd8a8b0e909ed35911f7beebfc24b0563ee/stripe-15.2.0-py3-none-any.whl", hash = "sha256:e74d45bb07bb912fdc4ce81ffbdf184bcb92ce6e210cb7939966e537021c1842", size = 2168459, upload-time = "2026-05-27T20:34:42.312Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "tzdata" +version = "2026.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" }, +] + +[[package]] +name = "tzlocal" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761, upload-time = "2025-03-05T21:17:41.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" }, +] + +[[package]] +name = "upstash-redis" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/ec/a2d5027239be97421f0bb26ca74b7be3f5597e4222431c5bb6ccc7c0f018/upstash_redis-1.7.0.tar.gz", hash = "sha256:19b46dff2d81b89da5cbfb23a38232f7ca3b9ba441b22a1df766c64972d936d1", size = 47336, upload-time = "2026-03-18T14:17:46.874Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/00/d4aede377301a66a751c8c240c1d989124afb42ee8a201d7f7406306dde3/upstash_redis-1.7.0-py3-none-any.whl", hash = "sha256:6c17b4cd1f88bd0d34d978b9cf13041032482f7dfb4cdf8e00f6f6277db0006a", size = 49746, upload-time = "2026-03-18T14:17:45.955Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.49.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/1f/fa18009dea8469069cca78a4e877a008ab78f08b064bfc9ab891579077ff/uvicorn-0.49.0.tar.gz", hash = "sha256:ebf4271aa580d9de97f93192d4595176df6e91f9aae919ca73e4fc07df1e66a3", size = 91284, upload-time = "2026-06-03T22:01:30.448Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/fa/e1388bbcf24ef3274f45c0c1c7b501fd14971037c1b6ee23610553307497/uvicorn-0.49.0-py3-none-any.whl", hash = "sha256:ba3d14c3ee7e41c6c654c46c9eb489d33213cdd30aa1696eab1374337c13f68f", size = 71376, upload-time = "2026-06-03T22:01:29.037Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, + { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, + { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, + { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, + { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, + { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, + { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, +] + +[[package]] +name = "venue404-api" +version = "0.0.1" +source = { editable = "." } +dependencies = [ + { name = "alembic" }, + { name = "apscheduler" }, + { name = "cloudinary" }, + { name = "email-validator" }, + { name = "fastapi" }, + { name = "httpx" }, + { name = "passlib", extra = ["bcrypt"] }, + { name = "pgvector" }, + { name = "pillow" }, + { name = "psycopg2-binary" }, + { name = "pydantic-settings" }, + { name = "python-jose", extra = ["cryptography"] }, + { name = "python-multipart" }, + { name = "rapidfuzz" }, + { name = "resend" }, + { name = "sqlalchemy" }, + { name = "stripe" }, + { name = "upstash-redis" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[package.optional-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-dotenv" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "alembic", specifier = ">=1.13.0" }, + { name = "apscheduler", specifier = ">=3.10.0" }, + { name = "cloudinary", specifier = ">=1.40.0" }, + { name = "email-validator", specifier = ">=2.2.0" }, + { name = "fastapi", specifier = ">=0.111.0" }, + { name = "httpx", specifier = ">=0.27.0" }, + { name = "passlib", extras = ["bcrypt"], specifier = ">=1.7.4" }, + { name = "pgvector", specifier = ">=0.3.0" }, + { name = "pillow", specifier = ">=10.0.0" }, + { name = "psycopg2-binary", specifier = ">=2.9.0" }, + { name = "pydantic-settings", specifier = ">=2.3.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.0" }, + { name = "pytest-dotenv", marker = "extra == 'dev'", specifier = ">=0.5.2" }, + { name = "python-jose", extras = ["cryptography"], specifier = ">=3.3.0" }, + { name = "python-multipart", specifier = ">=0.0.9" }, + { name = "rapidfuzz", specifier = ">=3.14.5" }, + { name = "resend", specifier = ">=2.0.0" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.5.0" }, + { name = "sqlalchemy", specifier = ">=2.0.0" }, + { name = "stripe", specifier = ">=9.0.0" }, + { name = "upstash-redis", specifier = ">=1.0.0" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.30.0" }, +] +provides-extras = ["dev"] + +[[package]] +name = "watchfiles" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/2f/e42c992d2afda3108ea1c02acecc991b9f31d05c14adc2a7cee9ee211fc4/watchfiles-1.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26", size = 400115, upload-time = "2026-05-18T04:32:02.06Z" }, + { url = "https://files.pythonhosted.org/packages/5f/8f/6af2ea19065c91d8b0ea3516fdfc8c0d349f407e8e9fbf4e5a17360de8ad/watchfiles-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c", size = 393659, upload-time = "2026-05-18T04:30:50.951Z" }, + { url = "https://files.pythonhosted.org/packages/13/01/b32a967c56fb3e3e5be3db52c3d3b87fa4513aa367d8ed1ad96d42952e5f/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc", size = 453207, upload-time = "2026-05-18T04:31:04.231Z" }, + { url = "https://files.pythonhosted.org/packages/04/98/97557a812180338cb1abd32e1cffcc4588f59b5f23e0cb006b2ba95ba64a/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0", size = 459273, upload-time = "2026-05-18T04:31:50.377Z" }, + { url = "https://files.pythonhosted.org/packages/e8/a8/b4b08dcb7653b8087c6586f7ce649505900e866bbcfe40dc9587af02e686/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c", size = 489927, upload-time = "2026-05-18T04:31:42.485Z" }, + { url = "https://files.pythonhosted.org/packages/50/94/3dceea03545d2e5ddfd839f0ddd5e1cecbf1697b5a428d5ba11cef6af95d/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01", size = 570476, upload-time = "2026-05-18T04:31:03.071Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f2/d39a5450c3532092b91f81d274360e613c2371bc874a89c7a1a3c5e8d138/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8", size = 465650, upload-time = "2026-05-18T04:30:12.701Z" }, + { url = "https://files.pythonhosted.org/packages/22/24/ed72f68cbc1333ca9b9f2200aa048bb6658ae41709bc1caad4310f4bdffd/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5", size = 456398, upload-time = "2026-05-18T04:30:13.784Z" }, + { url = "https://files.pythonhosted.org/packages/0d/64/982ef4a4e5bab5b6e5b6becc8cd5e732f6130a78b855f0abec6439a9a135/watchfiles-1.2.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d", size = 465140, upload-time = "2026-05-18T04:31:52.111Z" }, + { url = "https://files.pythonhosted.org/packages/a0/0c/95282abf4ed680b6096010bcfc30c5fa7a041fc5aa5a2ad17a2cc6c75bba/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c", size = 630259, upload-time = "2026-05-18T04:31:25.676Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/607c1de1530c4bdcf2cf1d1ecc2505ddba5d96bd43ba9f2b0e79876f850f/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906", size = 659859, upload-time = "2026-05-18T04:30:24.333Z" }, + { url = "https://files.pythonhosted.org/packages/fa/08/d9e2e0f9e8e6791d33aefc694ad7eefa7f901f63caff84a81ded38692f9c/watchfiles-1.2.0-cp312-cp312-win32.whl", hash = "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898", size = 275480, upload-time = "2026-05-18T04:30:31.307Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e6/9d42569c0102645cc8cea5d8c7d8a1e9d4ada2cb7f05f75e554b8aa2202a/watchfiles-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379", size = 288718, upload-time = "2026-05-18T04:32:10.745Z" }, + { url = "https://files.pythonhosted.org/packages/0a/26/88e0dc6ee3898169d7fa22bb6a69cabf2502d2ee25cb8c876d1262d204f8/watchfiles-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5", size = 281026, upload-time = "2026-05-18T04:30:22.23Z" }, + { url = "https://files.pythonhosted.org/packages/d1/4d/70a7feced9f87e2ff26dba42667290f41694fc64646c67261fbb8cab5d5c/watchfiles-1.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98", size = 399730, upload-time = "2026-05-18T04:31:38.162Z" }, + { url = "https://files.pythonhosted.org/packages/31/3a/0da302f2307aee316922806ebd5726c542cbd787c938271cf14a074c7daf/watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44", size = 392842, upload-time = "2026-05-18T04:30:27.051Z" }, + { url = "https://files.pythonhosted.org/packages/db/ef/d5bdb705c224dbc256aa0c1ec47bf4e61ec52558f2afb44a71a1fe4d7015/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658", size = 452989, upload-time = "2026-05-18T04:31:11.945Z" }, + { url = "https://files.pythonhosted.org/packages/71/29/5495f2c1661949ef7a35e4d71111d129cfe7606414a26887a919d0a55406/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb", size = 458978, upload-time = "2026-05-18T04:30:52.606Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/7f9c07c433811c2fffd93e13fdfb7135de9aab5f2ae41be08960fa0047dc/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f", size = 490248, upload-time = "2026-05-18T04:31:36.003Z" }, + { url = "https://files.pythonhosted.org/packages/3c/11/d93632febc52fbc21be90231bb7c17fd5387f46c9076fd40a5f9c2ae6910/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0", size = 571847, upload-time = "2026-05-18T04:31:10.862Z" }, + { url = "https://files.pythonhosted.org/packages/55/b4/383173e73aabb07ad1d9c7aa859d95437ac46a6d6a1e11005facda0c9d19/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5", size = 465974, upload-time = "2026-05-18T04:30:17.006Z" }, + { url = "https://files.pythonhosted.org/packages/a7/6c/89b1a230a78f57c52dd8893adb1f92f94411721b6ec12596c56d98c74356/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71", size = 454782, upload-time = "2026-05-18T04:30:35.656Z" }, + { url = "https://files.pythonhosted.org/packages/24/62/1732118367cfff0a9fce3bf62ff4bfded09ef5df21d9d446b858b3f70a96/watchfiles-1.2.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3", size = 465182, upload-time = "2026-05-18T04:30:20.846Z" }, + { url = "https://files.pythonhosted.org/packages/28/96/716f7e5f51339bf22963f3345f9f27d7f3b30e2eadc597e257c881dd3c53/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0", size = 629841, upload-time = "2026-05-18T04:31:05.397Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/c40783950fd771ccf66ab3ec2722d188a9af1c7f96c6e811f36e40c6e03f/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427", size = 658028, upload-time = "2026-05-18T04:31:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/71/72/4508db1856d1d87fcbb3b63f4839bab1b5682cb0e8d224d122263c09654a/watchfiles-1.2.0-cp313-cp313-win32.whl", hash = "sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799", size = 275183, upload-time = "2026-05-18T04:30:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/f9/36/14b76ca57652e5cc5fd1c11f32a261292c08a0d19a00351013c2549cbfb2/watchfiles-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9", size = 288059, upload-time = "2026-05-18T04:32:07.937Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8d/0a85e395398d8d20fadfe5c5d32c726eee17a519e78fb356f2cf7531bffe/watchfiles-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077", size = 280186, upload-time = "2026-05-18T04:31:54.484Z" }, + { url = "https://files.pythonhosted.org/packages/37/68/36db056f1fdcc5f07302f56e631774d6835bcd6fa3ace402304621d5f9e5/watchfiles-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08", size = 399031, upload-time = "2026-05-18T04:30:44.576Z" }, + { url = "https://files.pythonhosted.org/packages/c1/64/01a9d6f66a82a5c101ce939274106cc72759d62427e153f01edd2b9f87c2/watchfiles-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9", size = 391205, upload-time = "2026-05-18T04:30:25.413Z" }, + { url = "https://files.pythonhosted.org/packages/84/2c/0a44fe058cb4bb7b8ede6b6670698bbb7c0400740e378d00022189b7b31d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4", size = 451892, upload-time = "2026-05-18T04:32:14.005Z" }, + { url = "https://files.pythonhosted.org/packages/67/a1/351e0d56cd35e6488b5c8b4fb11a809a5bc923e8fe8fed9faf8920be0c89/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55", size = 458867, upload-time = "2026-05-18T04:31:22.279Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7d/9d09605187f1b838998624049fcf8bf47b73c1a3b76901fcac1782f62277/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925", size = 490217, upload-time = "2026-05-18T04:31:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/60/5d/a17a16eccb182f04188cd308ec24b1a71a9b5c4e7098269cf35d9fa56d02/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4", size = 571458, upload-time = "2026-05-18T04:32:11.875Z" }, + { url = "https://files.pythonhosted.org/packages/d3/3d/4dd457062083ab1938e5dfd45032eb425cee2ac817287ca8ff4356183e5d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2", size = 464707, upload-time = "2026-05-18T04:30:43.492Z" }, + { url = "https://files.pythonhosted.org/packages/c6/71/ea8c57b128f5383de74d0c7d2d9c57ad7c9a65a930c451bd25d524b295b7/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9", size = 454663, upload-time = "2026-05-18T04:30:16.061Z" }, + { url = "https://files.pythonhosted.org/packages/53/fd/2e812bf938406d7db351f0703ddd3fc6c061cf30d96153a77bc79a943a44/watchfiles-1.2.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa", size = 463537, upload-time = "2026-05-18T04:31:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/86/56/d17a7f1dd1bc3035f1072694a551301272f1739c2d8e319c927cb9e29b38/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44", size = 629194, upload-time = "2026-05-18T04:31:14.141Z" }, + { url = "https://files.pythonhosted.org/packages/be/06/f1ff66bf5cae50aa4062779a0ecd0bbaf15e466195719074078947d9a17d/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72", size = 656194, upload-time = "2026-05-18T04:31:47.14Z" }, + { url = "https://files.pythonhosted.org/packages/e7/54/a9c7ea9a82a4ac65e7004c0a03920b5cdd2f9c3b678757d9cd425aa51d53/watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4", size = 400205, upload-time = "2026-05-18T04:32:05.153Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5d/c9ab3534374a4a67450696905d6ef16a04405448b8dc52bd752ae50423d4/watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281", size = 392508, upload-time = "2026-05-18T04:30:54.849Z" }, + { url = "https://files.pythonhosted.org/packages/26/ca/1ad30103535cf0cecd7b993e8d50edc5351b1820e38f2d22e3df58962feb/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d", size = 452448, upload-time = "2026-05-18T04:30:53.727Z" }, + { url = "https://files.pythonhosted.org/packages/37/a1/ceee2cdf2afbd715fa07758d39c9859513eae411b23196f7fd039e5feedd/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e", size = 459605, upload-time = "2026-05-18T04:30:23.312Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f6/421e30fd1cb3907a84ed92ab3f1983e37ba2dca015e9a894a048418417a2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242", size = 490757, upload-time = "2026-05-18T04:30:47.358Z" }, + { url = "https://files.pythonhosted.org/packages/41/b0/55ed1b97ed08be7bba6f9a541cac15f2a858e1d74d2b07b6da70a82aab00/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add", size = 568672, upload-time = "2026-05-18T04:30:38.915Z" }, + { url = "https://files.pythonhosted.org/packages/d1/cf/d8ae8a80dd7bafab395ea7681c10237311bbf34d37704a8c744e7cf31fc7/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f", size = 464197, upload-time = "2026-05-18T04:30:09.914Z" }, + { url = "https://files.pythonhosted.org/packages/7c/8a/3076c496ca8dafe0e8cd03fcebdfc47be4b1174b4e5b24ff6e396e6b3af2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7", size = 453181, upload-time = "2026-05-18T04:30:14.829Z" }, + { url = "https://files.pythonhosted.org/packages/e5/10/9745e17c98e7b8a86454df0a3c7b5686bd650383f1e9f26e4ebcbd6cc0c0/watchfiles-1.2.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e", size = 465109, upload-time = "2026-05-18T04:30:28.123Z" }, + { url = "https://files.pythonhosted.org/packages/8f/95/8ef4a95481d3e0cb52d62a06fa6e972e81424be2d9698b91a2fecca9904c/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06", size = 630653, upload-time = "2026-05-18T04:31:49.304Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e4/3b3bf36b0f829b50c6ebcb8d031583863c59f923d6a6af3d485e470d0fac/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba", size = 657838, upload-time = "2026-05-18T04:31:06.497Z" }, + { url = "https://files.pythonhosted.org/packages/21/b1/6cbbb50c1f3002ab568777d44aa21206dfb8807a840990c4037523b51812/watchfiles-1.2.0-cp314-cp314-win32.whl", hash = "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7", size = 275108, upload-time = "2026-05-18T04:30:06.891Z" }, + { url = "https://files.pythonhosted.org/packages/92/45/190ce6db8dcb4536682cf75d3889ff1a27182a58cb519d343cb6d9ea63d8/watchfiles-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103", size = 288441, upload-time = "2026-05-18T04:32:12.901Z" }, + { url = "https://files.pythonhosted.org/packages/74/0d/3eae1c2313ab08378431d907c3f8095ecca00f3eda33111cf4f0f2591799/watchfiles-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3", size = 280684, upload-time = "2026-05-18T04:31:26.902Z" }, + { url = "https://files.pythonhosted.org/packages/b1/75/fb64e6c25d6b5ca636d03df34ffb1c6e9873303e76d27967e045f8df088f/watchfiles-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2", size = 398857, upload-time = "2026-05-18T04:32:17.108Z" }, + { url = "https://files.pythonhosted.org/packages/73/4e/9f7adf01754cbf81843722ccfec169d8f26c69778281a302855cecd2ee08/watchfiles-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28", size = 392413, upload-time = "2026-05-18T04:31:07.911Z" }, + { url = "https://files.pythonhosted.org/packages/47/c8/bec626bcc2d69f44b9acb24ce7d60ed7b16b73628eea747fcbd169d8edda/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831", size = 452409, upload-time = "2026-05-18T04:31:20.142Z" }, + { url = "https://files.pythonhosted.org/packages/00/b7/b6362068e81e7c556d155a34c35d40ac3ef42d747b06d7f6e5bf58e359c2/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33", size = 458827, upload-time = "2026-05-18T04:32:06.219Z" }, + { url = "https://files.pythonhosted.org/packages/67/f8/9a813fa42afb1e0b4625e75f0479826644d3ee8dc287e093799bc01f390c/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4", size = 490104, upload-time = "2026-05-18T04:31:56.034Z" }, + { url = "https://files.pythonhosted.org/packages/2f/bf/27dfb6094ca4c9aad21298b5525b6c53cb36121ee454331d05161e58d130/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b", size = 571360, upload-time = "2026-05-18T04:31:57.133Z" }, + { url = "https://files.pythonhosted.org/packages/fb/39/44a096d67270ea93df91d33877dbe91fbda3aa4f8ec2edf799d93eda8736/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666", size = 464644, upload-time = "2026-05-18T04:30:57.33Z" }, + { url = "https://files.pythonhosted.org/packages/0e/80/c7472203bad6268e3ef1ad260739704847898938ad7ea8b63a5131f46b50/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925", size = 454771, upload-time = "2026-05-18T04:30:48.736Z" }, + { url = "https://files.pythonhosted.org/packages/51/cf/3b10b268b4b7f0fc26e9debb5eef1998b515887840f444cd3ec80c688755/watchfiles-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b", size = 463494, upload-time = "2026-05-18T04:31:33.826Z" }, + { url = "https://files.pythonhosted.org/packages/3d/3e/a4302545cd589262a0dc7d140e86f7688eba3f9c72776c27f7e23b8864c4/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30", size = 629383, upload-time = "2026-05-18T04:31:15.596Z" }, + { url = "https://files.pythonhosted.org/packages/db/99/d5649df0a9a410d45b7c882304d0b790903ac9b6e8f2cfd12114e0c6b9f2/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5", size = 656093, upload-time = "2026-05-18T04:31:58.707Z" }, + { url = "https://files.pythonhosted.org/packages/92/b9/362702539275019a54dd2e94511b31a9b89c5f9e6a21966de7eb692549fc/watchfiles-1.2.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374", size = 400109, upload-time = "2026-05-18T04:31:16.879Z" }, + { url = "https://files.pythonhosted.org/packages/8f/75/71d5ba62db781e5587bded1d944c675374bc4aa37ff33d5018d98e8b6538/watchfiles-1.2.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65", size = 392167, upload-time = "2026-05-18T04:31:28.058Z" }, + { url = "https://files.pythonhosted.org/packages/3c/01/c66dd95d0423fe30d31820e2d1d5bda773764131bbb6ac0cb1cf303ac328/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69", size = 452372, upload-time = "2026-05-18T04:31:00.836Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/2fe99557e72f85627c6a8eed50d889e8d101623e060a22ad75b875cb932d/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579", size = 459596, upload-time = "2026-05-18T04:31:34.96Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/d4acfa0023367428ed48351b3b9b267893037b6cadae55620c61c24bcfd4/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7", size = 490869, upload-time = "2026-05-18T04:31:59.923Z" }, + { url = "https://files.pythonhosted.org/packages/a4/5f/3164cbdce06c9fb95c4f7b9e2f9760b5e2797af43a9ecc317ef42a23a278/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2", size = 571641, upload-time = "2026-05-18T04:32:00.948Z" }, + { url = "https://files.pythonhosted.org/packages/41/e6/85d3731c55e65cd7690f3f803d24c139588aaf863e4bf2148fe7a7fa1a19/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6", size = 464444, upload-time = "2026-05-18T04:30:34.298Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7d/562641012b8b09872742c3b8adf9629ec479fd78f8d68ae4a0c13da8add6/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4", size = 453593, upload-time = "2026-05-18T04:31:23.464Z" }, + { url = "https://files.pythonhosted.org/packages/56/fe/cb8ef3d6f929d14158fdaaad9925985b7310abc9384dcd4d82dd0016fb59/watchfiles-1.2.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488", size = 465096, upload-time = "2026-05-18T04:31:30.384Z" }, + { url = "https://files.pythonhosted.org/packages/25/91/80908e835e100527a9267147b08c0eee1fa6ab0ffec15edc04d1d44885f7/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb", size = 630638, upload-time = "2026-05-18T04:30:49.89Z" }, + { url = "https://files.pythonhosted.org/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377", size = 657684, upload-time = "2026-05-18T04:31:32.027Z" }, +] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, + { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, + { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, + { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +] diff --git a/apps/owner-portal/.env.example b/apps/owner-portal/.env.example new file mode 100644 index 000000000..8808b3ad6 --- /dev/null +++ b/apps/owner-portal/.env.example @@ -0,0 +1,8 @@ +# Supabase (browser client - anon key only) +VITE_SUPABASE_URL=https://your-project-ref.supabase.co +VITE_SUPABASE_ANON_KEY=your-supabase-anon-key + +# FastAPI backend +VITE_API_BASE_URL=http://localhost:8000 + +VITE_STRIPE_PUBLISHABLE_KEY= diff --git a/apps/owner-portal/index.html b/apps/owner-portal/index.html new file mode 100644 index 000000000..e01724b35 --- /dev/null +++ b/apps/owner-portal/index.html @@ -0,0 +1,16 @@ + + + + + + + + Venue404 | Owner Portal + + + +
+ + + + \ No newline at end of file diff --git a/apps/owner-portal/package.json b/apps/owner-portal/package.json new file mode 100644 index 000000000..b8f7ade5d --- /dev/null +++ b/apps/owner-portal/package.json @@ -0,0 +1,31 @@ +{ + "name": "@venue404/owner-portal", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite --port 5398", + "build": "tsc && vite build", + "lint": "eslint src", + "preview": "vite preview" + }, + "dependencies": { + "@tailwindcss/vite": "^4.3.0", + "@tanstack/react-query": "^5.101.0", + "@venue404/api-client": "workspace:*", + "@venue404/ui": "workspace:*", + "lucide-react": "^1.17.0", + "react": "^18.3.0", + "react-dom": "^18.3.0", + "react-hot-toast": "^2.6.0", + "react-router-dom": "^6.23.0", + "recharts": "^3.8.1", + "tailwindcss": "^4.3.0" + }, + "devDependencies": { + "@types/react": "^18.3.0", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.0", + "vite": "^5.3.0" + } +} \ No newline at end of file diff --git a/apps/owner-portal/src/App.tsx b/apps/owner-portal/src/App.tsx new file mode 100644 index 000000000..97bc2ce8b --- /dev/null +++ b/apps/owner-portal/src/App.tsx @@ -0,0 +1,33 @@ +import { RouterProvider } from 'react-router-dom' +import { Toaster } from 'react-hot-toast' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { router } from './routes' + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: 1, + refetchOnWindowFocus: false, + }, + }, +}) + +export default function App() { + return ( + + + + + ) +} diff --git a/apps/owner-portal/src/components/CalendarPicker.tsx b/apps/owner-portal/src/components/CalendarPicker.tsx new file mode 100644 index 000000000..840d75393 --- /dev/null +++ b/apps/owner-portal/src/components/CalendarPicker.tsx @@ -0,0 +1,134 @@ +import { useState } from 'react' +import { ChevronLeft, ChevronRight } from 'lucide-react' + +type Props = { + selectedDate: string + onSelect: (date: string) => void + minDate?: string +} + +const DAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] + +export function CalendarPicker({ selectedDate, onSelect, minDate }: Props) { + const initialDate = selectedDate ? new Date(selectedDate) : new Date() + const [viewDate, setViewDate] = useState(new Date(initialDate.getFullYear(), initialDate.getMonth(), 1)) + + const handlePrevMonth = () => { + setViewDate(new Date(viewDate.getFullYear(), viewDate.getMonth() - 1, 1)) + } + const handleNextMonth = () => { + setViewDate(new Date(viewDate.getFullYear(), viewDate.getMonth() + 1, 1)) + } + + const getDaysInMonthGrid = (year: number, month: number) => { + const firstDay = new Date(year, month, 1).getDay() + const daysInMonth = new Date(year, month + 1, 0).getDate() + + const grid: (string | null)[] = [] + for (let i = 0; i < firstDay; i++) grid.push(null) + for (let d = 1; d <= daysInMonth; d++) { + const m = String(month + 1).padStart(2, '0') + const day = String(d).padStart(2, '0') + grid.push(`${year}-${m}-${day}`) + } + while (grid.length % 7 !== 0) grid.push(null) + return grid + } + + const year1 = viewDate.getFullYear() + const month1 = viewDate.getMonth() + const nextMonth = new Date(year1, month1 + 1, 1) + const year2 = nextMonth.getFullYear() + const month2 = nextMonth.getMonth() + + const grid1 = getDaysInMonthGrid(year1, month1) + const grid2 = getDaysInMonthGrid(year2, month2) + + const todayStr = new Date().toISOString().split('T')[0] + + const label1 = viewDate.toLocaleString('en-IN', { month: 'long', year: 'numeric' }) + const label2 = nextMonth.toLocaleString('en-IN', { month: 'long', year: 'numeric' }) + + const renderMonth = (grid: (string | null)[]) => ( +
+
+ {DAY_LABELS.map(label => ( +
+ {label} +
+ ))} +
+
+ {grid.map((dateStr, i) => { + if (!dateStr) return
+ + const isSelected = dateStr === selectedDate + const isPast = minDate ? dateStr < minDate : dateStr < todayStr + + return ( +
+ +
+ ) + })} +
+
+ ) + + return ( +
+
+ {/* Month 1 */} +
+
+ + {label1} +
+ {/* Mobile only right arrow */} + +
+
+
+ {renderMonth(grid1)} +
+ + {/* Month 2 */} +
+
+
+ {label2} + +
+ {renderMonth(grid2)} +
+
+ +
+ + + Selected + + + + Unavailable + +
+
+ ) +} diff --git a/apps/owner-portal/src/components/DurationInput.tsx b/apps/owner-portal/src/components/DurationInput.tsx new file mode 100644 index 000000000..97c182e7d --- /dev/null +++ b/apps/owner-portal/src/components/DurationInput.tsx @@ -0,0 +1,89 @@ +import React, { useState, useEffect } from 'react' +import { InfoTooltip } from '@venue404/ui' + +export interface DurationInputProps { + label: string + name: string + value?: number | string + defaultValue?: number | string + onChange?: (e: React.ChangeEvent) => void + required?: boolean + helperText?: string + info?: React.ReactNode +} + +export function DurationInput({ label, name, value, defaultValue, onChange, required, helperText, info }: DurationInputProps) { + const [hours, setHours] = useState('') + const [minutes, setMinutes] = useState('') + + // Initialize from value or defaultValue + useEffect(() => { + const val = value !== undefined ? value : defaultValue + if (val !== undefined && val !== '') { + const totalMin = Number(val) + if (!isNaN(totalMin)) { + setHours(Math.floor(totalMin / 60).toString() || '0') + setMinutes((totalMin % 60).toString() || '0') + } + } + }, [value, defaultValue]) + + const totalMinutes = (Number(hours) || 0) * 60 + (Number(minutes) || 0) + + const handleHoursChange = (e: React.ChangeEvent) => { + const newHours = e.target.value + if (value === undefined) { + setHours(newHours) + } + if (onChange) { + onChange({ target: { name, value: (Number(newHours) || 0) * 60 + (Number(minutes) || 0), type: 'number' } } as unknown as React.ChangeEvent) + } + } + + const handleMinutesChange = (e: React.ChangeEvent) => { + const newMinutes = e.target.value + if (value === undefined) { + setMinutes(newMinutes) + } + if (onChange) { + onChange({ target: { name, value: (Number(hours) || 0) * 60 + (Number(newMinutes) || 0), type: 'number' } } as unknown as React.ChangeEvent) + } + } + + return ( +
+ + +
+
+ + hrs +
+
+ + mins +
+
+ {helperText &&

{helperText}

} +
+ ) +} diff --git a/apps/owner-portal/src/components/OwnerFlowPanel.tsx b/apps/owner-portal/src/components/OwnerFlowPanel.tsx new file mode 100644 index 000000000..10e164ec9 --- /dev/null +++ b/apps/owner-portal/src/components/OwnerFlowPanel.tsx @@ -0,0 +1,184 @@ +export function OwnerFlowPanel() { + return ( +
+ {/* Dot-grid */} +