spr migrated books, and tester
This commit is contained in:
156
cfg/amar/models/__init__.py
Normal file
156
cfg/amar/models/__init__.py
Normal file
@@ -0,0 +1,156 @@
|
||||
"""
|
||||
Pawprint Models - Platform Agnostic Definitions
|
||||
|
||||
Portable to: TypeScript, Pydantic, Django, SQLAlchemy, etc.
|
||||
|
||||
Hierarchy:
|
||||
pawprint (abstract)
|
||||
├── artery → Pulse = Vein + Nest + Larder
|
||||
├── album → Book = Template + Larder
|
||||
└── ward → Table = Tools + Nest + Larder
|
||||
|
||||
Shared components: Nest, Larder
|
||||
System-specific: Vein (artery), Template (album), Tools (ward)
|
||||
|
||||
Rules:
|
||||
- Larder in album generated from Template = "Book (written)"
|
||||
- Same Larder exists independently in ward/artery
|
||||
- Nest contains runtime configs, credentials, targets
|
||||
- Larder contains data, provisions, stored content
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, List
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class Status(Enum):
|
||||
PENDING = "pending"
|
||||
PLANNED = "planned"
|
||||
BUILDING = "building"
|
||||
DEV = "dev"
|
||||
LIVE = "live"
|
||||
READY = "ready"
|
||||
|
||||
|
||||
class System(Enum):
|
||||
ARTERY = "artery"
|
||||
ALBUM = "album"
|
||||
WARD = "ward"
|
||||
|
||||
|
||||
# === Shared Components ===
|
||||
|
||||
@dataclass
|
||||
class Nest:
|
||||
"""Runtime environment configuration.
|
||||
|
||||
Contains: credentials, targets, runtime configs.
|
||||
Shared across: artery, ward
|
||||
"""
|
||||
name: str
|
||||
status: Status = Status.PENDING
|
||||
# References to actual config files/secrets
|
||||
config_path: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Larder:
|
||||
"""Data storage / provisions.
|
||||
|
||||
Contains: data, transforms, parsers, dumps.
|
||||
Shared across: artery, album, ward
|
||||
|
||||
Note: When generated from Template in album, appears as "Book (written)"
|
||||
but exists as independent Larder in ward/artery.
|
||||
"""
|
||||
name: str
|
||||
status: Status = Status.PENDING
|
||||
# Optional source template (if generated)
|
||||
source_template: Optional[str] = None
|
||||
# Path to data
|
||||
data_path: Optional[str] = None
|
||||
|
||||
|
||||
# === System-Specific Components ===
|
||||
|
||||
@dataclass
|
||||
class Vein:
|
||||
"""Connector (artery-specific).
|
||||
|
||||
Single responsibility data connector.
|
||||
Examples: jira, google, slack, whatsapp, cash, vnc
|
||||
"""
|
||||
name: str
|
||||
status: Status = Status.PENDING
|
||||
system: System = field(default=System.ARTERY, init=False)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Template:
|
||||
"""Documentation template (album-specific).
|
||||
|
||||
Gherkin, BDD patterns, generators.
|
||||
Examples: feature-form, gherkin
|
||||
"""
|
||||
name: str
|
||||
status: Status = Status.PENDING
|
||||
system: System = field(default=System.ALBUM, init=False)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Tool:
|
||||
"""Execution tool (ward-specific).
|
||||
|
||||
Test runners, seeders, scripts.
|
||||
"""
|
||||
name: str
|
||||
status: Status = Status.PENDING
|
||||
system: System = field(default=System.WARD, init=False)
|
||||
|
||||
|
||||
# === Composed Types ===
|
||||
|
||||
@dataclass
|
||||
class Pulse:
|
||||
"""Composed data flow (artery).
|
||||
|
||||
Pulse = Vein + Nest + Larder
|
||||
"""
|
||||
name: str
|
||||
status: Status = Status.PENDING
|
||||
vein: Optional[Vein] = None
|
||||
nest: Optional[Nest] = None
|
||||
larder: Optional[Larder] = None
|
||||
system: System = field(default=System.ARTERY, init=False)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Book:
|
||||
"""Composed documentation (album).
|
||||
|
||||
Book = Template + Larder
|
||||
|
||||
Note: Output larder can be referenced independently in other systems.
|
||||
"""
|
||||
name: str
|
||||
status: Status = Status.PENDING
|
||||
template: Optional[Template] = None
|
||||
larder: Optional[Larder] = None
|
||||
# If this book produces a larder, it's tracked here
|
||||
output_larder: Optional[Larder] = None
|
||||
system: System = field(default=System.ALBUM, init=False)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Table:
|
||||
"""Composed execution bundle (ward).
|
||||
|
||||
Table = Tools + Nest + Larder
|
||||
"""
|
||||
name: str
|
||||
status: Status = Status.PENDING
|
||||
tools: List[Tool] = field(default_factory=list)
|
||||
nest: Optional[Nest] = None
|
||||
larder: Optional[Larder] = None
|
||||
system: System = field(default=System.WARD, init=False)
|
||||
191
cfg/amar/models/django/models.py
Normal file
191
cfg/amar/models/django/models.py
Normal file
@@ -0,0 +1,191 @@
|
||||
"""
|
||||
Django models - Generated from schema.json
|
||||
|
||||
DO NOT EDIT MANUALLY - Regenerate from schema.json
|
||||
"""
|
||||
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Status(models.TextChoices):
|
||||
PENDING = "pending", "Pending"
|
||||
PLANNED = "planned", "Planned"
|
||||
BUILDING = "building", "Building"
|
||||
DEV = "dev", "Dev"
|
||||
LIVE = "live", "Live"
|
||||
READY = "ready", "Ready"
|
||||
|
||||
|
||||
class System(models.TextChoices):
|
||||
ARTERY = "artery", "Artery"
|
||||
ALBUM = "album", "Album"
|
||||
WARD = "ward", "Ward"
|
||||
|
||||
|
||||
# === Shared Components ===
|
||||
|
||||
class Nest(models.Model):
|
||||
"""Runtime environment configuration. Shared across artery, ward."""
|
||||
name = models.CharField(max_length=255, help_text="Unique identifier")
|
||||
slug = models.SlugField(max_length=255, unique=True, help_text="URL-friendly identifier")
|
||||
title = models.CharField(max_length=255, help_text="Display title for UI")
|
||||
status = models.ForeignKey(Status, on_delete=models.SET_NULL, blank=True, null=True, related_name="nests")
|
||||
config_path = models.CharField(max_length=255, blank=True, null=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "pawprint_nest"
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
|
||||
class Larder(models.Model):
|
||||
"""Data storage. When generated from Template = 'Book (written)'. Independent in ward/artery."""
|
||||
name = models.CharField(max_length=255, help_text="Unique identifier")
|
||||
slug = models.SlugField(max_length=255, unique=True, help_text="URL-friendly identifier")
|
||||
title = models.CharField(max_length=255, help_text="Display title for UI")
|
||||
status = models.ForeignKey(Status, on_delete=models.SET_NULL, blank=True, null=True, related_name="larders")
|
||||
source_template = models.CharField(max_length=255, blank=True, null=True, help_text="Template name if generated")
|
||||
data_path = models.CharField(max_length=255, blank=True, null=True, help_text="Path to data files")
|
||||
|
||||
class Meta:
|
||||
db_table = "pawprint_larder"
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
|
||||
# === System-Specific Components ===
|
||||
|
||||
class Vein(models.Model):
|
||||
"""Connector (artery). Single responsibility."""
|
||||
name = models.CharField(max_length=255, help_text="Unique identifier")
|
||||
slug = models.SlugField(max_length=255, unique=True, help_text="URL-friendly identifier")
|
||||
title = models.CharField(max_length=255, help_text="Display title for UI")
|
||||
status = models.ForeignKey(Status, on_delete=models.SET_NULL, blank=True, null=True, related_name="veins")
|
||||
system = models.CharField(max_length=20, default="artery", editable=False)
|
||||
|
||||
class Meta:
|
||||
db_table = "pawprint_vein"
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
|
||||
class Template(models.Model):
|
||||
"""Documentation template (album). Gherkin, BDD patterns."""
|
||||
name = models.CharField(max_length=255, help_text="Unique identifier")
|
||||
slug = models.SlugField(max_length=255, unique=True, help_text="URL-friendly identifier")
|
||||
title = models.CharField(max_length=255, help_text="Display title for UI")
|
||||
status = models.ForeignKey(Status, on_delete=models.SET_NULL, blank=True, null=True, related_name="templates")
|
||||
template_path = models.CharField(max_length=255, blank=True, null=True, help_text="Path to template files")
|
||||
system = models.CharField(max_length=20, default="album", editable=False)
|
||||
|
||||
class Meta:
|
||||
db_table = "pawprint_template"
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
|
||||
class Tool(models.Model):
|
||||
"""Execution tool (ward). Test runners, seeders."""
|
||||
name = models.CharField(max_length=255, help_text="Unique identifier")
|
||||
slug = models.SlugField(max_length=255, unique=True, help_text="URL-friendly identifier")
|
||||
title = models.CharField(max_length=255, help_text="Display title for UI")
|
||||
status = models.ForeignKey(Status, on_delete=models.SET_NULL, blank=True, null=True, related_name="tools")
|
||||
system = models.CharField(max_length=20, default="ward", editable=False)
|
||||
|
||||
class Meta:
|
||||
db_table = "pawprint_tool"
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
|
||||
class Monitor(models.Model):
|
||||
"""Service monitor (ward). Health checks, status watchers."""
|
||||
name = models.CharField(max_length=255, help_text="Unique identifier")
|
||||
slug = models.SlugField(max_length=255, unique=True, help_text="URL-friendly identifier")
|
||||
title = models.CharField(max_length=255, help_text="Display title for UI")
|
||||
status = models.ForeignKey(Status, on_delete=models.SET_NULL, blank=True, null=True, related_name="monitors")
|
||||
system = models.CharField(max_length=20, default="ward", editable=False)
|
||||
|
||||
class Meta:
|
||||
db_table = "pawprint_monitor"
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
|
||||
class Cabinet(models.Model):
|
||||
"""Tool cabinet (ward). Contains 0+ tools."""
|
||||
name = models.CharField(max_length=255, help_text="Unique identifier")
|
||||
slug = models.SlugField(max_length=255, unique=True, help_text="URL-friendly identifier")
|
||||
title = models.CharField(max_length=255, help_text="Display title for UI")
|
||||
status = models.ForeignKey(Status, on_delete=models.SET_NULL, blank=True, null=True, related_name="cabinets")
|
||||
tools = models.ManyToManyField(Tool, blank=True)
|
||||
system = models.CharField(max_length=20, default="ward", editable=False)
|
||||
|
||||
class Meta:
|
||||
db_table = "pawprint_cabinet"
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
|
||||
# === Composed Types ===
|
||||
|
||||
class Pulse(models.Model):
|
||||
"""Composed data flow (artery). Pulse = Vein + Nest + Larder."""
|
||||
name = models.CharField(max_length=255, help_text="Unique identifier")
|
||||
slug = models.SlugField(max_length=255, unique=True, help_text="URL-friendly identifier")
|
||||
title = models.CharField(max_length=255, help_text="Display title for UI")
|
||||
status = models.ForeignKey(Status, on_delete=models.SET_NULL, blank=True, null=True, related_name="pulses")
|
||||
vein = models.ForeignKey(Vein, on_delete=models.SET_NULL, blank=True, null=True, related_name="pulses")
|
||||
nest = models.ForeignKey(Nest, on_delete=models.SET_NULL, blank=True, null=True, related_name="pulses")
|
||||
larder = models.ForeignKey(Larder, on_delete=models.SET_NULL, blank=True, null=True, related_name="pulses")
|
||||
system = models.CharField(max_length=20, default="artery", editable=False)
|
||||
|
||||
class Meta:
|
||||
db_table = "pawprint_pulse"
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
|
||||
class Book(models.Model):
|
||||
"""Composed documentation (album). Book = Template + Larder."""
|
||||
name = models.CharField(max_length=255, help_text="Unique identifier")
|
||||
slug = models.SlugField(max_length=255, unique=True, help_text="URL-friendly identifier")
|
||||
title = models.CharField(max_length=255, help_text="Display title for UI")
|
||||
status = models.ForeignKey(Status, on_delete=models.SET_NULL, blank=True, null=True, related_name="books")
|
||||
template = models.ForeignKey(Template, on_delete=models.SET_NULL, blank=True, null=True, related_name="books")
|
||||
larder = models.ForeignKey(Larder, on_delete=models.SET_NULL, blank=True, null=True, related_name="books")
|
||||
output_larder = models.ForeignKey(Larder, on_delete=models.SET_NULL, blank=True, null=True, related_name="books")
|
||||
system = models.CharField(max_length=20, default="album", editable=False)
|
||||
|
||||
class Meta:
|
||||
db_table = "pawprint_book"
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
|
||||
class Table(models.Model):
|
||||
"""Composed execution bundle (ward). Table = Cabinet + Nest + Larders."""
|
||||
name = models.CharField(max_length=255, help_text="Unique identifier")
|
||||
slug = models.SlugField(max_length=255, unique=True, help_text="URL-friendly identifier")
|
||||
title = models.CharField(max_length=255, help_text="Display title for UI")
|
||||
status = models.ForeignKey(Status, on_delete=models.SET_NULL, blank=True, null=True, related_name="tables")
|
||||
cabinet = models.ForeignKey(Cabinet, on_delete=models.SET_NULL, blank=True, null=True, related_name="tables")
|
||||
nest = models.ForeignKey(Nest, on_delete=models.SET_NULL, blank=True, null=True, related_name="tables")
|
||||
larders = models.ManyToManyField(Larder, blank=True)
|
||||
system = models.CharField(max_length=20, default="ward", editable=False)
|
||||
|
||||
class Meta:
|
||||
db_table = "pawprint_table"
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
213
cfg/amar/models/prisma/schema.prisma
Normal file
213
cfg/amar/models/prisma/schema.prisma
Normal file
@@ -0,0 +1,213 @@
|
||||
// Prisma schema - Generated from schema.json
|
||||
//
|
||||
// DO NOT EDIT MANUALLY - Regenerate from schema.json
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
enum Status {
|
||||
PENDING
|
||||
PLANNED
|
||||
BUILDING
|
||||
DEV
|
||||
LIVE
|
||||
READY
|
||||
}
|
||||
|
||||
enum System {
|
||||
ARTERY
|
||||
ALBUM
|
||||
WARD
|
||||
}
|
||||
|
||||
// === Shared Components ===
|
||||
|
||||
/// Runtime environment configuration. Shared across artery, ward.
|
||||
model Nest {
|
||||
id Int @id @default(autoincrement())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
name String @unique
|
||||
slug String @unique
|
||||
title String
|
||||
status Status? @relation(fields: [statusId], references: [id])
|
||||
statusId Int?
|
||||
config_path String?
|
||||
|
||||
@@map("pawprint_nest")
|
||||
}
|
||||
|
||||
/// Data storage. When generated from Template = 'Book (written)'. Independent in ward/artery.
|
||||
model Larder {
|
||||
id Int @id @default(autoincrement())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
name String @unique
|
||||
slug String @unique
|
||||
title String
|
||||
status Status? @relation(fields: [statusId], references: [id])
|
||||
statusId Int?
|
||||
source_template String?
|
||||
data_path String?
|
||||
|
||||
@@map("pawprint_larder")
|
||||
}
|
||||
|
||||
// === System-Specific Components ===
|
||||
|
||||
/// Connector (artery). Single responsibility.
|
||||
model Vein {
|
||||
id Int @id @default(autoincrement())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
name String @unique
|
||||
slug String @unique
|
||||
title String
|
||||
status Status? @relation(fields: [statusId], references: [id])
|
||||
statusId Int?
|
||||
system String @default("artery")
|
||||
|
||||
@@map("pawprint_vein")
|
||||
}
|
||||
|
||||
/// Documentation template (album). Gherkin, BDD patterns.
|
||||
model Template {
|
||||
id Int @id @default(autoincrement())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
name String @unique
|
||||
slug String @unique
|
||||
title String
|
||||
status Status? @relation(fields: [statusId], references: [id])
|
||||
statusId Int?
|
||||
template_path String?
|
||||
system String @default("album")
|
||||
|
||||
@@map("pawprint_template")
|
||||
}
|
||||
|
||||
/// Execution tool (ward). Test runners, seeders.
|
||||
model Tool {
|
||||
id Int @id @default(autoincrement())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
name String @unique
|
||||
slug String @unique
|
||||
title String
|
||||
status Status? @relation(fields: [statusId], references: [id])
|
||||
statusId Int?
|
||||
system String @default("ward")
|
||||
|
||||
@@map("pawprint_tool")
|
||||
}
|
||||
|
||||
/// Service monitor (ward). Health checks, status watchers.
|
||||
model Monitor {
|
||||
id Int @id @default(autoincrement())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
name String @unique
|
||||
slug String @unique
|
||||
title String
|
||||
status Status? @relation(fields: [statusId], references: [id])
|
||||
statusId Int?
|
||||
system String @default("ward")
|
||||
|
||||
@@map("pawprint_monitor")
|
||||
}
|
||||
|
||||
/// Tool cabinet (ward). Contains 0+ tools.
|
||||
model Cabinet {
|
||||
id Int @id @default(autoincrement())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
name String @unique
|
||||
slug String @unique
|
||||
title String
|
||||
status Status? @relation(fields: [statusId], references: [id])
|
||||
statusId Int?
|
||||
tools Tool[]
|
||||
system String @default("ward")
|
||||
|
||||
@@map("pawprint_cabinet")
|
||||
}
|
||||
|
||||
// === Composed Types ===
|
||||
|
||||
/// Composed data flow (artery). Pulse = Vein + Nest + Larder.
|
||||
model Pulse {
|
||||
id Int @id @default(autoincrement())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
name String @unique
|
||||
slug String @unique
|
||||
title String
|
||||
status Status? @relation(fields: [statusId], references: [id])
|
||||
statusId Int?
|
||||
vein Vein? @relation(fields: [veinId], references: [id])
|
||||
veinId Int?
|
||||
nest Nest? @relation(fields: [nestId], references: [id])
|
||||
nestId Int?
|
||||
larder Larder? @relation(fields: [larderId], references: [id])
|
||||
larderId Int?
|
||||
system String @default("artery")
|
||||
|
||||
@@map("pawprint_pulse")
|
||||
}
|
||||
|
||||
/// Composed documentation (album). Book = Template + Larder.
|
||||
model Book {
|
||||
id Int @id @default(autoincrement())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
name String @unique
|
||||
slug String @unique
|
||||
title String
|
||||
status Status? @relation(fields: [statusId], references: [id])
|
||||
statusId Int?
|
||||
template Template? @relation(fields: [templateId], references: [id])
|
||||
templateId Int?
|
||||
larder Larder? @relation(fields: [larderId], references: [id])
|
||||
larderId Int?
|
||||
output_larder Larder? @relation(fields: [output_larderId], references: [id])
|
||||
output_larderId Int?
|
||||
system String @default("album")
|
||||
|
||||
@@map("pawprint_book")
|
||||
}
|
||||
|
||||
/// Composed execution bundle (ward). Table = Cabinet + Nest + Larders.
|
||||
model Table {
|
||||
id Int @id @default(autoincrement())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
name String @unique
|
||||
slug String @unique
|
||||
title String
|
||||
status Status? @relation(fields: [statusId], references: [id])
|
||||
statusId Int?
|
||||
cabinet Cabinet? @relation(fields: [cabinetId], references: [id])
|
||||
cabinetId Int?
|
||||
nest Nest? @relation(fields: [nestId], references: [id])
|
||||
nestId Int?
|
||||
larders Larder[]
|
||||
system String @default("ward")
|
||||
|
||||
@@map("pawprint_table")
|
||||
}
|
||||
187
cfg/amar/models/pydantic/__init__.py
Normal file
187
cfg/amar/models/pydantic/__init__.py
Normal file
@@ -0,0 +1,187 @@
|
||||
"""
|
||||
Pydantic models - Generated from schema.json
|
||||
|
||||
DO NOT EDIT MANUALLY - Regenerate from schema.json
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
from typing import Optional, List, Literal
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class Status(str, Enum):
|
||||
PENDING = "pending"
|
||||
PLANNED = "planned"
|
||||
BUILDING = "building"
|
||||
DEV = "dev"
|
||||
LIVE = "live"
|
||||
READY = "ready"
|
||||
|
||||
|
||||
class System(str, Enum):
|
||||
ARTERY = "artery"
|
||||
ALBUM = "album"
|
||||
WARD = "ward"
|
||||
|
||||
|
||||
class ToolType(str, Enum):
|
||||
APP = "app"
|
||||
CLI = "cli"
|
||||
|
||||
|
||||
# === Shared Components ===
|
||||
|
||||
class Nest(BaseModel):
|
||||
"""Runtime environment configuration. Shared across artery, ward."""
|
||||
name: str # Unique identifier
|
||||
slug: str # URL-friendly identifier
|
||||
title: str # Display title for UI
|
||||
status: Optional[Status] = None
|
||||
config_path: Optional[str] = None
|
||||
|
||||
|
||||
class Larder(BaseModel):
|
||||
"""Data storage. When generated from Template = 'Book (written)'. Independent in ward/artery."""
|
||||
name: str # Unique identifier
|
||||
slug: str # URL-friendly identifier
|
||||
title: str # Display title for UI
|
||||
status: Optional[Status] = None
|
||||
source_template: Optional[str] = None
|
||||
data_path: Optional[str] = None
|
||||
|
||||
|
||||
# === System-Specific Components ===
|
||||
|
||||
class Vein(BaseModel):
|
||||
"""Connector (artery). Single responsibility."""
|
||||
name: str # Unique identifier
|
||||
slug: str # URL-friendly identifier
|
||||
title: str # Display title for UI
|
||||
status: Optional[Status] = None
|
||||
system: Literal["artery"] = "artery"
|
||||
mock: Optional[bool] = None
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class Template(BaseModel):
|
||||
"""Documentation template (album). Gherkin, BDD patterns."""
|
||||
name: str # Unique identifier
|
||||
slug: str # URL-friendly identifier
|
||||
title: str # Display title for UI
|
||||
status: Optional[Status] = None
|
||||
template_path: Optional[str] = None
|
||||
system: Literal["album"] = "album"
|
||||
|
||||
|
||||
class Tool(BaseModel):
|
||||
"""Execution tool (ward). Test runners, seeders."""
|
||||
name: str # Unique identifier
|
||||
slug: str # URL-friendly identifier
|
||||
title: str # Display title for UI
|
||||
status: Optional[Status] = None
|
||||
system: Literal["ward"] = "ward"
|
||||
type: Optional[ToolType] = None
|
||||
description: Optional[str] = None
|
||||
path: Optional[str] = None
|
||||
url: Optional[str] = None
|
||||
cli: Optional[str] = None
|
||||
|
||||
|
||||
class Monitor(BaseModel):
|
||||
"""Service monitor (ward). Health checks, status watchers."""
|
||||
name: str # Unique identifier
|
||||
slug: str # URL-friendly identifier
|
||||
title: str # Display title for UI
|
||||
status: Optional[Status] = None
|
||||
system: Literal["ward"] = "ward"
|
||||
|
||||
|
||||
class Cabinet(BaseModel):
|
||||
"""Tool cabinet (ward). Contains 0+ tools."""
|
||||
name: str # Unique identifier
|
||||
slug: str # URL-friendly identifier
|
||||
title: str # Display title for UI
|
||||
status: Optional[Status] = None
|
||||
tools: List[Tool] = Field(default_factory=list)
|
||||
system: Literal["ward"] = "ward"
|
||||
|
||||
|
||||
# === Composed Types ===
|
||||
|
||||
class Pulse(BaseModel):
|
||||
"""Composed data flow (artery). Pulse = Vein + Nest + Larder."""
|
||||
name: str # Unique identifier
|
||||
slug: str # URL-friendly identifier
|
||||
title: str # Display title for UI
|
||||
status: Optional[Status] = None
|
||||
vein: Optional[Vein] = None
|
||||
nest: Optional[Nest] = None
|
||||
larder: Optional[Larder] = None
|
||||
system: Literal["artery"] = "artery"
|
||||
|
||||
|
||||
class Book(BaseModel):
|
||||
"""Composed documentation (album). Book = Template + Larder."""
|
||||
name: str # Unique identifier
|
||||
slug: str # URL-friendly identifier
|
||||
title: str # Display title for UI
|
||||
status: Optional[Status] = None
|
||||
template: Optional[Template] = None
|
||||
larder: Optional[Larder] = None
|
||||
output_larder: Optional[Larder] = None
|
||||
system: Literal["album"] = "album"
|
||||
|
||||
|
||||
class Table(BaseModel):
|
||||
"""Composed execution bundle (ward). Table = Cabinet + Nest + Larders."""
|
||||
name: str # Unique identifier
|
||||
slug: str # URL-friendly identifier
|
||||
title: str # Display title for UI
|
||||
status: Optional[Status] = None
|
||||
cabinet: Optional[Cabinet] = None
|
||||
nest: Optional[Nest] = None
|
||||
larders: List[Larder] = Field(default_factory=list)
|
||||
system: Literal["ward"] = "ward"
|
||||
|
||||
|
||||
# === Collection wrappers for JSON files ===
|
||||
|
||||
class NestCollection(BaseModel):
|
||||
items: List[Nest] = Field(default_factory=list)
|
||||
|
||||
|
||||
class LarderCollection(BaseModel):
|
||||
items: List[Larder] = Field(default_factory=list)
|
||||
|
||||
|
||||
class VeinCollection(BaseModel):
|
||||
items: List[Vein] = Field(default_factory=list)
|
||||
|
||||
|
||||
class TemplateCollection(BaseModel):
|
||||
items: List[Template] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ToolCollection(BaseModel):
|
||||
items: List[Tool] = Field(default_factory=list)
|
||||
|
||||
|
||||
class MonitorCollection(BaseModel):
|
||||
items: List[Monitor] = Field(default_factory=list)
|
||||
|
||||
|
||||
class CabinetCollection(BaseModel):
|
||||
items: List[Cabinet] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PulseCollection(BaseModel):
|
||||
items: List[Pulse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class BookCollection(BaseModel):
|
||||
items: List[Book] = Field(default_factory=list)
|
||||
|
||||
|
||||
class TableCollection(BaseModel):
|
||||
items: List[Table] = Field(default_factory=list)
|
||||
|
||||
163
cfg/amar/models/schema.json
Normal file
163
cfg/amar/models/schema.json
Normal file
@@ -0,0 +1,163 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "Pawprint Models",
|
||||
"description": "Platform-agnostic model definitions. Portable to TypeScript, Pydantic, Django, Prisma.",
|
||||
"definitions": {
|
||||
"Status": {
|
||||
"type": "string",
|
||||
"enum": ["pending", "planned", "building", "dev", "live", "ready"]
|
||||
},
|
||||
"System": {
|
||||
"type": "string",
|
||||
"enum": ["artery", "album", "ward"]
|
||||
},
|
||||
"Nest": {
|
||||
"type": "object",
|
||||
"description": "Runtime environment configuration. Shared across artery, ward.",
|
||||
"properties": {
|
||||
"name": { "type": "string", "description": "Unique identifier" },
|
||||
"slug": { "type": "string", "description": "URL-friendly identifier" },
|
||||
"title": { "type": "string", "description": "Display title for UI" },
|
||||
"status": { "$ref": "#/definitions/Status" },
|
||||
"config_path": { "type": "string" }
|
||||
},
|
||||
"required": ["name", "slug", "title"]
|
||||
},
|
||||
"Larder": {
|
||||
"type": "object",
|
||||
"description": "Data storage. When generated from Template = 'Book (written)'. Independent in ward/artery.",
|
||||
"properties": {
|
||||
"name": { "type": "string", "description": "Unique identifier" },
|
||||
"slug": { "type": "string", "description": "URL-friendly identifier" },
|
||||
"title": { "type": "string", "description": "Display title for UI" },
|
||||
"status": { "$ref": "#/definitions/Status" },
|
||||
"source_template": { "type": "string", "description": "Template name if generated" },
|
||||
"data_path": { "type": "string", "description": "Path to data files" }
|
||||
},
|
||||
"required": ["name", "slug", "title"]
|
||||
},
|
||||
"Vein": {
|
||||
"type": "object",
|
||||
"description": "Connector (artery). Single responsibility.",
|
||||
"properties": {
|
||||
"name": { "type": "string", "description": "Unique identifier" },
|
||||
"slug": { "type": "string", "description": "URL-friendly identifier" },
|
||||
"title": { "type": "string", "description": "Display title for UI" },
|
||||
"status": { "$ref": "#/definitions/Status" },
|
||||
"system": { "const": "artery" }
|
||||
},
|
||||
"required": ["name", "slug", "title"]
|
||||
},
|
||||
"Template": {
|
||||
"type": "object",
|
||||
"description": "Documentation template (album). Gherkin, BDD patterns.",
|
||||
"properties": {
|
||||
"name": { "type": "string", "description": "Unique identifier" },
|
||||
"slug": { "type": "string", "description": "URL-friendly identifier" },
|
||||
"title": { "type": "string", "description": "Display title for UI" },
|
||||
"status": { "$ref": "#/definitions/Status" },
|
||||
"template_path": { "type": "string", "description": "Path to template files" },
|
||||
"system": { "const": "album" }
|
||||
},
|
||||
"required": ["name", "slug", "title"]
|
||||
},
|
||||
"ToolType": {
|
||||
"type": "string",
|
||||
"enum": ["app", "cli"],
|
||||
"description": "Type of tool: app (web UI) or cli (command line)"
|
||||
},
|
||||
"Tool": {
|
||||
"type": "object",
|
||||
"description": "Execution tool (ward). Test runners, seeders.",
|
||||
"properties": {
|
||||
"name": { "type": "string", "description": "Unique identifier" },
|
||||
"slug": { "type": "string", "description": "URL-friendly identifier" },
|
||||
"title": { "type": "string", "description": "Display title for UI" },
|
||||
"status": { "$ref": "#/definitions/Status" },
|
||||
"system": { "const": "ward" },
|
||||
"type": { "$ref": "#/definitions/ToolType" },
|
||||
"description": { "type": "string", "description": "Human-readable description" },
|
||||
"path": { "type": "string", "description": "Path to tool source" },
|
||||
"url": { "type": "string", "description": "URL path for app tools" },
|
||||
"cli": { "type": "string", "description": "CLI command for cli tools" }
|
||||
},
|
||||
"required": ["name", "slug", "title"]
|
||||
},
|
||||
"Monitor": {
|
||||
"type": "object",
|
||||
"description": "Service monitor (ward). Health checks, status watchers.",
|
||||
"properties": {
|
||||
"name": { "type": "string", "description": "Unique identifier" },
|
||||
"slug": { "type": "string", "description": "URL-friendly identifier" },
|
||||
"title": { "type": "string", "description": "Display title for UI" },
|
||||
"status": { "$ref": "#/definitions/Status" },
|
||||
"system": { "const": "ward" }
|
||||
},
|
||||
"required": ["name", "slug", "title"]
|
||||
},
|
||||
"Cabinet": {
|
||||
"type": "object",
|
||||
"description": "Tool cabinet (ward). Contains 0+ tools.",
|
||||
"properties": {
|
||||
"name": { "type": "string", "description": "Unique identifier" },
|
||||
"slug": { "type": "string", "description": "URL-friendly identifier" },
|
||||
"title": { "type": "string", "description": "Display title for UI" },
|
||||
"status": { "$ref": "#/definitions/Status" },
|
||||
"tools": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/definitions/Tool" }
|
||||
},
|
||||
"system": { "const": "ward" }
|
||||
},
|
||||
"required": ["name", "slug", "title"]
|
||||
},
|
||||
"Pulse": {
|
||||
"type": "object",
|
||||
"description": "Composed data flow (artery). Pulse = Vein + Nest + Larder.",
|
||||
"properties": {
|
||||
"name": { "type": "string", "description": "Unique identifier" },
|
||||
"slug": { "type": "string", "description": "URL-friendly identifier" },
|
||||
"title": { "type": "string", "description": "Display title for UI" },
|
||||
"status": { "$ref": "#/definitions/Status" },
|
||||
"vein": { "$ref": "#/definitions/Vein" },
|
||||
"nest": { "$ref": "#/definitions/Nest" },
|
||||
"larder": { "$ref": "#/definitions/Larder" },
|
||||
"system": { "const": "artery" }
|
||||
},
|
||||
"required": ["name", "slug", "title"]
|
||||
},
|
||||
"Book": {
|
||||
"type": "object",
|
||||
"description": "Composed documentation (album). Book = Template + Larder.",
|
||||
"properties": {
|
||||
"name": { "type": "string", "description": "Unique identifier" },
|
||||
"slug": { "type": "string", "description": "URL-friendly identifier" },
|
||||
"title": { "type": "string", "description": "Display title for UI" },
|
||||
"status": { "$ref": "#/definitions/Status" },
|
||||
"template": { "$ref": "#/definitions/Template" },
|
||||
"larder": { "$ref": "#/definitions/Larder" },
|
||||
"output_larder": { "$ref": "#/definitions/Larder" },
|
||||
"system": { "const": "album" }
|
||||
},
|
||||
"required": ["name", "slug", "title"]
|
||||
},
|
||||
"Table": {
|
||||
"type": "object",
|
||||
"description": "Composed execution bundle (ward). Table = Cabinet + Nest + Larders.",
|
||||
"properties": {
|
||||
"name": { "type": "string", "description": "Unique identifier" },
|
||||
"slug": { "type": "string", "description": "URL-friendly identifier" },
|
||||
"title": { "type": "string", "description": "Display title for UI" },
|
||||
"status": { "$ref": "#/definitions/Status" },
|
||||
"cabinet": { "$ref": "#/definitions/Cabinet" },
|
||||
"nest": { "$ref": "#/definitions/Nest" },
|
||||
"larders": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/definitions/Larder" }
|
||||
},
|
||||
"system": { "const": "ward" }
|
||||
},
|
||||
"required": ["name", "slug", "title"]
|
||||
}
|
||||
}
|
||||
}
|
||||
6
cfg/amar/monitors/turnos/__init__.py
Normal file
6
cfg/amar/monitors/turnos/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
Turnos Monitor - At-a-glance view of request/turno pipeline.
|
||||
|
||||
Shows the flow: Request -> Vet Accept -> Payment -> Turno
|
||||
Color-coded by state, minimal info (vet - pet owner).
|
||||
"""
|
||||
244
cfg/amar/monitors/turnos/index.html
Normal file
244
cfg/amar/monitors/turnos/index.html
Normal file
@@ -0,0 +1,244 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="refresh" content="30">
|
||||
<title>Turnos · {{ nest_name }}</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
background: #111827;
|
||||
color: #f3f4f6;
|
||||
min-height: 100vh;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0 1rem;
|
||||
border-bottom: 1px solid #374151;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.nest-badge {
|
||||
font-size: 0.7rem;
|
||||
background: #374151;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.total {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
.total small {
|
||||
font-size: 0.8rem;
|
||||
color: #9ca3af;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* Pipeline columns */
|
||||
.pipeline {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.column {
|
||||
background: #1f2937;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.column-header {
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 2px solid;
|
||||
}
|
||||
|
||||
.column-count {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.column-items {
|
||||
padding: 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Individual request card */
|
||||
.card {
|
||||
background: #111827;
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.6rem;
|
||||
font-size: 0.75rem;
|
||||
border-left: 3px solid;
|
||||
}
|
||||
|
||||
.card-id {
|
||||
font-weight: 600;
|
||||
color: #9ca3af;
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
.card-vet {
|
||||
color: #60a5fa;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.card-petowner {
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
font-size: 0.65rem;
|
||||
color: #6b7280;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* Age indicators */
|
||||
.card.old {
|
||||
background: #450a0a;
|
||||
}
|
||||
.card.warn {
|
||||
background: #422006;
|
||||
}
|
||||
|
||||
/* Indicators */
|
||||
.indicator {
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
.indicator.paid { background: #22c55e; }
|
||||
.indicator.scheduled { background: #3b82f6; }
|
||||
|
||||
/* Empty state */
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: #6b7280;
|
||||
padding: 2rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
footer {
|
||||
margin-top: 1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid #374151;
|
||||
font-size: 0.7rem;
|
||||
color: #6b7280;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.refresh-notice {
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 0.5; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.view-toggle a {
|
||||
color: #6b7280;
|
||||
text-decoration: none;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.3rem 0.6rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.view-toggle a:hover { background: #374151; }
|
||||
.view-toggle a.active { background: #374151; color: #f3f4f6; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>
|
||||
Turnos
|
||||
<span class="nest-badge">{{ nest_name }}</span>
|
||||
</h1>
|
||||
<div class="header-right">
|
||||
<div class="view-toggle">
|
||||
<a href="?view=pipeline" class="active">Pipeline</a>
|
||||
<a href="?view=list">List</a>
|
||||
</div>
|
||||
<div class="total">
|
||||
{{ total }}
|
||||
<small>activos</small>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{% if total == 0 %}
|
||||
<div class="empty">
|
||||
No hay solicitudes activas
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="pipeline">
|
||||
{% for state in active_states %}
|
||||
{% set info = states.get(state, ('?', '#888', 99)) %}
|
||||
{% set items = by_state.get(state, []) %}
|
||||
<div class="column">
|
||||
<div class="column-header" style="border-color: {{ info[1] }}; color: {{ info[1] }};">
|
||||
<span>{{ info[0] }}</span>
|
||||
<span class="column-count">{{ items|length }}</span>
|
||||
</div>
|
||||
<div class="column-items">
|
||||
{% for item in items %}
|
||||
<div class="card {{ item.age_class }}" style="border-color: {{ info[1] }};">
|
||||
<div class="card-id">#{{ item.id }}</div>
|
||||
<div class="card-vet">{{ item.vet }}</div>
|
||||
<div class="card-petowner">{{ item.petowner }}</div>
|
||||
<div class="card-meta">
|
||||
{% if item.is_paid %}<span class="indicator paid" title="Pagado"></span>{% endif %}
|
||||
{% if item.is_scheduled %}<span class="indicator scheduled" title="Coordinado"></span>{% endif %}
|
||||
{{ item.age_hours }}h
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<footer>
|
||||
<span class="refresh-notice">Auto-refresh 30s</span>
|
||||
<span><a href="/health" style="color: #6b7280;">/health</a></span>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
188
cfg/amar/monitors/turnos/list.html
Normal file
188
cfg/amar/monitors/turnos/list.html
Normal file
@@ -0,0 +1,188 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="refresh" content="30">
|
||||
<title>Turnos List · {{ nest_name }}</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
background: #111827;
|
||||
color: #f3f4f6;
|
||||
min-height: 100vh;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0 1rem;
|
||||
border-bottom: 1px solid #374151;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.nest-badge {
|
||||
font-size: 0.7rem;
|
||||
background: #374151;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.total {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
.view-toggle a {
|
||||
color: #6b7280;
|
||||
text-decoration: none;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.3rem 0.6rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.view-toggle a:hover { background: #374151; }
|
||||
.view-toggle a.active { background: #374151; color: #f3f4f6; }
|
||||
|
||||
/* Table */
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
padding: 0.5rem;
|
||||
color: #9ca3af;
|
||||
font-weight: 500;
|
||||
border-bottom: 1px solid #374151;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 0.5rem;
|
||||
border-bottom: 1px solid #1f2937;
|
||||
}
|
||||
|
||||
tr:hover td {
|
||||
background: #1f2937;
|
||||
}
|
||||
|
||||
.state-badge {
|
||||
display: inline-block;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.vet { color: #60a5fa; }
|
||||
.petowner { color: #d1d5db; }
|
||||
.id { color: #6b7280; font-family: monospace; }
|
||||
.age { color: #6b7280; }
|
||||
.age.warn { color: #fbbf24; }
|
||||
.age.old { color: #f87171; }
|
||||
|
||||
.indicator {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
.indicator.paid { background: #22c55e; }
|
||||
.indicator.scheduled { background: #3b82f6; }
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: #6b7280;
|
||||
padding: 3rem;
|
||||
}
|
||||
|
||||
footer {
|
||||
margin-top: 1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid #374151;
|
||||
font-size: 0.7rem;
|
||||
color: #6b7280;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>
|
||||
Turnos
|
||||
<span class="nest-badge">{{ nest_name }}</span>
|
||||
</h1>
|
||||
<div class="header-right">
|
||||
<div class="view-toggle">
|
||||
<a href="?view=pipeline">Pipeline</a>
|
||||
<a href="?view=list" class="active">List</a>
|
||||
</div>
|
||||
<div class="total">{{ total }}</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{% if total == 0 %}
|
||||
<div class="empty">No hay solicitudes activas</div>
|
||||
{% else %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Estado</th>
|
||||
<th>Veterinario</th>
|
||||
<th>Cliente</th>
|
||||
<th>Flags</th>
|
||||
<th>Edad</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in items %}
|
||||
<tr>
|
||||
<td class="id">{{ item.id }}</td>
|
||||
<td>
|
||||
<span class="state-badge" style="background: {{ item.state_color }}20; color: {{ item.state_color }};">
|
||||
{{ item.state_label }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="vet">{{ item.vet }}</td>
|
||||
<td class="petowner">{{ item.petowner }}</td>
|
||||
<td>
|
||||
{% if item.is_paid %}<span class="indicator paid" title="Pagado"></span>{% endif %}
|
||||
{% if item.is_scheduled %}<span class="indicator scheduled" title="Coordinado"></span>{% endif %}
|
||||
</td>
|
||||
<td class="age {{ item.age_class }}">{{ item.age_hours }}h</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
<footer>
|
||||
<span>Auto-refresh 30s</span>
|
||||
<span><a href="/health" style="color: #6b7280;">/health</a></span>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
270
cfg/amar/monitors/turnos/main.py
Normal file
270
cfg/amar/monitors/turnos/main.py
Normal file
@@ -0,0 +1,270 @@
|
||||
"""
|
||||
Turnos Monitor - At-a-glance view of the request/turno pipeline.
|
||||
|
||||
Request -> Vet Accept + Payment -> Turno (VetVisit)
|
||||
|
||||
Run standalone:
|
||||
python -m ward.tools.monitors.turnos.main
|
||||
|
||||
Or use uvicorn:
|
||||
uvicorn ward.tools.monitors.turnos.main:app --port 12010 --reload
|
||||
"""
|
||||
|
||||
import os
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.engine import Engine
|
||||
|
||||
app = FastAPI(title="Turnos Monitor", version="0.1.0")
|
||||
|
||||
templates = Jinja2Templates(directory=Path(__file__).parent)
|
||||
|
||||
# =============================================================================
|
||||
# NEST CONFIG - Pluggable environment targeting
|
||||
# =============================================================================
|
||||
# Default nest: local development database
|
||||
# Override with env vars or future nest selector UI
|
||||
|
||||
NEST_CONFIG = {
|
||||
"name": os.getenv("NEST_NAME", "local"),
|
||||
"db": {
|
||||
"host": os.getenv("DB_HOST", "localhost"),
|
||||
"port": os.getenv("DB_PORT", "5433"), # local default
|
||||
"name": os.getenv("DB_NAME", "amarback"),
|
||||
"user": os.getenv("DB_USER", "mariano"),
|
||||
"password": os.getenv("DB_PASSWORD", ""),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def get_db_url() -> str:
|
||||
"""Build database URL from nest config."""
|
||||
db = NEST_CONFIG["db"]
|
||||
return f"postgresql://{db['user']}:{db['password']}@{db['host']}:{db['port']}/{db['name']}"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# STATE DEFINITIONS
|
||||
# =============================================================================
|
||||
# Pipeline states with labels, colors, and order
|
||||
|
||||
STATES = {
|
||||
"pending": ("Sin Atender", "#fbbf24", 1), # amber
|
||||
"in_progress_vet": ("Buscando Vet", "#f97316", 2), # orange
|
||||
"vet_asked": ("Esperando Vet", "#fb923c", 3), # orange light
|
||||
"vet_accepted": ("Vet OK", "#4ade80", 4), # green
|
||||
"in_progress_pay": ("Esp. Pago", "#60a5fa", 5), # blue
|
||||
"payed": ("Pagado", "#2dd4bf", 6), # teal
|
||||
"coordinated": ("Coordinado", "#22c55e", 7), # green
|
||||
"not_coordinated": ("Sin Coord.", "#facc15", 8), # yellow
|
||||
"completed": ("Turno", "#059669", 9), # emerald
|
||||
"rejected": ("Rechazado", "#f87171", 10), # red
|
||||
}
|
||||
|
||||
# States to show in the active pipeline (exclude end states)
|
||||
ACTIVE_STATES = [
|
||||
"pending",
|
||||
"in_progress_vet",
|
||||
"vet_asked",
|
||||
"vet_accepted",
|
||||
"in_progress_pay",
|
||||
"payed",
|
||||
"not_coordinated",
|
||||
"coordinated",
|
||||
]
|
||||
|
||||
# =============================================================================
|
||||
# DATABASE
|
||||
# =============================================================================
|
||||
|
||||
_engine: Optional[Engine] = None
|
||||
|
||||
|
||||
def get_engine() -> Optional[Engine]:
|
||||
"""Get or create database engine (lazy singleton)."""
|
||||
global _engine
|
||||
if _engine is None:
|
||||
try:
|
||||
_engine = create_engine(get_db_url(), pool_pre_ping=True)
|
||||
except Exception as e:
|
||||
print(f"[turnos] DB engine error: {e}")
|
||||
return _engine
|
||||
|
||||
|
||||
def fetch_active_requests() -> list[dict]:
|
||||
"""Fetch active service requests with vet and petowner info."""
|
||||
engine = get_engine()
|
||||
if not engine:
|
||||
return []
|
||||
|
||||
query = text("""
|
||||
SELECT
|
||||
sr.id,
|
||||
sr.state,
|
||||
sr.created_at,
|
||||
sr.date_coordinated,
|
||||
sr.hour_coordinated,
|
||||
sr.pay_number,
|
||||
po.first_name || ' ' || COALESCE(po.last_name, '') as petowner_name,
|
||||
COALESCE(v.first_name || ' ' || v.last_name, '') as vet_name
|
||||
FROM solicitudes_servicerequest sr
|
||||
JOIN mascotas_petowner po ON sr.petowner_id = po.id
|
||||
LEFT JOIN mascotas_veterinarian v ON sr.veterinarian_id = v.id
|
||||
WHERE sr.state = ANY(:states)
|
||||
ORDER BY
|
||||
CASE sr.state
|
||||
WHEN 'pending' THEN 1
|
||||
WHEN 'in_progress_vet' THEN 2
|
||||
WHEN 'vet_asked' THEN 3
|
||||
WHEN 'vet_accepted' THEN 4
|
||||
WHEN 'in_progress_pay' THEN 5
|
||||
WHEN 'payed' THEN 6
|
||||
WHEN 'not_coordinated' THEN 7
|
||||
WHEN 'coordinated' THEN 8
|
||||
ELSE 9
|
||||
END,
|
||||
sr.created_at DESC
|
||||
""")
|
||||
|
||||
try:
|
||||
with engine.connect() as conn:
|
||||
result = conn.execute(query, {"states": ACTIVE_STATES})
|
||||
rows = result.fetchall()
|
||||
|
||||
requests = []
|
||||
for row in rows:
|
||||
state_info = STATES.get(row.state, ("?", "#888", 99))
|
||||
age_h = _hours_since(row.created_at)
|
||||
|
||||
requests.append({
|
||||
"id": row.id,
|
||||
"state": row.state,
|
||||
"state_label": state_info[0],
|
||||
"state_color": state_info[1],
|
||||
"petowner": row.petowner_name.strip(),
|
||||
"vet": row.vet_name.strip() if row.vet_name else "-",
|
||||
"is_paid": bool(row.pay_number),
|
||||
"is_scheduled": bool(row.date_coordinated and row.hour_coordinated),
|
||||
"age_hours": age_h,
|
||||
"age_class": "old" if age_h > 48 else ("warn" if age_h > 24 else ""),
|
||||
})
|
||||
return requests
|
||||
|
||||
except Exception as e:
|
||||
print(f"[turnos] Query error: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def fetch_counts() -> dict[str, int]:
|
||||
"""Fetch count per state."""
|
||||
engine = get_engine()
|
||||
if not engine:
|
||||
return {}
|
||||
|
||||
query = text("""
|
||||
SELECT state, COUNT(*) as cnt
|
||||
FROM solicitudes_servicerequest
|
||||
WHERE state = ANY(:states)
|
||||
GROUP BY state
|
||||
""")
|
||||
|
||||
try:
|
||||
with engine.connect() as conn:
|
||||
result = conn.execute(query, {"states": ACTIVE_STATES})
|
||||
return {row.state: row.cnt for row in result.fetchall()}
|
||||
except Exception as e:
|
||||
print(f"[turnos] Count error: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
def _hours_since(dt: Optional[datetime]) -> int:
|
||||
"""Hours since datetime."""
|
||||
if not dt:
|
||||
return 0
|
||||
try:
|
||||
now = datetime.now(dt.tzinfo) if dt.tzinfo else datetime.now()
|
||||
return int((now - dt).total_seconds() / 3600)
|
||||
except:
|
||||
return 0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ROUTES
|
||||
# =============================================================================
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
"""Health check."""
|
||||
engine = get_engine()
|
||||
db_ok = False
|
||||
if engine:
|
||||
try:
|
||||
with engine.connect() as conn:
|
||||
conn.execute(text("SELECT 1"))
|
||||
db_ok = True
|
||||
except:
|
||||
pass
|
||||
|
||||
return {
|
||||
"status": "ok" if db_ok else "degraded",
|
||||
"service": "turnos-monitor",
|
||||
"nest": NEST_CONFIG["name"],
|
||||
"database": "connected" if db_ok else "disconnected",
|
||||
}
|
||||
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
def index(request: Request, view: str = "pipeline"):
|
||||
"""Main monitor view. ?view=list for list view."""
|
||||
requests_data = fetch_active_requests()
|
||||
counts = fetch_counts()
|
||||
|
||||
# Group by state
|
||||
by_state = {s: [] for s in ACTIVE_STATES}
|
||||
for req in requests_data:
|
||||
if req["state"] in by_state:
|
||||
by_state[req["state"]].append(req)
|
||||
|
||||
template = "list.html" if view == "list" else "index.html"
|
||||
|
||||
return templates.TemplateResponse(template, {
|
||||
"request": request,
|
||||
"items": requests_data,
|
||||
"by_state": by_state,
|
||||
"counts": counts,
|
||||
"states": STATES,
|
||||
"active_states": ACTIVE_STATES,
|
||||
"total": len(requests_data),
|
||||
"nest_name": NEST_CONFIG["name"],
|
||||
"view": view,
|
||||
})
|
||||
|
||||
|
||||
@app.get("/api/data")
|
||||
def api_data():
|
||||
"""JSON API endpoint."""
|
||||
return {
|
||||
"nest": NEST_CONFIG["name"],
|
||||
"requests": fetch_active_requests(),
|
||||
"counts": fetch_counts(),
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# MAIN
|
||||
# =============================================================================
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(
|
||||
"main:app",
|
||||
host="0.0.0.0",
|
||||
port=int(os.getenv("PORT", "12010")),
|
||||
reload=True,
|
||||
)
|
||||
73
cfg/amar/tester/tests/README.md
Normal file
73
cfg/amar/tester/tests/README.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# Contract Tests
|
||||
|
||||
API contract tests organized by Django app, with optional workflow tests.
|
||||
|
||||
## Testing Modes
|
||||
|
||||
Two modes via `CONTRACT_TEST_MODE` environment variable:
|
||||
|
||||
| Mode | Command | Description |
|
||||
|------|---------|-------------|
|
||||
| **api** (default) | `pytest tests/contracts/` | Fast, Django test client, test DB |
|
||||
| **live** | `CONTRACT_TEST_MODE=live pytest tests/contracts/` | Real HTTP, LiveServerTestCase, test DB |
|
||||
|
||||
### Mode Comparison
|
||||
|
||||
| | `api` (default) | `live` |
|
||||
|---|---|---|
|
||||
| **Base class** | `APITestCase` | `LiveServerTestCase` |
|
||||
| **HTTP** | In-process (Django test client) | Real HTTP via `requests` |
|
||||
| **Auth** | `force_authenticate()` | JWT tokens via API |
|
||||
| **Database** | Django test DB (isolated) | Django test DB (isolated) |
|
||||
| **Speed** | ~3-5 sec | ~15-30 sec |
|
||||
| **Server** | None (in-process) | Auto-started by Django |
|
||||
|
||||
### Key Point: Both Modes Use Test Database
|
||||
|
||||
Neither mode touches your real database. Django automatically:
|
||||
1. Creates a test database (prefixed with `test_`)
|
||||
2. Runs migrations
|
||||
3. Destroys it after tests complete
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
tests/contracts/
|
||||
├── base.py # Mode switcher (imports from base_api or base_live)
|
||||
├── base_api.py # APITestCase implementation
|
||||
├── base_live.py # LiveServerTestCase implementation
|
||||
├── conftest.py # pytest-django configuration
|
||||
├── endpoints.py # API paths (single source of truth)
|
||||
├── helpers.py # Shared test data helpers
|
||||
│
|
||||
├── mascotas/ # Django app: mascotas
|
||||
│ ├── test_pet_owners.py
|
||||
│ ├── test_pets.py
|
||||
│ └── test_coverage.py
|
||||
│
|
||||
├── productos/ # Django app: productos
|
||||
│ ├── test_services.py
|
||||
│ └── test_cart.py
|
||||
│
|
||||
├── solicitudes/ # Django app: solicitudes
|
||||
│ └── test_service_requests.py
|
||||
│
|
||||
└── workflows/ # Multi-step API sequences (e.g., turnero booking flow)
|
||||
└── test_turnero_general.py
|
||||
```
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
# All contract tests
|
||||
pytest tests/contracts/
|
||||
|
||||
# Single app
|
||||
pytest tests/contracts/mascotas/
|
||||
|
||||
# Single file
|
||||
pytest tests/contracts/mascotas/test_pet_owners.py
|
||||
|
||||
# Live mode (real HTTP)
|
||||
CONTRACT_TEST_MODE=live pytest tests/contracts/
|
||||
```
|
||||
2
cfg/amar/tester/tests/__init__.py
Normal file
2
cfg/amar/tester/tests/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# Contract tests - black-box HTTP tests that validate API contracts
|
||||
# These tests are decoupled from Django and can run against any implementation
|
||||
164
cfg/amar/tester/tests/base.py
Normal file
164
cfg/amar/tester/tests/base.py
Normal file
@@ -0,0 +1,164 @@
|
||||
"""
|
||||
Pure HTTP Contract Tests - Base Class
|
||||
|
||||
Framework-agnostic: works against ANY backend implementation.
|
||||
Does NOT manage database - expects a ready environment.
|
||||
|
||||
Requirements:
|
||||
- Server running at CONTRACT_TEST_URL
|
||||
- Database migrated and seeded
|
||||
- Test user exists OR CONTRACT_TEST_TOKEN provided
|
||||
|
||||
Usage:
|
||||
CONTRACT_TEST_URL=http://127.0.0.1:8000 pytest
|
||||
CONTRACT_TEST_TOKEN=your_jwt_token pytest
|
||||
"""
|
||||
|
||||
import os
|
||||
import unittest
|
||||
import httpx
|
||||
|
||||
from .endpoints import Endpoints
|
||||
|
||||
|
||||
def get_base_url():
|
||||
"""Get base URL from environment (required)"""
|
||||
url = os.environ.get("CONTRACT_TEST_URL", "")
|
||||
if not url:
|
||||
raise ValueError("CONTRACT_TEST_URL environment variable required")
|
||||
return url.rstrip("/")
|
||||
|
||||
|
||||
class ContractTestCase(unittest.TestCase):
|
||||
"""
|
||||
Base class for pure HTTP contract tests.
|
||||
|
||||
Features:
|
||||
- Framework-agnostic (works with Django, FastAPI, Node, etc.)
|
||||
- Pure HTTP via requests library
|
||||
- No database access - all data through API
|
||||
- JWT authentication
|
||||
"""
|
||||
|
||||
# Auth credentials - override via environment
|
||||
TEST_USER_EMAIL = os.environ.get("CONTRACT_TEST_USER", "contract_test@example.com")
|
||||
TEST_USER_PASSWORD = os.environ.get("CONTRACT_TEST_PASSWORD", "testpass123")
|
||||
|
||||
# Class-level cache
|
||||
_base_url = None
|
||||
_token = None
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
"""Set up once per test class"""
|
||||
super().setUpClass()
|
||||
cls._base_url = get_base_url()
|
||||
|
||||
# Use provided token or fetch one
|
||||
cls._token = os.environ.get("CONTRACT_TEST_TOKEN", "")
|
||||
if not cls._token:
|
||||
cls._token = cls._fetch_token()
|
||||
|
||||
@classmethod
|
||||
def _fetch_token(cls):
|
||||
"""Get JWT token for authentication"""
|
||||
url = f"{cls._base_url}{Endpoints.TOKEN}"
|
||||
try:
|
||||
response = httpx.post(url, json={
|
||||
"username": cls.TEST_USER_EMAIL,
|
||||
"password": cls.TEST_USER_PASSWORD,
|
||||
}, timeout=10)
|
||||
if response.status_code == 200:
|
||||
return response.json().get("access", "")
|
||||
else:
|
||||
print(f"Warning: Token request failed with {response.status_code}")
|
||||
except httpx.RequestError as e:
|
||||
print(f"Warning: Token request failed: {e}")
|
||||
return ""
|
||||
|
||||
@property
|
||||
def base_url(self):
|
||||
return self._base_url
|
||||
|
||||
@property
|
||||
def token(self):
|
||||
return self._token
|
||||
|
||||
def _auth_headers(self):
|
||||
"""Get authorization headers"""
|
||||
if self.token:
|
||||
return {"Authorization": f"Bearer {self.token}"}
|
||||
return {}
|
||||
|
||||
# =========================================================================
|
||||
# HTTP helpers
|
||||
# =========================================================================
|
||||
|
||||
def get(self, path: str, params: dict = None, **kwargs):
|
||||
"""GET request"""
|
||||
url = f"{self.base_url}{path}"
|
||||
headers = {**self._auth_headers(), **kwargs.pop("headers", {})}
|
||||
response = httpx.get(url, params=params, headers=headers, timeout=30, **kwargs)
|
||||
return self._wrap_response(response)
|
||||
|
||||
def post(self, path: str, data: dict = None, **kwargs):
|
||||
"""POST request with JSON"""
|
||||
url = f"{self.base_url}{path}"
|
||||
headers = {**self._auth_headers(), **kwargs.pop("headers", {})}
|
||||
response = httpx.post(url, json=data, headers=headers, timeout=30, **kwargs)
|
||||
return self._wrap_response(response)
|
||||
|
||||
def put(self, path: str, data: dict = None, **kwargs):
|
||||
"""PUT request with JSON"""
|
||||
url = f"{self.base_url}{path}"
|
||||
headers = {**self._auth_headers(), **kwargs.pop("headers", {})}
|
||||
response = httpx.put(url, json=data, headers=headers, timeout=30, **kwargs)
|
||||
return self._wrap_response(response)
|
||||
|
||||
def patch(self, path: str, data: dict = None, **kwargs):
|
||||
"""PATCH request with JSON"""
|
||||
url = f"{self.base_url}{path}"
|
||||
headers = {**self._auth_headers(), **kwargs.pop("headers", {})}
|
||||
response = httpx.patch(url, json=data, headers=headers, timeout=30, **kwargs)
|
||||
return self._wrap_response(response)
|
||||
|
||||
def delete(self, path: str, **kwargs):
|
||||
"""DELETE request"""
|
||||
url = f"{self.base_url}{path}"
|
||||
headers = {**self._auth_headers(), **kwargs.pop("headers", {})}
|
||||
response = httpx.delete(url, headers=headers, timeout=30, **kwargs)
|
||||
return self._wrap_response(response)
|
||||
|
||||
def _wrap_response(self, response):
|
||||
"""Add .data attribute for consistency with DRF responses"""
|
||||
try:
|
||||
response.data = response.json()
|
||||
except Exception:
|
||||
response.data = None
|
||||
return response
|
||||
|
||||
# =========================================================================
|
||||
# Assertion helpers
|
||||
# =========================================================================
|
||||
|
||||
def assert_status(self, response, expected_status: int):
|
||||
"""Assert response has expected status code"""
|
||||
self.assertEqual(
|
||||
response.status_code,
|
||||
expected_status,
|
||||
f"Expected {expected_status}, got {response.status_code}. "
|
||||
f"Response: {response.data if hasattr(response, 'data') else response.content[:500]}"
|
||||
)
|
||||
|
||||
def assert_has_fields(self, data: dict, *fields: str):
|
||||
"""Assert dictionary has all specified fields"""
|
||||
missing = [f for f in fields if f not in data]
|
||||
self.assertEqual(missing, [], f"Missing fields: {missing}. Got: {list(data.keys())}")
|
||||
|
||||
def assert_is_list(self, data, min_length: int = 0):
|
||||
"""Assert data is a list with minimum length"""
|
||||
self.assertIsInstance(data, list)
|
||||
self.assertGreaterEqual(len(data), min_length)
|
||||
|
||||
|
||||
__all__ = ["ContractTestCase"]
|
||||
29
cfg/amar/tester/tests/conftest.py
Normal file
29
cfg/amar/tester/tests/conftest.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""
|
||||
Contract Tests Configuration
|
||||
|
||||
Supports two testing modes via CONTRACT_TEST_MODE environment variable:
|
||||
|
||||
# Fast mode (default) - Django test client, test DB
|
||||
pytest tests/contracts/
|
||||
|
||||
# Live mode - Real HTTP with LiveServerTestCase, test DB
|
||||
CONTRACT_TEST_MODE=live pytest tests/contracts/
|
||||
"""
|
||||
|
||||
import os
|
||||
import pytest
|
||||
|
||||
# Let pytest-django handle Django setup via pytest.ini DJANGO_SETTINGS_MODULE
|
||||
|
||||
|
||||
def pytest_configure(config):
|
||||
"""Register custom markers"""
|
||||
config.addinivalue_line(
|
||||
"markers", "workflow: marks test as a workflow/flow test (runs endpoint tests in sequence)"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def contract_test_mode():
|
||||
"""Return current test mode"""
|
||||
return os.environ.get("CONTRACT_TEST_MODE", "api")
|
||||
38
cfg/amar/tester/tests/endpoints.py
Normal file
38
cfg/amar/tester/tests/endpoints.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""
|
||||
API Endpoints - Single source of truth for contract tests.
|
||||
|
||||
If API paths or versioning changes, update here only.
|
||||
"""
|
||||
|
||||
|
||||
class Endpoints:
|
||||
"""API endpoint paths"""
|
||||
|
||||
# ==========================================================================
|
||||
# Mascotas
|
||||
# ==========================================================================
|
||||
PET_OWNERS = "/mascotas/api/v1/pet-owners/"
|
||||
PET_OWNER_DETAIL = "/mascotas/api/v1/pet-owners/{id}/"
|
||||
PETS = "/mascotas/api/v1/pets/"
|
||||
PET_DETAIL = "/mascotas/api/v1/pets/{id}/"
|
||||
COVERAGE_CHECK = "/mascotas/api/v1/coverage/check/"
|
||||
|
||||
# ==========================================================================
|
||||
# Productos
|
||||
# ==========================================================================
|
||||
SERVICES = "/productos/api/v1/services/"
|
||||
CATEGORIES = "/productos/api/v1/categories/"
|
||||
CART = "/productos/api/v1/cart/"
|
||||
CART_DETAIL = "/productos/api/v1/cart/{id}/"
|
||||
|
||||
# ==========================================================================
|
||||
# Solicitudes
|
||||
# ==========================================================================
|
||||
SERVICE_REQUESTS = "/solicitudes/service-requests/"
|
||||
SERVICE_REQUEST_DETAIL = "/solicitudes/service-requests/{id}/"
|
||||
|
||||
# ==========================================================================
|
||||
# Auth
|
||||
# ==========================================================================
|
||||
TOKEN = "/api/token/"
|
||||
TOKEN_REFRESH = "/api/token/refresh/"
|
||||
44
cfg/amar/tester/tests/helpers.py
Normal file
44
cfg/amar/tester/tests/helpers.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""
|
||||
Contract Tests - Shared test data helpers.
|
||||
|
||||
Used across all endpoint tests to generate consistent test data.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
|
||||
def unique_email(prefix="test"):
|
||||
"""Generate unique email for test data"""
|
||||
return f"{prefix}_{int(time.time() * 1000)}@contract-test.local"
|
||||
|
||||
|
||||
def sample_pet_owner(email=None):
|
||||
"""Generate sample pet owner data"""
|
||||
return {
|
||||
"first_name": "Test",
|
||||
"last_name": "Usuario",
|
||||
"email": email or unique_email("owner"),
|
||||
"phone": "1155667788",
|
||||
"address": "Av. Santa Fe 1234",
|
||||
"geo_latitude": -34.5955,
|
||||
"geo_longitude": -58.4166,
|
||||
}
|
||||
|
||||
|
||||
SAMPLE_CAT = {
|
||||
"name": "TestCat",
|
||||
"pet_type": "CAT",
|
||||
"is_neutered": False,
|
||||
}
|
||||
|
||||
SAMPLE_DOG = {
|
||||
"name": "TestDog",
|
||||
"pet_type": "DOG",
|
||||
"is_neutered": False,
|
||||
}
|
||||
|
||||
SAMPLE_NEUTERED_CAT = {
|
||||
"name": "NeuteredCat",
|
||||
"pet_type": "CAT",
|
||||
"is_neutered": True,
|
||||
}
|
||||
1
cfg/amar/tester/tests/mascotas/__init__.py
Normal file
1
cfg/amar/tester/tests/mascotas/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Contract tests for mascotas app endpoints
|
||||
53
cfg/amar/tester/tests/mascotas/test_coverage.py
Normal file
53
cfg/amar/tester/tests/mascotas/test_coverage.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""
|
||||
Contract Tests: Coverage Check API
|
||||
|
||||
Endpoint: /mascotas/api/v1/coverage/check/
|
||||
App: mascotas
|
||||
|
||||
Used to check if a location has veterinary coverage before proceeding with turnero.
|
||||
"""
|
||||
|
||||
from ..base import ContractTestCase
|
||||
from ..endpoints import Endpoints
|
||||
|
||||
|
||||
class TestCoverageCheck(ContractTestCase):
|
||||
"""GET /mascotas/api/v1/coverage/check/"""
|
||||
|
||||
def test_with_coordinates_returns_200(self):
|
||||
"""Coverage check should accept lat/lng parameters"""
|
||||
response = self.get(Endpoints.COVERAGE_CHECK, params={
|
||||
"lat": -34.6037,
|
||||
"lng": -58.3816,
|
||||
})
|
||||
|
||||
self.assert_status(response, 200)
|
||||
|
||||
def test_returns_coverage_boolean(self):
|
||||
"""Coverage check should return coverage boolean"""
|
||||
response = self.get(Endpoints.COVERAGE_CHECK, params={
|
||||
"lat": -34.6037,
|
||||
"lng": -58.3816,
|
||||
})
|
||||
|
||||
self.assert_status(response, 200)
|
||||
self.assert_has_fields(response.data, "coverage")
|
||||
self.assertIsInstance(response.data["coverage"], bool)
|
||||
|
||||
def test_returns_vet_count(self):
|
||||
"""Coverage check should return number of available vets"""
|
||||
response = self.get(Endpoints.COVERAGE_CHECK, params={
|
||||
"lat": -34.6037,
|
||||
"lng": -58.3816,
|
||||
})
|
||||
|
||||
self.assert_status(response, 200)
|
||||
self.assert_has_fields(response.data, "vet_count")
|
||||
self.assertIsInstance(response.data["vet_count"], int)
|
||||
|
||||
def test_without_coordinates_fails(self):
|
||||
"""Coverage check without coordinates should fail"""
|
||||
response = self.get(Endpoints.COVERAGE_CHECK)
|
||||
|
||||
# Should return 400 or similar error
|
||||
self.assertIn(response.status_code, [400, 422])
|
||||
171
cfg/amar/tester/tests/mascotas/test_pet_owners.py
Normal file
171
cfg/amar/tester/tests/mascotas/test_pet_owners.py
Normal file
@@ -0,0 +1,171 @@
|
||||
"""
|
||||
Contract Tests: Pet Owners API
|
||||
|
||||
Endpoint: /mascotas/api/v1/pet-owners/
|
||||
App: mascotas
|
||||
|
||||
Related Tickets:
|
||||
- VET-536: Paso 0 - Test creación del petowner invitado
|
||||
- VET-535: Establecer y definir test para las apis vinculadas al procesos de solicitar turno general
|
||||
|
||||
Context: In the turnero general flow (guest booking), a "guest" pet owner is created
|
||||
with a mock email (e.g., invitado-1759415377297@example.com). This user is fundamental
|
||||
for subsequent steps as it provides the address used to filter available services.
|
||||
|
||||
TBD: PetOwnerViewSet needs pagination - currently loads all records on list().
|
||||
See mascotas/views/api/v1/views/petowner_views.py:72
|
||||
Using email filter in tests to avoid loading 14k+ records.
|
||||
"""
|
||||
|
||||
import time
|
||||
from ..base import ContractTestCase
|
||||
from ..endpoints import Endpoints
|
||||
from ..helpers import sample_pet_owner
|
||||
|
||||
|
||||
class TestPetOwnerCreate(ContractTestCase):
|
||||
"""POST /mascotas/api/v1/pet-owners/
|
||||
|
||||
VET-536: Tests for guest petowner creation (Step 0 of turnero flow)
|
||||
"""
|
||||
|
||||
def test_create_returns_201(self):
|
||||
"""
|
||||
Creating a pet owner returns 201 with the created resource.
|
||||
|
||||
Request (from production turnero):
|
||||
POST /mascotas/api/v1/pet-owners/
|
||||
{
|
||||
"first_name": "Juan",
|
||||
"last_name": "Pérez",
|
||||
"email": "invitado-1733929847293@example.com",
|
||||
"phone": "1155667788",
|
||||
"address": "Av. Santa Fe 1234, Buenos Aires",
|
||||
"geo_latitude": -34.5955,
|
||||
"geo_longitude": -58.4166
|
||||
}
|
||||
|
||||
Response (201):
|
||||
{
|
||||
"id": 12345,
|
||||
"first_name": "Juan",
|
||||
"last_name": "Pérez",
|
||||
"email": "invitado-1733929847293@example.com",
|
||||
"phone": "1155667788",
|
||||
"address": "Av. Santa Fe 1234, Buenos Aires",
|
||||
"geo_latitude": -34.5955,
|
||||
"geo_longitude": -58.4166,
|
||||
"pets": [],
|
||||
"created_at": "2024-12-11T15:30:47.293Z"
|
||||
}
|
||||
"""
|
||||
data = sample_pet_owner()
|
||||
|
||||
response = self.post(Endpoints.PET_OWNERS, data)
|
||||
|
||||
self.assert_status(response, 201)
|
||||
self.assert_has_fields(response.data, "id", "email", "first_name", "last_name")
|
||||
self.assertEqual(response.data["email"], data["email"])
|
||||
|
||||
def test_requires_email(self):
|
||||
"""
|
||||
Pet owner creation requires email (current behavior).
|
||||
|
||||
Note: The turnero guest flow uses a mock email created by frontend
|
||||
(e.g., invitado-1759415377297@example.com). The API always requires email.
|
||||
This test ensures the contract enforcement - no petowner without email.
|
||||
"""
|
||||
data = {
|
||||
"address": "Av. Corrientes 1234",
|
||||
"first_name": "Invitado",
|
||||
"last_name": str(int(time.time())),
|
||||
}
|
||||
|
||||
response = self.post(Endpoints.PET_OWNERS, data)
|
||||
|
||||
self.assert_status(response, 400)
|
||||
|
||||
def test_duplicate_email_returns_existing(self):
|
||||
"""
|
||||
Creating pet owner with existing email returns the existing record.
|
||||
|
||||
Note: API has upsert behavior - returns 200 with existing record,
|
||||
not 400 error. This allows frontend to "create or get" in one call.
|
||||
Important for guest flow - if user refreshes/retries, we don't create duplicates.
|
||||
"""
|
||||
data = sample_pet_owner()
|
||||
first_response = self.post(Endpoints.PET_OWNERS, data)
|
||||
first_id = first_response.data["id"]
|
||||
|
||||
response = self.post(Endpoints.PET_OWNERS, data) # Same email
|
||||
|
||||
# Returns 200 with existing record (upsert behavior)
|
||||
self.assert_status(response, 200)
|
||||
self.assertEqual(response.data["id"], first_id)
|
||||
|
||||
def test_address_and_geolocation_persisted(self):
|
||||
"""
|
||||
Pet owner address and geolocation coordinates are persisted correctly.
|
||||
|
||||
The address is critical for the turnero flow - it's used to filter available
|
||||
services by location. Geolocation (lat/lng) may be obtained from Google Maps API.
|
||||
"""
|
||||
data = sample_pet_owner()
|
||||
|
||||
response = self.post(Endpoints.PET_OWNERS, data)
|
||||
|
||||
self.assert_status(response, 201)
|
||||
self.assert_has_fields(response.data, "address", "geo_latitude", "geo_longitude")
|
||||
self.assertEqual(response.data["address"], data["address"])
|
||||
# Verify geolocation fields are numeric (not null/empty)
|
||||
self.assertIsNotNone(response.data.get("geo_latitude"))
|
||||
self.assertIsNotNone(response.data.get("geo_longitude"))
|
||||
|
||||
|
||||
class TestPetOwnerRetrieve(ContractTestCase):
|
||||
"""GET /mascotas/api/v1/pet-owners/{id}/"""
|
||||
|
||||
def test_get_by_id_returns_200(self):
|
||||
"""GET pet owner by ID returns owner details"""
|
||||
# Create owner first
|
||||
data = sample_pet_owner()
|
||||
create_response = self.post(Endpoints.PET_OWNERS, data)
|
||||
owner_id = create_response.data["id"]
|
||||
|
||||
response = self.get(Endpoints.PET_OWNER_DETAIL.format(id=owner_id))
|
||||
|
||||
self.assert_status(response, 200)
|
||||
self.assertEqual(response.data["id"], owner_id)
|
||||
self.assert_has_fields(response.data, "id", "first_name", "last_name", "address", "pets")
|
||||
|
||||
def test_nonexistent_returns_404(self):
|
||||
"""GET non-existent owner returns 404"""
|
||||
response = self.get(Endpoints.PET_OWNER_DETAIL.format(id=999999))
|
||||
|
||||
self.assert_status(response, 404)
|
||||
|
||||
|
||||
class TestPetOwnerList(ContractTestCase):
|
||||
"""GET /mascotas/api/v1/pet-owners/"""
|
||||
|
||||
def test_list_with_email_filter_returns_200(self):
|
||||
"""GET pet owners filtered by email returns 200"""
|
||||
# Filter by email to avoid loading 14k+ records (no pagination on this endpoint)
|
||||
response = self.get(Endpoints.PET_OWNERS, params={"email": "nonexistent@test.com"})
|
||||
|
||||
self.assert_status(response, 200)
|
||||
|
||||
def test_list_filter_by_email_works(self):
|
||||
"""Can filter pet owners by email"""
|
||||
# Create a pet owner first
|
||||
data = sample_pet_owner()
|
||||
self.post(Endpoints.PET_OWNERS, data)
|
||||
|
||||
# Filter by that email
|
||||
response = self.get(Endpoints.PET_OWNERS, params={"email": data["email"]})
|
||||
|
||||
self.assert_status(response, 200)
|
||||
# Should find exactly one
|
||||
results = response.data if isinstance(response.data, list) else response.data.get("results", [])
|
||||
self.assertEqual(len(results), 1)
|
||||
self.assertEqual(results[0]["email"], data["email"])
|
||||
171
cfg/amar/tester/tests/mascotas/test_pets.py
Normal file
171
cfg/amar/tester/tests/mascotas/test_pets.py
Normal file
@@ -0,0 +1,171 @@
|
||||
"""
|
||||
Contract Tests: Pets API
|
||||
|
||||
Endpoint: /mascotas/api/v1/pets/
|
||||
App: mascotas
|
||||
|
||||
Related Tickets:
|
||||
- VET-537: Paso 1 - Test creación de la mascota vinculada al petowner invitado
|
||||
- VET-535: Establecer y definir test para las apis vinculadas al procesos de solicitar turno general
|
||||
|
||||
Context: In the turnero general flow (Step 1), a pet is created and linked to the guest
|
||||
pet owner. The pet data (type, name, neutered status) combined with the owner's address
|
||||
is used to filter available services and veterinarians.
|
||||
"""
|
||||
|
||||
from ..base import ContractTestCase
|
||||
from ..endpoints import Endpoints
|
||||
from ..helpers import (
|
||||
sample_pet_owner,
|
||||
unique_email,
|
||||
SAMPLE_CAT,
|
||||
SAMPLE_DOG,
|
||||
SAMPLE_NEUTERED_CAT,
|
||||
)
|
||||
|
||||
|
||||
class TestPetCreate(ContractTestCase):
|
||||
"""POST /mascotas/api/v1/pets/
|
||||
|
||||
VET-537: Tests for pet creation linked to guest petowner (Step 1 of turnero flow)
|
||||
"""
|
||||
|
||||
def _create_owner(self):
|
||||
"""Helper to create a pet owner"""
|
||||
data = sample_pet_owner(unique_email("pet_owner"))
|
||||
response = self.post(Endpoints.PET_OWNERS, data)
|
||||
return response.data["id"]
|
||||
|
||||
def test_create_cat_returns_201(self):
|
||||
"""
|
||||
Creating a cat returns 201 with pet_type CAT.
|
||||
|
||||
Request (from production turnero):
|
||||
POST /mascotas/api/v1/pets/
|
||||
{
|
||||
"name": "Luna",
|
||||
"pet_type": "CAT",
|
||||
"is_neutered": false,
|
||||
"owner": 12345
|
||||
}
|
||||
|
||||
Response (201):
|
||||
{
|
||||
"id": 67890,
|
||||
"name": "Luna",
|
||||
"pet_type": "CAT",
|
||||
"is_neutered": false,
|
||||
"owner": 12345,
|
||||
"breed": null,
|
||||
"birth_date": null,
|
||||
"created_at": "2024-12-11T15:31:15.123Z"
|
||||
}
|
||||
"""
|
||||
owner_id = self._create_owner()
|
||||
data = {**SAMPLE_CAT, "owner": owner_id}
|
||||
|
||||
response = self.post(Endpoints.PETS, data)
|
||||
|
||||
self.assert_status(response, 201)
|
||||
self.assert_has_fields(response.data, "id", "name", "pet_type", "owner")
|
||||
self.assertEqual(response.data["pet_type"], "CAT")
|
||||
self.assertEqual(response.data["name"], "TestCat")
|
||||
|
||||
def test_create_dog_returns_201(self):
|
||||
"""
|
||||
Creating a dog returns 201 with pet_type DOG.
|
||||
|
||||
Validates that both major pet types (CAT/DOG) are supported in the contract.
|
||||
"""
|
||||
owner_id = self._create_owner()
|
||||
data = {**SAMPLE_DOG, "owner": owner_id}
|
||||
|
||||
response = self.post(Endpoints.PETS, data)
|
||||
|
||||
self.assert_status(response, 201)
|
||||
self.assertEqual(response.data["pet_type"], "DOG")
|
||||
|
||||
def test_neutered_status_persisted(self):
|
||||
"""
|
||||
Neutered status is persisted correctly.
|
||||
|
||||
This is important business data that may affect service recommendations
|
||||
or veterinarian assignments.
|
||||
"""
|
||||
owner_id = self._create_owner()
|
||||
data = {**SAMPLE_NEUTERED_CAT, "owner": owner_id}
|
||||
|
||||
response = self.post(Endpoints.PETS, data)
|
||||
|
||||
self.assert_status(response, 201)
|
||||
self.assertTrue(response.data["is_neutered"])
|
||||
|
||||
def test_requires_owner(self):
|
||||
"""
|
||||
Pet creation without owner should fail.
|
||||
|
||||
Enforces the required link between pet and petowner - critical for the
|
||||
turnero flow where pets must be associated with the guest user.
|
||||
"""
|
||||
data = SAMPLE_CAT.copy()
|
||||
|
||||
response = self.post(Endpoints.PETS, data)
|
||||
|
||||
self.assert_status(response, 400)
|
||||
|
||||
def test_invalid_pet_type_rejected(self):
|
||||
"""
|
||||
Invalid pet_type should be rejected.
|
||||
|
||||
Currently only CAT and DOG are supported. This test ensures the contract
|
||||
validates pet types correctly.
|
||||
"""
|
||||
owner_id = self._create_owner()
|
||||
data = {
|
||||
"name": "InvalidPet",
|
||||
"pet_type": "HAMSTER",
|
||||
"owner": owner_id,
|
||||
}
|
||||
|
||||
response = self.post(Endpoints.PETS, data)
|
||||
|
||||
self.assert_status(response, 400)
|
||||
|
||||
|
||||
class TestPetRetrieve(ContractTestCase):
|
||||
"""GET /mascotas/api/v1/pets/{id}/"""
|
||||
|
||||
def _create_owner_with_pet(self):
|
||||
"""Helper to create owner and pet"""
|
||||
owner_data = sample_pet_owner(unique_email("pet_owner"))
|
||||
owner_response = self.post(Endpoints.PET_OWNERS, owner_data)
|
||||
owner_id = owner_response.data["id"]
|
||||
|
||||
pet_data = {**SAMPLE_CAT, "owner": owner_id}
|
||||
pet_response = self.post(Endpoints.PETS, pet_data)
|
||||
return pet_response.data["id"]
|
||||
|
||||
def test_get_by_id_returns_200(self):
|
||||
"""GET pet by ID returns pet details"""
|
||||
pet_id = self._create_owner_with_pet()
|
||||
|
||||
response = self.get(Endpoints.PET_DETAIL.format(id=pet_id))
|
||||
|
||||
self.assert_status(response, 200)
|
||||
self.assertEqual(response.data["id"], pet_id)
|
||||
|
||||
def test_nonexistent_returns_404(self):
|
||||
"""GET non-existent pet returns 404"""
|
||||
response = self.get(Endpoints.PET_DETAIL.format(id=999999))
|
||||
|
||||
self.assert_status(response, 404)
|
||||
|
||||
|
||||
class TestPetList(ContractTestCase):
|
||||
"""GET /mascotas/api/v1/pets/"""
|
||||
|
||||
def test_list_returns_200(self):
|
||||
"""GET pets list returns 200 (with pagination)"""
|
||||
response = self.get(Endpoints.PETS, params={"page_size": 1})
|
||||
|
||||
self.assert_status(response, 200)
|
||||
1
cfg/amar/tester/tests/productos/__init__.py
Normal file
1
cfg/amar/tester/tests/productos/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Contract tests for productos app endpoints
|
||||
149
cfg/amar/tester/tests/productos/test_cart.py
Normal file
149
cfg/amar/tester/tests/productos/test_cart.py
Normal file
@@ -0,0 +1,149 @@
|
||||
"""
|
||||
Contract Tests: Cart API
|
||||
|
||||
Endpoint: /productos/api/v1/cart/
|
||||
App: productos
|
||||
|
||||
Related Tickets:
|
||||
- VET-538: Test creación de cart vinculado al petowner
|
||||
- VET-535: Establecer y definir test para las apis vinculadas al procesos de solicitar turno general
|
||||
|
||||
Context: In the turnero general flow (Step 2), a cart is created for the guest petowner.
|
||||
The cart holds selected services and calculates price summary (subtotals, discounts, total).
|
||||
|
||||
TBD: CartViewSet needs pagination/filtering - list endpoint hangs on large dataset.
|
||||
See productos/api/v1/viewsets.py:93
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from ..base import ContractTestCase
|
||||
from ..endpoints import Endpoints
|
||||
from ..helpers import sample_pet_owner, unique_email
|
||||
|
||||
|
||||
class TestCartCreate(ContractTestCase):
|
||||
"""POST /productos/api/v1/cart/
|
||||
|
||||
VET-538: Tests for cart creation linked to petowner (Step 2 of turnero flow)
|
||||
"""
|
||||
|
||||
def _create_petowner(self):
|
||||
"""Helper to create a pet owner"""
|
||||
data = sample_pet_owner(unique_email("cart_owner"))
|
||||
response = self.post(Endpoints.PET_OWNERS, data)
|
||||
return response.data["id"]
|
||||
|
||||
def test_create_cart_for_petowner(self):
|
||||
"""
|
||||
Creating a cart returns 201 and links to petowner.
|
||||
|
||||
Request (from production turnero):
|
||||
POST /productos/api/v1/cart/
|
||||
{
|
||||
"petowner": 12345,
|
||||
"services": []
|
||||
}
|
||||
|
||||
Response (201):
|
||||
{
|
||||
"id": 789,
|
||||
"petowner": 12345,
|
||||
"veterinarian": null,
|
||||
"items": [],
|
||||
"resume": [
|
||||
{"concept": "SUBTOTAL", "amount": "0.00", "order": 1},
|
||||
{"concept": "COSTO_SERVICIO", "amount": "0.00", "order": 2},
|
||||
{"concept": "DESCUENTO", "amount": "0.00", "order": 3},
|
||||
{"concept": "TOTAL", "amount": "0.00", "order": 4},
|
||||
{"concept": "ADELANTO", "amount": "0.00", "order": 5}
|
||||
],
|
||||
"extra_details": "",
|
||||
"pets": [],
|
||||
"pet_reasons": []
|
||||
}
|
||||
"""
|
||||
owner_id = self._create_petowner()
|
||||
data = {
|
||||
"petowner": owner_id,
|
||||
"services": []
|
||||
}
|
||||
|
||||
response = self.post(Endpoints.CART, data)
|
||||
|
||||
self.assert_status(response, 201)
|
||||
self.assert_has_fields(response.data, "id", "petowner", "items")
|
||||
self.assertEqual(response.data["petowner"], owner_id)
|
||||
|
||||
def test_cart_has_price_summary_fields(self):
|
||||
"""
|
||||
Cart response includes price summary fields.
|
||||
|
||||
These fields are critical for turnero flow - user needs to see:
|
||||
- resume: array with price breakdown (SUBTOTAL, DESCUENTO, TOTAL, etc)
|
||||
- items: cart items with individual pricing
|
||||
"""
|
||||
owner_id = self._create_petowner()
|
||||
data = {"petowner": owner_id, "services": []}
|
||||
|
||||
response = self.post(Endpoints.CART, data)
|
||||
|
||||
self.assert_status(response, 201)
|
||||
# Price fields should exist (may be 0 for empty cart)
|
||||
self.assert_has_fields(response.data, "resume", "items")
|
||||
|
||||
def test_empty_cart_has_zero_totals(self):
|
||||
"""
|
||||
Empty cart (no services) has zero price totals.
|
||||
|
||||
Validates initial state before services are added.
|
||||
"""
|
||||
owner_id = self._create_petowner()
|
||||
data = {"petowner": owner_id, "services": []}
|
||||
|
||||
response = self.post(Endpoints.CART, data)
|
||||
|
||||
self.assert_status(response, 201)
|
||||
# Empty cart should have resume with zero amounts
|
||||
self.assertIn("resume", response.data)
|
||||
# Find TOTAL concept in resume
|
||||
total_item = next((item for item in response.data["resume"] if item["concept"] == "TOTAL"), None)
|
||||
self.assertIsNotNone(total_item)
|
||||
self.assertEqual(total_item["amount"], "0.00")
|
||||
|
||||
|
||||
class TestCartRetrieve(ContractTestCase):
|
||||
"""GET /productos/api/v1/cart/{id}/"""
|
||||
|
||||
def _create_petowner_with_cart(self):
|
||||
"""Helper to create petowner and cart"""
|
||||
owner_data = sample_pet_owner(unique_email("cart_owner"))
|
||||
owner_response = self.post(Endpoints.PET_OWNERS, owner_data)
|
||||
owner_id = owner_response.data["id"]
|
||||
|
||||
cart_data = {"petowner": owner_id, "services": []}
|
||||
cart_response = self.post(Endpoints.CART, cart_data)
|
||||
return cart_response.data["id"]
|
||||
|
||||
def test_get_cart_by_id_returns_200(self):
|
||||
"""GET cart by ID returns cart details"""
|
||||
cart_id = self._create_petowner_with_cart()
|
||||
|
||||
response = self.get(Endpoints.CART_DETAIL.format(id=cart_id))
|
||||
|
||||
self.assert_status(response, 200)
|
||||
self.assertEqual(response.data["id"], cart_id)
|
||||
|
||||
def test_detail_returns_404_for_nonexistent(self):
|
||||
"""GET /cart/{id}/ returns 404 for non-existent cart"""
|
||||
response = self.get(Endpoints.CART_DETAIL.format(id=999999))
|
||||
self.assert_status(response, 404)
|
||||
|
||||
|
||||
class TestCartList(ContractTestCase):
|
||||
"""GET /productos/api/v1/cart/"""
|
||||
|
||||
@pytest.mark.skip(reason="TBD: Cart list hangs - needs pagination/filtering. Checking if dead code.")
|
||||
def test_list_returns_200(self):
|
||||
"""GET /cart/ returns 200"""
|
||||
response = self.get(Endpoints.CART)
|
||||
self.assert_status(response, 200)
|
||||
112
cfg/amar/tester/tests/productos/test_categories.py
Normal file
112
cfg/amar/tester/tests/productos/test_categories.py
Normal file
@@ -0,0 +1,112 @@
|
||||
"""
|
||||
Contract Tests: Categories API
|
||||
|
||||
Endpoint: /productos/api/v1/categories/
|
||||
App: productos
|
||||
|
||||
Returns service categories filtered by location availability.
|
||||
Categories without available services in location should be hidden.
|
||||
"""
|
||||
|
||||
from ..base import ContractTestCase
|
||||
from ..endpoints import Endpoints
|
||||
|
||||
|
||||
class TestCategoriesList(ContractTestCase):
|
||||
"""GET /productos/api/v1/categories/"""
|
||||
|
||||
def test_list_returns_200(self):
|
||||
"""GET categories returns 200"""
|
||||
response = self.get(Endpoints.CATEGORIES, params={"page_size": 10})
|
||||
|
||||
self.assert_status(response, 200)
|
||||
|
||||
def test_returns_list(self):
|
||||
"""GET categories returns a list"""
|
||||
response = self.get(Endpoints.CATEGORIES, params={"page_size": 10})
|
||||
|
||||
self.assert_status(response, 200)
|
||||
data = response.data
|
||||
# Handle paginated or non-paginated response
|
||||
categories = data["results"] if isinstance(data, dict) and "results" in data else data
|
||||
self.assertIsInstance(categories, list)
|
||||
|
||||
def test_categories_have_required_fields(self):
|
||||
"""
|
||||
Each category should have id, name, and description.
|
||||
|
||||
Request (from production turnero):
|
||||
GET /productos/api/v1/categories/
|
||||
|
||||
Response (200):
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Consulta General",
|
||||
"description": "Consultas veterinarias generales"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "Vacunación",
|
||||
"description": "Servicios de vacunación"
|
||||
}
|
||||
]
|
||||
"""
|
||||
response = self.get(Endpoints.CATEGORIES, params={"page_size": 10})
|
||||
|
||||
data = response.data
|
||||
categories = data["results"] if isinstance(data, dict) and "results" in data else data
|
||||
|
||||
if len(categories) > 0:
|
||||
category = categories[0]
|
||||
self.assert_has_fields(category, "id", "name", "description")
|
||||
|
||||
def test_only_active_categories_returned(self):
|
||||
"""
|
||||
Only active categories are returned in the list.
|
||||
|
||||
Business rule: Inactive categories should not be visible to users.
|
||||
"""
|
||||
response = self.get(Endpoints.CATEGORIES, params={"page_size": 50})
|
||||
|
||||
data = response.data
|
||||
categories = data["results"] if isinstance(data, dict) and "results" in data else data
|
||||
|
||||
# All categories should be active (no 'active': False in response)
|
||||
# This is enforced at queryset level in CategoryViewSet
|
||||
self.assertIsInstance(categories, list)
|
||||
|
||||
|
||||
class TestCategoryRetrieve(ContractTestCase):
|
||||
"""GET /productos/api/v1/categories/{id}/"""
|
||||
|
||||
def test_get_category_by_id_returns_200(self):
|
||||
"""
|
||||
GET category by ID returns category details.
|
||||
|
||||
First fetch list to get a valid ID, then retrieve that category.
|
||||
"""
|
||||
# Get first category
|
||||
list_response = self.get(Endpoints.CATEGORIES, params={"page_size": 1})
|
||||
if list_response.status_code != 200:
|
||||
self.skipTest("No categories available for testing")
|
||||
|
||||
data = list_response.data
|
||||
categories = data["results"] if isinstance(data, dict) and "results" in data else data
|
||||
|
||||
if len(categories) == 0:
|
||||
self.skipTest("No categories available for testing")
|
||||
|
||||
category_id = categories[0]["id"]
|
||||
|
||||
# Test detail endpoint
|
||||
response = self.get(f"{Endpoints.CATEGORIES}{category_id}/")
|
||||
|
||||
self.assert_status(response, 200)
|
||||
self.assertEqual(response.data["id"], category_id)
|
||||
|
||||
def test_nonexistent_category_returns_404(self):
|
||||
"""GET non-existent category returns 404"""
|
||||
response = self.get(f"{Endpoints.CATEGORIES}999999/")
|
||||
|
||||
self.assert_status(response, 404)
|
||||
122
cfg/amar/tester/tests/productos/test_services.py
Normal file
122
cfg/amar/tester/tests/productos/test_services.py
Normal file
@@ -0,0 +1,122 @@
|
||||
"""
|
||||
Contract Tests: Services API
|
||||
|
||||
Endpoint: /productos/api/v1/services/
|
||||
App: productos
|
||||
|
||||
Returns available veterinary services filtered by pet type and location.
|
||||
Critical for vet assignment automation.
|
||||
"""
|
||||
|
||||
from ..base import ContractTestCase
|
||||
from ..endpoints import Endpoints
|
||||
from ..helpers import sample_pet_owner, unique_email, SAMPLE_CAT, SAMPLE_DOG
|
||||
|
||||
|
||||
class TestServicesList(ContractTestCase):
|
||||
"""GET /productos/api/v1/services/"""
|
||||
|
||||
def test_list_returns_200(self):
|
||||
"""GET services returns 200"""
|
||||
response = self.get(Endpoints.SERVICES, params={"page_size": 10})
|
||||
|
||||
self.assert_status(response, 200)
|
||||
|
||||
def test_returns_list(self):
|
||||
"""GET services returns a list"""
|
||||
response = self.get(Endpoints.SERVICES, params={"page_size": 10})
|
||||
|
||||
self.assert_status(response, 200)
|
||||
data = response.data
|
||||
# Handle paginated or non-paginated response
|
||||
services = data["results"] if isinstance(data, dict) and "results" in data else data
|
||||
self.assertIsInstance(services, list)
|
||||
|
||||
def test_services_have_required_fields(self):
|
||||
"""Each service should have id and name"""
|
||||
response = self.get(Endpoints.SERVICES, params={"page_size": 10})
|
||||
|
||||
data = response.data
|
||||
services = data["results"] if isinstance(data, dict) and "results" in data else data
|
||||
|
||||
if len(services) > 0:
|
||||
service = services[0]
|
||||
self.assert_has_fields(service, "id", "name")
|
||||
|
||||
def test_accepts_pet_id_filter(self):
|
||||
"""Services endpoint accepts pet_id parameter"""
|
||||
response = self.get(Endpoints.SERVICES, params={"pet_id": 1})
|
||||
|
||||
# Should not error (even if pet doesn't exist, endpoint should handle gracefully)
|
||||
self.assertIn(response.status_code, [200, 404])
|
||||
|
||||
|
||||
class TestServicesFiltering(ContractTestCase):
|
||||
"""GET /productos/api/v1/services/ with filters"""
|
||||
|
||||
def _create_owner_with_cat(self):
|
||||
"""Helper to create owner and cat"""
|
||||
owner_data = sample_pet_owner(unique_email("service_owner"))
|
||||
owner_response = self.post(Endpoints.PET_OWNERS, owner_data)
|
||||
owner_id = owner_response.data["id"]
|
||||
|
||||
pet_data = {**SAMPLE_CAT, "owner": owner_id}
|
||||
pet_response = self.post(Endpoints.PETS, pet_data)
|
||||
return pet_response.data["id"]
|
||||
|
||||
def _create_owner_with_dog(self):
|
||||
"""Helper to create owner and dog"""
|
||||
owner_data = sample_pet_owner(unique_email("service_owner"))
|
||||
owner_response = self.post(Endpoints.PET_OWNERS, owner_data)
|
||||
owner_id = owner_response.data["id"]
|
||||
|
||||
pet_data = {**SAMPLE_DOG, "owner": owner_id}
|
||||
pet_response = self.post(Endpoints.PETS, pet_data)
|
||||
return pet_response.data["id"]
|
||||
|
||||
def test_filter_services_by_cat(self):
|
||||
"""
|
||||
Services filtered by cat pet_id returns appropriate services.
|
||||
|
||||
Request (from production turnero):
|
||||
GET /productos/api/v1/services/?pet_id=123
|
||||
|
||||
Response structure validates services available for CAT type.
|
||||
"""
|
||||
cat_id = self._create_owner_with_cat()
|
||||
response = self.get(Endpoints.SERVICES, params={"pet_id": cat_id, "page_size": 10})
|
||||
|
||||
# Should return services or handle gracefully
|
||||
self.assertIn(response.status_code, [200, 404])
|
||||
if response.status_code == 200:
|
||||
data = response.data
|
||||
services = data["results"] if isinstance(data, dict) and "results" in data else data
|
||||
self.assertIsInstance(services, list)
|
||||
|
||||
def test_filter_services_by_dog(self):
|
||||
"""
|
||||
Services filtered by dog pet_id returns appropriate services.
|
||||
|
||||
Different pet types may have different service availability.
|
||||
"""
|
||||
dog_id = self._create_owner_with_dog()
|
||||
response = self.get(Endpoints.SERVICES, params={"pet_id": dog_id, "page_size": 10})
|
||||
|
||||
self.assertIn(response.status_code, [200, 404])
|
||||
if response.status_code == 200:
|
||||
data = response.data
|
||||
services = data["results"] if isinstance(data, dict) and "results" in data else data
|
||||
self.assertIsInstance(services, list)
|
||||
|
||||
def test_services_without_pet_returns_all(self):
|
||||
"""
|
||||
Services without pet filter returns all available services.
|
||||
|
||||
Used for initial service browsing before pet selection.
|
||||
"""
|
||||
response = self.get(Endpoints.SERVICES, params={"page_size": 10})
|
||||
|
||||
self.assert_status(response, 200)
|
||||
data = response.data
|
||||
services = data["results"] if isinstance(data, dict) and "results" in data else data
|
||||
self.assertIsInstance(services, list)
|
||||
1
cfg/amar/tester/tests/solicitudes/__init__.py
Normal file
1
cfg/amar/tester/tests/solicitudes/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Contract tests for solicitudes app endpoints
|
||||
56
cfg/amar/tester/tests/solicitudes/test_service_requests.py
Normal file
56
cfg/amar/tester/tests/solicitudes/test_service_requests.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""
|
||||
Contract Tests: Service Requests API
|
||||
|
||||
Endpoint: /solicitudes/service-requests/
|
||||
App: solicitudes
|
||||
|
||||
Creates and manages service requests (appointment bookings).
|
||||
"""
|
||||
|
||||
from ..base import ContractTestCase
|
||||
from ..endpoints import Endpoints
|
||||
|
||||
|
||||
class TestServiceRequestList(ContractTestCase):
|
||||
"""GET /solicitudes/service-requests/"""
|
||||
|
||||
def test_list_returns_200(self):
|
||||
"""GET should return list of service requests (with pagination)"""
|
||||
response = self.get(Endpoints.SERVICE_REQUESTS, params={"page_size": 1})
|
||||
|
||||
self.assert_status(response, 200)
|
||||
|
||||
def test_returns_list(self):
|
||||
"""GET should return a list (possibly paginated)"""
|
||||
response = self.get(Endpoints.SERVICE_REQUESTS, params={"page_size": 10})
|
||||
|
||||
data = response.data
|
||||
requests_list = data["results"] if isinstance(data, dict) and "results" in data else data
|
||||
self.assertIsInstance(requests_list, list)
|
||||
|
||||
|
||||
class TestServiceRequestFields(ContractTestCase):
|
||||
"""Field validation for service requests"""
|
||||
|
||||
def test_has_state_field(self):
|
||||
"""Service requests should have a state/status field"""
|
||||
response = self.get(Endpoints.SERVICE_REQUESTS, params={"page_size": 1})
|
||||
|
||||
data = response.data
|
||||
requests_list = data["results"] if isinstance(data, dict) and "results" in data else data
|
||||
|
||||
if len(requests_list) > 0:
|
||||
req = requests_list[0]
|
||||
has_state = "state" in req or "status" in req
|
||||
self.assertTrue(has_state, "Service request should have state/status field")
|
||||
|
||||
|
||||
class TestServiceRequestCreate(ContractTestCase):
|
||||
"""POST /solicitudes/service-requests/"""
|
||||
|
||||
def test_create_requires_fields(self):
|
||||
"""Creating service request with empty data should fail"""
|
||||
response = self.post(Endpoints.SERVICE_REQUESTS, {})
|
||||
|
||||
# Should return 400 with validation errors
|
||||
self.assert_status(response, 400)
|
||||
1
cfg/amar/tester/tests/workflows/__init__.py
Normal file
1
cfg/amar/tester/tests/workflows/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Contract tests for frontend workflows (compositions of endpoint tests)
|
||||
65
cfg/amar/tester/tests/workflows/test_turnero_general.py
Normal file
65
cfg/amar/tester/tests/workflows/test_turnero_general.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""
|
||||
Workflow Test: General Turnero Flow
|
||||
|
||||
This is a COMPOSITION test that validates the full turnero flow
|
||||
by calling endpoints in sequence. Use this to ensure the flow works
|
||||
end-to-end, but individual endpoint behavior is tested in app folders.
|
||||
|
||||
Flow:
|
||||
1. Check coverage at address
|
||||
2. Create pet owner (guest with mock email)
|
||||
3. Create pet for owner
|
||||
4. Get available services for pet
|
||||
5. Create service request
|
||||
|
||||
Frontend route: /turnos/
|
||||
User type: Guest (invitado)
|
||||
"""
|
||||
|
||||
from ..base import ContractTestCase
|
||||
from ..endpoints import Endpoints
|
||||
from ..helpers import sample_pet_owner, unique_email, SAMPLE_CAT
|
||||
|
||||
|
||||
class TestTurneroGeneralFlow(ContractTestCase):
|
||||
"""
|
||||
End-to-end flow test for general turnero.
|
||||
|
||||
Note: This tests the SEQUENCE of calls, not individual endpoint behavior.
|
||||
Individual endpoint tests are in mascotas/, productos/, solicitudes/.
|
||||
"""
|
||||
|
||||
def test_full_flow_sequence(self):
|
||||
"""
|
||||
Complete turnero flow should work end-to-end.
|
||||
|
||||
This test validates that a guest user can complete the full
|
||||
appointment booking flow.
|
||||
"""
|
||||
# Step 0: Check coverage at address
|
||||
coverage_response = self.get(Endpoints.COVERAGE_CHECK, params={
|
||||
"lat": -34.6037,
|
||||
"lng": -58.3816,
|
||||
})
|
||||
self.assert_status(coverage_response, 200)
|
||||
|
||||
# Step 1: Create pet owner (frontend creates mock email for guest)
|
||||
mock_email = unique_email("invitado")
|
||||
owner_data = sample_pet_owner(mock_email)
|
||||
owner_response = self.post(Endpoints.PET_OWNERS, owner_data)
|
||||
self.assert_status(owner_response, 201)
|
||||
owner_id = owner_response.data["id"]
|
||||
|
||||
# Step 2: Create pet for owner
|
||||
pet_data = {**SAMPLE_CAT, "owner": owner_id}
|
||||
pet_response = self.post(Endpoints.PETS, pet_data)
|
||||
self.assert_status(pet_response, 201)
|
||||
pet_id = pet_response.data["id"]
|
||||
|
||||
# Step 3: Get services (optionally filtered by pet)
|
||||
services_response = self.get(Endpoints.SERVICES, params={"pet_id": pet_id})
|
||||
# Services endpoint may return 200 even without pet filter
|
||||
self.assertIn(services_response.status_code, [200, 404])
|
||||
|
||||
# Note: Steps 4-5 (select date/time, create service request) require
|
||||
# more setup (available times, cart, etc.) and are tested separately.
|
||||
Reference in New Issue
Block a user