4 Commits

6 changed files with 175 additions and 4 deletions

View File

@@ -22,6 +22,7 @@ from models import (
Parent, ParentCreate, Parent, ParentCreate,
Vet, VetCreate, Vet, VetCreate,
Treatment, TreatmentCreate, Treatment, TreatmentCreate,
Procedure, ProcedureNotes,
Pet, PetCreate, Pet, PetCreate,
Appointment, AppointmentCreate, Appointment, AppointmentCreate,
) )
@@ -268,6 +269,7 @@ def create_appointment(payload: AppointmentCreate) -> Appointment:
id=_appointments_next_id, id=_appointments_next_id,
date=payload.date, date=payload.date,
notes=payload.notes, notes=payload.notes,
procedures=payload.procedures,
pet=_pets[payload.pet_id], pet=_pets[payload.pet_id],
vet=_vets[payload.vet_id], vet=_vets[payload.vet_id],
treatment=_treatments[payload.treatment_id], treatment=_treatments[payload.treatment_id],
@@ -287,6 +289,7 @@ def update_appointment(appointment_id: int, payload: AppointmentCreate) -> Appoi
id=appointment_id, id=appointment_id,
date=payload.date, date=payload.date,
notes=payload.notes if payload.notes is not None else current.notes, notes=payload.notes if payload.notes is not None else current.notes,
procedures=payload.procedures,
pet=_pets.get(payload.pet_id, current.pet), pet=_pets.get(payload.pet_id, current.pet),
vet=_vets.get(payload.vet_id, current.vet), vet=_vets.get(payload.vet_id, current.vet),
treatment=_treatments.get(payload.treatment_id, current.treatment), treatment=_treatments.get(payload.treatment_id, current.treatment),
@@ -335,10 +338,18 @@ def _seed_data():
_pets[5] = Pet(id=5, name="Rocky", species="dog", age=3, weight=30.0, birthDate=date(2023, 11, 5), parents=[_parents[4]], metadata=meta) _pets[5] = Pet(id=5, name="Rocky", species="dog", age=3, weight=30.0, birthDate=date(2023, 11, 5), parents=[_parents[4]], metadata=meta)
_pets_next_id = 6 _pets_next_id = 6
_appointments[1] = Appointment(id=1, date=datetime(2026, 6, 18, 9, 0, tzinfo=timezone.utc), notes="Annual checkup", pet=_pets[1], vet=_vets[1], treatment=_treatments[1], metadata=meta) _appointments[1] = Appointment(id=1, date=datetime(2026, 6, 18, 9, 0, tzinfo=timezone.utc), notes="Annual checkup",
_appointments[2] = Appointment(id=2, date=datetime(2026, 6, 18, 10, 30, tzinfo=timezone.utc), notes="Dental cleaning", pet=_pets[2], vet=_vets[2], treatment=_treatments[3], metadata=meta) procedures=[Procedure(name="Physical Exam", cost=50.0), Procedure(name="Heart Rate", notes=ProcedureNotes(summary="Normal rhythm"))],
_appointments[3] = Appointment(id=3, date=datetime(2026, 6, 19, 11, 0, tzinfo=timezone.utc), notes="Vaccination booster", pet=_pets[3], vet=_vets[3], treatment=_treatments[2], metadata=meta) pet=_pets[1], vet=_vets[1], treatment=_treatments[1], metadata=meta)
_appointments[4] = Appointment(id=4, date=datetime(2026, 6, 20, 14, 0, tzinfo=timezone.utc), notes="Follow-up after surgery", pet=_pets[5], vet=_vets[1], treatment=_treatments[4], metadata=meta) _appointments[2] = Appointment(id=2, date=datetime(2026, 6, 18, 10, 30, tzinfo=timezone.utc), notes="Dental cleaning",
procedures=[Procedure(name="Scaling", cost=80.0), Procedure(name="Polishing", cost=40.0, notes=ProcedureNotes(summary="High-speed polish"))],
pet=_pets[2], vet=_vets[2], treatment=_treatments[3], metadata=meta)
_appointments[3] = Appointment(id=3, date=datetime(2026, 6, 19, 11, 0, tzinfo=timezone.utc), notes="Vaccination booster",
procedures=[Procedure(name="DHPP Vaccine", cost=35.0), Procedure(name="Rabies Vaccine", cost=45.0)],
pet=_pets[3], vet=_vets[3], treatment=_treatments[2], metadata=meta)
_appointments[4] = Appointment(id=4, date=datetime(2026, 6, 20, 14, 0, tzinfo=timezone.utc), notes="Follow-up after surgery",
procedures=[Procedure(name="Pre-op Exam", cost=30.0), Procedure(name="Surgery", cost=200.0), Procedure(name="Post-op Care", cost=50.0)],
pet=_pets[5], vet=_vets[1], treatment=_treatments[4], metadata=meta)
_appointments_next_id = 5 _appointments_next_id = 5

View File

@@ -25,15 +25,29 @@ Example:
uvicorn main:app uvicorn main:app
""" """
from contextlib import asynccontextmanager
from starlette.middleware.cors import CORSMiddleware from starlette.middleware.cors import CORSMiddleware
from openapi_first.app import OpenAPIFirstApp from openapi_first.app import OpenAPIFirstApp
import routes import routes
from sse import start_worker, stop_worker
@asynccontextmanager
async def lifespan(app):
start_worker()
try:
yield
finally:
stop_worker()
app = OpenAPIFirstApp( app = OpenAPIFirstApp(
openapi_path="openapi.yaml", openapi_path="openapi.yaml",
routes_module=routes, routes_module=routes,
title="Veterinary Clinic Service", title="Veterinary Clinic Service",
lifespan=lifespan,
) )
app.add_middleware( app.add_middleware(

View File

@@ -38,6 +38,18 @@ class Vet(VetBase):
id: int id: int
class ProcedureNotes(BaseModel):
summary: str | None = None
details: str | None = None
class Procedure(BaseModel):
name: str | None = None
description: str | None = None
cost: float | None = None
notes: ProcedureNotes | None = None
class TreatmentBase(BaseModel): class TreatmentBase(BaseModel):
label: str label: str
description: str | None = None description: str | None = None
@@ -74,6 +86,7 @@ class Pet(PetBase):
class AppointmentBase(BaseModel): class AppointmentBase(BaseModel):
date: datetime date: datetime
notes: str | None = None notes: str | None = None
procedures: list[Procedure] = []
metadata: Metadata | None = None metadata: Metadata | None = None

View File

@@ -23,6 +23,56 @@ components:
type: string type: string
format: date-time format: date-time
Procedure:
type: object
x-display-format: "{name} — ${cost}"
properties:
name:
type: string
x-order: 1
x-label: "Procedure Name"
description:
type: string
x-order: 2
x-label: "Description"
cost:
type: number
format: float
x-order: 3
x-label: "Cost"
notes:
$ref: '#/components/schemas/ProcedureNotes'
x-order: 4
x-label: "Notes"
ProcedureNotes:
type: object
x-display-format: "{summary}"
properties:
summary:
type: string
x-order: 1
x-label: "Summary"
details:
type: string
x-order: 2
x-label: "Details"
Call:
type: object
x-resource: calls
x-primary-key: _received_at
x-display-format: "{sound}"
x-list-columns: [sound]
properties:
sound:
type: string
enum: [woof, meow, coo]
x-label: "Sound"
x-order: 1
x-filterable: false
required: [sound]
Parent: Parent:
type: object type: object
x-resource: parents x-resource: parents
@@ -234,6 +284,13 @@ components:
x-order: 2 x-order: 2
x-label: "Notes" x-label: "Notes"
x-description: "Any additional notes" x-description: "Any additional notes"
procedures:
type: array
items:
$ref: '#/components/schemas/Procedure'
x-order: 3
x-label: "Procedures"
x-description: "List of procedures performed during this appointment"
pet: pet:
$ref: '#/components/schemas/Pet' $ref: '#/components/schemas/Pet'
x-fk: x-fk:
@@ -327,6 +384,19 @@ components:
$ref: '#/components/schemas/ErrorBody' $ref: '#/components/schemas/ErrorBody'
paths: paths:
/calls:
get:
summary: Stream random animal sounds via SSE
operationId: stream_calls
x-sse: true
responses:
'200':
description: SSE stream of random animal sounds
content:
text/event-stream:
schema:
$ref: '#/components/schemas/Call'
/parents: /parents:
get: get:
summary: List parents (paginated) summary: List parents (paginated)
@@ -942,6 +1012,7 @@ paths:
$ref: '#/components/responses/ValidationError' $ref: '#/components/responses/ValidationError'
'500': '500':
$ref: '#/components/responses/InternalServerError' $ref: '#/components/responses/InternalServerError'
/appointments/{id}: /appointments/{id}:
get: get:
operationId: get_appointment operationId: get_appointment

View File

@@ -11,6 +11,9 @@ in the OpenAPI specification.
""" """
from fastapi import Response, HTTPException, UploadFile from fastapi import Response, HTTPException, UploadFile
from fastapi.responses import StreamingResponse
from sse import subscribe, unsubscribe
from models import ( from models import (
ParentCreate, ParentCreate,
@@ -363,3 +366,18 @@ def delete_appointment(id: int, response: Response):
except KeyError: except KeyError:
raise HTTPException(status_code=404, detail="Appointment not found") raise HTTPException(status_code=404, detail="Appointment not found")
response.status_code = 204 response.status_code = 204
async def stream_calls():
"""Stream random animal sounds via SSE."""
q = await subscribe()
async def event_generator():
try:
while True:
data = await q.get()
yield f"data: {data}\n\n"
finally:
unsubscribe(q)
return StreamingResponse(event_generator(), media_type="text/event-stream")

View File

@@ -0,0 +1,44 @@
"""
SSE broadcast for the animal-sounds worker.
Not part of the openapi_first library API surface.
"""
import asyncio
import random
import json
_sounds = ["woof", "meow", "coo"]
_subscribers: list[asyncio.Queue] = []
_worker_task: asyncio.Task | None = None
async def _sound_worker():
while True:
sound = random.choice(_sounds)
data = json.dumps({"sound": sound})
for q in _subscribers:
await q.put(data)
await asyncio.sleep(random.uniform(1, 5))
def start_worker():
global _worker_task
_worker_task = asyncio.create_task(_sound_worker())
def stop_worker():
if _worker_task is not None:
_worker_task.cancel()
async def subscribe() -> asyncio.Queue:
q: asyncio.Queue = asyncio.Queue()
_subscribers.append(q)
return q
def unsubscribe(q: asyncio.Queue):
if q in _subscribers:
_subscribers.remove(q)