Compare commits

..

9 Commits

Author SHA1 Message Date
47b4b87851 update gen script 2026-02-04 09:17:33 -03:00
b4081cff3e modelgen completed 2026-02-04 09:04:13 -03:00
buenosairesam
35796c0c3b updated docs 2026-01-27 09:51:42 -03:00
buenosairesam
0351e5c7a6 updated docs 2026-01-27 09:50:03 -03:00
buenosairesam
3df1465bf5 fix: jinja2 template syntax in artery index 2026-01-27 09:38:56 -03:00
buenosairesam
dcc5191ba3 fix: rename VNC to VPN vein 2026-01-27 09:24:20 -03:00
buenosairesam
220d3dc5a6 fix: pass rooms and depots to artery template to fix 500 error 2026-01-27 09:24:05 -03:00
buenosairesam
fa7bbe3953 Improve soleprint docs: bilingual EN/ES, system pages, architecture cleanup 2026-01-27 09:24:05 -03:00
buenosairesam
ed1c8f6c96 Add /spr/ route for soleprint index, fix sidebar system labels 2026-01-27 09:24:05 -03:00
676 changed files with 23323 additions and 65600 deletions

27
.gitattributes vendored
View File

@@ -1,27 +0,0 @@
# Copied verbatim from rig/.gitattributes, and deliberately duplicated rather
# than shared: rig/ must carry its own so it survives being handed over on its
# own, and spr had none at all despite shipping ctrl/*.sh and generating
# gen/<room>/ctrl/*.sh.
#
# Line endings are normalised to LF in the repository and on checkout, on every
# platform. Without this, a checkout on Windows/WSL rewrites files to CRLF and
# every one of them shows up as modified without anyone having touched it.
#
# For the scripts it is not cosmetic: a shell script with CRLF fails on Linux
# with `bad interpreter: /usr/bin/env bash^M`, which reads as a broken installer
# rather than a line-ending problem — the worst possible first impression on a
# machine where nothing has been proven yet.
* text=auto eol=lf
*.sh text eol=lf
*.py text eol=lf
*.env text eol=lf
*.yaml text eol=lf
*.yml text eol=lf
# Never touch binaries.
*.png binary
*.jpg binary
*.zip binary
*.tar binary
*.gz binary

24
.gitignore vendored
View File

@@ -9,36 +9,12 @@ __pycache__/
.venv/
venv/
# Secrets. There was no rule here, which is how a real API key ended up tracked
# in station/tools/tester/.env. Templates still ship.
.env
!.env.example
# Node
node_modules/
# Built library bundles (regenerate with `pnpm build` in the package).
# The dist is what a container boots; it is an artifact, not source.
dist/
# Generated runnable instance (entirely gitignored - regenerate with build.py)
gen/
# Specs and sheets uploaded through shuntgen's UI. Inputs someone dropped in a
# browser, not source — and often a client's real data.
soleprint/station/tools/shuntgen/uploads/
# Room configurations (separate repo - contains credentials and room-specific data)
# Keep cfg/standalone/ and cfg/sample/ as templates, ignore actual rooms
cfg/amar/
cfg/dlt/
# Add new rooms here as they are created
# cfg/<room>/
# Client rigs. A rig is a copy of rig/ renamed after the environment it models,
# so its k8s files spell out a real architecture — exactly the thing that must
# not land here. They are versioned in their own repo.
#
# Anchored at the ROOT on purpose: a copy is a SIBLING of rig/, so a rule inside
# rig/.gitignore cannot see it.
*-rig/

147
CLAUDE.md
View File

@@ -51,19 +51,20 @@ spr/
│ └── amar/ # Amar room config
│ ├── config.json
│ ├── data/
│ ├── soleprint/ # Room overlay — merged over soleprint/ on build
│ │ ── artery/ # room shunts, pulses
└── shunts/amar/
│ │ ── atlas/ # room books
└── books/
│ │ ── station/ # room tool configs
│ │ │ └── tools/datagen/
│ │ └── nginx/
│ ├── ctrl/ # Room lifecycle scripts (copied into gen/<room>/)
│ ├── artery/ # Amar-specific (merged into output)
│ │ ── shunts/amar/
├── atlas/ # Amar-specific books
│ │ ── books/
├── station/ # Amar-specific tools config
│ │ ── tools/datagen/
│ ├── link/ # Bridge to managed app
── amar/ # The managed app itself
── soleprint/ # Soleprint docker config
│ ├── databrowse/
│ ├── tester/
│ ├── monitors/
│ └── models/
├── ctrl/ # Build/run scripts (see Build & Run)
├── ctrl/ # Build/run scripts
└── gen/ # Built instances (gitignored)
├── standalone/
@@ -99,38 +100,26 @@ Each room in `cfg/` has:
- `config.json` - Framework branding/terminology
- `data/` - Data files (veins.json, shunts.json, etc.)
Room-specific system configs live under `cfg/<room>/soleprint/` and are merged over
the core `soleprint/` tree at build time:
- `soleprint/artery/` - Room-specific shunts, pulses
- `soleprint/atlas/` - Room-specific books
- `soleprint/station/` - Room-specific tool configs (datagen generators, tester tests)
A room's own lifecycle scripts live in `cfg/<room>/ctrl/` and land in
`gen/<room>/ctrl/` — that is what `make start <room>` dispatches to.
Room-specific system configs (merged into output):
- `artery/` - Room-specific shunts, pulses
- `atlas/` - Room-specific books
- `station/` - Room-specific tool configs (datagen, tester tests, etc.)
## Build & Run
`make` is the front door — one target per `ctrl/` script, with the subcommand as an
argument (`make cluster up`, not `make cluster-up`). The logic lives in the scripts,
never in the Makefile. `make help` lists every target.
```bash
make # = make help
make build [room|all|models] # -> gen/<room>/ (default: standalone)
make start [room] [-d] # dispatches to gen/<room>/ctrl/start.sh
make stop [room]
make cluster [up|down|status] # the shared `spr` kind cluster
make component [list|sync|watch|publish|diff]
make deploy [--build|--sync-only]
```
# Build
python build.py # -> gen/standalone/
python build.py --cfg amar # -> gen/amar/
python build.py --all # -> all rooms
Every script stays runnable on its own — the standalone rule holds:
# Run bare-metal
cd gen/standalone && python run.py
```bash
python build.py --cfg amar # -> gen/amar/
cd gen/standalone && python run.py # bare-metal
./ctrl/cluster.sh up # still runs directly; rig builds the cluster
cd gen/<room> && ./ctrl/start.sh # each room owns its lifecycle scripts
# Using ctrl scripts
./ctrl/build.sh [room]
./ctrl/start.sh [room] [-d]
./ctrl/stop.sh [room]
```
## Adding a New Room
@@ -140,12 +129,12 @@ mkdir -p cfg/newroom/data
cp cfg/standalone/config.json cfg/newroom/
cp -r cfg/standalone/data/* cfg/newroom/data/
# Add room-specific configs as needed (note the soleprint/ overlay level):
# cfg/newroom/soleprint/artery/shunts/...
# cfg/newroom/soleprint/atlas/books/...
# cfg/newroom/soleprint/station/tools/datagen/<name>.py
# Add room-specific configs as needed:
# cfg/newroom/artery/shunts/...
# cfg/newroom/atlas/books/...
# cfg/newroom/station/tools/...
make build newroom
python build.py --cfg newroom
```
## Ports
@@ -160,51 +149,10 @@ make build newroom
|------|---------|
| modelgen | Generate models from config |
| datagen | Generate test data (uses faker) |
| tester | HTTP contract test runner |
| tester | BDD/playwright test runner |
| graphgen | Generate navigable model graphs |
| databrowse | SQL data browser |
## Shared UI (`soleprint/common/ui`)
Upstream copy of the `soleprint-ui` package. `mpr/ui/framework` and
`meetus/ui/framework` are **downstream copies** synced by `ctrl/spr.py` — edit here,
never there, or the next sync overwrites the change.
```bash
cd soleprint/common/ui
pnpm install && pnpm typecheck && pnpm test
pnpm build # -> dist/soleprint-ui.js + dist/style.css
```
**The theme ships with the bundle.** Every component styles itself with
`var(--surface-0)` and friends from `src/tokens.css`, so `src/index.ts` imports
`src/theme.css` (tokens + `base.css`). A bundle without it renders broken, not merely
unbranded. Retheme by overriding the variables in `tokens.css`; don't inline colours
in components.
`dist/` and `node_modules/` are gitignored — the dist is an artifact a container
boots, not source. To hand someone a running artifact without the sources:
```bash
python ctrl/spr.py publish soleprint-ui <dest> --dist # bundle + manifest only
```
## Tests
**No test bodies are committed to core.** `soleprint/station/tools/tester/` ships the
base class, runner and UI; tests belong to a room at
`cfg/<room>/soleprint/station/tools/tester/tests/`.
Two rules, both in `tester/tests/test_template.py`:
1. **One suite, any environment** — a test states what the API promises, never who
implements it or where it runs.
2. **No helper framework tools** — stdlib `unittest` + `httpx`. No pytest fixtures,
no framework test client, no factories, no ORM or database access.
Django, where it appears, is an optional private DB editor via its admin — never the
framework, and never something a test reaches into.
## External Paths
| What | Path |
@@ -213,35 +161,6 @@ framework, and never something a test reaches into.
## Files Ignored
- `gen/` - Regenerate with `make build [room]`
- `dist/` - Build artifact; regenerate with `pnpm build`
- `node_modules/`
- `gen/` - Regenerate with `python build.py`
- `fails/`, `def/` - Drafts
- `__pycache__/`, `.venv/`
## Known Broken (found, not yet fixed)
- `tester/tests/example/test_health.py` imports `pytest`, which is neither a
dependency nor allowed here — `tests/README.md` says stdlib `unittest` and
`httpx`, no pytest. It fails to import, so `python -m tester discover` reports
it as `_FailedTest`. It also duplicates `test_template.py`, which is the
sanctioned example. Removing it is probably the fix; core ships no tests.
- Old vocabulary survives in prose — `album`, `larder`, `ward`, `nest` and
`pawprint` still appear in READMEs, `docs/`, and this file's history. Live code
is clean; the docs lag.
## Fixed
- **A real API key was tracked** in `station/tools/tester/.env`. Untracked, `.env`
added to `.gitignore`, `.env.example` added. **The key is still in git history
and must be rotated** — untracking does not unpublish it.
- Client vocabulary is out of live code. `atlas/main.py` was written entirely as
`Album`/`larder`/`PAWPRINT_URL`; `databrowse` docs described a `larder/` the
code stopped using; `get-api-key.sh` defaulted to the client's database name.
- Two dead back-links, found while renaming: `atlas/main.py` passed
`pawprint_url` and `artery/index.html` read `pawprint_url`, while `run.py`
passes `soleprint_url`. Neither "← Soleprint" link had ever rendered.
- `atlas/main.py` fetched `/api/data/album`; `main.py` serves `/api/data/atlas`.
`get_data()` had been failing and returning empty lists.
- `tester/tests/_dev/test_health.py` imported a non-existent `..endpoints`. The
import was unused; removing it makes the module discoverable (2 tests).

View File

@@ -1,85 +0,0 @@
# Thin control Makefile — one target per ctrl/ script, and the subcommand is an
# argument rather than a second target: `make cluster down`, not `make cluster-down`.
#
# The logic lives in the scripts, never here. Each target maps to exactly one
# file, and that file holds the variants:
#
# make cluster up -> ctrl/cluster.sh up
# make build amar -> ctrl/build.sh amar
#
# Bare words pass straight through. Anything starting with a dash would be
# swallowed by make itself, so pass those via ARGS instead:
#
# make component ARGS="publish soleprint-ui /tmp/out --dist"
# make deploy ARGS="--build"
#
# Every script stays runnable on its own (./ctrl/cluster.sh up still works, and each
# built room keeps its own gen/<room>/ctrl/*.sh) — the standalone rule holds, and
# this only saves typing.
#
# Start with: make build && make start
# The room to act on when none is named. Rooms live in cfg/ and build into gen/.
ROOM ?= standalone
PYTHON ?= python3
export PYTHON
# Words after the target become the script's subcommand. Make would otherwise
# treat them as goals of their own, so each gets a no-op rule.
ARGS := $(wordlist 2,$(words $(MAKECMDGOALS)),$(MAKECMDGOALS))
ifneq ($(ARGS),)
# Declares each extra word as a target that does nothing: `:` is an empty rule
# body and `@` silences it. Without this, `make build sample` runs the build and
# then fails with "No rule to make target 'sample'", because make reads every
# word on the line as something it has been asked to build.
$(eval $(ARGS):;@:)
# ...and as PHONY, because some of those words name real directories. `cfg`,
# `ctrl`, `docs`, `gen` and `init` all exist at this level, and make considers a
# target that is an existing directory already built — so `make build ctrl` ran
# the build and then printed "make: 'ctrl' is up to date". The empty rule above
# is not enough on its own; only .PHONY stops make consulting the filesystem.
.PHONY: $(ARGS)
endif
.DEFAULT_GOAL := help
.PHONY: help build start stop dist theme docs cluster deploy component
help: ## list targets
@grep -hE '^[a-z]+:.*?##' $(MAKEFILE_LIST) | sed 's/:.*##/\t/' | expand -t16
# ── rooms ──────────────────────────────────────────────────────────────────
build: ## build a room into gen/ [<room>|all|models]
bash ctrl/build.sh $(or $(ARGS),$(ROOM))
start: ## run a built room [<room>] [-d] [--build]
bash ctrl/start.sh $(or $(ARGS),$(ROOM))
stop: ## stop a running room [<room>]
bash ctrl/stop.sh $(or $(ARGS),$(ROOM))
dist: ## compile the plexus UIs to single files [<room>]
bash ctrl/dist.sh $(or $(ARGS),$(ROOM))
# ── theme ──────────────────────────────────────────────────────────────────
theme: ## ad-hoc pages [new|parts|bake|check|export|run FILE]
bash ctrl/theme.sh $(or $(ARGS),bake)
# ── docs ───────────────────────────────────────────────────────────────────
docs: ## documentation [serve [port]|graphs [theme]] (default serve)
bash ctrl/docs.sh $(or $(ARGS),serve)
# ── cluster ────────────────────────────────────────────────────────────────
cluster: ## shared kind cluster [up|down|status] (default status)
bash ctrl/cluster.sh $(or $(ARGS),status)
# ── distribution ───────────────────────────────────────────────────────────
component: ## publish components [list|sync|watch|publish|diff]
$(PYTHON) ctrl/spr.py $(or $(ARGS),list)
deploy: ## push standalone to the server [--build|--sync-only]
bash ctrl/deploy.sh $(ARGS)

View File

@@ -1,15 +1,51 @@
# soleprint
# Soleprint
Development workflow platform. Wraps existing applications with tools, testing, and documentation — without touching source code.
Development workflow platform. Run, test, and document everything in one place.
*Cada paso deja huella.*
## Quick Start
---
```bash
# Build and run standalone
python build.py
cd gen/standalone && docker compose up
# Visit http://localhost:12000
# Build and run with room config
python build.py --cfg <room>
cd gen/<room>/soleprint && docker compose up
```
## Commands
```bash
# Build
python build.py # -> gen/standalone/
python build.py --cfg <room> # -> gen/<room>/
python build.py --all # -> all rooms
# Using ctrl scripts
./ctrl/build.sh # Build standalone
./ctrl/build.sh <room> # Build room
./ctrl/start.sh # Start standalone
./ctrl/start.sh <room> # Start room
./ctrl/stop.sh <room> # Stop
./ctrl/logs.sh <room> # View logs
## Docs
# Bare-metal dev (without Docker)
cd gen/standalone && python run.py
```
## Adding a New Room
```bash
cd docs && python -m http.server 8080
# 1. Create room config
mkdir -p cfg/myroom/data
cp cfg/standalone/config.json cfg/myroom/
cp -r cfg/standalone/data/* cfg/myroom/data/
# 2. Build and run
python build.py --cfg myroom
cd gen/myroom/soleprint && docker compose up
```
Visit `http://localhost:8080`. Start with [Concepts](docs/data/en/concepts.md) for the mental model, then [Quickstart](docs/data/en/quickstart.md) to run your first room.
Or use the generation UI: `python -m http.server 8080` in `spr/` and visit `/generate.html`.

21
berth/.gitignore vendored
View File

@@ -1,21 +0,0 @@
# Machine-local config and credentials. Never committed.
ctrl/.env
# Rendered output. Regenerate with `make services render <target>`.
# Generated config is an artifact, not source — the estate file is the source.
#
# The path is ctrl/render/out/, NOT render/out/. A pattern containing a slash is
# anchored to the directory holding this .gitignore, so `render/out/` would mean
# berth/render/out/ — which does not exist, and the real output would have been
# committed. Caught by `git check-ignore -v`, which is the only way to be sure.
ctrl/render/out/
# The "default" scratch bucket: always gitignored, never versioned.
def/
# Key material. NEVER committed.
#
# Anchored to ctrl/ for the same reason as render/out/ above: a pattern with a
# slash resolves against this file's own directory. `git check-ignore -v` is the
# only way to confirm it, and ctrl/vpn.sh refuses to write a key until it does.
ctrl/.secrets/

View File

@@ -1,78 +0,0 @@
# One target per ctrl/ script; the subcommand is an argument, not a second
# target: `make estate show`, not `make estate-show`. The logic lives in the
# scripts, never here.
#
# Config layers, weakest first: ctrl/versions.env < ctrl/env.d/<target>.env <
# ctrl/.env < the environment. So `make estate plan TARGET=gcp` beats all.
#
# Every target defaults to its READ-ONLY verb, and the verbs that change a live
# estate are not reachable by a bare word. Rationale: README.md.
ESTATE := $(or $(shell sed -n 's/^ESTATE=//p' ctrl/.env 2>/dev/null),$(shell ls estate/*.json 2>/dev/null | head -1 | xargs -r basename | sed 's/\.json$$//'))
TARGET := $(or $(shell sed -n 's/^TARGET=//p' ctrl/.env 2>/dev/null),aws)
.PHONY: help check selftest estate services vpn dns certs host ports registry docs
help: ## list targets
@grep -hE '^[a-z][a-z-]*:.*?##' $(MAKEFILE_LIST) | sed 's/:.*##/\t/' | expand -t16
# ── preflight ──────────────────────────────────────────────────────────────
check: ## is this estate coherent? reports, never fixes
bash ctrl/check.sh
selftest: ## does berth still do what it says? exits 1 if not
bash ctrl/selftest.sh
ports: ## port map [show|verify] (default show)
bash ctrl/ports.sh $(or $(ARGS),show)
# ── the estate ─────────────────────────────────────────────────────────────
estate: ## the estate [show|list|plan|apply|destroy] (default show)
bash ctrl/estate.sh $(or $(ARGS),show)
services: ## gateway routes [list|render <target>|deploy] (default list)
bash ctrl/services.sh $(or $(ARGS),list)
# ── the network ────────────────────────────────────────────────────────────
vpn: ## overlays [list|show <ov>|check|render|keygen] (default list)
bash ctrl/vpn.sh $(or $(ARGS),list)
# ── names and trust ────────────────────────────────────────────────────────
dns: ## DNS records [list|add|add-wildcard|remove] (default list)
bash ctrl/dns.sh $(or $(ARGS),list)
certs: ## TLS [status|verify|renew|push] (default status)
bash ctrl/certs.sh $(or $(ARGS),status)
# ── the box ────────────────────────────────────────────────────────────────
host: ## the remote box [status|ports|services] (default status)
bash ctrl/host.sh $(or $(ARGS),status)
registry: ## the image registry [status] (default status)
bash ctrl/registry.sh $(or $(ARGS),status)
# ── docs ───────────────────────────────────────────────────────────────────
docs: ## documentation [serve|graphs] (default serve)
bash ctrl/docs.sh $(or $(ARGS),serve)
# ── swallowing the argument words — MUST BE LAST IN THIS FILE ──────────────
#
# Words after the target are arguments, but make reads each as a goal, so each
# gets a no-op rule. This block must come AFTER the real targets: when an
# argument names one (`make host ports`, `make vpn check`), the last definition
# wins, and it has to be the no-op. With it first, make ran both scripts.
#
# Make's "overriding recipe" warning is the swallow working as intended.
ARGS := $(wordlist 2,$(words $(MAKECMDGOALS)),$(MAKECMDGOALS))
ifneq ($(ARGS),)
$(eval $(ARGS):;@:)
# .PHONY too: some of those words name real directories (ctrl, estate, render),
# and make treats an existing directory as already built.
.PHONY: $(ARGS)
endif

View File

@@ -1,250 +0,0 @@
# berth
spr's deploy half. A rig is a mobile installation; a **berth** is the allocated, paid-for
place where it is moored and actually operates. Local rig → remote berth.
```bash
make check # is this estate coherent? reports, never fixes
make estate show # the description, resolved
make vpn check # the overlay: addresses, routing, bindings, key hygiene
make services render aws
make ports verify # does the local map still agree with rig?
```
`rm -rf berth/` is the uninstall.
---
## What berth is
**One description of an estate, with a swappable executor.** The description is the artifact;
the tool that runs it is a rendering.
| layer | what berth uses | note |
| --- | --- | --- |
| overlay | **WireGuard**, config generated per peer | GPL-2.0, in-kernel, and **no coordination server** |
| infra | **OpenTofu** — one executor, not a pair | plain `.tf`; `terraform` works identically |
| gateway | Caddy locally, nginx on the box | a projection with per-target rules, not a format conversion |
| pipeline | Woodpecker | Actions/GitLab reachable from the same description; not built |
The estate's facts — domain, hosts, ports, instance size, firewall rules, services — live in
**one file every rendering reads** (`estate/<name>.json`), rather than being restated in
one tool's language and again in another's. **Swappability is bought by the description, not
by maintaining two renderings** — a rendering you *can* produce, not one you *must* keep in
step. Two live renderings cost you every resource twice, forever, with nothing enforcing that
they agree.
**The seam belongs in a script, not in a tool.** A tool's schema is a ceiling you do not
control.
*(This once leaned on a specific precedent, since withdrawn — `✖ B2` in [STALE.md](STALE.md).)*
**OpenTofu, and only OpenTofu.** Terraform has been BUSL-licensed since 2023; OpenTofu is the
CLI-compatible MPL-2.0 fork (Linux Foundation). Write plain `.tf` that runs under both; the
scripts say `tofu`, and `terraform` works identically.
**Pipelines are the second executor axis, and are not built.** Woodpecker is the
self-hosted rendering; Actions and GitLab CI are the standards that must be reachable from
the same description. Named here so the infra seam is not designed in a way that forecloses
it.
---
## The overlay is berth's network layer
Two instances in different clouds cannot share a VPC. A WireGuard overlay gives them one flat
address space that berth owns and can reproduce on any provider — and, under a flaky
environment, a second layer beneath whatever the provider offers.
That inverts the usual cloud pattern. Instead of a VPC with security groups and private
subnets, each instance gets a public IP, opens **only** the WireGuard port, and carries
everything else inside the tunnel. **The security boundary moves out of the provider's VPC
and into a layer that is identical on AWS, on GCP, and on a laptop behind NAT.**
**Plain WireGuard, not Tailscale/Headscale/NetBird.** Tailscale's client is open but its
coordination plane is proprietary SaaS; Headscale and NetBird are open but add a control plane
to run. Plain WireGuard needs no server at all.
**The cost is real and accepted:** no NAT traversal, no relay, no peer discovery. A peer behind
NAT must dial one with a public endpoint. Fine here — the instances have public IPs and the dev
box roams — but two roaming peers cannot reach each other. That is why `PersistentKeepalive` is
a checked invariant rather than a detail.
**Keys never enter the description.** Private keys are generated on the peer that owns them
(`make vpn keygen`) into `ctrl/.secrets/` and injected only at render time; public keys live in
the estate, because a config cannot be built without them. `vpn.sh` **refuses to write** either
a key or a rendered config until `git check-ignore` confirms the path is ignored — this repo
has already been bitten once by a `.gitignore` pattern anchoring to the wrong directory.
This also decides something about the IaC layer: **OpenTofu must never generate a WireGuard
private key**, because Terraform-lineage state stores every resource attribute in plaintext.
---
## Two rules that are not style preferences
### 1. Every default is the read-only verb
```
make estate -> show make certs -> status
make dns -> list make host -> status
make estate apply / destroy -> print the plan, then refuse without --yes
```
rig's `make cluster` defaults to `up`, because every rig verb is safe — a kind cluster is
disposable. berth's are not: `tofu destroy` costs money and takes live DNS with it. **A
tool where every verb is safe must not grow verbs that are not.**
The failure this prevents is not hypothetical. `ppl/ctrl/certs.sh:42` is `CMD="${1:-all}"`,
so a bare `./ctrl/certs.sh` there issues a real Let's Encrypt cert, rsyncs it to the gateway,
and reloads nginx. berth's `certs` defaults to `status`.
### 2. berth and rig share a convention, not code
Neither imports the other. They match on **shape** — the `make <noun> <verb>` dispatch, the
four-source config layering, the key names — and consistency is verified by
**recomputation**: `make ports verify` recomputes rig's `20000 + (cksum(name) % 200) * 10`
to check the local map, rather than sourcing rig's `lib/config.sh`.
Copying three stable lines is the whole cost of not coupling them. A shared library would
put something outside `rig/` on rig's path, and rig's promise is that
`grep -rIn -iE 'soleprint|\bspr\b'` across it returns nothing.
**rig is also unaware that berth exists.** `ppl/local/Caddyfile` is berth's to generate; rig
must not reference `local.ar` — its handover scrub refuses the string.
---
## The gateway doctrine
Practised across this codebase for a long time and never written down, so: written down.
- **Caddy where routing is dynamic and config-driven** — the in-cluster gateway that
multiplexes by Host header, and the host-side `.local.ar` name→port map.
- **nginx where it is a static server or a plain long-running compose service on the box.**
- **Envoy in `mpr`** — a deliberate one-off, not a third pattern.
- **`ingress-nginx` only as a kind addon** — a different thing again from either gateway.
### Rendering is a projection, not a format conversion
The local Caddyfile and the box's nginx are not two spellings of the same content:
| | local (Caddy) | cloud (nginx) |
| --- | --- | --- |
| granularity | one file | one file per vhost |
| blocks per service | one | two (`:80` redirect + `:443` server) |
| TLS | none; every address needs an explicit `:80` | one shared wildcard cert |
| upstream | `localhost:<port>` | container name + `resolver 127.0.0.11` |
| name depth | free | constrained by the cert |
| ambiguity | most specific wins | exact, else `default_server` (= load order) |
The `:80` is not decoration: without it Caddy 2 defaults each site to `:443` with auto-HTTPS,
which on `*.local.ar` means cert provisioning that fails and breaks the listener. The
variable upstream is not decoration either: naming the upstream in a variable forces runtime
DNS resolution, so nginx **starts when the upstream container is absent** — which is what
lets one nginx front a dozen independent compose stacks.
Because the two disambiguate by **opposite** rules, a name set that is unambiguous locally
can be ambiguous on the box. `make check` asserts against the projection, not the source.
### Installing generated vhosts — an order that is not optional
1. Generated config lands in `conf.d/generated/`, **not** `conf.d/`. `ppl/ctrl/deploy.sh`
rsyncs with `--delete`; sharing a directory means one set gets erased.
2. `nginx.conf` needs a **third** include line — its `conf.d/*.conf` glob does not recurse,
which is why `conf.d/soleprint/*.conf` already needs its own.
3. That include changes **load order**, and load order decides which `:443` block catches
unmatched names. So `default.conf`'s commented-out `:443 default_server` must be restored
**first**. `make check` fails on it deliberately: it is a gate, not a warning.
---
## What the checks assert, and why
The scripts are deliberately thin on comment — the reasoning lives here. Every check below
corresponds to something that is wrong, or was wrong, in a real estate.
### `make check` — the estate
| assertion | the failure it catches |
| --- | --- |
| every service name is covered by an issued cert SAN | **a wildcard matches exactly one label.** `*.d.com` covers `a.d.com` but not `a.b.d.com`, which needs its own SAN. Surfaces otherwise as a browser TLS warning, far from its cause |
| a `:443 default_server` exists | DNS and the cert are wildcard but nginx matches `server_name` exactly, so without one the fallback for any unknown name is whichever vhost loads first — alphabetically, by accident |
| firewall rules and listeners agree | a rule allowing a port nothing listens on is **dead config**; a service no compose file declares is **undocumented state**. Neither is visible from one side alone, which is why the inventory has two halves |
| `HOST` is an ssh alias, never a hostname | there is no `Host <domain>` block, so a bare hostname falls through to the global defaults, ssh offers every key in the agent in turn, and `MaxAuthTries` (6) trips with *"Too many authentication failures"* before reaching the right one. The aliases set `IdentitiesOnly yes` |
### `make vpn check` — the overlay
| assertion | the failure it catches |
| --- | --- |
| peer addresses unique and inside the subnet | a duplicate is a silent misroute, never an error |
| AllowedIPs do not overlap | AllowedIPs is **cryptokey routing** — the route table and the ACL at once. Overlapping ranges resolve to the last match, so an overlap is both a misroute and an unintended grant |
| something carries `PersistentKeepalive` if anything roams | without it a NAT mapping expires and the tunnel works only while traffic flows outward — *"works sometimes"*, the hardest failure to read. Note it is **not** a property of the roaming peer's own entry: the roaming machine sets it on the entry for the peer it **dials** |
| the listen port is in the firewall | otherwise no peer can be dialed at all |
| no private key in the description | public keys are *also* 44-char base64, so the shape proves nothing. The real assertions are **no field named `priv*`** and **no key outside a `public_key` field** |
| overlay-reached services bind a reachable address | **a tunnel cannot reach loopback.** A service on `127.0.0.1` is unreachable over the overlay; one on `0.0.0.0` is reachable but also exposed to the whole LAN |
### Capturing the overlay
```bash
sudo wg show | make vpn capture --write
```
`wg show` has three forms and **only the first is safe**:
| form | safe | why |
| --- | --- | --- |
| `wg show` | **yes** | prints `private key: (hidden)` |
| `wg show <if> dump` | **no** | field 1 of the first line *is* the private key |
| `wg showconf <if>` | **no** | prints `PrivateKey=` outright |
`capture` refuses the latter two by shape. It matches peers by **allowed-ips address, not
public key** — the keys are exactly what is missing at that point — and **drops a roaming
peer's endpoint in the parser**, since that value is a home ISP address and a roaming peer
has no stable endpoint anyway.
## Layout
```
berth/
├── Makefile one target per ctrl/ script; the verb is an argument
├── STALE.md withdrawn assumptions, each with a check that runs
├── estate/<name>.json THE ARTIFACT — one description, many renderings
└── ctrl/
├── check.sh reports and instructs; never fixes
├── estate.sh show | list | plan | apply --yes | destroy --yes
├── services.sh list | render <aws|gcp|local> | deploy
├── ports.sh show | verify (the rig coincidence check)
├── dns.sh certs.sh host.sh registry.sh docs.sh
├── versions.env pinned toolchain (weakest layer)
├── env.d/<target>.env provider shape: aws | gcp
├── .env.example -> ctrl/.env, machine-local (gitignored)
├── lib/config.sh the four-layer load, from rig
├── lib/estate.sh reading and projecting the estate
└── render/*.tmpl nginx vhost shapes, substituted with sed
```
Config layers, weakest first: `versions.env``env.d/<target>.env``ctrl/.env` → the
caller's environment. So `make estate plan TARGET=gcp` beats everything.
**Identity is explicit — the inversion of rig.** rig derives its name from its folder so that
copies never collide. berth refuses to guess, because a deployment has exactly one production
and a wrong guess acts on the wrong estate. `ESTATE` names a file; the only convenience is
that a single `estate/*.json` is used without being asked for.
**`python3`, not `jq`.** rig ships a pinned static `jq` because its floor is "docker and
nothing else" on a machine it does not control. berth's floor is already higher, so
`python3` is a dependency it *has* rather than one it *adds* — the same reasoning by which
rig chose `sed` over `envsubst`.
---
## Status
**B0 (this) is the shape.** `estate/mcrn.json` is marked `UNVERIFIED`: it records what the
repos *claim*, because `ppl/infra/` was written and never applied — no `~/.pulumi`, no
`venv`, no stack state, files dated `mar 6`. B1's read-only inventory is what replaces those
claims with observations. Until then, `estate plan` and `estate apply` refuse: there is
nothing truthful to compare against yet.
`make check` currently fails on two real things — see `def/plans/36.0/berth.md`.

View File

@@ -1,104 +0,0 @@
# berth — withdrawn assumptions
**Everything in this file is no longer true.**
It exists so the live docs stay short and so a withdrawn assumption cannot quietly return:
each entry carries a **check**, and `ctrl/selftest.sh` runs every one of them. A retraction
that is only prose is a retraction nobody re-reads.
Kept rather than deleted for the reason the docgen thread already wrote down —
*a requirement that disappears without explanation comes back.*
### For agents
- **Do not restate these** in a plan, a README or a comment. One line pointing here is enough.
- **Ids are stable.** Cite `✖ B3`; do not re-explain it.
- **When you withdraw an assumption, move it here** — quoted claim, where it came from, what
superseded it and why, what changed in the code, and a check that proves it is gone.
- **Only withdrawn things belong here.** A warning that is still actionable is a live rule,
however historical it sounds, and stays where it is.
---
**✖ B1 — "Pulumi is the source in spr, and Terraform must match the config."**
*(INDEX §5, carried from 35.3)* Withdrawn 2026-09-12. berth uses **OpenTofu and only
OpenTofu**. The rule assumed *open source* and *industry standard* pull apart — Terraform
being BUSL, Pulumi being the open alternative. OpenTofu is both: the standard language, plain
Terraform-compatible HCL, under MPL-2.0. Swappability was never bought by keeping two
renderings; it is bought by `estate/*.json` being the artifact — a rendering you *can*
produce, not one you *must* maintain.
**Gone from:** `ctrl/versions.env` (no `PULUMI_VERSION`), `ctrl/estate.sh` (`plan` runs one
executor), `ctrl/lib/config.sh` (`PULUMI_STACK``TOFU_WORKSPACE`), `ctrl/check.sh`
(toolchain list).
**Deliberately kept:** `README.md` and `estate/mcrn.json` both record that `ppl/infra/` was
written and never applied — *"no `~/.pulumi`, no venv, no stack state"*. That is a historical
fact about the estate, not a live dependency.
**Check:** no `pulumi` in `ctrl/` or `Makefile`.
**✖ B2 — "ctlptl's rejection is the precedent for *the seam belongs in a script, not a tool*."**
*(INDEX §5, `berth/README.md`)* Withdrawn 2026-09-12. **ctlptl was reinstated** — pinned in
`rig/ctrl/versions.env` at v0.9.4 — and had been removed for the wrong reason. The rule may
still hold; it now has to stand on its own reasoning rather than that example.
**Gone from:** `README.md` — the argument is stated directly, with no borrowed evidence.
**Check:** `ctlptl` appears nowhere in berth.
**✖ B3 — "`wg show` is safe; `wg showconf` is not."** *(my own note, 2026-09-12)* Incomplete,
and the gap is the dangerous one. There are **three** forms, and `wg show <if> dump` puts the
**private key in field 1 of the first line**. Stated as a two-way distinction, the `dump` form
reads as safe.
**Now:** `wg show` plain is safe; `dump` and `showconf` are not. `ctrl/vpn.sh capture` refuses
the latter two **by shape**, rather than parsing around them.
**Check:** `vpn.sh` names all three forms, and `capture` rejects both unsafe ones.
**✖ B4 — "A roaming peer must set `PersistentKeepalive` on its own entry."**
*(`ctrl/vpn.sh`, first draft)* Wrong side, and wrong in the direction that looks fine:
`PersistentKeepalive` is set per-peer in a config, so the roaming machine sets it on the entry
for the peer it **dials**. The original check would have **warned on a correctly configured
overlay**.
**Now:** checked once per overlay — if anything roams, some peer entry must carry a keepalive.
**Check:** the invariant is not keyed on the roaming peer's own `keepalive` field.
**✖ B5 — "The Makefile's pass-through block goes near the top, with the other variables."**
*(`Makefile`, inherited from rig's layout)* Withdrawn 2026-09-12. When a subcommand **names a
real target**, make has two recipes for it and the **last definition wins** — so with the block
first, `make host ports` ran `ctrl/host.sh ports` *and* `ctrl/ports.sh ports`, the second
failing because `ports` is not one of its verbs. Same for `make host services`, `make vpn
check`, `make vpn show estate`.
**Now:** the `$(eval $(ARGS):;@:)` block is **last in the file**, so the no-op wins and the
word is swallowed — which is what an argument is. Make's *"overriding recipe"* warning is the
swallow working.
**Check:** every colliding invocation dispatches to exactly one script.
**✖ B6 — "A base64 key in the description can be caught by its shape."**
*(`ctrl/vpn.sh check`, first draft)* WireGuard **public** keys are also 44-char base64 and
legitimately live in the estate, so shape alone proves nothing and would flag correct data.
**Now:** two assertions instead — **no field named `priv*`**, and **no base64 key outside a
`public_key` field**.
**Check:** the estate's public keys do not trip the secret check.
**✖ B7 — "`network.wireguard` is where the overlay is described."** *(`estate/mcrn.json`)*
Superseded 2026-09-12: WireGuard is berth's network layer, not one service's transport, so it
is a top-level `vpn` block with named overlays and peers. `ctrl/check.sh` and
`ctrl/registry.sh` were repointed.
**Gone from:** the estate schema — a `wireguard_moved` tombstone marks the old key.
**Check:** nothing reads `network.wireguard.*`.
**✖ B8 — "berth and rig are related through their first uses."** *(early framing)* Withdrawn:
**rig and berth are peers — neither depends on the other.** They match on shape (dispatch,
config layering, key names) and consistency is verified by **recomputation**, never by
dependency. A shared library would put something outside `rig/` on rig's path.
**Check:** berth imports nothing from rig; `ports.sh` recomputes the port formula and agrees
with rig's golden values.
**✖ B9 — "`langfuse.mcrn.ar` is an exception that cannot be generated."**
*(`estate/mcrn.json`, `raw: true`)* Withdrawn 2026-09-14. It was filed as the one route a
template could not express — a static `upstream{}` to a WireGuard address, with no `resolver`
and no `set $var`. It was not an exception; it was **the first instance of the general case**.
Those three properties are not three decisions, they are one: *this service is reached by
address on the overlay, not by name on the docker network.* Naming that decision —
`placement` — makes the file renderable.
**Gone from:** `estate/mcrn.json``lng` and `langfuse` were **two entries for one socket**
and are now one service with `placement: local`, `peer: nrft`, and a `local_host` for the
name it answers to locally. `raw` is dropped.
**Check:** the generated vhost matches the hand-written one, normalised for comments and
whitespace — proof against a live route rather than an assertion.

View File

@@ -1,18 +0,0 @@
# Machine-local config. Copy to ctrl/.env (gitignored) and edit.
#
# The estate's FACTS live in estate/<name>.json.
# The provider's SHAPE lives in ctrl/env.d/<target>.env.
# This file is only what differs between machines, plus credentials.
# ESTATE is required when estate/ holds more than one file.
# ESTATE=mcrn
# TARGET=aws
# ssh ALIASES, never hostnames — check.sh refuses a value containing a dot.
# HOST=mcrn # app user, no sudo
# HOST_ADMIN=mcrn-admin # sudo, only where genuinely required
# Credentials: names only, never values. The secrets stay in ~/.aws and
# ~/.config/gcloud where their own tooling manages them.
# AWS_PROFILE=default
# GCP_PROJECT=

View File

@@ -1,58 +0,0 @@
#!/usr/bin/env bash
# The wildcard TLS cert for the gateway.
#
# Usage:
# ./certs.sh status # SANs issued vs SANs the services need
# ./certs.sh verify # inspect the cert served on :443
# ./certs.sh renew # refuses: issues a real cert
# ./certs.sh push # refuses: ships to a live gateway
set -euo pipefail
cd "$(dirname "$0")"
source ./lib/config.sh
source ./lib/estate.sh
load_config
status() {
local issued; issued=$(estate_get "certs.issued" | python3 -c 'import json,sys
try: print("\n".join(json.load(sys.stdin)))
except Exception: pass')
echo "issued SANs (estate/${ESTATE}.json: certs.issued):"
echo "$issued" | sed 's/^/ /'
echo
echo "SANs the services NEED (derived from services[]):"
estate_sans | sed 's/^/ /'
echo
local missing=0 s
while IFS= read -r s; do
[ -z "$s" ] && continue
grep -qxF "$s" <<< "$issued" || { echo "MISSING: $s"; missing=1; }
done < <(estate_sans)
[ "$missing" = 0 ] && echo "the issued cert covers every derived name."
echo
echo "certbot image: $(eval echo "\$$CERTBOT_IMAGE_VAR") provider: $DNS_PROVIDER"
}
verify() {
echo "would run:"
echo " echo | openssl s_client -connect ${DOMAIN}:443 -servername ${DOMAIN} 2>/dev/null \\"
echo " | openssl x509 -noout -dates -ext subjectAltName"
echo
echo "read-only against a live host — announce and approve first (§7)."
}
refuse() {
echo "REFUSING: '$1' acts on a live cert and a live gateway." >&2
echo " renew issues a real Let's Encrypt cert (rate-limited)." >&2
echo " push rsyncs to the gateway and reloads nginx." >&2
echo " Neither runs without explicit approval." >&2
exit 1
}
case "${1:-status}" in
status) status ;;
verify) verify ;;
renew|push) refuse "$1" ;;
*) echo "usage: $0 [status|verify|renew|push]" >&2; exit 1 ;;
esac

View File

@@ -1,153 +0,0 @@
#!/usr/bin/env bash
# Is this estate coherent? Reports and instructs; never fixes.
#
# Takes no subcommand — there is one question to ask.
# Every check corresponds to something wrong in the estate today.
set -euo pipefail
cd "$(dirname "$0")"
source ./lib/config.sh
source ./lib/estate.sh
load_config
WORST=0
note() { echo " $*"; }
warn() { echo " WARN $*"; [ "$WORST" -lt 1 ] && WORST=1; return 0; }
bad() { echo " FAIL $*"; WORST=2; return 0; }
echo "estate: $ESTATE target: $TARGET domain: $DOMAIN"
echo
# 1. cert coverage: a wildcard matches exactly one label.
echo "certs — does the cert cover every name the services serve?"
issued=$(estate_get "certs.issued" | python3 -c 'import json,sys
try: print("\n".join(json.load(sys.stdin)))
except Exception: pass')
if [ -z "$issued" ]; then
warn "no certs.issued in the estate file — cannot check coverage."
else
while IFS=$'\x1f' read -r name host up kind raw placement peer port lhost; do
[ -z "$host" ] && continue
fqdn="${host}.${DOMAIN}"
# A '*' host stands for "any single label here" — check the deepest
# name it can produce, which is the one that fails.
probe="$fqdn"
case "$host" in \*.*) probe="anyroom.${host#\*.}.${DOMAIN}" ;; esac
covered=""
while IFS= read -r san; do
[ -z "$san" ] && continue
if san_covers "$probe" "$san"; then covered=1; break; fi
done <<< "$issued"
if [ -z "$covered" ]; then
bad "$name: '$probe' is covered by NO issued SAN"
note " issued: $(echo "$issued" | tr '\n' ' ')"
note " a wildcard matches exactly ONE label — reissue with"
note " -d '*.${host#\*.}.${DOMAIN}' or move the name one level up"
fi
done < <(estate_services "$TARGET")
[ "$WORST" -lt 2 ] && note "every service name is covered."
fi
echo
# 2. unmatched names: DNS and the cert are wildcard, nginx is exact, so
# without a :443 default_server the fallback is whichever vhost loads first.
echo "gateway — is there a deliberate answer for unmatched names?"
DEFAULT_CONF="${PPL_DIR:-$HOME/wdir/semester/ppl}/gateway/nginx/conf.d/default.conf"
if [ ! -f "$DEFAULT_CONF" ]; then
note "ppl not on this machine at $DEFAULT_CONF — skipped."
elif grep -qE '^\s*listen\s+443.*default_server' "$DEFAULT_CONF"; then
note "default.conf has a :443 default_server."
else
bad "default.conf has NO :443 default_server."
note " Unmatched names fall through to the first-loaded vhost."
note " This is a PREREQUISITE for generating any config: adding a"
note " generated include changes load order, and load order is what"
note " currently decides the fallback."
fi
echo
# 3. a rule allowing a port nothing listens on is dead config; a service no
# compose file declares is undocumented state. Needs both halves to see.
echo "firewall — rules against listeners"
estate_get "firewall" | python3 -c '
import json,sys
try: fw = json.load(sys.stdin)
except Exception: fw = []
for r in fw:
n = r.get("note")
print(" %-6s %-5s %s" % (r["port"], r.get("proto","tcp"), r.get("desc","")))
if n: print(" UNRESOLVED: " + n)
'
note "listener side: unknown until captured (ss -ltnp over ssh $HOST)."
# Ask the structure, not the prose: the condition is "does a peer still lack a
# public key", not "is there a _status string". _status is ALWAYS non-empty —
# capture rewrites it to "CAPTURED ..." — so testing it for emptiness pinned
# this warning on permanently, including after the capture it asks for.
uncaptured=$(estate_get "vpn.overlays.estate.peers" 2>/dev/null | python3 -c '
import json, sys
try:
peers = json.load(sys.stdin)
except Exception:
sys.exit(0)
print(" ".join(n for n, p in peers.items() if not p.get("public_key")))
' 2>/dev/null)
if [ -n "$uncaptured" ]; then
warn "overlay: public keys not captured for:$uncaptured — see 'make vpn check'"
note " 10.8.0.1 carries the registry and woodpecker gRPC;"
note " 10.8.0.2 backs langfuse. Nothing in the tree creates the"
note " interface — a fresh box cannot start the gateway compose."
note " capture with: sudo wg show | make vpn capture --write"
else
note "overlay: $(estate_get 'vpn._status')"
fi
note "overlay detail: make vpn show estate"
echo
# 4. ssh aliases, never hostnames: a bare hostname offers every agent key and
# trips MaxAuthTries before reaching the right one.
echo "ssh — aliases, never hostnames"
for var in HOST HOST_ADMIN; do
val="${!var:-}"
if [ -z "$val" ]; then
warn "$var is unset."
elif [[ "$val" == *.* ]]; then
bad "$var='$val' looks like a hostname, not a ~/.ssh/config alias."
note " A bare hostname trips MaxAuthTries before reaching the key."
elif [ -f "$HOME/.ssh/config" ] && grep -qiE "^\s*Host\s+.*\b${val}\b" "$HOME/.ssh/config"; then
note "$var=$val — Host block present."
else
warn "$var='$val' has no matching Host block in ~/.ssh/config."
fi
done
for f in "$HOME/wdir/semester/ppl/ctrl/.env"; do
[ -f "$f" ] || continue
if grep -qE '^SERVER=.*\.' "$f"; then
warn "$f sets SERVER to a hostname, not an alias — every ppl script inherits it."
fi
done
echo
# ── 5. toolchain ───────────────────────────────────────────────────────────
echo "toolchain"
for t in python3 "$TOFU_BIN" aws gcloud ssh rsync wg; do
if command -v "$t" >/dev/null 2>&1; then
note "$(printf '%-8s' "$t") present"
else
note "$(printf '%-8s' "$t") MISSING — $(case $t in
tofu) echo 'blocks: estate plan/apply; terraform works identically' ;;
wg) echo 'blocks: vpn keygen and the overlay checks' ;;
aws) echo 'blocks: the control-plane inventory, dns on route53' ;;
gcloud) echo 'blocks: the gcp estate' ;;
*) echo 'blocks: most things' ;;
esac)"
fi
done
echo
case "$WORST" in
0) echo "OK" ;;
1) echo "OK, with warnings" ;;
2) echo "PROBLEMS FOUND — see FAIL lines above" ;;
esac
exit 0

View File

@@ -1,81 +0,0 @@
#!/usr/bin/env bash
# DNS records, over whichever provider the target names.
#
# Usage:
# ./dns.sh list
# ./dns.sh add <subdomain> # <sub>.<domain> -> <domain>
# ./dns.sh add-wildcard <sub>
# ./dns.sh remove <subdomain>
#
# Read-only verbs announce and wait; mutating ones refuse.
set -euo pipefail
cd "$(dirname "$0")"
source ./lib/config.sh
source ./lib/estate.sh
load_config
announce() {
echo "would run:"
printf ' %s\n' "$*"
}
list() {
case "$DNS_PROVIDER" in
route53)
announce "aws route53 list-resource-record-sets --hosted-zone-id $AWS_HOSTED_ZONE_ID" \
"--query 'ResourceRecordSets[].[Name,Type,TTL,ResourceRecords[0].Value]' --output table"
;;
google)
announce "gcloud dns record-sets list --zone=${ESTATE}-zone --project=${GCP_PROJECT:-<unset>}"
;;
*) echo "unknown DNS_PROVIDER: $DNS_PROVIDER" >&2; exit 1 ;;
esac
echo
echo "read-only, but NOT run: each batch is announced and"
echo "waited on. Approve it and it runs."
}
mutate() {
local verb="$1" sub="${2:-}"
[ -z "$sub" ] && { echo "usage: $0 $verb <subdomain>" >&2; exit 1; }
local name
case "$verb" in
add) name="${sub}.${DOMAIN}" ;;
add-wildcard) name="*.${sub}.${DOMAIN}" ;;
remove) name="${sub}.${DOMAIN}" ;;
esac
echo "$verb: $name -> $DOMAIN (provider: $DNS_PROVIDER)"
echo
# The wildcard-depth rule again, applied BEFORE the record is created rather
# than discovered in a browser afterwards.
if [ "$verb" = "add" ]; then
local issued; issued=$(estate_get "certs.issued" | python3 -c 'import json,sys
try: print("\n".join(json.load(sys.stdin)))
except Exception: pass')
local covered=""
while IFS= read -r san; do
[ -z "$san" ] && continue
san_covers "$name" "$san" && { covered=1; break; }
done <<< "$issued"
[ -z "$covered" ] && {
echo "WARNING: '$name' is covered by no issued SAN." >&2
echo " A wildcard matches ONE label. The record would" >&2
echo " resolve and then fail TLS. Reissue the cert first." >&2
echo >&2
}
fi
echo "REFUSING: this changes live DNS." >&2
echo " Nothing is created, modified or deleted on any account without" >&2
echo " explicit approval for that specific action." >&2
exit 1
}
case "${1:-list}" in
list) list ;;
add|add-wildcard|remove) mutate "$@" ;;
*) echo "usage: $0 [list|add <sub>|add-wildcard <sub>|remove <sub>]" >&2; exit 1 ;;
esac

View File

@@ -1,27 +0,0 @@
#!/usr/bin/env bash
# Documentation.
#
# Usage:
# ./docs.sh serve|graphs
set -euo pipefail
cd "$(dirname "$0")"
source ./lib/config.sh
source ./lib/estate.sh
load_config
case "${1:-serve}" in
serve)
echo "berth has no doc server yet — the README is the documentation."
echo " $(cd .. && pwd)/README.md"
echo
echo "the estate, resolved: make estate show"
;;
graphs)
echo "not implemented. The estate file is the graph's source; a"
echo "renderer belongs with docgen/graphgen, which is another thread's"
echo "another thread's — so this is a handoff, not a stub to fill in here."
;;
*) echo "usage: $0 [serve|graphs]" >&2; exit 1 ;;
esac

View File

@@ -1,14 +0,0 @@
# Target: AWS — the estate that actually runs today (mcrn.ar).
TARGET_NAME=aws
CLOUD=aws
REGION=us-east-1
INSTANCE_TYPE=t3.small
# The Route53 hosted zone. It ALREADY EXISTS and is reused, never created —
# see estate.sh's refusal to create a zone on this target.
AWS_HOSTED_ZONE_ID=Z02279903503ZMIB5FC1N
SSH_KEY_NAME=mcrn
# certbot's DNS-01 plugin for this provider.
CERTBOT_IMAGE_VAR=CERTBOT_AWS_IMAGE
DNS_PROVIDER=route53

View File

@@ -1,17 +0,0 @@
# Target: GCP — the replica, on its own domain.
#
# The domain differs from AWS's on purpose: this target CREATES a DNS zone
# where aws reuses one, so a shared domain would create a second authoritative
# zone and break live DNS. The domain lives in estate/nrft.json, not here.
TARGET_NAME=gcp
CLOUD=gcp
REGION=us-central1
INSTANCE_TYPE=e2-small
GCP_PROJECT=
GCP_ZONE=us-central1-a
# Delegation order: create the zone and let it answer first, then set the
# nameservers at the registrar — it validates that they respond.
CERTBOT_IMAGE_VAR=CERTBOT_GCP_IMAGE
DNS_PROVIDER=google

View File

@@ -1,101 +0,0 @@
#!/usr/bin/env bash
# The estate: what it is, what would change, and — behind a gate — changing it.
#
# Usage:
# ./estate.sh # show
# ./estate.sh list # every estate/*.json
# ./estate.sh plan # tofu plan, read-only
# ./estate.sh apply --yes # refuses without --yes
# ./estate.sh destroy --yes
#
# Why the default is read-only: ../README.md
set -euo pipefail
cd "$(dirname "$0")"
source ./lib/config.sh
source ./lib/estate.sh
load_config
show() {
echo "estate: $ESTATE ($ESTATE_FILE)"
echo "target: $TARGET (cloud=$CLOUD region=$REGION)"
echo "domain: $DOMAIN"
echo "host: $HOST (sudo: $HOST_ADMIN)"
echo "workspace: $TOFU_WORKSPACE"
echo
local status; status=$(estate_get "_meta.status")
[ -n "$status" ] && echo " !! $status" && echo
echo "services in scope for '$TARGET':"
local name host up kind raw
while IFS=$'\x1f' read -r name host up kind raw placement peer port lhost; do
[ -z "$name" ] && continue
printf ' %-12s %-14s %-24s %s%s\n' \
"$name" "${host:--}" "${up:--}" "$kind" \
"$([ -n "$raw" ] && echo ' [hand-written]')"
done < <(estate_services "$TARGET")
echo
echo "cert SANs (derived, not listed):"
estate_sans | sed 's/^/ /'
}
list() {
local f n
printf '%-12s %-16s %s\n' ESTATE DOMAIN STATUS
for f in ../estate/*.json; do
[ -f "$f" ] || continue
n=$(basename "$f" .json)
printf '%-12s %-16s %s%s\n' "$n" \
"$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1])).get("domain",""))' "$f")" \
"$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1])).get("_meta",{}).get("status",""))' "$f")" \
"$([ "$n" = "$ESTATE" ] && echo ' <- this one')"
done
}
# Read-only. Meaningful only once state is imported: against empty state,
# plan reports "create N resources", which is not drift.
plan() {
echo "== $TOFU_BIN plan =="
if ! command -v "$TOFU_BIN" >/dev/null; then
echo " $TOFU_BIN not installed — skipped." >&2
echo " OpenTofu is the MPL-2.0 fork; 'terraform' works identically." >&2
else
echo " would run: $TOFU_BIN plan -var-file=<(estate)"
fi
echo
echo "NOTE: not wired up yet, and that is the point. tofu plan against empty"
echo " state reports \"create N resources\" — which is not drift, it is an"
echo " empty state. It becomes the check that proves the description"
echo " matches reality only once state is IMPORTED from the inventory."
echo " ppl/infra/ describes an aspiration: it was never applied."
}
# The gate. Two things have to be true: --yes present, AND the plan shown first.
require_yes() {
local verb="$1"; shift
local yes=""
for a in "$@"; do [ "$a" = "--yes" ] && yes=1; done
if [ -z "$yes" ]; then
echo "refusing to $verb without --yes." >&2
echo >&2
echo " $verb changes a live, billable estate and can take DNS with it." >&2
echo " Read the plan first: make estate plan" >&2
echo " Then: ./ctrl/estate.sh $verb --yes" >&2
exit 1
fi
echo "refusing to $verb: not implemented, and deliberately so." >&2
echo " The executor is not wired up, and nothing is imported yet, so" >&2
echo " there is nothing truthful to apply." >&2
exit 1
}
case "${1:-show}" in
show) show ;;
list) list ;;
plan) plan ;;
apply) shift; require_yes apply "$@" ;;
destroy) shift; require_yes destroy "$@" ;;
*) echo "usage: $0 [show|list|plan|apply --yes|destroy --yes]" >&2; exit 1 ;;
esac

View File

@@ -1,63 +0,0 @@
#!/usr/bin/env bash
# The remote box — the other half of the inventory.
#
# Usage:
# ./host.sh status|ports|services
#
# Announces what it would run over `ssh $HOST` and does not run it. Always the
# ssh alias, never a hostname: there is no `Host mcrn.ar` block, so a bare
# hostname offers every agent key and trips MaxAuthTries.
set -euo pipefail
cd "$(dirname "$0")"
source ./lib/config.sh
source ./lib/estate.sh
load_config
guard_alias() {
if [[ "$HOST" == *.* ]]; then
echo "HOST='$HOST' is a hostname, not a ~/.ssh/config alias. Refusing." >&2
exit 1
fi
}
announce_batch() {
echo "would run, over 'ssh $HOST':"
printf ' %s\n' "$@"
echo
echo "read-only, and NOT run. Announce-first applies to EACH batch, not"
echo "once per session — so the commands can be read and"
echo "learned rather than scrolled past."
}
guard_alias
case "${1:-status}" in
status)
announce_batch \
"uname -a; uptime; df -h /" \
"docker ps --format '{{.Names}}\t{{.Image}}\t{{.Ports}}'" \
"docker network inspect gateway --format '{{range .Containers}}{{.Name}} {{end}}'" \
"systemctl list-units --type=service --state=running --no-pager" \
"systemctl list-timers --no-pager"
echo
echo "sudo-only, over 'ssh $HOST_ADMIN' and only where genuinely needed:"
echo " wg show # the WireGuard peers that exist nowhere in the tree"
;;
ports)
announce_batch "ss -ltnp"
echo "the estate declares these firewall rules:"
estate_get "firewall" | python3 -c '
import json,sys
for r in json.load(sys.stdin):
print(" %-6s %-5s %s" % (r["port"], r.get("proto","tcp"), r.get("desc","")))'
;;
services)
announce_batch "docker compose -f ~/ppl/gateway/docker-compose.yml ps"
echo "the estate declares $(estate_services "$TARGET" | grep -c . ) service(s) for target '$TARGET'."
echo "the gateway compose declares 8. The difference is sibling repos'"
echo "stacks joining the shared 'gateway' network — intended design, but"
echo "nothing in the tree lists it. The inventory produces that list."
;;
*) echo "usage: $0 [status|ports|services]" >&2; exit 1 ;;
esac

View File

@@ -1,94 +0,0 @@
# Shared config loading. Sourced, never executed. Run from ctrl/.
#
# Precedence, weakest first:
# ctrl/versions.env pinned toolchain (committed)
# ctrl/env.d/<target>.env provider shape: aws|gcp (committed)
# ctrl/.env machine-local + secrets (gitignored)
# the caller's env `make estate plan TARGET=gcp` (always wins)
CONFIG_OVERRIDABLE="TARGET ESTATE CLOUD REGION INSTANCE_TYPE
HOST HOST_ADMIN AWS_PROFILE AWS_HOSTED_ZONE_ID
GCP_PROJECT GCP_ZONE TOFU_WORKSPACE"
# rig's port formula, reproduced rather than imported. cksum because it is
# POSIX and gives the same value on every machine. Used to verify, not allocate.
derive_port_base() {
local h; h=$(printf '%s' "$1" | cksum | awk '{print $1}')
echo $((20000 + (h % 200) * 10))
}
_config_restore() {
local line
while IFS= read -r line; do
if [ -n "$line" ]; then
eval "export $line"
fi
done <<< "$1"
# A while loop returns its last body command's status; the trailing empty
# line would otherwise make this return 1 and trip `set -e` in the caller.
return 0
}
load_config() {
local k saved=""
for k in $CONFIG_OVERRIDABLE; do
# ${!k+x} distinguishes "set but empty" from "unset" — an explicit
# FOO= on the command line is a real choice and must survive.
if [ -n "${!k+x}" ]; then
saved+="$k=$(printf '%q' "${!k}")"$'\n'
fi
done
set -a
source ./versions.env
[ -f ./.env ] && source ./.env
set +a
# Re-apply overrides now so TARGET is the caller's before we pick the file.
_config_restore "$saved"
local target="${TARGET:-aws}"
if [ ! -f "./env.d/${target}.env" ]; then
echo "no such target: env.d/${target}.env" >&2
echo "available: $(ls env.d/*.env 2>/dev/null | xargs -n1 basename | sed 's/\.env$//' | tr '\n' ' ')" >&2
exit 1
fi
set -a
source "./env.d/${target}.env"
[ -f ./.env ] && source ./.env
set +a
_config_restore "$saved"
TARGET="$target"
# Identity is explicit: berth never guesses which estate it is acting on.
# The one convenience: a single estate/*.json is used without being asked.
if [ -z "${ESTATE:-}" ]; then
local n; n=$(ls ../estate/*.json 2>/dev/null | wc -l)
if [ "$n" = "1" ]; then
ESTATE=$(basename "$(ls ../estate/*.json)" .json)
else
echo "ESTATE is not set and estate/ holds $n candidates — refusing to guess." >&2
echo "available: $(ls ../estate/*.json 2>/dev/null | xargs -n1 basename | sed 's/\.json$//' | tr '\n' ' ')" >&2
echo "set it: make estate show ESTATE=<name>, or ESTATE= in ctrl/.env" >&2
exit 1
fi
fi
ESTATE_FILE="../estate/${ESTATE}.json"
if [ ! -f "$ESTATE_FILE" ]; then
echo "no such estate: estate/${ESTATE}.json" >&2
echo "available: $(ls ../estate/*.json 2>/dev/null | xargs -n1 basename | sed 's/\.json$//' | tr '\n' ' ')" >&2
exit 1
fi
# Facts come from the estate file, never restated in a target env.
DOMAIN=$(estate_get "domain")
HOST="${HOST:-$(estate_get "host")}"
HOST_ADMIN="${HOST_ADMIN:-$(estate_get "host_admin")}"
# Workspace == target, so the two can never mean different things.
TOFU_WORKSPACE="${TOFU_WORKSPACE:-$TARGET}"
}

View File

@@ -1,147 +0,0 @@
# Reading and projecting estate/<name>.json. Sourced, never executed.
#
# python3 rather than jq: berth's floor already includes python3, so it is a
# dependency berth has rather than one it adds.
estate_get() {
python3 -c '
import json, sys
d = json.load(open(sys.argv[1]))
for k in sys.argv[2].split("."):
if isinstance(d, list):
try: k = int(k)
except ValueError: sys.exit(0)
try: d = d[k]
except Exception: sys.exit(0)
print("" if d is None else d if isinstance(d, str) else json.dumps(d))
' "$ESTATE_FILE" "$1"
}
# Services in scope for one target. A service names its targets; absent = all.
# Fields are US-separated (0x1f), not tab: tab is IFS whitespace, so bash
# collapses a run of them and an empty field would shift every later column.
estate_services() {
local target="${1:-$TARGET}"
python3 -c '
import json, sys
d = json.load(open(sys.argv[1]))
target = sys.argv[2]
for s in d.get("services", []):
tg = s.get("targets")
if tg is not None and target not in tg:
continue
print("\x1f".join([
s.get("name", ""),
s.get("host", ""),
str(s.get(target + "_upstream", s.get("upstream", "")) or ""),
s.get("kind", "proxy"),
"raw" if s.get("raw") else "",
s.get("placement", "box"),
s.get("peer", ""),
str(s.get("port", "") or ""),
s.get("local_host", s.get("host", "")),
]))
' "$ESTATE_FILE" "$target"
}
# The SAN list the services need, derived — never a literal list.
estate_sans() {
python3 -c '
import json, sys
d = json.load(open(sys.argv[1]))
domain = d["domain"]
sans = [domain]
depths = set()
for s in d.get("services", []):
h = s.get("host", "")
if not h:
continue
# A wildcard matches exactly ONE label. "git" needs *.domain; "dlt.spr"
# needs *.spr.domain. The parent of the leaf is what has to be covered.
parent = h.split(".", 1)[1] if "." in h else ""
depths.add(parent)
for p in sorted(depths):
sans.append("*." + (p + "." if p else "") + domain)
for s in sans:
print(s)
' "$ESTATE_FILE"
}
# Is <fqdn> covered by <san>? A wildcard matches exactly one label.
san_covers() {
local fqdn="$1" san="$2"
[ "$fqdn" = "$san" ] && return 0
case "$san" in
\*.*)
local suffix="${san#\*.}"
# Must end in .suffix AND have exactly one extra label.
case "$fqdn" in
*".$suffix") [ "${fqdn%".$suffix"}" = "${fqdn%%.*}" ] && return 0 ;;
esac
;;
esac
return 1
}
# ── the overlay ────────────────────────────────────────────────────────────
# Every overlay name, one per line.
overlay_names() {
python3 -c '
import json, sys
d = json.load(open(sys.argv[1]))
for n in d.get("vpn", {}).get("overlays", {}):
print(n)
' "$ESTATE_FILE"
}
# Peers of one overlay, US-separated:
# name, address, role, endpoint, public_key, allowed_ips, keepalive
overlay_peers() {
python3 -c '
import json, sys
d = json.load(open(sys.argv[1]))
ov = d.get("vpn", {}).get("overlays", {}).get(sys.argv[2], {})
for name, p in ov.get("peers", {}).items():
print("\x1f".join(str(x) if x is not None else "" for x in [
name, p.get("address"), p.get("role"), p.get("endpoint"),
p.get("public_key"), p.get("allowed_ips"), p.get("keepalive"),
]))
' "$ESTATE_FILE" "$1"
}
overlay_get() { estate_get "vpn.overlays.$1.$2"; }
# Is an address inside a CIDR? Pure python so there is no ipcalc dependency —
# berth's floor already includes python3 because the IaC side needs it.
addr_in_subnet() {
python3 -c '
import ipaddress, sys
try:
sys.exit(0 if ipaddress.ip_address(sys.argv[1]) in ipaddress.ip_network(sys.argv[2], strict=False) else 1)
except ValueError:
sys.exit(2)
' "$1" "$2"
}
# The upstream a service actually resolves to, as "host:port".
#
# A PLACED service has no literal `upstream` field: ✖ B9 replaced langfuse's
# hand-written `10.8.0.2:3000` with placement+peer+port, because being reached
# by address on the overlay is ONE decision, not three properties. Everything
# that asks "what does this service point at" must therefore resolve it the
# same way, or it silently sees an empty string and skips the service — which
# is exactly how vpn.sh's bindings invariant went quiet after B9 landed.
#
# usage: service_upstream <up> <placement> <peer> <port>
service_upstream() {
local up="$1" placement="$2" peer="$3" port="$4"
case "$placement" in
local|instance)
local addr; addr="$(overlay_get estate "peers.${peer}.address")"
[ -z "$addr" ] && return 1
printf '%s:%s' "$addr" "$port"
;;
*) printf '%s' "$up" ;;
esac
}

View File

@@ -1,78 +0,0 @@
#!/usr/bin/env bash
# The local port map, and whether it still agrees with rig.
#
# Usage:
# ./ports.sh show # DERIVED / ACTIVE / SOURCE
# ./ports.sh verify # recompute rig's formula, report drift
#
# berth recomputes rig's port formula rather than importing it, so neither
# depends on the other. See ../README.md.
set -euo pipefail
cd "$(dirname "$0")"
source ./lib/config.sh
source ./lib/estate.sh
load_config
# name<US>host<US>local_port for everything that has one.
_local_ports() {
python3 -c '
import json, sys
d = json.load(open(sys.argv[1]))
for s in d.get("services", []):
p = s.get("local_port")
if p:
print("\x1f".join([s.get("name",""), s.get("host",""), str(p)]))
' "$ESTATE_FILE"
}
show() {
local ld; ld=$(estate_get "local_domain"); : "${ld:=local.ar}"
printf '%-12s %-22s %-8s %-8s %s\n' NAME ADDRESS ACTIVE DERIVED SOURCE
local name host port base
while IFS=$'\x1f' read -r name host port; do
[ -z "$name" ] && continue
base=$(derive_port_base "$host")
if [ "$port" = "$base" ]; then
printf '%-12s %-22s %-8s %-8s %s\n' "$name" "${host}.${ld}" "$port" "$base" "derived"
else
printf '%-12s %-22s %-8s %-8s %s\n' "$name" "${host}.${ld}" "$port" "$base" "override"
fi
done < <(_local_ports)
echo
echo "DERIVED is what rig's formula gives for that name. ACTIVE is what the"
echo "estate records. 'override' is not an error — most of these were never"
echo "rigs. 'make ports verify' says which ones should have matched."
}
verify() {
local name host port base rc=0 checked=0
while IFS=$'\x1f' read -r name host port; do
[ -z "$name" ] && continue
# Only 20000-21999 is rig's to predict; anything else was never derived.
if [ "$port" -lt 20000 ] || [ "$port" -gt 21999 ]; then
continue
fi
checked=$((checked + 1))
base=$(derive_port_base "$host")
if [ "$port" != "$base" ]; then
echo "DRIFT $name (${host}): estate says $port, rig's formula gives $base"
echo " either the rig pinned HTTP_PORT in its ctrl/.env, or the"
echo " folder was renamed. The Caddy map is stale either way."
rc=1
else
echo "ok $name (${host}): $port"
fi
done < <(_local_ports)
echo
echo "checked $checked rig-shaped port(s) in 20000-21999."
[ "$rc" = 0 ] && echo "no drift." || echo "drift found — regenerate with 'make services render local'."
return 0
}
case "${1:-show}" in
show) show ;;
verify) verify ;;
*) echo "usage: $0 [show|verify]" >&2; exit 1 ;;
esac

View File

@@ -1,33 +0,0 @@
#!/usr/bin/env bash
# The image registry — remote, and reachable only over the overlay.
#
# Usage:
# ./registry.sh status
#
# One verb: berth reports on a registry running on someone else's box. Starting
# and stopping it is that box's business.
set -euo pipefail
cd "$(dirname "$0")"
source ./lib/config.sh
source ./lib/estate.sh
load_config
status() {
local wg_server; wg_server=$(overlay_get estate "peers.box.address")
echo "registry: registry.${DOMAIN} (public pull via /v2/)"
echo "push: ${wg_server}:5000 (WireGuard-only, not in the firewall)"
echo
echo "would run:"
echo " curl -s https://registry.${DOMAIN}/v2/_catalog"
echo
echo "NOTE: the push endpoint binds ${wg_server}, and $(estate_get 'vpn._status')."
echo " A freshly-provisioned box cannot start the gateway compose file"
echo " at all, because that bind fails."
}
case "${1:-status}" in
status) status ;;
*) echo "usage: $0 [status]" >&2; exit 1 ;;
esac

View File

@@ -1,32 +0,0 @@
# ${NAME} — GENERATED by berth from estate/${ESTATE}.json. Do not edit.
# Edit the estate file and re-run: make services render aws
server {
listen 80;
server_name ${FQDN};
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
server_name ${FQDN};
ssl_certificate /etc/nginx/certs/live/${DOMAIN}/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/live/${DOMAIN}/privkey.pem;
# Docker's embedded DNS. Naming the upstream in a VARIABLE forces runtime
# resolution, so nginx STARTS even when the upstream container is absent.
# With a literal proxy_pass, one stopped container takes the whole gateway
# down at reload — which is what makes one nginx able to front a dozen
# independent compose stacks.
resolver 127.0.0.11 valid=30s;
location / {
set $upstream_${NAME} ${UPSTREAM_HOST};
proxy_pass http://$upstream_${NAME}:${UPSTREAM_PORT};
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

View File

@@ -1,23 +0,0 @@
# ${NAME} — GENERATED by berth from estate/${ESTATE}.json. Do not edit.
# Edit the estate file and re-run: make services render aws
server {
listen 80;
server_name ${FQDN};
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
server_name ${FQDN};
ssl_certificate /etc/nginx/certs/live/${DOMAIN}/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/live/${DOMAIN}/privkey.pem;
root /usr/share/nginx/html/${NAME};
index index.html;
location / {
try_files $uri $uri/ =404;
}
}

View File

@@ -1,32 +0,0 @@
# ${NAME} — GENERATED by berth from estate/${ESTATE}.json. Do not edit.
# Placement: ${PLACEMENT} (${PEER}) — reached over the overlay, not the docker network.
upstream ${NAME}_backend {
server ${UPSTREAM_HOST}:${UPSTREAM_PORT};
}
server {
listen 80;
server_name ${FQDN};
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
server_name ${FQDN};
ssl_certificate /etc/nginx/certs/live/${DOMAIN}/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/live/${DOMAIN}/privkey.pem;
# No `resolver`, and no `set $var` indirection — deliberately. Those exist so
# nginx starts when a CONTAINER is absent; this upstream is a literal address
# on the overlay, which needs no DNS at all. The three properties are one
# decision, and placement is what decides them.
location / {
proxy_pass http://${NAME}_backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

View File

@@ -1,210 +0,0 @@
#!/usr/bin/env bash
# What berth has settled, and what it has withdrawn, written down as assertions.
#
# Two halves:
# - decisions that hold. Failing one means "you are about to undo this".
# - every entry in ../STALE.md. Failing one means a withdrawn assumption came
# back. That is the half that makes STALE.md an audit surface and not an
# archive — a retraction nobody re-reads is a retraction that decays.
#
# Scope: no cloud, no ssh, no sudo, no network. Cheap enough to actually run.
# `make check` reports on the world and never fails; this exits 1, like rig's.
#
# Usage: make selftest (or: bash ctrl/selftest.sh)
set -uo pipefail # NOT -e: one failing check must not abort the rest
cd "$(dirname "$0")"
source ./lib/config.sh
rc=0
passed=0
check() { # name, expected, actual
if [ "$2" = "$3" ]; then
printf ' ok %s\n' "$1"
passed=$((passed + 1))
else
printf ' FAIL %s\n expected: %s\n got: %s\n' "$1" "$2" "$3"
rc=1
fi
}
note() { printf '\n%s\n' "$1"; }
skip() { printf ' skip %s (%s)\n' "$1" "$2"; }
# An absence check must not match the files that RECORD the absence. STALE.md
# names every withdrawn thing by definition, and this file names them again to
# assert them — so both are excluded, or every check fails on itself. rig hits
# the same wall and assembles its pattern from fragments for the same reason.
NOSELF="--exclude=selftest.sh --exclude=STALE.md"
absent() { grep -rIl $NOSELF "$@" 2>/dev/null | wc -l; }
# A throwaway estate, for the checks that have to run berth rather than read it.
TMP_ESTATE=_selftest
cleanup() { rm -f "../estate/${TMP_ESTATE}.json"; }
trap cleanup EXIT
note "the withdrawn assumptions — ../STALE.md, one check each"
# B1 — Pulumi. The two surviving mentions are historical fact about ppl/infra
# and live in README.md and the estate, not in anything that runs.
check "B1 no pulumi in the code" "0" "$(absent -i pulumi . ../Makefile)"
# B2 — the ctlptl precedent. Withdrawn; the argument stands on its own now.
check "B2 the withdrawn precedent is cited nowhere" "0" "$(absent -i ctlptl ..)"
# B3 — `wg show <if> dump` leaks the private key in field 1. Stating only
# show-vs-showconf makes the dump form read as safe.
check "B3 all three wg forms are named" "yes" \
"$(grep -q 'dump' vpn.sh && grep -q 'showconf' vpn.sh && echo yes || echo no)"
check "B3 capture refuses showconf-shaped input" "1" \
"$(printf '[Interface]\nPrivateKey = x\n' | bash vpn.sh capture >/dev/null 2>&1; echo $?)"
check "B3 capture refuses dump-shaped input" "1" \
"$(printf 'priv\tpub\t51820\toff\n' | bash vpn.sh capture >/dev/null 2>&1; echo $?)"
# B4 — keepalive belongs to the peer that DIALS, not the one that roams. The
# first version warned on a correctly configured overlay, so the check is run
# against one: hub carries the keepalive, nrft roams.
python3 - <<'PY'
import json, collections
d = json.load(open("../estate/mcrn.json"), object_pairs_hook=collections.OrderedDict)
d["vpn"]["overlays"]["estate"]["peers"]["box"]["keepalive"] = 25
json.dump(d, open("../estate/_selftest.json", "w"), indent=2, ensure_ascii=False)
PY
check "B4 a correct overlay raises no keepalive warning" "0" \
"$(ESTATE=$TMP_ESTATE bash vpn.sh check 2>/dev/null | grep -ci 'no peer entry carries')"
# B6 — public keys are 44-char base64 too, so shape alone would flag correct
# data. Same fixture, with a real-shaped public key on a peer.
python3 - <<'PY'
import base64, collections, json, os
d = json.load(open("../estate/_selftest.json"), object_pairs_hook=collections.OrderedDict)
d["vpn"]["overlays"]["estate"]["peers"]["box"]["public_key"] = base64.b64encode(os.urandom(32)).decode()
json.dump(d, open("../estate/_selftest.json", "w"), indent=2, ensure_ascii=False)
PY
check "B6 a public key does not trip the secret check" "0" \
"$(ESTATE=$TMP_ESTATE bash vpn.sh check 2>/dev/null | grep -c 'FAIL.*key')"
cleanup # the fixture is done with; two estate files would make load_config
# refuse to guess below, which is right but reads as a config failure
# B5 — the pass-through block must be LAST, or a subcommand that names a real
# target runs that target too. Checked through make, not by reading the file.
note "B5 a subcommand that names a target dispatches once"
for combo in "host ports" "host services" "vpn check" "vpn show estate" "estate show"; do
check " make $combo" "1" \
"$(cd .. && make -n $combo 2>/dev/null | grep -c 'bash ctrl/')"
done
# B7 — the overlay moved out of network.wireguard into a top-level vpn block.
check "B7 nothing reads network.wireguard" "0" "$(absent 'network\.wireguard' .)"
# B8 — peers, not relatives. berth sources nothing from rig.
check "B8 berth sources nothing from rig" "0" "$(absent -E 'rig/ctrl|\.\./rig' .)"
# B9 — langfuse was filed as an exception a template could not express. It was
# the general case. The proof is a live route: render it and diff against the
# hand-written file, normalised for comments and whitespace.
LIVE=/home/mariano/wdir/semester/ppl/gateway/nginx/conf.d/langfuse.conf
if [ -f "$LIVE" ]; then
norm() { sed -e 's/#.*//' -e 's/[[:space:]]\+/ /g' -e 's/^ //' -e 's/ $//' -e '/^$/d' "$1"; }
bash services.sh render aws >/dev/null 2>&1
check "B9 the generated vhost reproduces the live one" "same" \
"$(diff -q <(norm ./render/out/aws/langfuse.conf) <(norm "$LIVE") >/dev/null 2>&1 \
&& echo same || echo different)"
else
skip "B9 generated vhost matches the live one" "ppl not on this machine"
fi
note "the safety contract — berth's verbs are not all safe"
check "estate defaults to show" "show" "$(cd .. && make -n estate 2>/dev/null | grep -oE 'estate\.sh [a-z]+' | awk '{print $2}')"
check "certs defaults to status" "status" "$(cd .. && make -n certs 2>/dev/null | grep -oE 'certs\.sh [a-z]+' | awk '{print $2}')"
check "dns defaults to list" "list" "$(cd .. && make -n dns 2>/dev/null | grep -oE 'dns\.sh [a-z]+' | awk '{print $2}')"
check "vpn defaults to list" "list" "$(cd .. && make -n vpn 2>/dev/null | grep -oE 'vpn\.sh [a-z]+' | awk '{print $2}')"
for verb in apply destroy; do
check "estate $verb refuses without --yes" "1" \
"$(bash estate.sh "$verb" >/dev/null 2>&1; echo $?)"
done
for verb in renew push; do
check "certs $verb refuses" "1" \
"$(bash certs.sh "$verb" >/dev/null 2>&1; echo $?)"
done
check "dns add refuses to change live DNS" "1" \
"$(bash dns.sh add selftest >/dev/null 2>&1; echo $?)"
check "vpn up refuses without --yes" "1" \
"$(bash vpn.sh up >/dev/null 2>&1; echo $?)"
note "config — the caller's env beats the files"
# Generated from CONFIG_OVERRIDABLE, so a new key enrols itself.
test_value() {
case "$1" in
TARGET) echo "gcp" ;;
ESTATE) echo "mcrn" ;;
*) echo "selftest-sentinel" ;;
esac
}
for key in $CONFIG_OVERRIDABLE; do
want="$(test_value "$key")"
got="$(export "$key=$want"; load_config >/dev/null 2>&1; echo "${!key}")"
check " caller's $key wins" "$want" "$got"
done
note "rig agreement — recomputed, never imported"
# rig pins these same constants in its own selftest. Both arrive at them from
# the same formula with no shared code, which is the coupling rule made testable.
check "derive_port_base rig" "20310" "$(derive_port_base rig)"
check "derive_port_base foo" "21690" "$(derive_port_base foo)"
check "derive_port_base my-proj" "21030" "$(derive_port_base my-proj)"
note "containment — berth writes nothing outside berth/"
check "no tracked change outside berth/" "0" \
"$(cd ../.. && git status --porcelain 2>/dev/null | grep -vc '^.. berth/')"
check "generated output is ignored" "yes" \
"$(cd .. && git check-ignore -q ctrl/render/out && echo yes || echo no)"
# A trailing-slash pattern matches directories only, so ask about a path
# inside it rather than the (not-yet-existing) directory itself.
check "key material is ignored" "yes" \
"$(cd .. && git check-ignore -q ctrl/.secrets/vpn/any.key && echo yes || echo no)"
note "capture and the checks that read it — three bugs found by running, 2026-09-14"
# 1. A placed service's upstream is DERIVED (✖ B9). Anything reading the raw
# `upstream` field sees "" and skips it — which is how vpn.sh's bindings
# invariant, the "my configurations broke" detector, went quiet the day
# placement landed while still printing OK. Vacuous passes are the failure
# mode this whole file exists to catch.
check "a placed service resolves to a real upstream" "10.8.0.2:3000" \
"$(bash -c 'source ./lib/config.sh; source ./lib/estate.sh; load_config >/dev/null;
service_upstream "" local nrft 3000')"
check "bindings actually inspects a service" "1" \
"$(bash ./vpn.sh check 2>/dev/null | grep -c 'no service currently has an overlay address' \
| awk '{print 1-$1}')"
# 2. A listen port belongs to a PEER. The roaming peer's is an ephemeral source
# port; writing it to the overlay renames the port the firewall rule is
# checked against — silently, since both are plausible integers.
check "a roaming peer's port is not the overlay's port" "51820" \
"$(python3 -c 'import json;print(json.load(open("../estate/mcrn.json"))["vpn"]["overlays"]["estate"]["listen_port"])')"
# 3. _status is always non-empty — capture rewrites it rather than clearing it —
# so a warning gated on "is it set" can never turn off, including after the
# capture it asks for. Gate on the structure instead.
check "the capture warning clears once keys are in" "0" \
"$(bash ./check.sh 2>/dev/null | grep -c 'public keys not captured')"
note "every STALE entry has a check here"
# Not "$0": line 15 cd's into this script's directory, so a relative $0 no
# longer resolves. After the cd the file is simply selftest.sh.
# Ids are counted wherever they appear — B5's sits in a note(), not a check name.
entries="$(grep -c '^\*\*✖ B' ../STALE.md)"
checked="$(grep -oE '\bB[1-9][0-9]?\b' selftest.sh | sort -u | wc -l)"
check "STALE.md entries are all covered" "$entries" "$checked"
printf '\n%d passed' "$passed"
[ "$rc" -ne 0 ] && printf ', SOME FAILED'
printf '\n'
exit "$rc"

View File

@@ -1,183 +0,0 @@
#!/usr/bin/env bash
# Gateway routes, projected from the estate onto one target.
#
# Usage:
# ./services.sh # list
# ./services.sh render aws # -> render/out/aws/*.conf (nginx vhosts)
# ./services.sh render local # -> render/out/local/Caddyfile
# ./services.sh deploy # refuses; ppl/ctrl/deploy.sh ships config
#
# Each target is a projection with its own rules, not a format conversion.
# The nine axes they disagree on, and the install order: ../README.md
set -euo pipefail
cd "$(dirname "$0")"
source ./lib/config.sh
source ./lib/estate.sh
load_config
OUT_ROOT="./render/out"
list() {
printf '%-12s %-16s %-22s %-9s %s\n' NAME FQDN UPSTREAM PLACEMENT SOURCE
local name host up kind raw
while IFS=$'\x1f' read -r name host up kind raw placement peer port lhost; do
[ -z "$name" ] && continue
# A placed service has no literal `upstream` — it is derived from the
# peer's overlay address, so show what it actually resolves to.
local shown; shown="$(service_upstream "$up" "$placement" "$peer" "$port")" || shown=""
printf '%-12s %-16s %-22s %-9s %s\n' \
"$name" "${host}.${DOMAIN}" "${shown:--}" "$placement" \
"$([ -n "$raw" ] && echo 'hand-written' || echo 'generated')"
done < <(estate_services "$TARGET")
echo
echo "hand-written entries are NOT generated and NOT overwritten."
echo "run 'make estate show' to see why each one is an exception."
}
render_cloud() {
# Two statements: `local a="$1" b="$a"` expands all arguments before any
# assignment, so $a would still be unset.
local target="$1"
local out="$OUT_ROOT/$target"
rm -rf "$out"; mkdir -p "$out"
local name host up kind raw uhost uport n=0 skipped=0
while IFS=$'\x1f' read -r name host up kind raw placement peer port lhost; do
[ -z "$name" ] && continue
if [ -n "$raw" ]; then
skipped=$((skipped + 1))
continue
fi
# Placement picks the rendering. A container on the estate's own network
# is reached by NAME through docker's resolver; anything on the overlay
# is reached by ADDRESS and needs no DNS. That is one decision, not the
# three properties (upstream{}, no resolver, no set $var) it produces.
local tmpl
case "$placement" in
local|instance)
tmpl=./render/nginx-upstream.tmpl
uhost="$(overlay_get estate "peers.${peer}.address")"
uport="$port" # resolved via service_upstream's same rule
if [ -z "$uhost" ]; then
echo " ! $name: placement '$placement' names peer '$peer', which has no address" >&2
continue
fi
;;
hosted)
echo " ! $name: placement 'hosted' is declared but not rendered yet" >&2
continue
;;
*)
if [ "$kind" = "static" ]; then
tmpl=./render/nginx-static.tmpl
uhost=""; uport=""
else
tmpl=./render/nginx-proxy.tmpl
uhost="${up%%:*}"; uport="${up##*:}"
fi
;;
esac
sed -e "s|\${NAME}|${name}|g" \
-e "s|\${ESTATE}|${ESTATE}|g" \
-e "s|\${FQDN}|${host}.${DOMAIN}|g" \
-e "s|\${DOMAIN}|${DOMAIN}|g" \
-e "s|\${PLACEMENT}|${placement}|g" \
-e "s|\${PEER}|${peer}|g" \
-e "s|\${UPSTREAM_HOST}|${uhost}|g" \
-e "s|\${UPSTREAM_PORT}|${uport}|g" \
"$tmpl" > "$out/${name}.conf"
n=$((n + 1))
done < <(estate_services "$target")
echo "wrote $n vhost(s) to $out/ ($skipped hand-written, left alone)"
cat <<EONOTE
TO INSTALL THESE, THREE THINGS MUST HAPPEN IN THIS ORDER — and the order is the
whole reason this is not a one-liner:
1. These land in conf.d/generated/, NOT conf.d/. ppl/ctrl/deploy.sh rsyncs the
gateway with --delete; generated and hand-written config sharing one
directory means one of them gets erased.
2. nginx.conf needs a THIRD include line. Its conf.d/*.conf glob does not
recurse — which is exactly why conf.d/soleprint/*.conf already needs its
own line at nginx.conf:28-30.
3. That new include changes LOAD ORDER, and load order decides which :443
block catches unmatched names. So default.conf's commented-out
':443 default_server' must be restored FIRST. 'make check' fails on this
today, deliberately — it is a gate, not a warning.
EONOTE
}
render_local() {
local out="$OUT_ROOT/local"; mkdir -p "$out"
local ld; ld=$(estate_get "local_domain")
: "${ld:=local.ar}"
local name host up kind raw port drift=0
{
cat <<EOH
# GENERATED by berth from estate/${ESTATE}.json. Do not edit.
# Regenerate: make services render local
#
# Install: sudo ln -sf \$PWD/Caddyfile /etc/caddy/Caddyfile && sudo systemctl reload caddy
# All *.${ld} resolve to 127.0.0.1 via dnsmasq.
#
# Every site address carries an explicit :80. Without it Caddy 2 defaults to
# :443 with auto-HTTPS, which on *.${ld} means cert provisioning attempts that
# fail and break the listener. Plain HTTP only on this host.
#
# Caddy matches the MOST SPECIFIC site address, not the first — the opposite of
# nginx, which matches exactly and otherwise falls to default_server. A name set
# that is unambiguous here can be ambiguous on the box.
EOH
while IFS=$'\x1f' read -r name host up kind raw placement peer port lhost; do
[ -z "$name" ] && continue
port=$(python3 -c '
import json,sys
d=json.load(open(sys.argv[1]))
for s in d.get("services",[]):
if s.get("name")==sys.argv[2]:
print(s.get("local_port") or ""); break
' "$ESTATE_FILE" "$name")
[ -z "$port" ] && continue
echo
echo "${lhost}.${ld}:80, *.${lhost}.${ld}:80 {"
echo " reverse_proxy localhost:${port}"
echo "}"
done < <(estate_services local)
} > "$out/Caddyfile"
echo "wrote $out/Caddyfile"
echo
echo "rig is not consulted and does not know this exists — its handover"
echo "scrub refuses the string '${ld}'. Where a port belongs to a rig,"
echo "'make ports verify' RECOMPUTES rig's formula to check it rather than"
echo "importing rig's code. Convention, verified; not a dependency."
}
case "${1:-list}" in
list) list ;;
render)
shift
# `case "${1:-X}"` defaults the match but leaves $1 empty.
t="${1:-$TARGET}"
case "$t" in
local) render_local ;;
aws|gcp) render_cloud "$t" ;;
*) echo "usage: $0 render [aws|gcp|local]" >&2; exit 1 ;;
esac
;;
deploy)
echo "berth does not ship config; ppl/ctrl/deploy.sh does." >&2
echo " berth's half is the DESCRIPTION and the render. Shipping is" >&2
echo " rsync + compose against a live box, and it belongs where the" >&2
echo " credentials are: berth is the tool, ppl is the estate that" >&2
echo " holds the secrets." >&2
exit 1
;;
*) echo "usage: $0 [list|render [aws|gcp|local]|deploy]" >&2; exit 1 ;;
esac

View File

@@ -1,11 +0,0 @@
# Pinned toolchain. Committed. The weakest config layer.
# The infra executor. One, not a pair — see README.md.
# This pin is a placeholder; set it from `tofu version` once installed.
TOFU_VERSION=1.9.0
TOFU_BIN=tofu
# certbot runs as a throwaway container so the DNS plugin's credentials never
# have to be installed on this machine.
CERTBOT_AWS_IMAGE=certbot/dns-route53:latest
CERTBOT_GCP_IMAGE=certbot/dns-google:latest

View File

@@ -1,478 +0,0 @@
#!/usr/bin/env bash
# Overlays — WireGuard as berth's network layer.
#
# Usage:
# ./vpn.sh # list
# ./vpn.sh show <overlay> # topology
# ./vpn.sh check # invariants
# ./vpn.sh render <peer> # peer's wg0.conf -> render/out/vpn/
# ./vpn.sh keygen <peer> # keypair -> .secrets/; prints only the public key
# ./vpn.sh up|down --yes # refuses; the host operates its own tunnel
#
# sudo wg show | ./vpn.sh capture [--write]
#
# `wg show` is the only safe form: `wg show <if> dump` puts the private key in
# field 1, and `wg showconf` prints it outright. capture refuses both.
#
# Rationale, topology and key handling: ../README.md
set -euo pipefail
cd "$(dirname "$0")"
source ./lib/config.sh
source ./lib/estate.sh
load_config
SECRETS_DIR="./.secrets/vpn"
OUT_DIR="./render/out/vpn"
WORST=0
note() { echo " $*"; }
warn() { echo " WARN $*"; [ "$WORST" -lt 1 ] && WORST=1; return 0; }
bad() { echo " FAIL $*"; WORST=2; return 0; }
# Which addresses belong to THIS machine, so checks can distinguish what they
# can actually see from what needs capturing elsewhere.
my_overlay_addrs() { ip -4 -o addr show 2>/dev/null | awk '{split($4,a,"/"); print a[1]}'; }
is_me() { my_overlay_addrs | grep -qxF "$1"; }
list() {
local n sub port peers
for n in $(overlay_names); do
sub=$(overlay_get "$n" subnet)
port=$(overlay_get "$n" listen_port)
peers=$(overlay_peers "$n" | grep -c . || true)
printf '%-10s %-16s port %-7s %s peer(s)\n' "$n" "$sub" "$port" "$peers"
note "$(overlay_get "$n" purpose)"
done
local st; st=$(estate_get "vpn._status")
[ -n "$st" ] && { echo; echo " !! $st"; }
}
show() {
local ov="${1:-}"
[ -z "$ov" ] && { echo "usage: $0 show <overlay>" >&2; exit 1; }
overlay_names | grep -qxF "$ov" || {
echo "no such overlay: $ov" >&2
echo "available: $(overlay_names | tr '\n' ' ')" >&2; exit 1; }
echo "overlay: $ov subnet $(overlay_get "$ov" subnet) udp/$(overlay_get "$ov" listen_port)"
echo
printf '%-8s %-12s %-9s %-22s %s\n' PEER ADDRESS ROLE ENDPOINT PUBKEY
local name addr role ep pk aips ka
while IFS=$'\x1f' read -r name addr role ep pk aips ka; do
[ -z "$name" ] && continue
printf '%-8s %-12s %-9s %-22s %s%s\n' \
"$name" "$addr" "$role" "${ep:-}" "${pk:-}" \
"$(is_me "$addr" && echo ' <- this machine')"
done < <(overlay_peers "$ov")
}
check() {
local ov name addr role ep pk aips ka
for ov in $(overlay_names); do
local sub port
sub=$(overlay_get "$ov" subnet); port=$(overlay_get "$ov" listen_port)
echo "overlay '$ov' — $sub udp/$port"
# 1. addresses: unique, and inside the subnet. Two peers sharing an
# address is a silent misroute, never an error message.
local addrs; addrs=$(overlay_peers "$ov" | cut -d$'\x1f' -f2 | grep -v '^$' || true)
local dupes; dupes=$(echo "$addrs" | sort | uniq -d)
[ -n "$dupes" ] && bad "duplicate peer addresses: $(echo "$dupes" | tr '\n' ' ')"
while IFS= read -r a; do
[ -z "$a" ] && continue
addr_in_subnet "$a" "$sub" || bad "$a is outside $sub"
done <<< "$addrs"
while IFS=$'\x1f' read -r name addr role ep pk aips ka; do
[ -z "$name" ] && continue
# AllowedIPs is cryptokey routing — route table and ACL at once.
case "$aips" in
*0.0.0.0/0*) warn "$name: AllowedIPs includes 0.0.0.0/0 — full-tunnel. Deliberate?" ;;
esac
# A peer with no endpoint cannot be dialed; it must initiate.
if [ -z "$ep" ] && [ "$role" != "roaming" ]; then
warn "$name: role '$role' but no endpoint — nothing can dial it."
fi
# Keepalive is NOT on the roaming peer's own entry — it is set on
# the entry for the peer it dials. Checked per-overlay below.
[ -z "$pk" ] && note "$name: public_key not captured yet"
done < <(overlay_peers "$ov")
# If anything roams, some peer entry must carry a keepalive.
if overlay_peers "$ov" | cut -d$'\x1f' -f3 | grep -qx roaming; then
if ! overlay_peers "$ov" | cut -d$'\x1f' -f7 | grep -qE '^[0-9]+$'; then
warn "a peer roams but no peer entry carries PersistentKeepalive"
note " the roaming side sets it on the entry for the peer it dials;"
note " without it the NAT mapping expires and the tunnel works only"
note " while traffic flows outward — 'works sometimes'"
else
note "keepalive present on the dialed peer."
fi
fi
# 4. the listen port must be open wherever a peer is dialable.
local fwports; fwports=$(estate_get "firewall" | python3 -c '
import json,sys
try: print(" ".join(str(r.get("port")) for r in json.load(sys.stdin)))
except Exception: pass')
case " $fwports " in
*" $port "*) note "udp/$port present in the firewall description." ;;
*) bad "udp/$port is in no firewall rule — no peer could be dialed." ;;
esac
echo
done
# Public keys are also 44-char base64, so shape alone proves nothing. The
# assertions are: no field named private, no key outside a public_key field.
echo "secrets — the description must never carry a private key"
local leaked
leaked=$(python3 - estate/../../estate/*.json <<'PY' 2>/dev/null || true
import json, re, sys, glob
KEY = re.compile(r'^[A-Za-z0-9+/]{43}=$')
bad = []
for f in glob.glob("../estate/*.json"):
def walk(node, path):
if isinstance(node, dict):
for k, v in node.items():
if re.search(r'priv', k, re.I):
bad.append(f"{f}: field '{'.'.join(path+[k])}' is named private")
walk(v, path + [k])
elif isinstance(node, list):
for i, v in enumerate(node): walk(v, path + [str(i)])
elif isinstance(node, str) and KEY.match(node):
if not path or 'public' not in path[-1]:
bad.append(f"{f}: base64 key at '{'.'.join(path)}' is not a public_key field")
walk(json.load(open(f)), [])
print("\n".join(bad))
PY
)
if [ -n "$leaked" ]; then
echo "$leaked" | while IFS= read -r l; do [ -n "$l" ] && bad "$l"; done
else
note "clean — no private-named field, no stray key material."
fi
echo
# A service reached over the overlay must bind an address the tunnel can
# reach. Loopback cannot be reached through a tunnel.
echo "bindings — services reached over the overlay must bind a reachable address"
local checked=0
while IFS=$'\x1f' read -r name host up kind raw placement peer port lhost; do
# Resolve placement first: a placed service's upstream is derived, not
# literal, so reading `up` alone skips it and this invariant goes quiet.
up="$(service_upstream "$up" "$placement" "$peer" "$port")" || true
[ -z "$up" ] && continue
local uhost="${up%%:*}" uport="${up##*:}"
addr_in_subnet "$uhost" "$(overlay_get estate subnet)" 2>/dev/null || continue
checked=$((checked + 1))
if is_me "$uhost"; then
local binds; binds=$(ss -ltn 2>/dev/null | awk -v p=":$uport\$" '$4 ~ p {print $4}')
if [ -z "$binds" ]; then
bad "$name: nothing listens on :$uport here, but $uhost:$uport is its upstream"
elif echo "$binds" | grep -q '^127\.0\.0\.1:'; then
bad "$name: :$uport binds 127.0.0.1 — unreachable over the overlay"
note " the tunnel cannot reach loopback; bind 0.0.0.0 or $uhost"
else
note "$name: :$uport binds $(echo "$binds" | tr '\n' ' ')— reachable"
echo "$binds" | grep -q '^0\.0\.0\.0:' && \
note " (0.0.0.0 also exposes it to the LAN; $uhost alone would be tighter)"
fi
else
note "$name: upstream $uhost is another peer — needs capture there"
fi
done < <(estate_services "$TARGET")
[ "$checked" = 0 ] && note "no service currently has an overlay address as its upstream."
echo
case "$WORST" in
0) echo "OK" ;;
1) echo "OK, with warnings" ;;
2) echo "PROBLEMS FOUND — see FAIL lines above" ;;
esac
return 0
}
# A .gitignore pattern containing a slash anchors to its own directory, so the
# only way to know a path is ignored is to ask git.
assert_ignored() {
local path="$1"
if ! git check-ignore -q "$path" 2>/dev/null; then
echo "REFUSING: '$path' is not gitignored." >&2
echo " Writing key material there would stage it on the next 'git add'." >&2
echo " Verify with: git check-ignore -v $path" >&2
exit 1
fi
}
keygen() {
local peer="${1:-}"
[ -z "$peer" ] && { echo "usage: $0 keygen <peer>" >&2; exit 1; }
command -v wg >/dev/null || { echo "wg not installed." >&2; exit 1; }
mkdir -p "$SECRETS_DIR"
assert_ignored "$SECRETS_DIR"
local kf="$SECRETS_DIR/${peer}.key"
[ -e "$kf" ] && { echo "REFUSING: $kf exists. Delete it deliberately to rotate." >&2; exit 1; }
( umask 077; wg genkey > "$kf" )
echo "private key -> $kf (0600, gitignored, never leaves this machine)"
echo
echo "public key for the estate description:"
echo " $(wg pubkey < "$kf")"
echo
echo "Paste that into estate/*.json under vpn.overlays.<ov>.peers.${peer}.public_key."
echo "The private key stays here and is injected only at render time."
}
render() {
local peer="${1:-}"
[ -z "$peer" ] && { echo "usage: $0 render <peer>" >&2; exit 1; }
mkdir -p "$OUT_DIR"
assert_ignored "$OUT_DIR"
local ov=estate
local found=""
local name addr role ep pk aips ka
while IFS=$'\x1f' read -r name addr role ep pk aips ka; do
[ "$name" = "$peer" ] && found=1 && break
done < <(overlay_peers "$ov")
[ -z "$found" ] && { echo "no such peer '$peer' in overlay '$ov'" >&2; exit 1; }
local missing=""
while IFS=$'\x1f' read -r name addr role ep pk aips ka; do
[ -z "$pk" ] && missing="$missing $name"
done < <(overlay_peers "$ov")
if [ -n "$missing" ]; then
echo "REFUSING to render: public keys not captured for:$missing" >&2
echo " A config without every peer's public key is a config that silently" >&2
echo " drops those peers. Capture first: sudo wg show | $0 capture --write" >&2
exit 1
fi
echo "would write $OUT_DIR/${peer}.conf (all keys present)"
}
# Reads `wg show` on stdin. Peers match by allowed-ips address, not public key,
# because the keys are what is missing. A roaming peer's endpoint is a home
# address and has no stable value — dropped in the parser, not just unused.
capture() {
local write="" as_peer=""
while [ $# -gt 0 ]; do
case "$1" in
--write) write=1 ;;
--as) shift; as_peer="${1:-}"
[ -z "$as_peer" ] && { echo "--as needs a peer name" >&2; exit 1; } ;;
*) echo "capture: unknown argument '$1'" >&2; exit 1 ;;
esac
shift
done
local input; input=$(cat)
if [ -z "$input" ]; then
echo "nothing on stdin." >&2
echo " run: sudo wg show | $0 capture" >&2
exit 1
fi
# Refuse the unsafe forms outright rather than parsing around them.
if printf '%s' "$input" | grep -qiE '^\s*PrivateKey\s*=|^\[Interface\]'; then
echo "REFUSING: this looks like 'wg showconf' output — it contains a PRIVATE KEY." >&2
echo " Use 'sudo wg show' (plain). It prints 'private key: (hidden)'." >&2
exit 1
fi
if ! printf '%s' "$input" | grep -q 'interface:'; then
echo "REFUSING: this does not look like 'wg show' output." >&2
echo " If it was 'wg show <if> dump': that form's first field IS the" >&2
echo " private key. Use 'sudo wg show' with no subcommand." >&2
exit 1
fi
WRITE="$write" AS_PEER="$as_peer" INPUT="$input" python3 - "$ESTATE_FILE" <<'PYCAP'
import collections, ipaddress, json, os, re, sys
text = os.environ["INPUT"]
write = os.environ.get("WRITE") == "1"
path = sys.argv[1]
iface, peers, cur = {}, [], None
for line in text.splitlines():
st = line.strip()
if st.startswith("interface:"):
cur = iface; cur["name"] = st.split(":", 1)[1].strip(); continue
if st.startswith("peer:"):
cur = {"public_key": st.split(":", 1)[1].strip()}; peers.append(cur); continue
if cur is None or ":" not in st:
continue
k, v = st.split(":", 1)
k, v = k.strip().lower(), v.strip()
if k == "private key":
continue # never recorded, whatever it says
if k == "public key": cur["public_key"] = v
elif k == "listening port": cur["listen_port"] = v
elif k == "allowed ips": cur["allowed_ips"] = v
elif k == "endpoint": cur["endpoint"] = v
elif k == "persistent keepalive":
m = re.search(r"(\d+)", v)
if m: cur["keepalive"] = int(m.group(1))
d = json.load(open(path), object_pairs_hook=collections.OrderedDict)
ov = d["vpn"]["overlays"]["estate"]
# address -> peer name, from what the estate already declares
by_addr = {p["address"]: n for n, p in ov["peers"].items() if p.get("address")}
by_key = {p["public_key"]: n for n, p in ov["peers"].items() if p.get("public_key")}
subnet = ipaddress.ip_network(ov["subnet"]) if ov.get("subnet") else None
hubs = [n for n, p in ov["peers"].items() if p.get("role") == "hub"]
# Whose interface block is this? `--as` names it explicitly, and that is the only
# thing that works for output captured over ssh: the addresses on THIS machine
# say nothing about the machine the output came from.
as_peer = os.environ.get("AS_PEER") or ""
if as_peer:
if as_peer not in ov["peers"]:
print("no peer named %r in this overlay. known: %s"
% (as_peer, ", ".join(ov["peers"])))
raise SystemExit(1)
me = as_peer
else:
me = None
local = os.popen(
"ip -4 -o addr show 2>/dev/null | awk '{split($4,a,\"/\"); print a[1]}'"
).read().split()
for n, p in ov["peers"].items():
if p.get("address") and p["address"] in local:
me = n
changes = []
conflicts = []
staged = {}
def setf(peer, field, val, why=""):
p = ov["peers"][peer]
if val is None or p.get(field) == val:
return
# Two values for one field means the input is from another machine.
prev = staged.get((peer, field))
if prev is not None and prev != val:
conflicts.append((peer, field, prev, val))
return
staged[(peer, field)] = val
changes.append((peer, field, p.get(field), val, why))
if write:
p[field] = val
if me and iface.get("public_key"):
setf(me, "public_key", iface["public_key"], "(this machine's interface)")
def match(pr):
# 1. The public key IS the identity. Use it whenever the estate knows it.
n = by_key.get(pr["public_key"])
if n:
return n
nets = [a.strip() for a in pr.get("allowed_ips", "").split(",") if a.strip()]
# 2. An allowed-ip that is a declared peer address — the ordinary spoke case.
for a in nets:
if a.split("/")[0] in by_addr:
return by_addr[a.split("/")[0]]
# 3. A peer routing the WHOLE overlay is the hub seen from a spoke. Its
# allowed_ips is the subnet itself, so no single address ever matches it.
if subnet and len(hubs) == 1:
for a in nets:
try:
if ipaddress.ip_network(a, strict=False).supernet_of(subnet):
return hubs[0]
except ValueError:
continue
return None
for pr in peers:
name = match(pr)
if not name:
changes.append(("?", "UNMATCHED", None,
"allowed_ips=%s key=%s" % (pr.get("allowed_ips"), pr["public_key"][:12] + "..."),
"no estate peer has this key, this address, or this route"))
continue
setf(name, "public_key", pr.get("public_key"))
setf(name, "allowed_ips", pr.get("allowed_ips"))
setf(name, "keepalive", pr.get("keepalive"))
# endpoint: recorded ONLY for a non-roaming peer. For a roaming one the
# value is a home ISP address and is deliberately dropped here.
if pr.get("endpoint"):
if ov["peers"][name].get("role") == "roaming":
changes.append((name, "endpoint", None, "(dropped: roaming peer)",
"a home address is the one sensitive field; roaming peers have no stable endpoint"))
else:
setf(name, "endpoint", pr["endpoint"])
if me and iface.get("listen_port"):
try:
lp = int(iface["listen_port"])
except ValueError:
lp = None
if lp is not None:
# A listen port belongs to the PEER, not to the overlay. A roaming peer's
# is an ephemeral source port chosen by the kernel; writing it to the
# overlay would rename the port the firewall rule is checked against.
setf(me, "listen_port", lp)
if ov["peers"][me].get("role") == "hub" and ov.get("listen_port") != lp:
changes.append(("(overlay)", "listen_port", ov.get("listen_port"), lp,
"the hub's port is the overlay's port"))
if write:
ov["listen_port"] = lp
if conflicts:
print("REFUSING: the same field was reported twice with different values.\n")
for peer, field, a, b in conflicts:
print(" %s.%s: %s vs %s" % (peer, field, a, b))
print("\nThis usually means `wg show` output from one machine was piped into")
print("capture on another. Run capture on the machine the output came from.")
raise SystemExit(1)
if not changes:
print("nothing to record — the estate already matches what wg reports.")
else:
print("%-9s %-12s %-22s %s" % ("PEER", "FIELD", "WAS", "WOULD BE"))
for peer, field, was, val, why in changes:
print("%-9s %-12s %-22s %s" % (peer, field, was if was is not None else "—", val))
if why: print(" %s" % why)
if write:
still = [n for n, p in ov["peers"].items() if not p.get("public_key")]
if not still:
d["vpn"]["_status"] = ("CAPTURED %s — public keys, allowed-ips and keepalive read from "
"`wg show`. Roaming endpoints deliberately not recorded."
% __import__("datetime").date.today())
json.dump(d, open(path, "w"), indent=2, ensure_ascii=False)
open(path, "a").write("\n")
print("\nwritten to %s" % path)
else:
print("\nnothing written. Add --write to record it.")
PYCAP
}
refuse() {
local verb="$1"; shift
local yes=""
for a in "$@"; do [ "$a" = "--yes" ] && yes=1; done
[ -z "$yes" ] && {
echo "refusing to $verb without --yes." >&2
echo " $verb changes live networking — it can cut the path this session" >&2
echo " is reaching the estate through. Read 'make vpn check' first." >&2
exit 1; }
echo "refusing to $verb: not implemented. Bringing a tunnel up or down is" >&2
echo " the host's business, and the live one is systemd-managed" >&2
echo " (wg-quick@wg0). berth describes and renders; it does not operate." >&2
exit 1
}
case "${1:-list}" in
list) list ;;
show) shift; show "${1:-}" ;;
check) check ;;
render) shift; render "${1:-}" ;;
keygen) shift; keygen "${1:-}" ;;
capture) shift; capture "$@" ;;
up|down) v="$1"; shift; refuse "$v" "$@" ;;
*) echo "usage: $0 [list|show <ov>|check|render <peer>|keygen <peer>|capture [--as <peer>] [--write]|up --yes|down --yes]" >&2; exit 1 ;;
esac

View File

@@ -1,326 +0,0 @@
{
"_meta": {
"status": "UNVERIFIED — derived from the repos, not from the estate",
"why": "ppl/infra/ was written and never applied: no ~/.pulumi, no infra/venv, no stack state, files dated 'mar 6'. The estate was built in the console and the IaC is aspirational. B1's inventory is what replaces these values with observed ones; until it runs, every field here is a CLAIM.",
"never_record": "credential values. Resource ids and settings only. nova's gateway secret is deliberately absent from this file even though it is committed in plaintext in ppl/gateway/nginx/conf.d/nova.conf — see services[].raw.",
"sources": [
"ppl/infra/__main__.py",
"ppl/ctrl/dns.sh",
"ppl/ctrl/certs.sh",
"ppl/gateway/docker-compose.yml",
"ppl/gateway/nginx/conf.d/",
"ppl/local/Caddyfile"
],
"placement": {
"box": "a container on the estate's own docker network — upstream is the container name",
"local": "a rig cluster on a peer, reached over the overlay — upstream is that peer's address",
"instance": "a dedicated cloud instance on the overlay — same rendering as `local`",
"hosted": "a managed endpoint. Declared so moving to one is a one-line change; unused.",
"_why": "A service says WHERE it runs. How it is reached follows from that, and the three properties of a static-upstream vhost — upstream{}, no resolver, no set $var — are one decision rather than three."
}
},
"domain": "mcrn.ar",
"local_domain": "local.ar",
"host": "mcrn",
"host_admin": "mcrn-admin",
"instance": {
"type": "t3.small",
"disk_gb": 30,
"disk_type": "gp3",
"image": "debian-12",
"user": "mariano"
},
"firewall": [
{
"port": 22,
"proto": "tcp",
"desc": "SSH"
},
{
"port": 80,
"proto": "tcp",
"desc": "HTTP"
},
{
"port": 443,
"proto": "tcp",
"desc": "HTTPS"
},
{
"port": 3022,
"proto": "tcp",
"desc": "Gitea SSH",
"note": "compose maps 3022:22 but GITEA__server__SSH_PORT=22, so gitea advertises :22 in clone URLs while listening on :3022. B1 confirms which is real."
},
{
"port": 51820,
"proto": "udp",
"desc": "WireGuard",
"note": "ABSENT from ppl/infra/__main__.py's four rules — but the tunnel is live (ping 10.8.0.1 succeeds), so the real security group must already allow it. The code therefore does not describe the estate. Confirm in V1."
}
],
"network": {
"docker_network": "gateway",
"docker_network_note": "A fixed, externally-joinable bridge name. Every unrelated app stack on the box joins it so nginx can resolve them by container name. This is why the gateway compose declares 8 services while nginx routes 20+ hostnames.",
"wireguard_moved": "superseded by the top-level `vpn` block"
},
"vpn": {
"_status": "CAPTURED 2026-09-14 — public keys, allowed-ips and keepalive read from `wg show`. Roaming endpoints deliberately not recorded.",
"_never_record": "private keys. `wg show` prints 'private key: (hidden)' and is the safe capture command. `wg showconf` dumps PrivateKey= in clear — never use it.",
"overlays": {
"estate": {
"purpose": "Connects the estate's machines across clouds without a shared VPC, and carries everything that does not need to be publicly reachable.",
"subnet": "10.8.0.0/24",
"listen_port": 51820,
"peers": {
"box": {
"address": "10.8.0.1",
"role": "hub",
"note": "mcrn.ar. Has a public IP, so it is the peer others dial. Carries the registry (:5000) and woodpecker's gRPC (:9000), both bound to this address and therefore overlay-only.",
"endpoint": "3.23.204.197:51820",
"public_key": "zVYCmi3xucuX7k/aDhrOUPyN4GRk96ffSDD6dUFQjh4=",
"allowed_ips": "10.8.0.0/24",
"keepalive": 25,
"listen_port": 51820
},
"nrft": {
"address": "10.8.0.2",
"role": "roaming",
"note": "The dev box. Behind NAT, so it must initiate and needs PersistentKeepalive. Verified: wg0 UP at 10.8.0.2/24, ping 10.8.0.1 0% loss at 153ms.",
"endpoint": null,
"public_key": "zlIBGs4y5rt6uVdmFBasHpafht6ErxG+R3ySCg5rh3s=",
"allowed_ips": "10.8.0.2/32, 192.168.1.0/24",
"keepalive": null,
"listen_port": 36145
},
"work": {
"address": "10.8.0.3",
"role": "roaming",
"note": "A work computer, granted access when it was needed. Identified by the user at capture time, 2026-09-14 — it was NOT in the description before, and the wire is where it was found. No handshake and no transfer have ever been recorded for it, so it is a standing grant rather than a live peer: it can connect, and never has. Whether to keep or revoke it is the host's call.",
"endpoint": null,
"public_key": "ruSZwKt/p60GVsTLSAhcKBIXKkSZsf0gWSmSH1+UgE0=",
"allowed_ips": "10.8.0.3/32",
"keepalive": null
}
}
}
}
},
"databases": [
"gitea",
"woodpecker",
"umami"
],
"certs": {
"issued": [
"mcrn.ar",
"*.mcrn.ar",
"*.spr.mcrn.ar"
],
"issued_source": "ppl/ctrl/certs.sh:92 — the -d flags passed to certbot",
"note": "What the cert ACTUALLY covers. estate_sans() derives what the services NEED. check.sh compares the two; the difference is the finding, not a restatement."
},
"services": [
{
"name": "gitea",
"host": "git",
"upstream": "gitea:3000",
"targets": [
"aws"
]
},
{
"name": "woodpecker",
"host": "ci",
"upstream": "woodpecker-server:8000",
"targets": [
"aws"
]
},
{
"name": "registry",
"host": "registry",
"upstream": "registry:5000",
"targets": [
"aws"
]
},
{
"name": "umami",
"host": "analytics",
"upstream": "umami:3000",
"targets": [
"aws"
]
},
{
"name": "docserve",
"host": "docs",
"upstream": "docserve:8020",
"targets": [
"aws"
]
},
{
"name": "ghost",
"host": "notes",
"upstream": "ghost:2368",
"targets": [
"aws"
]
},
{
"name": "deskmeter",
"host": "deskmeter",
"upstream": "dmweb:10000",
"targets": [
"aws"
],
"local_port": 10000
},
{
"name": "sysmonstm",
"host": "sysmonstm",
"upstream": "sysmonstm-edge:8080",
"targets": [
"aws"
],
"local_port": 8020
},
{
"name": "malvalava",
"host": "malvalava",
"upstream": "mlvclean-frontend:80",
"targets": [
"aws"
],
"local_port": 30090
},
{
"name": "soleprint",
"host": "soleprint",
"upstream": "soleprint:8000",
"targets": [
"aws"
],
"local_port": 12000
},
{
"name": "dlt",
"host": "dlt.spr",
"upstream": "dlt_spr:8000",
"targets": [
"aws"
]
},
{
"name": "sample",
"host": "sample.spr",
"upstream": "sample_spr:8000",
"targets": [
"aws"
]
},
{
"name": "mariano",
"host": "mariano",
"kind": "static",
"targets": [
"aws"
]
},
{
"name": "rigui",
"host": "rig",
"kind": "static",
"targets": [
"aws"
],
"local_port": 20310
},
{
"name": "unt",
"host": "unt",
"targets": [
"local"
],
"local_port": 8040
},
{
"name": "mpr",
"host": "mpr",
"targets": [
"local"
],
"local_port": 30080
},
{
"name": "nvi",
"host": "nvi",
"targets": [
"local"
],
"local_port": 8060
},
{
"name": "eth",
"host": "eth",
"targets": [
"local"
],
"local_port": 8050
},
{
"name": "amar",
"host": "amar",
"targets": [
"local"
],
"local_port": 8030
},
{
"name": "nova",
"host": "nova",
"upstream": "nova-ui:80",
"targets": [
"aws"
],
"raw": true,
"raw_why": "Gated on an X-Gateway-Secret header whose value is committed in plaintext. The value is NOT recorded here. Worse: stellarair.conf proxies to the SAME nova-ui:80 upstream WITHOUT the check, so the gate is bypassable by hostname. Stays hand-written until that is decided."
},
{
"name": "stellarair",
"host": "stellarair",
"upstream": "nova-ui:80",
"targets": [
"aws"
],
"raw": true,
"raw_why": "See nova. Same upstream, no header gate."
},
{
"name": "langfuse",
"host": "langfuse",
"local_host": "lng",
"placement": "local",
"peer": "nrft",
"port": 3000,
"targets": [
"aws",
"local"
],
"local_port": 3000,
"note": "One service, one socket, two names. It was two entries with one flagged `raw`; placement is what made the exception expressible, so it is generated now."
},
{
"name": "legacy",
"host": "*.soleprint",
"upstream": "soleprint:8000",
"targets": [
"aws"
],
"raw": true,
"raw_why": "A regex server_name with a named capture plus sub_filter injection — not expressible as a template. ALSO BROKEN: its /api/, /admin/, /static/ and / blocks proxy to 127.0.0.1, i.e. inside the nginx container where nothing listens, so every legacy room 502s. Only /wrapper/ uses the correct container-name form."
}
]
}

500
build.py
View File

@@ -23,12 +23,9 @@ Generated structure for managed rooms:
"""
import argparse
import importlib.util
import json
import logging
import shutil
import stat
import subprocess
import sys
from pathlib import Path
@@ -51,59 +48,6 @@ def ensure_dir(path: Path):
path.mkdir(parents=True, exist_ok=True)
def _rmtree_resilient(path: Path):
"""Remove path tree, tolerating root-owned files written by containers.
Docker containers that mount gen/ as a volume sometimes write files as
root (e.g. __pycache__). A plain shutil.rmtree then fails with EACCES.
We first try shutil.rmtree; if that hits a PermissionError we fall back
to deleting the offending files from inside an ephemeral alpine container.
"""
def _chmod_and_retry(func, target, exc_info):
try:
Path(target).chmod(stat.S_IWUSR | stat.S_IRUSR | stat.S_IXUSR)
func(target)
except Exception:
raise
try:
shutil.rmtree(path, onerror=_chmod_and_retry)
return
except PermissionError:
pass
log.info(" (falling back to docker-based cleanup)")
subprocess.run(
["docker", "run", "--rm", "-v", f"{path.parent}:/work",
"alpine:3", "sh", "-c", f"rm -rf /work/{path.name}"],
check=True,
)
# Never swept into a built room, wherever they appear in a source tree.
#
# This is a SECURITY boundary, not tidiness. gen/<room>/ is the docker build
# context, soleprint/Dockerfile is `COPY . .`, and there is no .dockerignore —
# so anything that reaches gen/ reaches an image layer, and registry.mcrn.ar is
# public-read. That is how station/tools/tester/.env, gitignored since the last
# incident, still ended up baked into soleprint_localtest-soleprint:latest with
# its API key intact. .gitignore does not bind shutil.
#
# Applied to bulk directory copies only. A caller naming a single file is making
# an explicit request (cfg/<room>/.env.example is the one that matters) and is
# left alone.
ALWAYS_IGNORE = {".git", "__pycache__", "node_modules", ".venv", "venv", ".env"}
ALWAYS_IGNORE_SUFFIXES = (".pyc", ".pyo")
def is_ignored(name: str) -> bool:
return name in ALWAYS_IGNORE or name.endswith(ALWAYS_IGNORE_SUFFIXES)
def _copytree_ignore(directory, files):
return {f for f in files if is_ignored(f)}
def copy_path(source: Path, target: Path, quiet: bool = False):
"""Copy file or directory, resolving symlinks."""
if target.is_symlink():
@@ -115,7 +59,7 @@ def copy_path(source: Path, target: Path, quiet: bool = False):
target.unlink()
if source.is_dir():
shutil.copytree(source, target, symlinks=False, ignore=_copytree_ignore)
shutil.copytree(source, target, symlinks=False)
if not quiet:
log.info(f" {target.name}/")
else:
@@ -135,8 +79,6 @@ def merge_into(source: Path, target: Path):
for item in source.rglob("*"):
if item.is_file():
rel = item.relative_to(source)
if any(is_ignored(part) for part in rel.parts):
continue
dest = target / rel
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(item, dest)
@@ -220,11 +162,9 @@ def build_managed(output_dir: Path, cfg_name: str, config: dict):
log.info(f"Building managed ({managed_name})...")
# Copy repos (relative paths resolve from SPR_ROOT)
# Copy repos
for repo_name, repo_path in repos.items():
source = Path(repo_path)
if not source.is_absolute():
source = SPR_ROOT / source
target = managed_dir / repo_name
if copy_repo(source, target):
log.info(f" {repo_name}/")
@@ -331,374 +271,16 @@ def copy_cfg(output_dir: Path, room: str):
# Now in cfg/<room>/soleprint/
room_soleprint = room_cfg / "soleprint"
if room_soleprint.exists():
systems = {"artery", "atlas", "station"}
for item in room_soleprint.iterdir():
if item.name in systems:
# Merge system dirs into already-copied framework code
log.info(f" Merging {room} {item.name}...")
merge_into(item, output_dir / item.name)
elif item.is_file():
copy_path(item, output_dir / item.name)
elif item.is_dir():
# Copy non-system dirs as-is (nginx/, etc.)
if item.is_file():
copy_path(item, output_dir / item.name)
def load_cabinets(room: str) -> list[dict]:
"""The dependency containers a room asked for, in the order it listed them.
Read from cfg/<room>/data/cabinets.json — the same shape and the same place
as its sibling data/*.json files, so nothing new has to know about it.
Entries are {"name": "postgres"} and may carry an "env" override.
"""
path = SPR_ROOT / "cfg" / room / "data" / "cabinets.json"
if not path.exists():
return []
try:
entries = json.loads(path.read_text())
except ValueError as e:
log.warning(f" cabinets.json is not valid JSON, ignoring: {e}")
return []
out = []
for entry in entries if isinstance(entries, list) else []:
if isinstance(entry, str):
entry = {"name": entry}
if isinstance(entry, dict) and entry.get("name"):
out.append(entry)
return out
def resolve_cabinets(requested: list[dict]) -> list[dict]:
"""Expand each request into its definition, pulling in what it depends on.
Airflow without postgres is a container that exits on boot, so a cabinet's
depends_on is added for you rather than left as something to remember.
"""
cabinets_dir = SPR_ROOT / "soleprint" / "station" / "cabinets"
resolved: dict[str, dict] = {}
def add(name: str, overrides: dict) -> None:
if name in resolved:
# Already pulled in as somebody's dependency. The room asking for it
# by name is the more specific statement, so its env still applies —
# otherwise declaring airflow before postgres would silently drop
# postgres's settings.
if overrides.get("env"):
resolved[name]["env"] = {
**resolved[name].get("env", {}),
**overrides["env"],
}
return
definition_path = cabinets_dir / name / "cabinet.json"
if not definition_path.exists():
available = sorted(
p.name for p in cabinets_dir.iterdir() if p.is_dir()
) if cabinets_dir.exists() else []
log.warning(f" no such cabinet: {name} (available: {', '.join(available) or 'none'})")
return
try:
definition = json.loads(definition_path.read_text())
except ValueError as e:
log.warning(f" cabinet {name} has invalid cabinet.json: {e}")
return
# Mark it claimed before recursing, so a dependency cycle terminates.
resolved[name] = definition
for dependency in definition.get("depends_on", []) or []:
add(dependency, {})
definition["env"] = {**definition.get("env", {}), **overrides.get("env", {})}
for entry in requested:
add(entry["name"], entry)
# Dependencies first, so compose reads in the order things start.
ordered: list[dict] = []
seen: set[str] = set()
def emit(name: str) -> None:
if name in seen or name not in resolved:
return
seen.add(name)
for dependency in resolved[name].get("depends_on", []) or []:
emit(dependency)
ordered.append(resolved[name])
for name in resolved:
emit(name)
return ordered
def compose_cabinets(output_dir: Path, room: str):
"""Merge the room's cabinets into its docker-compose.yml and .env.example.
This is the compile step for dependencies: a room declares postgres, and the
built instance comes out with postgres in its compose file rather than with
instructions for adding it.
"""
requested = load_cabinets(room)
if not requested:
return
try:
import yaml
except ImportError:
log.warning(
" cabinets need PyYAML to merge into docker-compose.yml "
"(pip install pyyaml) — skipping"
)
return
cabinets = resolve_cabinets(requested)
if not cabinets:
return
compose_path = output_dir / "docker-compose.yml"
if not compose_path.exists():
log.warning(
f" no docker-compose.yml in {output_dir.name}, "
f"so there is nothing to merge {len(cabinets)} cabinet(s) into"
)
return
original = compose_path.read_text()
# A YAML round-trip drops every comment, and the room's compose file leads
# with the one that says how to run it. Keep the header block; the rest is
# generated anyway.
header = []
for line in original.splitlines():
if line.startswith("#") or not line.strip():
header.append(line)
else:
break
while header and not header[-1].strip():
header.pop()
compose = yaml.safe_load(original) or {}
services = compose.setdefault("services", {})
volumes = compose.setdefault("volumes", {}) or {}
cabinets_dir = SPR_ROOT / "soleprint" / "station" / "cabinets"
added, skipped = [], []
for cabinet in cabinets:
name = cabinet["name"]
service_name = cabinet.get("service", name)
# The room's own compose file is the authority. A room that already
# declares `db` has arranged it deliberately, and silently replacing it
# would be the worst possible outcome of switching a cabinet on.
if service_name in services:
skipped.append(service_name)
continue
fragment_path = cabinets_dir / name / "service.yml"
if not fragment_path.exists():
log.warning(f" cabinet {name} has no service.yml")
continue
fragment = yaml.safe_load(fragment_path.read_text()) or {}
for key, value in fragment.items():
if key in services:
skipped.append(key)
continue
services[key] = value
added.append(key)
for volume in cabinet.get("volumes", []) or []:
volumes.setdefault(volume, None)
if volumes:
compose["volumes"] = volumes
rendered = yaml.safe_dump(compose, sort_keys=False, default_flow_style=False)
banner = f"# Cabinets merged in by build.py: {', '.join(c['name'] for c in cabinets)}.\n"
preamble = ("\n".join(header) + "\n" + banner + "\n") if header else banner + "\n"
compose_path.write_text(preamble + rendered)
if added:
log.info(f" cabinets: {', '.join(added)}")
if skipped:
log.info(f" cabinets already declared by the room, left alone: {', '.join(skipped)}")
_append_cabinet_env(output_dir, cabinets)
def _append_cabinet_env(output_dir: Path, cabinets: list[dict]):
"""Add each cabinet's settings to .env.example, without touching .env."""
example = output_dir / ".env.example"
existing = example.read_text() if example.exists() else ""
# Match whole settings, not substrings: `POSTGRES_DB=` appears inside
# `MY_POSTGRES_DB=`, and a substring test would decide the setting was
# already there and skip it.
declared = {
line.split("=", 1)[0].strip()
for line in existing.splitlines()
if "=" in line and not line.lstrip().startswith("#")
}
lines = []
for cabinet in cabinets:
env = cabinet.get("env", {})
if not env:
continue
block = [f"\n# ── {cabinet.get('title', cabinet['name'])} (cabinet) ──"]
for note in cabinet.get("notes", []) or []:
block.append(f"# {note}")
wrote = False
for key, value in env.items():
if key in declared:
continue
block.append(f"{key}={value}")
declared.add(key)
wrote = True
if wrote:
lines.extend(block)
if lines:
example.write_text(existing.rstrip("\n") + "\n" + "\n".join(lines) + "\n")
def load_plexuses(room: str) -> list[dict]:
"""The plexuses a room asked for. Same shape as its sibling data/*.json."""
path = SPR_ROOT / "cfg" / room / "data" / "plexuses.json"
if not path.exists():
return []
try:
raw = json.loads(path.read_text())
except ValueError as e:
log.warning(f" plexuses.json is not valid JSON, ignoring: {e}")
return []
entries = raw.get("items", raw) if isinstance(raw, dict) else raw
out = []
for entry in entries if isinstance(entries, list) else []:
if isinstance(entry, str):
entry = {"name": entry}
if isinstance(entry, dict) and entry.get("name"):
out.append(entry)
return out
def _theme_css(theme: str) -> str:
"""The token contract plus one theme, flattened for inlining.
Only the named theme ships alongside the others it can switch to, because
the export has to work with no server: there is no /theme.css to fetch.
"""
theme_dir = SPR_ROOT / "soleprint" / "common" / "theme"
parts = []
tokens = theme_dir / "tokens.css"
if tokens.exists():
parts.append(tokens.read_text())
# Every theme, so the switcher in the page has something to switch to.
for sheet in sorted((theme_dir / "themes").glob("*.css")):
parts.append(sheet.read_text())
return "\n".join(parts)
def _inline_svg(name: str, theme: str) -> str:
"""A rendered graph, stripped of its XML prolog so it can sit in HTML.
Inlined rather than <img>-linked so the page's CSS can recolour it when the
theme switches — graphviz writes class="node accent" into the SVG, and CSS
outranks the presentation attributes it bakes in.
"""
graphs = SPR_ROOT / "docs" / "graphs"
for candidate in (graphs / f"{name}.{theme}.svg", graphs / f"{name}.svg"):
if candidate.exists():
svg = candidate.read_text()
start = svg.find("<svg")
return svg[start:] if start >= 0 else svg
log.warning(f" no rendered graph '{name}' — run docs/graphs/render.sh")
return "<p>diagram not rendered</p>"
def build_plexuses(output_dir: Path, room: str):
"""Export each plexus the room declared to a single self-contained file.
A plexus is exported, not served. The output is one index.html carrying its
theme, its data and its diagram, so it survives a locked-down machine, a zip
attachment and a double-click — which is the whole point of the format.
"""
requested = load_plexuses(room)
if not requested:
return
source_root = SPR_ROOT / "soleprint" / "artery" / "plexuses"
built = []
for entry in requested:
name = entry["name"]
source = source_root / name
manifest_path = source / "plexus.json"
if not manifest_path.exists():
available = sorted(
p.name for p in source_root.iterdir() if p.is_dir()
) if source_root.exists() else []
log.warning(
f" no such plexus: {name} (available: {', '.join(available) or 'none'})"
)
continue
try:
manifest = json.loads(manifest_path.read_text())
except ValueError as e:
log.warning(f" plexus {name} has invalid plexus.json: {e}")
continue
# The room may override anything the plexus declares — theme first.
manifest.update({k: v for k, v in entry.items() if k != "name"})
template_path = source / "app" / "index.html"
if not template_path.exists():
log.warning(f" plexus {name} has no app/index.html")
continue
theme = manifest.get("theme", "soleprint")
data = {k: v for k, v in manifest.items() if not k.startswith("_")}
# A plexus may ship a showcase.py exposing collect(): anything it returns
# is merged into the page's data. The bundle uses it to run the real
# tools over the real fixtures at build time, so what the page shows
# cannot drift from what the tools do.
collector = source / "showcase.py"
if collector.exists():
try:
spec = importlib.util.spec_from_file_location(
f"plexus_{name}_showcase", collector
)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
data["showcase"] = module.collect()
except Exception as e:
log.warning(f" {name}: showcase.py failed ({type(e).__name__}: {e})")
page = template_path.read_text()
for token, value in (
("%%TITLE%%", manifest.get("title", name)),
("%%DESCRIPTION%%", manifest.get("description", "")),
("%%DEFAULT_THEME%%", theme),
("%%BUILT%%", f"{room} · built by soleprint build.py"),
("%%THEME_CSS%%", _theme_css(theme)),
("%%GRAPH%%", _inline_svg(manifest.get("graph", "system_overview"), theme)),
("%%BUNDLE%%", json.dumps(data, indent=2)),
):
page = page.replace(token, value)
target = output_dir / "plexuses" / name
ensure_dir(target)
(target / "index.html").write_text(page)
# Anything else in app/ rides along, for a plexus that outgrows one file.
for extra in (source / "app").iterdir():
if extra.name != "index.html":
copy_path(extra, target / extra.name, quiet=True)
built.append(f"{name} ({theme})")
if built:
log.info(f" plexuses: {', '.join(built)}")
# Merge room-specific system configs from soleprint subfolder
for system in ["artery", "atlas", "station"]:
room_system = room_soleprint / system
if room_system.exists():
log.info(f" Merging {room} {system}...")
merge_into(room_system, output_dir / system)
def build_soleprint(output_dir: Path, room: str):
@@ -713,7 +295,6 @@ def build_soleprint(output_dir: Path, room: str):
"index.html",
"requirements.txt",
"Dockerfile",
".dockerignore",
]:
if (soleprint / name).exists():
copy_path(soleprint / name, output_dir / name)
@@ -733,16 +314,6 @@ def build_soleprint(output_dir: Path, room: str):
# Room config (includes merging room-specific artery/atlas/station)
copy_cfg(output_dir, room)
# Dependency containers the room asked for, merged into its compose file.
# After copy_cfg, because the compose file being merged into is the room's.
log.info("Composing cabinets...")
compose_cabinets(output_dir, room)
# Plexuses are exported rather than served, so this is a compile step like
# the cabinet merge above — not something run.py does at request time.
log.info("Exporting plexuses...")
build_plexuses(output_dir, room)
# Generate models
log.info("Generating models...")
if not generate_models(output_dir, room):
@@ -760,7 +331,7 @@ def build(output_dir: Path, cfg_name: str | None = None, clean: bool = True):
# Clean output directory first
if clean and output_dir.exists():
log.info(f"Cleaning {output_dir}...")
_rmtree_resilient(output_dir)
shutil.rmtree(output_dir)
ensure_dir(output_dir)
@@ -777,15 +348,6 @@ def build(output_dir: Path, cfg_name: str | None = None, clean: bool = True):
# Standalone: everything in output_dir
build_soleprint(output_dir, room)
# Layer 7 (optional): render kind-cluster manifests
try:
from soleprint.ctrl.k8s import render_k8s
from soleprint.ctrl.k8s.render import k8s_enabled
if k8s_enabled(config):
render_k8s(room=room, config=config, gen_dir=output_dir)
except ImportError as e:
log.warning(f"k8s rendering unavailable: {e}")
log.info(f"\n✓ Built: {output_dir}")
@@ -799,33 +361,6 @@ def build_models_only():
sys.exit(1)
def build_plexuses_only(room: str):
"""Compile just the plexus UIs, without rebuilding the room around them.
The equivalent of `vite build` for this repo: the iteration loop when you
are working on the UI itself is edit, compile, reopen the file — and a full
room build to see a CSS change is a slow way to do that.
"""
output_dir = SPR_ROOT / "gen" / room
if not output_dir.exists():
log.error(f"Room '{room}' is not built — run: python build.py --cfg {room}")
sys.exit(1)
log.info(f"Compiling plexus UIs for {room}...")
build_plexuses(output_dir, room)
built = sorted((output_dir / "plexuses").glob("*/index.html"))
if not built:
log.warning(
f" nothing compiled — does cfg/{room}/data/plexuses.json list one?"
)
return
for page in built:
log.info(f" {page.relative_to(SPR_ROOT)} ({page.stat().st_size // 1024} KB)")
log.info("\n✓ Open directly — no server needed:")
log.info(f" xdg-open {built[0]}")
def main():
parser = argparse.ArgumentParser(description="Soleprint Build Tool")
@@ -833,26 +368,15 @@ def main():
parser.add_argument("--cfg", "-c", type=str, help="Room config name")
parser.add_argument("--all", action="store_true", help="Build all rooms")
parser.add_argument("--models", action="store_true", help="Only regenerate models")
parser.add_argument(
"--plexuses",
action="store_true",
help="Only compile the plexus UIs into an already-built room",
)
args = parser.parse_args()
if args.plexuses:
build_plexuses_only(args.cfg or "standalone")
elif args.models:
if args.models:
build_models_only()
elif args.all:
build(SPR_ROOT / "gen" / "standalone", None)
for room in (SPR_ROOT / "cfg").iterdir():
# cfg/ is itself a git repo and rooms may carry dot-dirs — skip them,
# or --all tries to build ".git" as a room.
if room.name.startswith(".") or room.name == "__pycache__":
continue
if room.is_dir() and room.name != "standalone":
if room.is_dir() and room.name not in ("__pycache__", "standalone"):
build(SPR_ROOT / "gen" / room.name, room.name)
else:
if args.output:

10
cfg/.gitignore vendored
View File

@@ -1,5 +1,6 @@
# Environment files with credentials (use .env.example as template)
**/.env
!sample/**/.env
# Database dumps (sensitive data)
**/dumps/*.sql
@@ -9,9 +10,6 @@ __pycache__/
*.pyc
*.pyo
standalone
sample
# standalone/ and sample/ are kept in the main soleprint repo as templates.
# Other rooms (amar/, dlt/, etc.) are listed in the repo-root .gitignore.
# These are kept in main soleprint repo as templates
standalone/
sample/

104
cfg/amar/.env Normal file
View File

@@ -0,0 +1,104 @@
# =============================================================================
# AMAR - Local Development Configuration
# =============================================================================
# =============================================================================
# DEPLOYMENT
# =============================================================================
DEPLOYMENT_NAME=amar
# =============================================================================
# NETWORK (shared with soleprint)
# =============================================================================
NETWORK_NAME=soleprint_network
# =============================================================================
# PATHS (local code locations)
# =============================================================================
BACKEND_PATH=/home/mariano/wdir/ama/amar_django_back
FRONTEND_PATH=/home/mariano/wdir/ama/amar_frontend
DOCKERFILE_BACKEND=/home/mariano/wdir/spr/cfg/amar/Dockerfile.backend
DOCKERFILE_FRONTEND=/home/mariano/wdir/spr/cfg/amar/Dockerfile.frontend
# =============================================================================
# DATABASE
# =============================================================================
DB_DUMP=dev.sql
# =============================================================================
# PORTS
# =============================================================================
BACKEND_PORT=8000
FRONTEND_PORT=3000
# =============================================================================
# BACKEND SERVER (Uvicorn)
# =============================================================================
BACKEND_WORKERS=1
BACKEND_RELOAD=--reload
# Database connection
POSTGRES_DB=amarback
POSTGRES_USER=postgres
POSTGRES_PASSWORD=localdev123
# =============================================================================
# DJANGO
# =============================================================================
SECRET_KEY=local-dev-key
DEBUG=True
DJANGO_ENV=development
ALLOWED_HOSTS=*
# =============================================================================
# CORS
# =============================================================================
CORS_ALLOW_ALL=true
CORS_ALLOWED_ORIGINS=
# =============================================================================
# GOOGLE SERVICES
# =============================================================================
SUBJECT_CALENDAR=
SHEET_ID=
RANGE_NAME=
GOOGLE_MAPS_API_KEY=
# =============================================================================
# ANALYTICS
# =============================================================================
GA4_MEASUREMENT_ID=
AMPLITUDE_API_KEY=
HOTJAR_API_KEY=
# =============================================================================
# MERCADO PAGO
# =============================================================================
ACCESS_TOKEN_MERCADO_PAGO=
MP_PLATFORM_ACCESS_TOKEN=
USER_ID=
# =============================================================================
# WEB PUSH
# =============================================================================
WEBPUSH_VAPID_PUBLIC_KEY=
WEBPUSH_VAPID_PRIVATE_KEY=
WEBPUSH_VAPID_ADMIN_EMAIL=
# =============================================================================
# INIT
# =============================================================================
USER_PASSWORD=initial_admin_password
# =============================================================================
# FRONTEND
# =============================================================================
NEXT_PUBLIC_APP_API_URL_BACKOFFICE=
NEXT_PUBLIC_APP_API_URL_STAGE=
NEXT_PUBLIC_IS_STAGE=false
NEXT_PUBLIC_FB_PIXEL_ID=
NEXT_PUBLIC_TAG_MANAGER=
NEXT_PUBLIC_WHATSAPP_CONTACT=
NEXT_PUBLIC_API_KEY=
NEXT_PUBLIC_AMPLITUDE_API_KEY=
NEXT_PUBLIC_GMAPS_API_KEY=

114
cfg/amar/.env.example Normal file
View File

@@ -0,0 +1,114 @@
# =============================================================================
# AMAR Room - Configuration Template
# =============================================================================
# Copy this to .env and fill in values for your environment.
# Uses absolute paths to plug soleprint to existing Dockerfiles.
# =============================================================================
# =============================================================================
# DEPLOYMENT
# =============================================================================
# Container name prefix (e.g., amar_backend, amar_db)
DEPLOYMENT_NAME=amar
# =============================================================================
# NETWORK
# =============================================================================
NETWORK_NAME=amar_network
# =============================================================================
# PATHS (absolute paths to external code/dockerfiles - REQUIRED)
# =============================================================================
BACKEND_PATH=/abs/path/to/amar_django_back
FRONTEND_PATH=/abs/path/to/amar_frontend
DOCKERFILE_BACKEND=/abs/path/to/Dockerfile.backend
DOCKERFILE_FRONTEND=/abs/path/to/Dockerfile.frontend
# =============================================================================
# DATABASE
# =============================================================================
# Database dump file (relative to dumps/ directory)
DB_DUMP=test.sql
# =============================================================================
# PORTS
# =============================================================================
BACKEND_PORT=8000
FRONTEND_PORT=3000
# Soleprint port
SOLEPRINT_PORT=12000
# =============================================================================
# BACKEND SERVER (Uvicorn)
# =============================================================================
# Dev: 1 worker with hot reload
# Prod: 4 workers without reload
BACKEND_WORKERS=1
BACKEND_RELOAD=--reload
# Database connection
POSTGRES_DB=amarback
POSTGRES_USER=postgres
POSTGRES_PASSWORD=your_secure_password_here
# =============================================================================
# DJANGO
# =============================================================================
SECRET_KEY=your_django_secret_key_here
DEBUG=False
ALLOWED_HOSTS=amar.room.mcrn.ar,localhost
DJANGO_ENV=production
# =============================================================================
# CORS
# =============================================================================
CORS_ALLOW_ALL=false
CORS_ALLOWED_ORIGINS=
# =============================================================================
# GOOGLE SERVICES
# =============================================================================
SUBJECT_CALENDAR=
SHEET_ID=
RANGE_NAME=
GOOGLE_MAPS_API_KEY=
# =============================================================================
# ANALYTICS
# =============================================================================
GA4_MEASUREMENT_ID=
AMPLITUDE_API_KEY=
HOTJAR_API_KEY=
# =============================================================================
# MERCADO PAGO
# =============================================================================
ACCESS_TOKEN_MERCADO_PAGO=
MP_PLATFORM_ACCESS_TOKEN=
USER_ID=
# =============================================================================
# WEB PUSH
# =============================================================================
WEBPUSH_VAPID_PUBLIC_KEY=
WEBPUSH_VAPID_PRIVATE_KEY=
WEBPUSH_VAPID_ADMIN_EMAIL=
# =============================================================================
# INIT
# =============================================================================
USER_PASSWORD=initial_admin_password
# =============================================================================
# FRONTEND
# =============================================================================
NEXT_PUBLIC_APP_API_URL_BACKOFFICE=
NEXT_PUBLIC_APP_API_URL_STAGE=
NEXT_PUBLIC_IS_STAGE=false
NEXT_PUBLIC_FB_PIXEL_ID=
NEXT_PUBLIC_TAG_MANAGER=
NEXT_PUBLIC_WHATSAPP_CONTACT=
NEXT_PUBLIC_API_KEY=
NEXT_PUBLIC_AMPLITUDE_API_KEY=
NEXT_PUBLIC_GMAPS_API_KEY=

View File

@@ -0,0 +1,71 @@
# =============================================================================
# Dockerfile for Django Backend with Uvicorn
# =============================================================================
# Usage:
# Development: WORKERS=1 RELOAD=--reload (source mounted, hot reload)
# Production: WORKERS=4 RELOAD="" (no reload, multiple workers)
# =============================================================================
# Stage 1: Base with system dependencies
# =============================================================================
FROM python:3.11-slim AS base
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
WORKDIR /app
# Install system dependencies (cached layer)
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
libpq-dev \
gdal-bin \
libgdal-dev \
libgeos-dev \
libproj-dev \
postgresql-client \
# WeasyPrint dependencies (for django-afip PDF generation)
libglib2.0-0 \
libpango-1.0-0 \
libpangocairo-1.0-0 \
libgdk-pixbuf-2.0-0 \
libffi-dev \
libcairo2 \
libgirepository1.0-dev \
gir1.2-pango-1.0 \
fonts-dejavu-core \
&& rm -rf /var/lib/apt/lists/*
# =============================================================================
# Stage 2: Dependencies (cached layer)
# =============================================================================
FROM base AS deps
# Copy only requirements for dependency installation
COPY requirements.txt .
# Install Python dependencies
RUN pip install --no-cache-dir -r requirements.txt
# =============================================================================
# Stage 3: Runtime (uvicorn with configurable workers)
# =============================================================================
FROM base AS runtime
# Copy dependencies from deps stage
COPY --from=deps /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages
COPY --from=deps /usr/local/bin /usr/local/bin
# Copy requirements (for reference)
COPY requirements.txt .
# Create directories
RUN mkdir -p /var/etc/static /app/media
# Note: Source code mounted at runtime for dev, copied for prod
EXPOSE 8000
# Uvicorn with configurable workers and reload
# Dev: WORKERS=1 RELOAD=--reload
# Prod: WORKERS=4 RELOAD=""
CMD uvicorn amar_django_back.asgi:application --host 0.0.0.0 --port 8000 --workers ${WORKERS:-1} ${RELOAD}

View File

@@ -0,0 +1,80 @@
# =============================================================================
# Multi-stage Dockerfile for Next.js Frontend
# =============================================================================
# Usage:
# Development: docker compose up (mounts source, hot reload)
# Production: docker compose -f docker-compose.yml -f docker-compose.prod.yml up
# =============================================================================
# Stage 1: Dependencies (cached layer)
# =============================================================================
FROM node:18-alpine AS deps
WORKDIR /app
# Copy only package files for dependency installation
COPY package*.json ./
# Install ALL dependencies (including devDependencies for dev mode)
RUN npm ci
# =============================================================================
# Stage 2: Development (hot reload, source mounted)
# =============================================================================
FROM node:18-alpine AS development
WORKDIR /app
# Copy dependencies from deps stage
COPY --from=deps /app/node_modules ./node_modules
# Copy package files (for npm scripts)
COPY package*.json ./
# Copy config files
COPY next.config.js postcss.config.js tailwind.config.js tsconfig.json ./
# Note: src/ and public/ are mounted at runtime for hot reload
# Start dev server
EXPOSE 3000
CMD ["npm", "run", "dev"]
# =============================================================================
# Stage 3: Builder (compile for production)
# =============================================================================
FROM node:18-alpine AS builder
WORKDIR /app
# Copy dependencies
COPY --from=deps /app/node_modules ./node_modules
# Copy all source files
COPY . .
# Build for production
RUN npm run build
# =============================================================================
# Stage 4: Production (optimized, minimal)
# =============================================================================
FROM node:18-alpine AS production
WORKDIR /app
ENV NODE_ENV=production
# Copy only production dependencies
COPY package*.json ./
RUN npm ci --only=production && npm cache clean --force
# Copy built application from builder
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public
COPY --from=builder /app/next.config.js ./
EXPOSE 3000
USER node
CMD ["npm", "run", "start"]

144
cfg/amar/config.json Normal file
View File

@@ -0,0 +1,144 @@
{
"framework": {
"name": "soleprint",
"slug": "soleprint",
"version": "0.1.0",
"description": "Development workflow and documentation system",
"tagline": "Mapping development footprints",
"icon": "👣",
"hub_port": 12000
},
"managed": {
"name": "amar",
"repos": {
"backend": "/home/mariano/wdir/ama/amar_django_back",
"frontend": "/home/mariano/wdir/ama/amar_frontend"
}
},
"systems": [
{
"key": "data_flow",
"name": "artery",
"slug": "artery",
"title": "Artery",
"tagline": "Todo lo vital",
"icon": "💉"
},
{
"key": "documentation",
"name": "atlas",
"slug": "atlas",
"title": "Atlas",
"tagline": "Documentación accionable",
"icon": "🗺️"
},
{
"key": "execution",
"name": "station",
"slug": "station",
"title": "Station",
"tagline": "Monitores, Entornos y Herramientas",
"icon": "🎛️"
}
],
"components": {
"shared": {
"config": {
"name": "room",
"title": "Room",
"description": "Runtime environment configuration",
"plural": "rooms"
},
"data": {
"name": "depot",
"title": "Depot",
"description": "Data storage / provisions",
"plural": "depots"
}
},
"data_flow": {
"connector": {
"name": "vein",
"title": "Vein",
"description": "Stateless API connector",
"plural": "veins"
},
"mock": {
"name": "shunt",
"title": "Shunt",
"description": "Fake connector for testing",
"plural": "shunts"
},
"composed": {
"name": "pulse",
"title": "Pulse",
"description": "Composed data flow",
"plural": "pulses",
"formula": "Vein + Room + Depot"
},
"app": {
"name": "plexus",
"title": "Plexus",
"description": "Full app with backend, frontend and DB",
"plural": "plexus"
}
},
"documentation": {
"pattern": {
"name": "template",
"title": "Template",
"description": "Documentation pattern",
"plural": "templates"
},
"library": {
"name": "book",
"title": "Book",
"description": "Documentation library"
},
"composed": {
"name": "book",
"title": "Book",
"description": "Composed documentation",
"plural": "books",
"formula": "Template + Depot"
}
},
"execution": {
"utility": {
"name": "tool",
"title": "Tool",
"description": "Execution utility",
"plural": "tools"
},
"watcher": {
"name": "monitor",
"title": "Monitor",
"description": "Service monitor",
"plural": "monitors"
},
"container": {
"name": "cabinet",
"title": "Cabinet",
"description": "Tool container",
"plural": "cabinets"
},
"workspace": {
"name": "desk",
"title": "Desk",
"description": "Execution workspace"
},
"workbench": {
"name": "desk",
"title": "Desk",
"description": "Work surface"
},
"composed": {
"name": "desk",
"title": "Desk",
"description": "Composed execution bundle",
"plural": "desks",
"formula": "Cabinet + Room + Depots"
}
}
}
}

9
cfg/amar/ctrl/.env.sync Normal file
View File

@@ -0,0 +1,9 @@
# Configuration for amar deployment
# Local source code paths
LOCAL_AMAR_BACKEND=/home/mariano/wdir/ama/amar_django_back
LOCAL_AMAR_FRONTEND=/home/mariano/wdir/ama/amar_frontend
# Test sync paths
TEST_SOURCE_PATH=/home/mariano/wdir/ama/amar_django_back/tests/contracts
WARD_TESTS_PATH=/home/mariano/wdir/ama/soleprint/ward/tools/tester/tests

33
cfg/amar/ctrl/logs.sh Executable file
View File

@@ -0,0 +1,33 @@
#!/bin/bash
# View amar room logs
#
# Usage:
# ./logs.sh # All logs (tail)
# ./logs.sh -f # Follow mode
# ./logs.sh backend # Specific container
# ./logs.sh soleprint # Soleprint logs
set -e
cd "$(dirname "$0")/.."
FOLLOW=""
TARGET=""
for arg in "$@"; do
case $arg in
-f|--follow) FOLLOW="-f" ;;
*) TARGET="$arg" ;;
esac
done
if [ -z "$TARGET" ]; then
echo "=== Amar ==="
docker compose logs --tail=20 $FOLLOW
echo ""
echo "=== Soleprint ==="
(cd soleprint && docker compose logs --tail=20 $FOLLOW)
elif [ "$TARGET" = "soleprint" ]; then
(cd soleprint && docker compose logs $FOLLOW)
else
docker logs $FOLLOW "amar_$TARGET" 2>/dev/null || docker logs $FOLLOW "$TARGET"
fi

40
cfg/amar/ctrl/start.sh Executable file
View File

@@ -0,0 +1,40 @@
#!/bin/bash
# Start amar room (managed app + soleprint)
#
# Usage:
# ./start.sh # Start all (foreground)
# ./start.sh -d # Start all (detached)
# ./start.sh amar # Start only amar
# ./start.sh soleprint # Start only soleprint
# ./start.sh --build # Rebuild images
set -e
cd "$(dirname "$0")/.."
BUILD=""
DETACH=""
TARGET="all"
for arg in "$@"; do
case $arg in
-d|--detached) DETACH="-d" ;;
--build) BUILD="--build" ;;
amar) TARGET="amar" ;;
soleprint) TARGET="soleprint" ;;
esac
done
if [ "$TARGET" = "all" ] || [ "$TARGET" = "amar" ]; then
echo "Starting amar..."
docker compose up $DETACH $BUILD
fi
if [ "$TARGET" = "all" ] || [ "$TARGET" = "soleprint" ]; then
echo "Starting soleprint..."
(cd soleprint && docker compose up $DETACH $BUILD)
fi
if [ -n "$DETACH" ]; then
echo ""
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" | grep -E "(amar|soleprint|NAMES)"
fi

7
cfg/amar/ctrl/status.sh Executable file
View File

@@ -0,0 +1,7 @@
#!/bin/bash
# Show amar room status
cd "$(dirname "$0")/.."
echo "=== Containers ==="
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" | grep -E "(amar|soleprint|NAMES)" || echo "No containers running"

25
cfg/amar/ctrl/stop.sh Executable file
View File

@@ -0,0 +1,25 @@
#!/bin/bash
# Stop amar room (managed app + soleprint)
#
# Usage:
# ./stop.sh # Stop all
# ./stop.sh amar # Stop only amar
# ./stop.sh soleprint # Stop only soleprint
set -e
cd "$(dirname "$0")/.."
TARGET="all"
[ -n "$1" ] && TARGET="$1"
if [ "$TARGET" = "all" ] || [ "$TARGET" = "soleprint" ]; then
echo "Stopping soleprint..."
(cd soleprint && docker compose down)
fi
if [ "$TARGET" = "all" ] || [ "$TARGET" = "amar" ]; then
echo "Stopping amar..."
docker compose down
fi
echo "Done."

117
cfg/amar/ctrl/xtras/reload-db.sh Executable file
View File

@@ -0,0 +1,117 @@
#!/bin/bash
# Smart database reload - only swaps if DB_DUMP changed
#
# Tracks which dump is currently loaded and only reloads if different.
# Edit DB_DUMP in .env and run this script - it handles the rest.
#
# Usage:
# ./reload-db.sh # Reload if DB_DUMP changed
# ./reload-db.sh --force # Force reload even if same
set -e
cd "$(dirname "$0")/../.."
FORCE=false
if [ "$1" = "--force" ]; then
FORCE=true
fi
# Get config from .env
DEPLOYMENT_NAME=$(grep "^DEPLOYMENT_NAME=" .env 2>/dev/null | cut -d'=' -f2 || echo "amar")
POSTGRES_DB=$(grep "^POSTGRES_DB=" .env 2>/dev/null | cut -d'=' -f2 || echo "amarback")
POSTGRES_USER=$(grep "^POSTGRES_USER=" .env 2>/dev/null | cut -d'=' -f2 || echo "postgres")
DB_DUMP=$(grep "^DB_DUMP=" .env 2>/dev/null | cut -d'=' -f2)
if [ -z "$DB_DUMP" ]; then
echo "Error: DB_DUMP not set in .env"
echo ""
echo "Add to .env:"
echo " DB_DUMP=dev.sql"
echo ""
echo "Available dumps:"
ls -1 dumps/*.sql 2>/dev/null | sed 's/dumps\// /' || echo " No dumps found in dumps/"
exit 1
fi
DUMP_FILE="dumps/${DB_DUMP}"
if [ ! -f "$DUMP_FILE" ]; then
echo "Error: Dump file not found: $DUMP_FILE"
echo ""
echo "Available dumps:"
ls -1 dumps/*.sql 2>/dev/null | sed 's/dumps\// /' || echo " No dumps found in dumps/"
exit 1
fi
DB_CONTAINER="${DEPLOYMENT_NAME}_db"
BACKEND_CONTAINER="${DEPLOYMENT_NAME}_backend"
STATE_FILE=".db_state"
# Check if db container is running
if ! docker ps --format "{{.Names}}" | grep -q "^${DB_CONTAINER}$"; then
echo "Error: Database container not running: $DB_CONTAINER"
echo "Start services first with: ./ctrl/start.sh -d"
exit 1
fi
# Check current state
if [ -f "$STATE_FILE" ] && [ "$FORCE" = false ]; then
CURRENT_DUMP=$(cat "$STATE_FILE")
if [ "$CURRENT_DUMP" = "$DB_DUMP" ]; then
echo "Database already loaded with: $DB_DUMP"
echo "Use --force to reload anyway"
exit 0
fi
echo "Database dump changed: $CURRENT_DUMP$DB_DUMP"
else
if [ "$FORCE" = true ]; then
echo "Force reloading database with: $DB_DUMP"
else
echo "Loading database with: $DB_DUMP"
fi
fi
echo ""
read -p "Continue with database reload? (y/N) " -n 1 -r
echo ""
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo "Cancelled."
exit 0
fi
echo ""
echo "[1/5] Stopping backend and celery services..."
docker compose stop backend celery celery-beat 2>/dev/null || true
echo ""
echo "[2/5] Dropping and recreating database..."
docker exec "$DB_CONTAINER" psql -U "$POSTGRES_USER" -d postgres -c "DROP DATABASE IF EXISTS $POSTGRES_DB WITH (FORCE);"
docker exec "$DB_CONTAINER" psql -U "$POSTGRES_USER" -d postgres -c "CREATE DATABASE $POSTGRES_DB;"
docker exec "$DB_CONTAINER" psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "CREATE EXTENSION IF NOT EXISTS postgis_topology;"
echo ""
echo "[3/5] Loading dump: $DB_DUMP..."
docker exec -i "$DB_CONTAINER" psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" < "$DUMP_FILE"
echo ""
echo "[4/5] Restarting services and running migrations..."
docker compose start backend celery celery-beat
echo "Waiting for backend to start..."
sleep 3
echo "Running migrations..."
docker exec "$BACKEND_CONTAINER" python manage.py migrate --noinput
echo ""
echo "[5/5] Updating state..."
echo "$DB_DUMP" > "$STATE_FILE"
echo ""
echo "=========================================="
echo " Database Reloaded Successfully"
echo "=========================================="
echo ""
echo "Current dump: $DB_DUMP"
echo "Database: $POSTGRES_DB"
echo ""

View File

@@ -1,5 +1,5 @@
"""
Soleprint Data Layer
Pawprint Data Layer
JSON file storage (future: MongoDB)
"""

79
cfg/amar/data/books.json Normal file
View File

@@ -0,0 +1,79 @@
{
"items": [
{
"name": "arch-model",
"slug": "arch-model",
"title": "Architecture Model",
"status": "ready",
"template": null,
"larder": {
"name": "arch-model",
"slug": "arch-model",
"title": "Architecture Model",
"status": "ready",
"source_template": null,
"data_path": "album/book/arch-model"
},
"output_larder": null,
"system": "album"
},
{
"name": "feature-flow",
"slug": "feature-flow",
"title": "Feature Flow Pipeline",
"status": "ready",
"template": null,
"larder": {
"name": "feature-flow",
"slug": "feature-flow",
"title": "Feature Flow Pipeline",
"status": "ready",
"source_template": null,
"data_path": "album/book/feature-flow"
},
"output_larder": null,
"system": "album"
},
{
"name": "gherkin-samples",
"slug": "gherkin-samples",
"title": "Gherkin Samples",
"status": "ready",
"template": null,
"larder": {
"name": "gherkin-samples",
"slug": "gherkin-samples",
"title": "Gherkin Samples",
"status": "ready",
"source_template": null,
"data_path": "album/book/gherkin-samples"
},
"output_larder": null,
"system": "album"
},
{
"name": "feature-form-samples",
"slug": "feature-form-samples",
"title": "Feature Form Samples",
"status": "ready",
"template": {
"name": "feature-form",
"slug": "feature-form",
"title": "Feature Form Template",
"status": "ready",
"template_path": "album/template/feature-form",
"system": "album"
},
"larder": {
"name": "feature-form",
"slug": "feature-form",
"title": "Feature Forms",
"status": "ready",
"source_template": "feature-form",
"data_path": "album/book/feature-form-samples/feature-form"
},
"output_larder": null,
"system": "album"
}
]
}

12
cfg/amar/data/depots.json Normal file
View File

@@ -0,0 +1,12 @@
{
"items": [
{
"name": "feature-form",
"slug": "feature-form",
"title": "Feature Forms",
"status": "ready",
"source_template": "feature-form",
"data_path": "album/book/feature-form-samples/feature-form"
}
]
}

View File

@@ -0,0 +1,22 @@
{
"items": [
{
"name": "turnos",
"slug": "turnos",
"title": "Turnos Monitor",
"status": "dev",
"system": "ward",
"description": "Pipeline view of requests → turnos. Shows vet-petowner at a glance.",
"path": "ward/monitor/turnos"
},
{
"name": "data_browse",
"slug": "data-browse",
"title": "Data Browse",
"status": "ready",
"system": "ward",
"description": "Quick navigation to test users and data states. Book/larder pattern with SQL mode for manual testing workflows.",
"path": "ward/monitor/data_browse"
}
]
}

5
cfg/amar/data/rooms.json Normal file
View File

@@ -0,0 +1,5 @@
{
"items": [
{"name": "pawprint-local", "slug": "pawprint-local", "title": "Pawprint Local", "status": "dev", "config_path": "deploy/pawprint-local"}
]
}

View File

@@ -0,0 +1,38 @@
# {{nombre_flujo}}
## Tipo de usuario
{{tipo_usuario}}
## Donde empieza
{{punto_entrada}}
## Que quiere hacer el usuario
{{objetivo}}
## Pasos
1. {{paso_1}}
2. {{paso_2}}
3. {{paso_3}}
## Que deberia pasar
- {{resultado_1}}
- {{resultado_2}}
## Problemas comunes
- {{problema_1}}
- {{problema_2}}
## Casos especiales
- {{caso_especial_1}}
## Flujos relacionados
- {{flujo_relacionado_1}}
## Notas tecnicas
- {{nota_tecnica_1}}

View File

@@ -0,0 +1,12 @@
{
"items": [
{
"name": "feature-form",
"slug": "feature-form",
"title": "Feature Form Template",
"status": "ready",
"template_path": "data/template/feature-form",
"system": "album"
}
]
}

48
cfg/amar/data/tools.json Normal file
View File

@@ -0,0 +1,48 @@
{
"items": [
{
"name": "tester",
"slug": "tester",
"title": "Contract Tests",
"status": "live",
"system": "ward",
"type": "app",
"description": "HTTP contract test runner with multi-environment support. Filter, run, and track tests against dev/stage/prod.",
"path": "ward/tools/tester",
"url": "/tools/tester/"
},
{
"name": "datagen",
"slug": "datagen",
"title": "Test Data Generator",
"status": "live",
"system": "ward",
"type": "cli",
"description": "Generate realistic test data for Amar domain (users, pets, services) and MercadoPago API responses. Used by mock veins and test seeders.",
"path": "ward/tools/datagen",
"cli": "python -m datagen"
},
{
"name": "generate_test_data",
"slug": "generate-test-data",
"title": "DB Test Data Extractor",
"status": "dev",
"system": "ward",
"type": "cli",
"description": "Extract representative subsets from PostgreSQL dumps for testing/development.",
"path": "ward/tools/generate_test_data",
"cli": "python -m generate_test_data"
},
{
"name": "modelgen",
"slug": "modelgen",
"title": "Model Generator",
"status": "dev",
"system": "ward",
"type": "cli",
"description": "Generate platform-specific models (Pydantic, Django, Prisma) from JSON Schema.",
"path": "ward/tools/modelgen",
"cli": "python -m modelgen"
}
]
}

60
cfg/amar/data/veins.json Normal file
View File

@@ -0,0 +1,60 @@
{
"items": [
{
"name": "jira",
"slug": "jira",
"title": "Jira",
"status": "live",
"system": "artery"
},
{
"name": "slack",
"slug": "slack",
"title": "Slack",
"status": "building",
"system": "artery"
},
{
"name": "google",
"slug": "google",
"title": "Google",
"status": "planned",
"system": "artery"
},
{
"name": "maps",
"slug": "maps",
"title": "Maps",
"status": "planned",
"system": "artery"
},
{
"name": "whatsapp",
"slug": "whatsapp",
"title": "WhatsApp",
"status": "planned",
"system": "artery"
},
{
"name": "gnucash",
"slug": "gnucash",
"title": "GNUCash",
"status": "planned",
"system": "artery"
},
{
"name": "vnc",
"slug": "vnc",
"title": "VNC",
"status": "planned",
"system": "artery"
},
{
"name": "ia",
"slug": "ia",
"title": "IA",
"status": "planned",
"system": "artery"
}
]
}

202
cfg/amar/docker-compose.yml Normal file
View File

@@ -0,0 +1,202 @@
# Amar Room - Docker Compose
#
# Creates: db, redis, backend, celery, celery-beat, frontend
# Network: Joins external network defined by NETWORK_NAME
#
# Usage:
# cd cfg/amar && docker compose up -d
#
# Required Environment Variables (from .env):
# - DEPLOYMENT_NAME: Prefix for container names
# - NETWORK_NAME: Network to join
# - BACKEND_PATH, FRONTEND_PATH: Source code paths (absolute)
# - DOCKERFILE_BACKEND, DOCKERFILE_FRONTEND: Dockerfile paths (absolute)
# - DB_DUMP: Database dump file (relative to dumps/)
# - Plus all app-specific vars (POSTGRES_*, etc.)
services:
db:
image: postgis/postgis:15-3.4
container_name: ${DEPLOYMENT_NAME}_db
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- pgdata:/var/lib/postgresql/data
# Database dump loaded on init (if volume is empty)
- ./dumps/${DB_DUMP}:/docker-entrypoint-initdb.d/dump.sql:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 5
networks:
- default
redis:
image: redis:7-alpine
container_name: ${DEPLOYMENT_NAME}_redis
volumes:
- redisdata:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
networks:
- default
backend:
build:
context: ${BACKEND_PATH}
dockerfile: ${DOCKERFILE_BACKEND}
target: runtime
container_name: ${DEPLOYMENT_NAME}_backend
environment:
- SECRET_KEY
- DEBUG
- ALLOWED_HOSTS
- DJANGO_ENV
- DB_NAME=${POSTGRES_DB}
- DB_USER=${POSTGRES_USER}
- DB_PASSWORD=${POSTGRES_PASSWORD}
- DB_HOST=db
- DB_PORT=5432
- CELERY_BROKER_URL=redis://redis:6379/0
- CELERY_RESULT_BACKEND=redis://redis:6379/0
- CORS_ALLOW_ALL
- CORS_ALLOWED_ORIGINS
# Uvicorn config (dev: 1 worker + reload, prod: 4 workers)
- WORKERS=${BACKEND_WORKERS:-1}
- RELOAD=${BACKEND_RELOAD:---reload}
# Google
- SUBJECT_CALENDAR
- SHEET_ID
- RANGE_NAME
- GOOGLE_MAPS_API_KEY
# Analytics
- GA4_MEASUREMENT_ID
- AMPLITUDE_API_KEY
- HOTJAR_API_KEY
# Payments
- ACCESS_TOKEN_MERCADO_PAGO
- MP_PLATFORM_ACCESS_TOKEN
- USER_ID
# Push
- WEBPUSH_VAPID_PUBLIC_KEY
- WEBPUSH_VAPID_PRIVATE_KEY
- WEBPUSH_VAPID_ADMIN_EMAIL
# Init
- USER_PASSWORD
volumes:
- ${BACKEND_PATH}:/app
- backend_static:/var/etc/static
- backend_media:/app/media
ports:
- "${BACKEND_PORT}:8000"
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
networks:
- default
command: >
sh -c "python manage.py migrate --noinput &&
uvicorn amar_django_back.asgi:application --host 0.0.0.0 --port 8000 --workers $${WORKERS:-1} $${RELOAD}"
celery:
build:
context: ${BACKEND_PATH}
dockerfile: ${DOCKERFILE_BACKEND}
target: runtime
container_name: ${DEPLOYMENT_NAME}_celery
environment:
- SECRET_KEY
- DEBUG
- DJANGO_ENV
- DB_NAME=${POSTGRES_DB}
- DB_USER=${POSTGRES_USER}
- DB_PASSWORD=${POSTGRES_PASSWORD}
- DB_HOST=db
- DB_PORT=5432
- CELERY_BROKER_URL=redis://redis:6379/0
- CELERY_RESULT_BACKEND=redis://redis:6379/0
volumes:
- ${BACKEND_PATH}:/app
- backend_media:/app/media
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
networks:
- default
command: celery -A amar_django_back worker -l INFO
celery-beat:
build:
context: ${BACKEND_PATH}
dockerfile: ${DOCKERFILE_BACKEND}
target: runtime
container_name: ${DEPLOYMENT_NAME}_celery_beat
environment:
- SECRET_KEY
- DEBUG
- DJANGO_ENV
- DB_NAME=${POSTGRES_DB}
- DB_USER=${POSTGRES_USER}
- DB_PASSWORD=${POSTGRES_PASSWORD}
- DB_HOST=db
- DB_PORT=5432
- CELERY_BROKER_URL=redis://redis:6379/0
- CELERY_RESULT_BACKEND=redis://redis:6379/0
volumes:
- ${BACKEND_PATH}:/app
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
networks:
- default
command: celery -A amar_django_back beat -l INFO
frontend:
build:
context: ${FRONTEND_PATH}
dockerfile: ${DOCKERFILE_FRONTEND}
target: development
container_name: ${DEPLOYMENT_NAME}_frontend
environment:
- NEXT_PUBLIC_APP_API_URL_BACKOFFICE
- NEXT_PUBLIC_APP_API_URL_STAGE
- NEXT_PUBLIC_IS_STAGE
- NEXT_PUBLIC_FB_PIXEL_ID
- NEXT_PUBLIC_TAG_MANAGER
- NEXT_PUBLIC_WHATSAPP_CONTACT
- NEXT_PUBLIC_API_KEY
- NEXT_PUBLIC_AMPLITUDE_API_KEY
- NEXT_PUBLIC_GMAPS_API_KEY
volumes:
- ${FRONTEND_PATH}/src:/app/src
- ${FRONTEND_PATH}/public:/app/public
ports:
- "${FRONTEND_PORT}:3000"
depends_on:
- backend
networks:
- default
# CMD from Dockerfile.frontend development stage: npm run dev
volumes:
pgdata:
redisdata:
backend_static:
backend_media:
networks:
default:
external: true
name: ${NETWORK_NAME}

13
cfg/amar/link/Dockerfile Normal file
View File

@@ -0,0 +1,13 @@
FROM python:3.11-slim
WORKDIR /app
# Install dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application
COPY . .
# Run
CMD ["python", "main.py"]

120
cfg/amar/link/README.md Normal file
View File

@@ -0,0 +1,120 @@
# Link Room - Adapter Layer
Provides framework-agnostic data navigation between managed apps (AMAR) and soleprint.
## Status: Initial Implementation ✅
**Working:**
- ✅ FastAPI service with adapter pattern
- ✅ BaseAdapter interface for pluggable frameworks
- ✅ DjangoAdapter with AMAR database queries
- ✅ Docker build and container starts
-`/health` endpoint (adapter loads successfully)
-`/api/queries` endpoint (lists available queries)
**Pending:**
- ⏳ Database connection (needs DB_HOST env var fix)
- ⏳ Complete all entity queries (Pet, Vet, ServiceRequest, etc.)
- ⏳ Ward integration (consume JSON and render graph)
## Architecture
```
Managed App (AMAR) ←─── link_room ───→ Soleprint (Ward)
↓ ↓ ↓
Database Adapter Layer Graph Renderer
(SQL → JSON) (JSON → SVG)
```
**JSON Contract:**
```json
{
"nodes": [
{"id": "User_123", "type": "User", "label": "john", "data": {...}}
],
"edges": [
{"from": "User_123", "to": "PetOwner_456", "label": "has profile"}
],
"summary": {
"title": "User #123",
"credentials": "john | Password: Amar2025!",
"fields": {"Email": "john@example.com"}
}
}
```
## Endpoints
- `GET /health` - Health check with adapter status
- `GET /api/queries` - List available predefined queries
- `GET /api/navigate?query=user_with_pets` - Query mode
- `GET /api/navigate?entity=User&id=123` - Entity navigation mode
## Available Queries
1. `user_with_pets` - User with Pet ownership
2. `user_with_requests` - User with ServiceRequests
## Usage
```bash
# Start (from core_room/ctrl)
./start.sh link_room -d --build
# Test
curl http://localhost:8100/health
curl http://localhost:8100/api/queries
curl "http://localhost:8100/api/navigate?query=user_with_pets"
# Logs
docker logs core_room_link_room
```
## Environment Variables
From `core_room/.env`:
- `NEST_NAME` - Container naming
- `NETWORK_NAME` - Docker network
- `DB_HOST` - Database host (needs fix: should point to db container)
- `DB_PORT` - Database port
- `DB_NAME` - Database name
- `DB_USER` - Database user
- `DB_PASSWORD` - Database password
- `ADAPTER_TYPE` - Adapter to use (default: django)
## Next Steps
1. **Fix DB connection** - Set correct DB_HOST in core_room/.env
2. **Complete queries** - Add remaining entity types
3. **Ward integration** - Create ward consumer for JSON
4. **Add graphviz rendering** - Move from data_browse reference
5. **Test end-to-end** - Query → JSON → SVG → Display
## Files
```
link_room/
├── README.md # This file
├── main.py # FastAPI app with endpoints
├── requirements.txt # Python dependencies
├── Dockerfile # Container build
├── docker-compose.yml # Service definition
└── adapters/
├── __init__.py # BaseAdapter interface
└── django.py # DjangoAdapter implementation
```
## Design Goals
**Framework-agnostic** - Works with Django, Rails, Express, etc.
**Decoupled** - Managed app owns data, link_room translates
**Pluggable** - Adapters for different frameworks
**Authenticated** - Ready for remote deployment
**Incremental** - Build and test each piece
## Reference
Previous approach (databrowse direct DB) saved in:
- Branch: `ref/databrowse-direct-db` (ward repo)
- Problem: Tight coupling, won't work remote
- Solution: This adapter pattern

View File

@@ -0,0 +1,43 @@
"""
Adapters for different managed app frameworks.
"""
from typing import Dict, List, Any, Optional
from abc import ABC, abstractmethod
class BaseAdapter(ABC):
"""Base adapter interface."""
def __init__(self, config: Dict[str, Any]):
"""
Initialize adapter with configuration.
Args:
config: Database connection or API endpoint configuration
"""
self.config = config
@abstractmethod
def navigate(
self,
query: Optional[str] = None,
entity: Optional[str] = None,
id: Optional[int] = None
) -> Dict[str, Any]:
"""
Navigate data graph.
Returns:
{
"nodes": [{"id": str, "type": str, "label": str, "data": dict}],
"edges": [{"from": str, "to": str, "label": str}],
"summary": {"title": str, "credentials": str|None, "fields": dict}
}
"""
pass
@abstractmethod
def get_queries(self) -> List[str]:
"""Return list of available query names."""
pass

View File

@@ -0,0 +1,235 @@
"""
Django adapter for AMAR.
Queries AMAR's PostgreSQL database directly.
"""
from typing import Dict, List, Any, Optional
from sqlalchemy import create_engine, text
from . import BaseAdapter
class DjangoAdapter(BaseAdapter):
"""Adapter for Django/AMAR."""
def __init__(self, config: Dict[str, Any]):
super().__init__(config)
self.engine = self._create_engine()
def _create_engine(self):
"""Create SQLAlchemy engine from config."""
db_url = (
f"postgresql://{self.config['user']}:{self.config['password']}"
f"@{self.config['host']}:{self.config['port']}/{self.config['name']}"
)
return create_engine(db_url, pool_pre_ping=True)
def _execute(self, sql: str) -> List[Dict[str, Any]]:
"""Execute SQL and return results as list of dicts."""
with self.engine.connect() as conn:
result = conn.execute(text(sql))
rows = result.fetchall()
columns = result.keys()
return [dict(zip(columns, row)) for row in rows]
def get_queries(self) -> List[str]:
"""Available predefined queries."""
return [
"user_with_pets",
"user_with_requests",
]
def navigate(
self,
query: Optional[str] = None,
entity: Optional[str] = None,
id: Optional[int] = None
) -> Dict[str, Any]:
"""Navigate data graph."""
if query:
return self._query_mode(query)
elif entity and id:
return self._entity_mode(entity, id)
else:
raise ValueError("Must provide either query or entity+id")
def _query_mode(self, query_name: str) -> Dict[str, Any]:
"""Execute predefined query."""
if query_name == "user_with_pets":
sql = """
SELECT
u.id as user_id, u.username, u.email,
po.id as petowner_id, po.first_name, po.last_name, po.phone,
p.id as pet_id, p.name as pet_name, p.pet_type, p.age
FROM auth_user u
JOIN mascotas_petowner po ON po.user_id = u.id
JOIN mascotas_pet p ON p.owner_id = po.id
WHERE p.deleted = false
LIMIT 1
"""
elif query_name == "user_with_requests":
sql = """
SELECT
u.id as user_id, u.username, u.email,
po.id as petowner_id, po.first_name, po.last_name,
sr.id as request_id, sr.state, sr.created_at
FROM auth_user u
JOIN mascotas_petowner po ON po.user_id = u.id
JOIN solicitudes_servicerequest sr ON sr.petowner_id = po.id
WHERE sr.deleted = false
ORDER BY sr.created_at DESC
LIMIT 1
"""
else:
raise ValueError(f"Unknown query: {query_name}")
rows = self._execute(sql)
if not rows:
return self._empty_response()
return self._rows_to_graph(rows[0])
def _entity_mode(self, entity: str, id: int) -> Dict[str, Any]:
"""Navigate to specific entity."""
if entity == "User":
sql = f"""
SELECT
u.id as user_id, u.username, u.email,
po.id as petowner_id, po.first_name, po.last_name, po.phone
FROM auth_user u
LEFT JOIN mascotas_petowner po ON po.user_id = u.id
WHERE u.id = {id}
"""
else:
raise ValueError(f"Unknown entity: {entity}")
rows = self._execute(sql)
if not rows:
return self._empty_response()
return self._rows_to_graph(rows[0])
def _rows_to_graph(self, row: Dict[str, Any]) -> Dict[str, Any]:
"""Convert SQL row to graph structure."""
nodes = []
edges = []
# User node
if "user_id" in row and row["user_id"]:
nodes.append({
"id": f"User_{row['user_id']}",
"type": "User",
"label": row.get("username") or row.get("email", ""),
"data": {
"id": row["user_id"],
"username": row.get("username"),
"email": row.get("email"),
}
})
# PetOwner node
if "petowner_id" in row and row["petowner_id"]:
name = f"{row.get('first_name', '')} {row.get('last_name', '')}".strip()
nodes.append({
"id": f"PetOwner_{row['petowner_id']}",
"type": "PetOwner",
"label": name or "PetOwner",
"data": {
"id": row["petowner_id"],
"first_name": row.get("first_name"),
"last_name": row.get("last_name"),
"phone": row.get("phone"),
}
})
if "user_id" in row and row["user_id"]:
edges.append({
"from": f"User_{row['user_id']}",
"to": f"PetOwner_{row['petowner_id']}",
"label": "has profile"
})
# Pet node
if "pet_id" in row and row["pet_id"]:
nodes.append({
"id": f"Pet_{row['pet_id']}",
"type": "Pet",
"label": row.get("pet_name", "Pet"),
"data": {
"id": row["pet_id"],
"name": row.get("pet_name"),
"pet_type": row.get("pet_type"),
"age": row.get("age"),
}
})
if "petowner_id" in row and row["petowner_id"]:
edges.append({
"from": f"PetOwner_{row['petowner_id']}",
"to": f"Pet_{row['pet_id']}",
"label": "owns"
})
# ServiceRequest node
if "request_id" in row and row["request_id"]:
nodes.append({
"id": f"ServiceRequest_{row['request_id']}",
"type": "ServiceRequest",
"label": f"Request #{row['request_id']}",
"data": {
"id": row["request_id"],
"state": row.get("state"),
"created_at": str(row.get("created_at", "")),
}
})
if "petowner_id" in row and row["petowner_id"]:
edges.append({
"from": f"PetOwner_{row['petowner_id']}",
"to": f"ServiceRequest_{row['request_id']}",
"label": "requested"
})
# Build summary from first User node
summary = self._build_summary(nodes)
return {
"nodes": nodes,
"edges": edges,
"summary": summary
}
def _build_summary(self, nodes: List[Dict]) -> Dict[str, Any]:
"""Build summary from nodes."""
# Find User node
user_node = next((n for n in nodes if n["type"] == "User"), None)
if user_node:
data = user_node["data"]
return {
"title": f"User #{data['id']}",
"credentials": f"{data.get('username', 'N/A')} | Password: Amar2025!",
"fields": {
"Email": data.get("email", "N/A"),
"Username": data.get("username", "N/A"),
}
}
# Fallback
return {
"title": "No data",
"credentials": None,
"fields": {}
}
def _empty_response(self) -> Dict[str, Any]:
"""Return empty response structure."""
return {
"nodes": [],
"edges": [],
"summary": {
"title": "No data found",
"credentials": None,
"fields": {}
}
}

View File

@@ -0,0 +1,25 @@
services:
link_nest:
build:
context: .
dockerfile: Dockerfile
container_name: ${NEST_NAME}_link_nest
ports:
- "8100:8000"
environment:
- PORT=8000
- ADAPTER_TYPE=${ADAPTER_TYPE:-django}
- DB_HOST=${DB_HOST}
- DB_PORT=${DB_PORT}
- DB_NAME=${DB_NAME}
- DB_USER=${DB_USER}
- DB_PASSWORD=${DB_PASSWORD}
volumes:
- ./:/app
networks:
- default
networks:
default:
external: true
name: ${NETWORK_NAME}

105
cfg/amar/link/main.py Normal file
View File

@@ -0,0 +1,105 @@
"""
Link Room - Adapter layer between managed apps and soleprint.
Exposes standardized JSON endpoints for data navigation.
Framework-agnostic via pluggable adapters.
"""
import os
from typing import Optional
from fastapi import FastAPI, HTTPException
app = FastAPI(title="Link Room", version="0.1.0")
# Lazy-loaded adapter instance
_adapter = None
def get_adapter():
"""Get or create adapter instance."""
global _adapter
if _adapter is None:
adapter_type = os.getenv("ADAPTER_TYPE", "django")
# Database config from environment
db_config = {
"host": os.getenv("DB_HOST", "localhost"),
"port": int(os.getenv("DB_PORT", "5432")),
"name": os.getenv("DB_NAME", "amarback"),
"user": os.getenv("DB_USER", "postgres"),
"password": os.getenv("DB_PASSWORD", ""),
}
if adapter_type == "django":
from adapters.django import DjangoAdapter
_adapter = DjangoAdapter(db_config)
else:
raise ValueError(f"Unknown adapter type: {adapter_type}")
return _adapter
@app.get("/health")
def health():
"""Health check."""
adapter_type = os.getenv("ADAPTER_TYPE", "django")
# Test adapter connection
adapter_ok = False
try:
adapter = get_adapter()
adapter_ok = True
except Exception as e:
print(f"Adapter error: {e}")
return {
"status": "ok" if adapter_ok else "degraded",
"service": "link-room",
"adapter": adapter_type,
"adapter_loaded": adapter_ok,
}
@app.get("/api/queries")
def list_queries():
"""List available predefined queries."""
adapter = get_adapter()
return {
"queries": adapter.get_queries()
}
@app.get("/api/navigate")
def navigate(query: Optional[str] = None, entity: Optional[str] = None, id: Optional[int] = None):
"""
Navigate data graph.
Query mode: ?query=user_with_pets
Navigation mode: ?entity=User&id=123
Returns:
{
"nodes": [...],
"edges": [...],
"summary": {...}
}
"""
try:
adapter = get_adapter()
result = adapter.navigate(query=query, entity=entity, id=id)
return result
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
print(f"Navigate error: {e}")
raise HTTPException(status_code=500, detail=str(e))
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"main:app",
host="0.0.0.0",
port=int(os.getenv("PORT", "8000")),
reload=True,
)

View File

@@ -0,0 +1,4 @@
fastapi
uvicorn[standard]
psycopg2-binary
sqlalchemy

32
cfg/amar/soleprint/.env Normal file
View File

@@ -0,0 +1,32 @@
# =============================================================================
# SOLEPRINT - Amar Room Configuration
# =============================================================================
# =============================================================================
# DEPLOYMENT
# =============================================================================
DEPLOYMENT_NAME=amar_soleprint
# =============================================================================
# NETWORK (shared with amar)
# =============================================================================
NETWORK_NAME=soleprint_network
# =============================================================================
# PATHS
# =============================================================================
SOLEPRINT_BARE_PATH=/home/mariano/wdir/spr/gen
# =============================================================================
# PORTS
# =============================================================================
SOLEPRINT_PORT=12000
# =============================================================================
# DATABASE (amar's DB for station tools)
# =============================================================================
DB_HOST=amar_db
DB_PORT=5432
DB_NAME=amarback
DB_USER=postgres
DB_PASSWORD=localdev123

View File

@@ -1,15 +1,20 @@
FROM python:3.12-slim
FROM python:3.11-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq-dev gcc \
# Install system dependencies
RUN apt-get update && apt-get install -y \
gcc \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
# Install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application
COPY . .
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -0,0 +1,118 @@
# Nginx for Local Development
## Overview
The `docker-compose.nginx.yml` file provides an **optional** nginx container for local development. This is **NOT** used on AWS (which uses bare metal nginx).
## When to Use
Use nginx container when you want to access services via friendly domains locally:
- `http://amarmascotas.local.com` → amar
- `http://soleprint.local.com` → soleprint + services
## Setup
### 1. Generate Nginx Config
```bash
cd ../ctrl/server
./setup.sh --local
```
This creates `/tmp/core_room.conf` with your local domain routing.
### 2. Ensure /etc/hosts is configured
```bash
# Should already be there, but verify:
grep "127.0.0.1.*amarmascotas.local.com" /etc/hosts
grep "127.0.0.1.*soleprint.local.com" /etc/hosts
```
### 3. Stop bare metal nginx (if running)
```bash
sudo systemctl stop nginx
# Optional: disable so it doesn't start on boot
sudo systemctl disable nginx
```
### 4. Start services WITH nginx
```bash
cd ../ctrl
./start.sh --with-nginx
# OR manually:
cd ../soleprint
docker compose -f docker-compose.yml -f docker-compose.nginx.yml up -d
```
## Without Nginx (Default)
If you don't use nginx, services are accessed via ports:
- `http://localhost:3000` → amar frontend
- `http://localhost:8000` → amar backend
- `http://localhost:13000` → soleprint
- `http://localhost:13001` → artery
- `http://localhost:13002` → album
- `http://localhost:13003` → ward
The default `docker-compose.yml` exposes these ports, so it works either way.
## Switching Between Nginx and Direct Access
**To use nginx routing:**
```bash
# Stop direct port access
cd ../ctrl && ./stop.sh
# Start with nginx
./start.sh --with-nginx
```
**To go back to direct ports:**
```bash
# Stop nginx version
cd ../soleprint
docker compose -f docker-compose.yml -f docker-compose.nginx.yml down
# Start without nginx
cd ../ctrl && ./start.sh
```
## AWS Production
On AWS, **do NOT use** `docker-compose.nginx.yml`. The AWS setup uses bare metal nginx configured via `ctrl/server/setup.sh --production`.
## Troubleshooting
**Port 80 already in use:**
```bash
# Check what's using it
ss -tlnp | grep :80
# If it's nginx bare metal
sudo systemctl stop nginx
# If it's another container
docker ps | grep :80
docker stop <container_name>
```
**Config not found error:**
```bash
# Make sure you ran setup first
cd ../ctrl/server && ./setup.sh --local
# Verify config exists
ls -la /tmp/core_room.conf
```
**DNS not resolving:**
```bash
# Check /etc/hosts
cat /etc/hosts | grep local.com
# Test DNS
ping -c 1 amarmascotas.local.com
```

View File

@@ -0,0 +1,13 @@
# Amar (MOCK) Vein Configuration
API_PORT=8005
# Mock data settings
MOCK_DATA_PATH=./mock_data
# Mock behavior
ENABLE_RANDOM_DELAYS=true
MIN_DELAY_MS=100
MAX_DELAY_MS=500
# Simulate errors
ERROR_RATE=0.0 # 0.0 to 1.0 (0% to 100%)

View File

@@ -0,0 +1,173 @@
# Amar (MOCK) Vein
Mock Amar API for testing - returns predictable test data for turnero flow testing.
## Purpose
Enables testing of the turnero flow (VET-535-540) without hitting the real Amar backend:
- Guest petowner creation (VET-536)
- Pet creation (VET-537)
- Cart creation and price calculation (VET-538)
- Service/category filtering (VET-539, VET-540)
- Service request creation
## Quick Start
```bash
# Install dependencies
pip install -r requirements.txt
# Start the mock server
python run.py
# API docs: http://localhost:8005/docs
# Health check: http://localhost:8005/health
```
## Configuration
Copy `.env.example` to `.env` and adjust:
```bash
API_PORT=8005 # Server port
ENABLE_RANDOM_DELAYS=true # Add realistic delays
MIN_DELAY_MS=100 # Minimum delay
MAX_DELAY_MS=500 # Maximum delay
ERROR_RATE=0.0 # Error rate (0.0 to 1.0)
```
## API Endpoints
### Turnero Flow (VET-536-540)
```bash
# Create guest petowner (VET-536)
POST /api/v1/pet-owners/
{
"address": "Av. Santa Fe 1234, Palermo"
}
# Create pet (VET-537)
POST /api/v1/pets/
{
"owner_id": 1234,
"name": "Luna",
"species": "DOG",
"age": 3,
"age_unit": "years"
}
# Create cart (VET-538)
POST /api/v1/cart/
{
"owner_id": 1234
}
# Add item to cart
POST /api/v1/cart/{cart_id}/items/
{
"service_id": 1,
"pet_id": 5678,
"quantity": 1
}
# List services (VET-540)
GET /api/v1/services/?species=DOG&neighborhood_id=1
# List categories (VET-539)
GET /api/v1/categories/?species=DOG&neighborhood_id=1
# Create service request
POST /solicitudes/service-requests/?cart_id=12345
```
### Mock Control
```bash
# Get mock database stats
GET /mock/stats
# Reset mock database
GET /mock/reset
```
## Response Format
All responses include `_mock: true` to identify mock data:
```json
{
"id": 1234,
"address": "Av. Santa Fe 1234, Palermo",
"is_guest": true,
"_mock": true
}
```
## Testing Scenarios
### Complete Turnero Flow
```python
import requests
BASE_URL = "http://localhost:8005"
# Step 1: Create guest petowner
owner_resp = requests.post(f"{BASE_URL}/api/v1/pet-owners/", json={
"address": "Av. Santa Fe 1234, Palermo"
})
owner = owner_resp.json()
# Step 2: Create pet
pet_resp = requests.post(f"{BASE_URL}/api/v1/pets/", json={
"owner_id": owner["id"],
"name": "Luna",
"species": "DOG",
"age": 3,
"age_unit": "years"
})
pet = pet_resp.json()
# Step 3: Create cart
cart_resp = requests.post(f"{BASE_URL}/api/v1/cart/", json={
"owner_id": owner["id"]
})
cart = cart_resp.json()
# Step 4: Get available services
services_resp = requests.get(
f"{BASE_URL}/api/v1/services/",
params={"species": "DOG", "neighborhood_id": owner["neighborhood"]["id"]}
)
services = services_resp.json()
# Step 5: Add service to cart
cart_resp = requests.post(f"{BASE_URL}/api/v1/cart/{cart['id']}/items/", json={
"service_id": services[0]["id"],
"pet_id": pet["id"],
"quantity": 1
})
cart = cart_resp.json()
print(f"Cart total: ${cart['resume']['total']}")
# Step 6: Create service request
request_resp = requests.post(
f"{BASE_URL}/solicitudes/service-requests/",
params={"cart_id": cart["id"]}
)
service_request = request_resp.json()
print(f"Service request created: {service_request['id']}")
```
## Data Generator
This vein uses the independent `datagen` tool from `ward/tools/datagen/amar.py`.
See `ward/tools/datagen/README.md` for data generation details.
## Notes
- Mock database is in-memory (resets on server restart)
- Use `/mock/reset` to clear data during testing
- All IDs are randomly generated on creation
- Multi-pet discounts are automatically calculated

View File

@@ -0,0 +1 @@
"""Amar (MOCK) vein - Mock Amar API for testing."""

View File

@@ -0,0 +1 @@
"""API routes for Amar mock vein."""

View File

@@ -0,0 +1,302 @@
"""API routes for Amar (MOCK) vein - Mock Amar API for testing."""
import asyncio
import random
from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import JSONResponse
from typing import Optional, List, Dict, Any
from pydantic import BaseModel
# Import datagen from ward/tools
import sys
from pathlib import Path
ward_tools_path = Path(__file__).parent.parent.parent.parent.parent / "ward" / "tools"
sys.path.insert(0, str(ward_tools_path))
from datagen.amar import AmarDataGenerator
from ..core.config import settings
router = APIRouter()
# In-memory storage for mock data (reset on restart)
MOCK_DB = {
"petowners": {},
"pets": {},
"carts": {},
"service_requests": {},
}
# Request/Response Models
class CreatePetOwnerRequest(BaseModel):
address: str
email: Optional[str] = None
phone: Optional[str] = None
class CreatePetRequest(BaseModel):
owner_id: int
name: str
species: str # DOG, CAT
age: Optional[int] = None
age_unit: str = "years" # years, months
class CreateCartRequest(BaseModel):
owner_id: int
class AddCartItemRequest(BaseModel):
service_id: int
pet_id: int
quantity: int = 1
async def _mock_delay():
"""Add realistic delay if enabled."""
if settings.enable_random_delays:
delay_ms = random.randint(settings.min_delay_ms, settings.max_delay_ms)
await asyncio.sleep(delay_ms / 1000)
def _maybe_error():
"""Randomly raise an error based on error_rate."""
if random.random() < settings.error_rate:
raise HTTPException(500, "Mock error: Simulated failure")
@router.get("/health")
async def health():
"""Health check endpoint."""
return {
"status": "ok",
"vein": "Amar\n(MOCK)",
"message": "Mock Amar API for testing",
"_mock": True,
}
@router.post("/api/v1/pet-owners/")
async def create_petowner(request: CreatePetOwnerRequest):
"""Create a guest petowner (VET-536)."""
await _mock_delay()
_maybe_error()
owner = AmarDataGenerator.petowner(
address=request.address,
is_guest=True,
email=request.email,
phone=request.phone,
)
# Store in mock DB
MOCK_DB["petowners"][owner["id"]] = owner
owner["_mock"] = True
return owner
@router.get("/api/v1/pet-owners/{owner_id}")
async def get_petowner(owner_id: int):
"""Get petowner by ID."""
await _mock_delay()
_maybe_error()
owner = MOCK_DB["petowners"].get(owner_id)
if not owner:
raise HTTPException(404, f"PetOwner {owner_id} not found")
return owner
@router.post("/api/v1/pets/")
async def create_pet(request: CreatePetRequest):
"""Create a pet (VET-537)."""
await _mock_delay()
_maybe_error()
# Verify owner exists
if request.owner_id not in MOCK_DB["petowners"]:
raise HTTPException(404, f"Owner {request.owner_id} not found")
pet = AmarDataGenerator.pet(
owner_id=request.owner_id,
name=request.name,
species=request.species,
age_value=request.age,
age_unit=request.age_unit,
)
# Store in mock DB
MOCK_DB["pets"][pet["id"]] = pet
pet["_mock"] = True
return pet
@router.get("/api/v1/pets/{pet_id}")
async def get_pet(pet_id: int):
"""Get pet by ID."""
await _mock_delay()
_maybe_error()
pet = MOCK_DB["pets"].get(pet_id)
if not pet:
raise HTTPException(404, f"Pet {pet_id} not found")
return pet
@router.post("/api/v1/cart/")
async def create_cart(request: CreateCartRequest):
"""Create a cart (VET-538)."""
await _mock_delay()
_maybe_error()
# Verify owner exists
if request.owner_id not in MOCK_DB["petowners"]:
raise HTTPException(404, f"Owner {request.owner_id} not found")
cart = AmarDataGenerator.cart(owner_id=request.owner_id)
# Store in mock DB
MOCK_DB["carts"][cart["id"]] = cart
cart["_mock"] = True
return cart
@router.post("/api/v1/cart/{cart_id}/items/")
async def add_cart_item(cart_id: int, request: AddCartItemRequest):
"""Add item to cart and recalculate summary."""
await _mock_delay()
_maybe_error()
cart = MOCK_DB["carts"].get(cart_id)
if not cart:
raise HTTPException(404, f"Cart {cart_id} not found")
# Get service price
services = AmarDataGenerator.SERVICES
service = next((s for s in services if s["id"] == request.service_id), None)
if not service:
raise HTTPException(404, f"Service {request.service_id} not found")
# Add item
item = {
"service_id": request.service_id,
"service_name": service["name"],
"pet_id": request.pet_id,
"quantity": request.quantity,
"price": service["price"],
}
cart["items"].append(item)
# Recalculate summary
cart = AmarDataGenerator.calculate_cart_summary(cart, cart["items"])
MOCK_DB["carts"][cart_id] = cart
cart["_mock"] = True
return cart
@router.get("/api/v1/cart/{cart_id}")
async def get_cart(cart_id: int):
"""Get cart by ID."""
await _mock_delay()
_maybe_error()
cart = MOCK_DB["carts"].get(cart_id)
if not cart:
raise HTTPException(404, f"Cart {cart_id} not found")
return cart
@router.get("/api/v1/services/")
async def list_services(
species: Optional[str] = Query(None),
neighborhood_id: Optional[int] = Query(None),
):
"""List available services filtered by species and neighborhood (VET-540)."""
await _mock_delay()
_maybe_error()
services = AmarDataGenerator.filter_services(
species=species,
neighborhood_id=neighborhood_id,
)
for service in services:
service["_mock"] = True
return services
@router.get("/api/v1/categories/")
async def list_categories(
species: Optional[str] = Query(None),
neighborhood_id: Optional[int] = Query(None),
):
"""List categories with available services (VET-539)."""
await _mock_delay()
_maybe_error()
categories = AmarDataGenerator.filter_categories(
species=species,
neighborhood_id=neighborhood_id,
)
for category in categories:
category["_mock"] = True
return categories
@router.post("/solicitudes/service-requests/")
async def create_service_request(cart_id: int, requested_date: Optional[str] = None):
"""Create a service request."""
await _mock_delay()
_maybe_error()
cart = MOCK_DB["carts"].get(cart_id)
if not cart:
raise HTTPException(404, f"Cart {cart_id} not found")
request = AmarDataGenerator.service_request(
cart_id=cart_id,
requested_date=requested_date,
)
MOCK_DB["service_requests"][request["id"]] = request
request["_mock"] = True
return request
@router.get("/mock/reset")
async def reset_mock_db():
"""Reset the mock database (useful for testing)."""
MOCK_DB["petowners"].clear()
MOCK_DB["pets"].clear()
MOCK_DB["carts"].clear()
MOCK_DB["service_requests"].clear()
return {
"message": "Mock database reset",
"_mock": True,
}
@router.get("/mock/stats")
async def mock_stats():
"""Get mock database statistics."""
return {
"petowners": len(MOCK_DB["petowners"]),
"pets": len(MOCK_DB["pets"]),
"carts": len(MOCK_DB["carts"]),
"service_requests": len(MOCK_DB["service_requests"]),
"_mock": True,
}

View File

@@ -0,0 +1 @@
"""Core logic for Amar mock API."""

View File

@@ -0,0 +1,27 @@
"""Configuration for Amar mock vein."""
from pathlib import Path
from pydantic_settings import BaseSettings
ENV_FILE = Path(__file__).parent.parent / ".env"
class AmarMockConfig(BaseSettings):
"""Configuration for Amar (MOCK) vein."""
api_port: int = 8005
mock_data_path: str = "./mock_data"
# Mock behavior
enable_random_delays: bool = True
min_delay_ms: int = 100
max_delay_ms: int = 500
error_rate: float = 0.0 # 0.0 to 1.0
model_config = {
"env_file": ENV_FILE if ENV_FILE.exists() else None,
"env_file_encoding": "utf-8",
}
settings = AmarMockConfig()

View File

@@ -0,0 +1,40 @@
"""Amar (MOCK) Vein - FastAPI app."""
from pathlib import Path
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from fastapi.middleware.cors import CORSMiddleware
from .api.routes import router
from .core.config import settings
app = FastAPI(
title="Amar API (MOCK)",
description="Mock Amar API for testing - returns predictable test data",
version="0.1.0",
)
# Enable CORS for testing from frontend
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # In production, specify exact origins
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Templates for configuration UI
templates = Jinja2Templates(directory=Path(__file__).parent / "templates")
@app.get("/", response_class=HTMLResponse)
def index(request: Request):
"""Mock configuration UI."""
return templates.TemplateResponse("index.html", {"request": request})
# Include router at root (matches real Amar API structure)
app.include_router(router)
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=settings.api_port)

View File

@@ -2,4 +2,3 @@ fastapi>=0.104.0
uvicorn>=0.24.0
pydantic>=2.0.0
pydantic-settings>=2.0.0
httpx>=0.25.0

View File

@@ -0,0 +1,18 @@
"""Standalone runner for Amar mock vein."""
import logging
import uvicorn
from main import app
from core.config import settings
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
if __name__ == "__main__":
logger.info(f"Starting Amar (MOCK) vein on port {settings.api_port}")
logger.info(f"API docs: http://localhost:{settings.api_port}/docs")
logger.info(f"Health check: http://localhost:{settings.api_port}/health")
uvicorn.run(app, host="0.0.0.0", port=settings.api_port, reload=True)

View File

@@ -0,0 +1,298 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Amar API (MOCK) - Configuration</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #111827;
color: #e5e7eb;
padding: 20px;
}
.container { max-width: 1200px; margin: 0 auto; }
header {
background: #f59e0b;
color: #111827;
padding: 20px;
border-radius: 8px;
margin-bottom: 24px;
}
h1 { font-size: 1.5rem; font-weight: 600; margin-bottom: 8px; }
.subtitle { opacity: 0.8; font-size: 0.875rem; }
.mock-badge {
display: inline-block;
background: #111827;
color: #f59e0b;
padding: 4px 12px;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
margin-left: 12px;
}
.section {
background: #1f2937;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
}
.section-header {
font-size: 1.1rem;
font-weight: 600;
margin-bottom: 16px;
color: #f9fafb;
}
.endpoint-list { display: flex; flex-direction: column; gap: 12px; }
.endpoint-card {
background: #374151;
border: 2px solid transparent;
border-radius: 6px;
padding: 16px;
cursor: pointer;
transition: all 0.2s;
}
.endpoint-card:hover { border-color: #f59e0b; background: #4b5563; }
.endpoint-card.active { border-color: #f59e0b; background: #4b5563; }
.endpoint-method {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
margin-right: 8px;
}
.method-post { background: #10b981; color: white; }
.method-get { background: #3b82f6; color: white; }
.endpoint-path { font-family: monospace; font-size: 0.875rem; }
.endpoint-desc { font-size: 0.75rem; color: #9ca3af; margin-top: 6px; }
.form-group { margin-bottom: 16px; }
.form-label {
display: block;
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 6px;
color: #f9fafb;
}
.form-input, .form-textarea {
width: 100%;
padding: 10px 12px;
background: #374151;
border: 1px solid #4b5563;
border-radius: 6px;
color: #e5e7eb;
font-size: 0.875rem;
font-family: monospace;
}
.form-textarea { min-height: 200px; font-family: monospace; }
.form-input:focus, .form-textarea:focus {
outline: none;
border-color: #f59e0b;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.btn-primary {
background: #f59e0b;
color: #111827;
}
.btn-primary:hover { background: #d97706; }
.btn-secondary {
background: #4b5563;
color: #e5e7eb;
margin-left: 8px;
}
.btn-secondary:hover { background: #6b7280; }
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 12px;
}
.stat-card {
background: #374151;
padding: 16px;
border-radius: 6px;
}
.stat-value { font-size: 1.5rem; font-weight: 600; color: #f59e0b; }
.stat-label { font-size: 0.75rem; color: #9ca3af; margin-top: 4px; }
</style>
</head>
<body>
<div class="container">
<header>
<h1>Amar API <span class="mock-badge">MOCK</span></h1>
<div class="subtitle">Configure mock responses for Amar backend endpoints</div>
</header>
<!-- Stats -->
<div class="section">
<div class="section-header">Mock Database Stats</div>
<div class="stats" id="stats">
<div class="stat-card">
<div class="stat-value">-</div>
<div class="stat-label">Pet Owners</div>
</div>
<div class="stat-card">
<div class="stat-value">-</div>
<div class="stat-label">Pets</div>
</div>
<div class="stat-card">
<div class="stat-value">-</div>
<div class="stat-label">Carts</div>
</div>
<div class="stat-card">
<div class="stat-value">-</div>
<div class="stat-label">Requests</div>
</div>
</div>
<div style="margin-top: 16px;">
<button class="btn btn-secondary" onclick="resetMock()">Reset Database</button>
</div>
</div>
<!-- Endpoint Configuration -->
<div class="section">
<div class="section-header">Configure Endpoint Responses</div>
<div class="endpoint-list">
<div class="endpoint-card" onclick="selectEndpoint('POST', '/api/v1/pet-owners/', 'petowner')">
<div>
<span class="endpoint-method method-post">POST</span>
<span class="endpoint-path">/api/v1/pet-owners/</span>
</div>
<div class="endpoint-desc">Create guest pet owner (VET-536)</div>
</div>
<div class="endpoint-card" onclick="selectEndpoint('POST', '/api/v1/pets/', 'pet')">
<div>
<span class="endpoint-method method-post">POST</span>
<span class="endpoint-path">/api/v1/pets/</span>
</div>
<div class="endpoint-desc">Create pet (VET-537)</div>
</div>
<div class="endpoint-card" onclick="selectEndpoint('POST', '/api/v1/cart/', 'cart')">
<div>
<span class="endpoint-method method-post">POST</span>
<span class="endpoint-path">/api/v1/cart/</span>
</div>
<div class="endpoint-desc">Create cart (VET-538)</div>
</div>
<div class="endpoint-card" onclick="selectEndpoint('GET', '/api/v1/services/', 'services')">
<div>
<span class="endpoint-method method-get">GET</span>
<span class="endpoint-path">/api/v1/services/</span>
</div>
<div class="endpoint-desc">List services (VET-540)</div>
</div>
<div class="endpoint-card" onclick="selectEndpoint('GET', '/api/v1/categories/', 'categories')">
<div>
<span class="endpoint-method method-get">GET</span>
<span class="endpoint-path">/api/v1/categories/</span>
</div>
<div class="endpoint-desc">List categories (VET-539)</div>
</div>
</div>
</div>
<!-- Response Editor -->
<div class="section" id="responseEditor" style="display: none;">
<div class="section-header">Edit Response</div>
<div class="form-group">
<label class="form-label">Endpoint</label>
<input class="form-input" id="endpointDisplay" readonly>
</div>
<div class="form-group">
<label class="form-label">Mock Response (JSON)</label>
<textarea class="form-textarea" id="responseJson" placeholder='{"id": 123, "name": "Luna", "_mock": true}'></textarea>
</div>
<div class="form-group">
<label class="form-label">HTTP Status Code</label>
<input type="number" class="form-input" id="statusCode" value="200">
</div>
<div class="form-group">
<label class="form-label">Delay (ms)</label>
<input type="number" class="form-input" id="delay" value="0">
</div>
<div>
<button class="btn btn-primary" onclick="saveResponse()">Save Response</button>
<button class="btn btn-secondary" onclick="closeEditor()">Cancel</button>
</div>
</div>
<!-- Quick Test -->
<div class="section">
<div class="section-header">Quick Test</div>
<p style="color: #9ca3af; margin-bottom: 12px;">Test endpoint URL to hit for configured responses:</p>
<div class="form-input" style="background: #374151; user-select: all;">
http://localhost:8005/api/v1/pet-owners/
</div>
</div>
</div>
<script>
let selectedEndpoint = null;
async function loadStats() {
try {
const resp = await fetch('/mock/stats');
const data = await resp.json();
document.getElementById('stats').innerHTML = `
<div class="stat-card"><div class="stat-value">${data.pet_owners || 0}</div><div class="stat-label">Pet Owners</div></div>
<div class="stat-card"><div class="stat-value">${data.pets || 0}</div><div class="stat-label">Pets</div></div>
<div class="stat-card"><div class="stat-value">${data.carts || 0}</div><div class="stat-label">Carts</div></div>
<div class="stat-card"><div class="stat-value">${data.service_requests || 0}</div><div class="stat-label">Requests</div></div>
`;
} catch (e) {
console.error('Failed to load stats:', e);
}
}
async function resetMock() {
if (confirm('Reset all mock data?')) {
await fetch('/mock/reset');
loadStats();
alert('Mock database reset');
}
}
function selectEndpoint(method, path, type) {
selectedEndpoint = {method, path, type};
document.querySelectorAll('.endpoint-card').forEach(c => c.classList.remove('active'));
event.currentTarget.classList.add('active');
document.getElementById('responseEditor').style.display = 'block';
document.getElementById('endpointDisplay').value = `${method} ${path}`;
document.getElementById('responseJson').value = getDefaultResponse(type);
}
function getDefaultResponse(type) {
const defaults = {
petowner: JSON.stringify({"id": 1234, "address": "Av Santa Fe 1234", "is_guest": true, "_mock": true}, null, 2),
pet: JSON.stringify({"id": 5678, "name": "Luna", "species": "DOG", "age": 3, "_mock": true}, null, 2),
cart: JSON.stringify({"id": 9012, "items": [], "total": 0, "_mock": true}, null, 2),
services: JSON.stringify([{"id": 1, "name": "Vacunación", "price": 1500, "_mock": true}], null, 2),
categories: JSON.stringify([{"id": 1, "name": "Salud", "services": 5, "_mock": true}], null, 2)
};
return defaults[type] || '{}';
}
function saveResponse() {
alert('Mock response saved (feature pending implementation)');
}
function closeEditor() {
document.getElementById('responseEditor').style.display = 'none';
selectedEndpoint = null;
document.querySelectorAll('.endpoint-card').forEach(c => c.classList.remove('active'));
}
loadStats();
setInterval(loadStats, 5000);
</script>
</body>
</html>

View File

@@ -0,0 +1,184 @@
digraph BackendArchitecture {
// Graph settings
rankdir=TB
compound=true
splines=ortho
node [shape=box, style="rounded,filled", fontname="Helvetica", fontsize=11]
edge [fontname="Helvetica", fontsize=9]
// Color scheme
// Django Core: #092E20 (dark green)
// Mascotas: #4A90D9 (blue)
// Productos: #50C878 (green)
// Solicitudes: #FF6B6B (coral)
// Common: #9B59B6 (purple)
// External: #F39C12 (orange)
// Payments: #E74C3C (red)
label="AMAR Mascotas - Backend Architecture (Django)\n\n"
labelloc="t"
fontsize=16
fontname="Helvetica-Bold"
// Django Core Cluster
subgraph cluster_django_core {
label="Django Core"
style="rounded,filled"
fillcolor="#E8F5E9"
color="#2E7D32"
auth_user [label="auth.User\n(Django Auth)", fillcolor="#C8E6C9"]
django_admin [label="Django Admin\nInterface", fillcolor="#C8E6C9"]
drf [label="Django REST\nFramework", fillcolor="#C8E6C9"]
jwt_auth [label="JWT Authentication\n(SimpleJWT)", fillcolor="#C8E6C9"]
}
// Mascotas App Cluster
subgraph cluster_mascotas {
label="mascotas (Pets & Veterinarians)"
style="rounded,filled"
fillcolor="#E3F2FD"
color="#1565C0"
petowner [label="PetOwner\n(Cliente/Tutor)", fillcolor="#BBDEFB"]
pet [label="Pet\n(Mascota)", fillcolor="#BBDEFB"]
veterinarian [label="Veterinarian\n(Profesional)", fillcolor="#BBDEFB"]
vetvisit [label="VetVisit\n(Consulta)", fillcolor="#BBDEFB"]
vetvisitreport [label="VetVisitReport\n(Informe Clinico)", fillcolor="#BBDEFB"]
availability [label="Availability /\nUnavailability", fillcolor="#BBDEFB"]
pet_health [label="PetVaccine /\nPetStudy", fillcolor="#BBDEFB"]
}
// Productos App Cluster
subgraph cluster_productos {
label="productos (Services & Pricing)"
style="rounded,filled"
fillcolor="#E8F5E9"
color="#2E7D32"
service [label="Service\n(Servicio)", fillcolor="#C8E6C9"]
category [label="Category / Group\n(Categorias)", fillcolor="#C8E6C9"]
prices [label="Prices\n(Precios)", fillcolor="#C8E6C9"]
discounts [label="Discounts\n(Descuentos)", fillcolor="#C8E6C9"]
cart [label="Cart / CartItem\n(Carrito)", fillcolor="#C8E6C9"]
combo [label="ServiceCombo\n(Paquetes)", fillcolor="#C8E6C9"]
}
// Solicitudes App Cluster
subgraph cluster_solicitudes {
label="solicitudes (Service Requests)"
style="rounded,filled"
fillcolor="#FFEBEE"
color="#C62828"
servicerequest [label="ServiceRequest\n(Solicitud)", fillcolor="#FFCDD2"]
statehistory [label="StateHistory\n(Historial)", fillcolor="#FFCDD2"]
vetasked [label="VeterinarianAsked\n(Vet Consultado)", fillcolor="#FFCDD2"]
reminders [label="Reminders\n(Recordatorios)", fillcolor="#FFCDD2"]
}
// Common App Cluster
subgraph cluster_common {
label="common (Shared Models)"
style="rounded,filled"
fillcolor="#F3E5F5"
color="#7B1FA2"
campaign [label="Campaign\n(Marketing)", fillcolor="#E1BEE7"]
tag [label="Tag\n(Etiquetas)", fillcolor="#E1BEE7"]
specialty [label="Specialty\n(Especialidades)", fillcolor="#E1BEE7"]
medication [label="Medication\n(Medicamentos)", fillcolor="#E1BEE7"]
breed [label="PetBreed / Vaccine\n/ Study", fillcolor="#E1BEE7"]
neighborhood [label="Neighborhood /\nProvince / Locality", fillcolor="#E1BEE7"]
turnfee [label="IndividualTurnFee\nGroup", fillcolor="#E1BEE7"]
}
// Payments App Cluster
subgraph cluster_payments {
label="payments (Payment Processing)"
style="rounded,filled"
fillcolor="#FCE4EC"
color="#AD1457"
mercadopago [label="MercadoPago\nAccount", fillcolor="#F8BBD9"]
mpnotification [label="MP Notification\n(Webhooks)", fillcolor="#F8BBD9"]
}
// External Integrations Cluster
subgraph cluster_external {
label="External Integrations"
style="rounded,filled"
fillcolor="#FFF3E0"
color="#E65100"
google_cal [label="Google Calendar\n(Agenda)", fillcolor="#FFE0B2"]
google_sheets [label="Google Sheets\n(Exports)", fillcolor="#FFE0B2"]
mercately [label="Mercately\n(WhatsApp)", fillcolor="#FFE0B2"]
afip [label="AFIP\n(Facturacion)", fillcolor="#FFE0B2"]
celery [label="Celery\n(Async Tasks)", fillcolor="#FFE0B2"]
}
// AFIP Integration
subgraph cluster_afip {
label="django_afip (Invoicing)"
style="rounded,filled"
fillcolor="#FFFDE7"
color="#F9A825"
receipt [label="Receipt\n(Comprobante)", fillcolor="#FFF9C4"]
taxpayer [label="TaxPayer\n(Contribuyente)", fillcolor="#FFF9C4"]
pos [label="PointOfSales\n(Punto de Venta)", fillcolor="#FFF9C4"]
}
// Relationships - Core
auth_user -> petowner [label="1:1 optional", color="#666"]
auth_user -> veterinarian [label="1:1", color="#666"]
drf -> jwt_auth [style=dashed, color="#999"]
// Relationships - Mascotas
petowner -> pet [label="1:N", color="#1565C0"]
petowner -> servicerequest [label="1:N", color="#1565C0"]
pet -> vetvisit [label="N:M", color="#1565C0"]
pet -> pet_health [label="1:N", color="#1565C0"]
veterinarian -> vetvisit [label="1:N", color="#1565C0"]
veterinarian -> availability [label="1:N", color="#1565C0"]
vetvisit -> vetvisitreport [label="1:N", color="#1565C0"]
vetvisit -> servicerequest [label="1:1", style=dashed, color="#1565C0"]
// Relationships - Productos
service -> category [label="N:1", color="#2E7D32"]
service -> prices [label="1:N", color="#2E7D32"]
service -> discounts [label="1:N", color="#2E7D32"]
cart -> petowner [label="N:1", color="#2E7D32"]
cart -> veterinarian [label="N:1 optional", color="#2E7D32", style=dashed]
service -> cart [label="via CartItem", color="#2E7D32"]
combo -> service [label="contains", color="#2E7D32"]
// Relationships - Solicitudes
servicerequest -> cart [label="1:1", color="#C62828"]
servicerequest -> statehistory [label="1:N", color="#C62828"]
servicerequest -> vetasked [label="1:N", color="#C62828"]
vetasked -> reminders [label="1:N", color="#C62828"]
veterinarian -> vetasked [label="N:1", color="#C62828"]
// Relationships - Common
petowner -> neighborhood [label="N:1", color="#7B1FA2"]
veterinarian -> specialty [label="N:M", color="#7B1FA2"]
veterinarian -> neighborhood [label="N:M coverage", color="#7B1FA2"]
servicerequest -> campaign [label="N:1 optional", color="#7B1FA2", style=dashed]
servicerequest -> tag [label="N:M", color="#7B1FA2"]
vetvisitreport -> medication [label="references", color="#7B1FA2", style=dashed]
veterinarian -> turnfee [label="N:M", color="#7B1FA2"]
// Relationships - Payments & External
servicerequest -> mercadopago [label="payment", color="#AD1457"]
mpnotification -> servicerequest [label="confirms", color="#AD1457"]
vetvisit -> google_cal [label="sync", color="#E65100", style=dashed]
servicerequest -> mercately [label="notify", color="#E65100", style=dashed]
reminders -> celery [label="scheduled", color="#E65100", style=dashed]
// AFIP relationships
vetvisit -> receipt [label="1:1 optional", color="#F9A825", style=dashed]
receipt -> taxpayer [label="N:1", color="#F9A825"]
receipt -> pos [label="N:1", color="#F9A825"]
}

View File

@@ -0,0 +1,585 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Generated by graphviz version 14.0.5 (0)
-->
<!-- Title: BackendArchitecture Pages: 1 -->
<svg width="1559pt" height="935pt"
viewBox="0.00 0.00 1559.00 935.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 931.4)">
<title>BackendArchitecture</title>
<polygon fill="white" stroke="none" points="-4,4 -4,-931.4 1555,-931.4 1555,4 -4,4"/>
<text xml:space="preserve" text-anchor="middle" x="775.5" y="-908.2" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="16.00">AMAR Mascotas &#45; Backend Architecture (Django)</text>
<g id="clust1" class="cluster">
<title>cluster_django_core</title>
<path fill="#e8f5e9" stroke="#2e7d32" d="M169,-496.4C169,-496.4 449,-496.4 449,-496.4 455,-496.4 461,-502.4 461,-508.4 461,-508.4 461,-726 461,-726 461,-732 455,-738 449,-738 449,-738 169,-738 169,-738 163,-738 157,-732 157,-726 157,-726 157,-508.4 157,-508.4 157,-502.4 163,-496.4 169,-496.4"/>
<text xml:space="preserve" text-anchor="middle" x="309" y="-718.8" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="16.00">Django Core</text>
</g>
<g id="clust2" class="cluster">
<title>cluster_mascotas</title>
<path fill="#e3f2fd" stroke="#1565c0" d="M481,-143.2C481,-143.2 733,-143.2 733,-143.2 739,-143.2 745,-149.2 745,-155.2 745,-155.2 745,-590.8 745,-590.8 745,-596.8 739,-602.8 733,-602.8 733,-602.8 481,-602.8 481,-602.8 475,-602.8 469,-596.8 469,-590.8 469,-590.8 469,-155.2 469,-155.2 469,-149.2 475,-143.2 481,-143.2"/>
<text xml:space="preserve" text-anchor="middle" x="607" y="-583.6" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="16.00">mascotas (Pets &amp; Veterinarians)</text>
</g>
<g id="clust3" class="cluster">
<title>cluster_productos</title>
<path fill="#e8f5e9" stroke="#2e7d32" d="M765,-496.4C765,-496.4 1141,-496.4 1141,-496.4 1147,-496.4 1153,-502.4 1153,-508.4 1153,-508.4 1153,-861.2 1153,-861.2 1153,-867.2 1147,-873.2 1141,-873.2 1141,-873.2 765,-873.2 765,-873.2 759,-873.2 753,-867.2 753,-861.2 753,-861.2 753,-508.4 753,-508.4 753,-502.4 759,-496.4 765,-496.4"/>
<text xml:space="preserve" text-anchor="middle" x="953" y="-854" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="16.00">productos (Services &amp; Pricing)</text>
</g>
<g id="clust4" class="cluster">
<title>cluster_solicitudes</title>
<path fill="#ffebee" stroke="#c62828" d="M1084,-143.2C1084,-143.2 1290,-143.2 1290,-143.2 1296,-143.2 1302,-149.2 1302,-155.2 1302,-155.2 1302,-455.6 1302,-455.6 1302,-461.6 1296,-467.6 1290,-467.6 1290,-467.6 1084,-467.6 1084,-467.6 1078,-467.6 1072,-461.6 1072,-455.6 1072,-455.6 1072,-155.2 1072,-155.2 1072,-149.2 1078,-143.2 1084,-143.2"/>
<text xml:space="preserve" text-anchor="middle" x="1187" y="-448.4" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="16.00">solicitudes (Service Requests)</text>
</g>
<g id="clust5" class="cluster">
<title>cluster_common</title>
<path fill="#f3e5f5" stroke="#7b1fa2" d="M20,-8C20,-8 774,-8 774,-8 780,-8 786,-14 786,-20 786,-20 786,-102.4 786,-102.4 786,-108.4 780,-114.4 774,-114.4 774,-114.4 20,-114.4 20,-114.4 14,-114.4 8,-108.4 8,-102.4 8,-102.4 8,-20 8,-20 8,-14 14,-8 20,-8"/>
<text xml:space="preserve" text-anchor="middle" x="397" y="-95.2" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="16.00">common (Shared Models)</text>
</g>
<g id="clust6" class="cluster">
<title>cluster_payments</title>
<path fill="#fce4ec" stroke="#ad1457" d="M1173,-496.4C1173,-496.4 1395,-496.4 1395,-496.4 1401,-496.4 1407,-502.4 1407,-508.4 1407,-508.4 1407,-590.8 1407,-590.8 1407,-596.8 1401,-602.8 1395,-602.8 1395,-602.8 1173,-602.8 1173,-602.8 1167,-602.8 1161,-596.8 1161,-590.8 1161,-590.8 1161,-508.4 1161,-508.4 1161,-502.4 1167,-496.4 1173,-496.4"/>
<text xml:space="preserve" text-anchor="middle" x="1284" y="-583.6" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="16.00">payments (Payment Processing)</text>
</g>
<g id="clust7" class="cluster">
<title>cluster_external</title>
<path fill="#fff3e0" stroke="#e65100" d="M1039,-8C1039,-8 1531,-8 1531,-8 1537,-8 1543,-14 1543,-20 1543,-20 1543,-102.4 1543,-102.4 1543,-108.4 1537,-114.4 1531,-114.4 1531,-114.4 1039,-114.4 1039,-114.4 1033,-114.4 1027,-108.4 1027,-102.4 1027,-102.4 1027,-20 1027,-20 1027,-14 1033,-8 1039,-8"/>
<text xml:space="preserve" text-anchor="middle" x="1285" y="-95.2" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="16.00">External Integrations</text>
</g>
<g id="clust8" class="cluster">
<title>cluster_afip</title>
<path fill="#fffde7" stroke="#f9a825" d="M806,-8C806,-8 1007,-8 1007,-8 1013,-8 1019,-14 1019,-20 1019,-20 1019,-237.6 1019,-237.6 1019,-243.6 1013,-249.6 1007,-249.6 1007,-249.6 806,-249.6 806,-249.6 800,-249.6 794,-243.6 794,-237.6 794,-237.6 794,-20 794,-20 794,-14 800,-8 806,-8"/>
<text xml:space="preserve" text-anchor="middle" x="906.5" y="-230.4" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="16.00">django_afip (Invoicing)</text>
</g>
<!-- auth_user -->
<g id="node1" class="node">
<title>auth_user</title>
<path fill="#c8e6c9" stroke="black" d="M440.93,-675.6C440.93,-675.6 381.07,-675.6 381.07,-675.6 375.07,-675.6 369.07,-669.6 369.07,-663.6 369.07,-663.6 369.07,-651.6 369.07,-651.6 369.07,-645.6 375.07,-639.6 381.07,-639.6 381.07,-639.6 440.93,-639.6 440.93,-639.6 446.93,-639.6 452.93,-645.6 452.93,-651.6 452.93,-651.6 452.93,-663.6 452.93,-663.6 452.93,-669.6 446.93,-675.6 440.93,-675.6"/>
<text xml:space="preserve" text-anchor="middle" x="411" y="-660.9" font-family="Helvetica,sans-Serif" font-size="11.00">auth.User</text>
<text xml:space="preserve" text-anchor="middle" x="411" y="-647.7" font-family="Helvetica,sans-Serif" font-size="11.00">(Django Auth)</text>
</g>
<!-- petowner -->
<g id="node5" class="node">
<title>petowner</title>
<path fill="#bbdefb" stroke="black" d="M725.15,-540.4C725.15,-540.4 662.85,-540.4 662.85,-540.4 656.85,-540.4 650.85,-534.4 650.85,-528.4 650.85,-528.4 650.85,-516.4 650.85,-516.4 650.85,-510.4 656.85,-504.4 662.85,-504.4 662.85,-504.4 725.15,-504.4 725.15,-504.4 731.15,-504.4 737.15,-510.4 737.15,-516.4 737.15,-516.4 737.15,-528.4 737.15,-528.4 737.15,-534.4 731.15,-540.4 725.15,-540.4"/>
<text xml:space="preserve" text-anchor="middle" x="694" y="-525.7" font-family="Helvetica,sans-Serif" font-size="11.00">PetOwner</text>
<text xml:space="preserve" text-anchor="middle" x="694" y="-512.5" font-family="Helvetica,sans-Serif" font-size="11.00">(Cliente/Tutor)</text>
</g>
<!-- auth_user&#45;&gt;petowner -->
<g id="edge1" class="edge">
<title>auth_user&#45;&gt;petowner</title>
<path fill="none" stroke="#666666" d="M424.94,-639.31C424.94,-602.3 424.94,-522 424.94,-522 424.94,-522 638.96,-522 638.96,-522"/>
<polygon fill="#666666" stroke="#666666" points="638.96,-525.5 648.96,-522 638.96,-518.5 638.96,-525.5"/>
<text xml:space="preserve" text-anchor="middle" x="645.27" y="-613.5" font-family="Helvetica,sans-Serif" font-size="9.00">1:1 optional</text>
</g>
<!-- veterinarian -->
<g id="node7" class="node">
<title>veterinarian</title>
<path fill="#bbdefb" stroke="black" d="M544.18,-405.2C544.18,-405.2 489.82,-405.2 489.82,-405.2 483.82,-405.2 477.82,-399.2 477.82,-393.2 477.82,-393.2 477.82,-381.2 477.82,-381.2 477.82,-375.2 483.82,-369.2 489.82,-369.2 489.82,-369.2 544.18,-369.2 544.18,-369.2 550.18,-369.2 556.18,-375.2 556.18,-381.2 556.18,-381.2 556.18,-393.2 556.18,-393.2 556.18,-399.2 550.18,-405.2 544.18,-405.2"/>
<text xml:space="preserve" text-anchor="middle" x="517" y="-390.5" font-family="Helvetica,sans-Serif" font-size="11.00">Veterinarian</text>
<text xml:space="preserve" text-anchor="middle" x="517" y="-377.3" font-family="Helvetica,sans-Serif" font-size="11.00">(Profesional)</text>
</g>
<!-- auth_user&#45;&gt;veterinarian -->
<g id="edge2" class="edge">
<title>auth_user&#45;&gt;veterinarian</title>
<path fill="none" stroke="#666666" d="M453.39,-664C477.9,-664 503.94,-664 503.94,-664 503.94,-664 503.94,-416.88 503.94,-416.88"/>
<polygon fill="#666666" stroke="#666666" points="507.44,-416.88 503.94,-406.88 500.44,-416.88 507.44,-416.88"/>
<text xml:space="preserve" text-anchor="middle" x="470.26" y="-519.7" font-family="Helvetica,sans-Serif" font-size="9.00">1:1</text>
</g>
<!-- django_admin -->
<g id="node2" class="node">
<title>django_admin</title>
<path fill="#c8e6c9" stroke="black" d="M339.55,-675.6C339.55,-675.6 278.45,-675.6 278.45,-675.6 272.45,-675.6 266.45,-669.6 266.45,-663.6 266.45,-663.6 266.45,-651.6 266.45,-651.6 266.45,-645.6 272.45,-639.6 278.45,-639.6 278.45,-639.6 339.55,-639.6 339.55,-639.6 345.55,-639.6 351.55,-645.6 351.55,-651.6 351.55,-651.6 351.55,-663.6 351.55,-663.6 351.55,-669.6 345.55,-675.6 339.55,-675.6"/>
<text xml:space="preserve" text-anchor="middle" x="309" y="-660.9" font-family="Helvetica,sans-Serif" font-size="11.00">Django Admin</text>
<text xml:space="preserve" text-anchor="middle" x="309" y="-647.7" font-family="Helvetica,sans-Serif" font-size="11.00">Interface</text>
</g>
<!-- drf -->
<g id="node3" class="node">
<title>drf</title>
<path fill="#c8e6c9" stroke="black" d="M236.63,-675.6C236.63,-675.6 177.37,-675.6 177.37,-675.6 171.37,-675.6 165.37,-669.6 165.37,-663.6 165.37,-663.6 165.37,-651.6 165.37,-651.6 165.37,-645.6 171.37,-639.6 177.37,-639.6 177.37,-639.6 236.63,-639.6 236.63,-639.6 242.63,-639.6 248.63,-645.6 248.63,-651.6 248.63,-651.6 248.63,-663.6 248.63,-663.6 248.63,-669.6 242.63,-675.6 236.63,-675.6"/>
<text xml:space="preserve" text-anchor="middle" x="207" y="-660.9" font-family="Helvetica,sans-Serif" font-size="11.00">Django REST</text>
<text xml:space="preserve" text-anchor="middle" x="207" y="-647.7" font-family="Helvetica,sans-Serif" font-size="11.00">Framework</text>
</g>
<!-- jwt_auth -->
<g id="node4" class="node">
<title>jwt_auth</title>
<path fill="#c8e6c9" stroke="black" d="M264.69,-540.4C264.69,-540.4 177.31,-540.4 177.31,-540.4 171.31,-540.4 165.31,-534.4 165.31,-528.4 165.31,-528.4 165.31,-516.4 165.31,-516.4 165.31,-510.4 171.31,-504.4 177.31,-504.4 177.31,-504.4 264.69,-504.4 264.69,-504.4 270.69,-504.4 276.69,-510.4 276.69,-516.4 276.69,-516.4 276.69,-528.4 276.69,-528.4 276.69,-534.4 270.69,-540.4 264.69,-540.4"/>
<text xml:space="preserve" text-anchor="middle" x="221" y="-525.7" font-family="Helvetica,sans-Serif" font-size="11.00">JWT Authentication</text>
<text xml:space="preserve" text-anchor="middle" x="221" y="-512.5" font-family="Helvetica,sans-Serif" font-size="11.00">(SimpleJWT)</text>
</g>
<!-- drf&#45;&gt;jwt_auth -->
<g id="edge3" class="edge">
<title>drf&#45;&gt;jwt_auth</title>
<path fill="none" stroke="#999999" stroke-dasharray="5,2" d="M207,-639.37C207,-639.37 207,-552.29 207,-552.29"/>
<polygon fill="#999999" stroke="#999999" points="210.5,-552.29 207,-542.29 203.5,-552.29 210.5,-552.29"/>
</g>
<!-- pet -->
<g id="node6" class="node">
<title>pet</title>
<path fill="#bbdefb" stroke="black" d="M714.45,-405.2C714.45,-405.2 673.55,-405.2 673.55,-405.2 667.55,-405.2 661.55,-399.2 661.55,-393.2 661.55,-393.2 661.55,-381.2 661.55,-381.2 661.55,-375.2 667.55,-369.2 673.55,-369.2 673.55,-369.2 714.45,-369.2 714.45,-369.2 720.45,-369.2 726.45,-375.2 726.45,-381.2 726.45,-381.2 726.45,-393.2 726.45,-393.2 726.45,-399.2 720.45,-405.2 714.45,-405.2"/>
<text xml:space="preserve" text-anchor="middle" x="694" y="-390.5" font-family="Helvetica,sans-Serif" font-size="11.00">Pet</text>
<text xml:space="preserve" text-anchor="middle" x="694" y="-377.3" font-family="Helvetica,sans-Serif" font-size="11.00">(Mascota)</text>
</g>
<!-- petowner&#45;&gt;pet -->
<g id="edge4" class="edge">
<title>petowner&#45;&gt;pet</title>
<path fill="none" stroke="#1565c0" d="M694,-504.17C694,-504.17 694,-417.09 694,-417.09"/>
<polygon fill="#1565c0" stroke="#1565c0" points="697.5,-417.09 694,-407.09 690.5,-417.09 697.5,-417.09"/>
<text xml:space="preserve" text-anchor="middle" x="701" y="-478.3" font-family="Helvetica,sans-Serif" font-size="9.00">1:N</text>
</g>
<!-- servicerequest -->
<g id="node18" class="node">
<title>servicerequest</title>
<path fill="#ffcdd2" stroke="black" d="M1163.82,-405.2C1163.82,-405.2 1094.18,-405.2 1094.18,-405.2 1088.18,-405.2 1082.18,-399.2 1082.18,-393.2 1082.18,-393.2 1082.18,-381.2 1082.18,-381.2 1082.18,-375.2 1088.18,-369.2 1094.18,-369.2 1094.18,-369.2 1163.82,-369.2 1163.82,-369.2 1169.82,-369.2 1175.82,-375.2 1175.82,-381.2 1175.82,-381.2 1175.82,-393.2 1175.82,-393.2 1175.82,-399.2 1169.82,-405.2 1163.82,-405.2"/>
<text xml:space="preserve" text-anchor="middle" x="1129" y="-390.5" font-family="Helvetica,sans-Serif" font-size="11.00">ServiceRequest</text>
<text xml:space="preserve" text-anchor="middle" x="1129" y="-377.3" font-family="Helvetica,sans-Serif" font-size="11.00">(Solicitud)</text>
</g>
<!-- petowner&#45;&gt;servicerequest -->
<g id="edge5" class="edge">
<title>petowner&#45;&gt;servicerequest</title>
<path fill="none" stroke="#1565c0" d="M729.12,-504.02C729.12,-468.96 729.12,-396 729.12,-396 729.12,-396 1070.44,-396 1070.44,-396"/>
<polygon fill="#1565c0" stroke="#1565c0" points="1070.44,-399.5 1080.44,-396 1070.44,-392.5 1070.44,-399.5"/>
<text xml:space="preserve" text-anchor="middle" x="1005" y="-478.3" font-family="Helvetica,sans-Serif" font-size="9.00">1:N</text>
</g>
<!-- neighborhood -->
<g id="node27" class="node">
<title>neighborhood</title>
<path fill="#e1bee7" stroke="black" d="M109.63,-52C109.63,-52 28.37,-52 28.37,-52 22.37,-52 16.37,-46 16.37,-40 16.37,-40 16.37,-28 16.37,-28 16.37,-22 22.37,-16 28.37,-16 28.37,-16 109.63,-16 109.63,-16 115.63,-16 121.63,-22 121.63,-28 121.63,-28 121.63,-40 121.63,-40 121.63,-46 115.63,-52 109.63,-52"/>
<text xml:space="preserve" text-anchor="middle" x="69" y="-37.3" font-family="Helvetica,sans-Serif" font-size="11.00">Neighborhood /</text>
<text xml:space="preserve" text-anchor="middle" x="69" y="-24.1" font-family="Helvetica,sans-Serif" font-size="11.00">Province / Locality</text>
</g>
<!-- petowner&#45;&gt;neighborhood -->
<g id="edge24" class="edge">
<title>petowner&#45;&gt;neighborhood</title>
<path fill="none" stroke="#7b1fa2" d="M655.77,-504.11C655.77,-441.12 655.77,-237 655.77,-237 655.77,-237 86.54,-237 86.54,-237 86.54,-237 86.54,-63.89 86.54,-63.89"/>
<polygon fill="#7b1fa2" stroke="#7b1fa2" points="90.04,-63.89 86.54,-53.89 83.04,-63.89 90.04,-63.89"/>
<text xml:space="preserve" text-anchor="middle" x="86" y="-301.7" font-family="Helvetica,sans-Serif" font-size="9.00">N:1</text>
</g>
<!-- vetvisit -->
<g id="node8" class="node">
<title>vetvisit</title>
<path fill="#bbdefb" stroke="black" d="M725.37,-322.4C725.37,-322.4 682.63,-322.4 682.63,-322.4 676.63,-322.4 670.63,-316.4 670.63,-310.4 670.63,-310.4 670.63,-298.4 670.63,-298.4 670.63,-292.4 676.63,-286.4 682.63,-286.4 682.63,-286.4 725.37,-286.4 725.37,-286.4 731.37,-286.4 737.37,-292.4 737.37,-298.4 737.37,-298.4 737.37,-310.4 737.37,-310.4 737.37,-316.4 731.37,-322.4 725.37,-322.4"/>
<text xml:space="preserve" text-anchor="middle" x="704" y="-307.7" font-family="Helvetica,sans-Serif" font-size="11.00">VetVisit</text>
<text xml:space="preserve" text-anchor="middle" x="704" y="-294.5" font-family="Helvetica,sans-Serif" font-size="11.00">(Consulta)</text>
</g>
<!-- pet&#45;&gt;vetvisit -->
<g id="edge6" class="edge">
<title>pet&#45;&gt;vetvisit</title>
<path fill="none" stroke="#1565c0" d="M698.54,-368.82C698.54,-368.82 698.54,-334.24 698.54,-334.24"/>
<polygon fill="#1565c0" stroke="#1565c0" points="702.04,-334.24 698.54,-324.24 695.04,-334.24 702.04,-334.24"/>
<text xml:space="preserve" text-anchor="middle" x="710.25" y="-343.1" font-family="Helvetica,sans-Serif" font-size="9.00">N:M</text>
</g>
<!-- pet_health -->
<g id="node11" class="node">
<title>pet_health</title>
<path fill="#bbdefb" stroke="black" d="M640.88,-322.4C640.88,-322.4 587.12,-322.4 587.12,-322.4 581.12,-322.4 575.12,-316.4 575.12,-310.4 575.12,-310.4 575.12,-298.4 575.12,-298.4 575.12,-292.4 581.12,-286.4 587.12,-286.4 587.12,-286.4 640.88,-286.4 640.88,-286.4 646.88,-286.4 652.88,-292.4 652.88,-298.4 652.88,-298.4 652.88,-310.4 652.88,-310.4 652.88,-316.4 646.88,-322.4 640.88,-322.4"/>
<text xml:space="preserve" text-anchor="middle" x="614" y="-307.7" font-family="Helvetica,sans-Serif" font-size="11.00">PetVaccine /</text>
<text xml:space="preserve" text-anchor="middle" x="614" y="-294.5" font-family="Helvetica,sans-Serif" font-size="11.00">PetStudy</text>
</g>
<!-- pet&#45;&gt;pet_health -->
<g id="edge7" class="edge">
<title>pet&#45;&gt;pet_health</title>
<path fill="none" stroke="#1565c0" d="M666.09,-368.72C666.09,-342.59 666.09,-298 666.09,-298 666.09,-298 664.73,-298 664.73,-298"/>
<polygon fill="#1565c0" stroke="#1565c0" points="664.73,-294.5 654.73,-298 664.73,-301.5 664.73,-294.5"/>
<text xml:space="preserve" text-anchor="middle" x="623" y="-343.1" font-family="Helvetica,sans-Serif" font-size="9.00">1:N</text>
</g>
<!-- veterinarian&#45;&gt;vetvisit -->
<g id="edge8" class="edge">
<title>veterinarian&#45;&gt;vetvisit</title>
<path fill="none" stroke="#1565c0" d="M556.46,-387C598.38,-387 658.66,-387 658.66,-387 658.66,-387 658.66,-310 658.66,-310 658.66,-310 659.85,-310 659.85,-310"/>
<polygon fill="#1565c0" stroke="#1565c0" points="659.06,-313.5 669.06,-310 659.06,-306.5 659.06,-313.5"/>
<text xml:space="preserve" text-anchor="middle" x="668" y="-343.1" font-family="Helvetica,sans-Serif" font-size="9.00">1:N</text>
</g>
<!-- availability -->
<g id="node10" class="node">
<title>availability</title>
<path fill="#bbdefb" stroke="black" d="M545.4,-322.4C545.4,-322.4 488.6,-322.4 488.6,-322.4 482.6,-322.4 476.6,-316.4 476.6,-310.4 476.6,-310.4 476.6,-298.4 476.6,-298.4 476.6,-292.4 482.6,-286.4 488.6,-286.4 488.6,-286.4 545.4,-286.4 545.4,-286.4 551.4,-286.4 557.4,-292.4 557.4,-298.4 557.4,-298.4 557.4,-310.4 557.4,-310.4 557.4,-316.4 551.4,-322.4 545.4,-322.4"/>
<text xml:space="preserve" text-anchor="middle" x="517" y="-307.7" font-family="Helvetica,sans-Serif" font-size="11.00">Availability /</text>
<text xml:space="preserve" text-anchor="middle" x="517" y="-294.5" font-family="Helvetica,sans-Serif" font-size="11.00">Unavailability</text>
</g>
<!-- veterinarian&#45;&gt;availability -->
<g id="edge9" class="edge">
<title>veterinarian&#45;&gt;availability</title>
<path fill="none" stroke="#1565c0" d="M503.94,-368.82C503.94,-368.82 503.94,-334.24 503.94,-334.24"/>
<polygon fill="#1565c0" stroke="#1565c0" points="507.44,-334.24 503.94,-324.24 500.44,-334.24 507.44,-334.24"/>
<text xml:space="preserve" text-anchor="middle" x="524" y="-343.1" font-family="Helvetica,sans-Serif" font-size="9.00">1:N</text>
</g>
<!-- vetasked -->
<g id="node20" class="node">
<title>vetasked</title>
<path fill="#ffcdd2" stroke="black" d="M1173.94,-322.4C1173.94,-322.4 1092.06,-322.4 1092.06,-322.4 1086.06,-322.4 1080.06,-316.4 1080.06,-310.4 1080.06,-310.4 1080.06,-298.4 1080.06,-298.4 1080.06,-292.4 1086.06,-286.4 1092.06,-286.4 1092.06,-286.4 1173.94,-286.4 1173.94,-286.4 1179.94,-286.4 1185.94,-292.4 1185.94,-298.4 1185.94,-298.4 1185.94,-310.4 1185.94,-310.4 1185.94,-316.4 1179.94,-322.4 1173.94,-322.4"/>
<text xml:space="preserve" text-anchor="middle" x="1133" y="-307.7" font-family="Helvetica,sans-Serif" font-size="11.00">VeterinarianAsked</text>
<text xml:space="preserve" text-anchor="middle" x="1133" y="-294.5" font-family="Helvetica,sans-Serif" font-size="11.00">(Vet Consultado)</text>
</g>
<!-- veterinarian&#45;&gt;vetasked -->
<g id="edge23" class="edge">
<title>veterinarian&#45;&gt;vetasked</title>
<path fill="none" stroke="#c62828" d="M530.06,-368.88C530.06,-354.89 530.06,-338 530.06,-338 530.06,-338 1129,-338 1129,-338 1129,-338 1129,-334.29 1129,-334.29"/>
<polygon fill="#c62828" stroke="#c62828" points="1132.5,-334.29 1129,-324.29 1125.5,-334.29 1132.5,-334.29"/>
<text xml:space="preserve" text-anchor="middle" x="959" y="-343.1" font-family="Helvetica,sans-Serif" font-size="9.00">N:1</text>
</g>
<!-- specialty -->
<g id="node24" class="node">
<title>specialty</title>
<path fill="#e1bee7" stroke="black" d="M224.66,-52C224.66,-52 151.34,-52 151.34,-52 145.34,-52 139.34,-46 139.34,-40 139.34,-40 139.34,-28 139.34,-28 139.34,-22 145.34,-16 151.34,-16 151.34,-16 224.66,-16 224.66,-16 230.66,-16 236.66,-22 236.66,-28 236.66,-28 236.66,-40 236.66,-40 236.66,-46 230.66,-52 224.66,-52"/>
<text xml:space="preserve" text-anchor="middle" x="188" y="-37.3" font-family="Helvetica,sans-Serif" font-size="11.00">Specialty</text>
<text xml:space="preserve" text-anchor="middle" x="188" y="-24.1" font-family="Helvetica,sans-Serif" font-size="11.00">(Especialidades)</text>
</g>
<!-- veterinarian&#45;&gt;specialty -->
<g id="edge25" class="edge">
<title>veterinarian&#45;&gt;specialty</title>
<path fill="none" stroke="#7b1fa2" d="M477.62,-387C392.9,-387 200.99,-387 200.99,-387 200.99,-387 200.99,-64 200.99,-64"/>
<polygon fill="#7b1fa2" stroke="#7b1fa2" points="204.49,-64 200.99,-54 197.49,-64 204.49,-64"/>
<text xml:space="preserve" text-anchor="middle" x="289.25" y="-260.3" font-family="Helvetica,sans-Serif" font-size="9.00">N:M</text>
</g>
<!-- veterinarian&#45;&gt;neighborhood -->
<g id="edge26" class="edge">
<title>veterinarian&#45;&gt;neighborhood</title>
<path fill="none" stroke="#7b1fa2" d="M477.71,-396C365.47,-396 51.46,-396 51.46,-396 51.46,-396 51.46,-63.96 51.46,-63.96"/>
<polygon fill="#7b1fa2" stroke="#7b1fa2" points="54.96,-63.96 51.46,-53.96 47.96,-63.96 54.96,-63.96"/>
<text xml:space="preserve" text-anchor="middle" x="158.01" y="-260.3" font-family="Helvetica,sans-Serif" font-size="9.00">N:M coverage</text>
</g>
<!-- turnfee -->
<g id="node28" class="node">
<title>turnfee</title>
<path fill="#e1bee7" stroke="black" d="M347.02,-52C347.02,-52 266.98,-52 266.98,-52 260.98,-52 254.98,-46 254.98,-40 254.98,-40 254.98,-28 254.98,-28 254.98,-22 260.98,-16 266.98,-16 266.98,-16 347.02,-16 347.02,-16 353.02,-16 359.02,-22 359.02,-28 359.02,-28 359.02,-40 359.02,-40 359.02,-46 353.02,-52 347.02,-52"/>
<text xml:space="preserve" text-anchor="middle" x="307" y="-37.3" font-family="Helvetica,sans-Serif" font-size="11.00">IndividualTurnFee</text>
<text xml:space="preserve" text-anchor="middle" x="307" y="-24.1" font-family="Helvetica,sans-Serif" font-size="11.00">Group</text>
</g>
<!-- veterinarian&#45;&gt;turnfee -->
<g id="edge30" class="edge">
<title>veterinarian&#45;&gt;turnfee</title>
<path fill="none" stroke="#7b1fa2" d="M477.44,-378C429.56,-378 355.29,-378 355.29,-378 355.29,-378 355.29,-64.01 355.29,-64.01"/>
<polygon fill="#7b1fa2" stroke="#7b1fa2" points="358.79,-64.01 355.29,-54.01 351.79,-64.01 358.79,-64.01"/>
<text xml:space="preserve" text-anchor="middle" x="364.25" y="-260.3" font-family="Helvetica,sans-Serif" font-size="9.00">N:M</text>
</g>
<!-- vetvisitreport -->
<g id="node9" class="node">
<title>vetvisitreport</title>
<path fill="#bbdefb" stroke="black" d="M694.34,-187.2C694.34,-187.2 621.66,-187.2 621.66,-187.2 615.66,-187.2 609.66,-181.2 609.66,-175.2 609.66,-175.2 609.66,-163.2 609.66,-163.2 609.66,-157.2 615.66,-151.2 621.66,-151.2 621.66,-151.2 694.34,-151.2 694.34,-151.2 700.34,-151.2 706.34,-157.2 706.34,-163.2 706.34,-163.2 706.34,-175.2 706.34,-175.2 706.34,-181.2 700.34,-187.2 694.34,-187.2"/>
<text xml:space="preserve" text-anchor="middle" x="658" y="-172.5" font-family="Helvetica,sans-Serif" font-size="11.00">VetVisitReport</text>
<text xml:space="preserve" text-anchor="middle" x="658" y="-159.3" font-family="Helvetica,sans-Serif" font-size="11.00">(Informe Clinico)</text>
</g>
<!-- vetvisit&#45;&gt;vetvisitreport -->
<g id="edge10" class="edge">
<title>vetvisit&#45;&gt;vetvisitreport</title>
<path fill="none" stroke="#1565c0" d="M688.49,-286.17C688.49,-286.17 688.49,-199.09 688.49,-199.09"/>
<polygon fill="#1565c0" stroke="#1565c0" points="691.99,-199.09 688.49,-189.09 684.99,-199.09 691.99,-199.09"/>
<text xml:space="preserve" text-anchor="middle" x="671" y="-260.3" font-family="Helvetica,sans-Serif" font-size="9.00">1:N</text>
</g>
<!-- vetvisit&#45;&gt;servicerequest -->
<g id="edge11" class="edge">
<title>vetvisit&#45;&gt;servicerequest</title>
<path fill="none" stroke="#1565c0" stroke-dasharray="5,2" d="M731.8,-322.73C731.8,-347.07 731.8,-387 731.8,-387 731.8,-387 1070.36,-387 1070.36,-387"/>
<polygon fill="#1565c0" stroke="#1565c0" points="1070.36,-390.5 1080.36,-387 1070.36,-383.5 1070.36,-390.5"/>
<text xml:space="preserve" text-anchor="middle" x="749.26" y="-343.1" font-family="Helvetica,sans-Serif" font-size="9.00">1:1</text>
</g>
<!-- google_cal -->
<g id="node31" class="node">
<title>google_cal</title>
<path fill="#ffe0b2" stroke="black" d="M1122.58,-52C1122.58,-52 1047.42,-52 1047.42,-52 1041.42,-52 1035.42,-46 1035.42,-40 1035.42,-40 1035.42,-28 1035.42,-28 1035.42,-22 1041.42,-16 1047.42,-16 1047.42,-16 1122.58,-16 1122.58,-16 1128.58,-16 1134.58,-22 1134.58,-28 1134.58,-28 1134.58,-40 1134.58,-40 1134.58,-46 1128.58,-52 1122.58,-52"/>
<text xml:space="preserve" text-anchor="middle" x="1085" y="-37.3" font-family="Helvetica,sans-Serif" font-size="11.00">Google Calendar</text>
<text xml:space="preserve" text-anchor="middle" x="1085" y="-24.1" font-family="Helvetica,sans-Serif" font-size="11.00">(Agenda)</text>
</g>
<!-- vetvisit&#45;&gt;google_cal -->
<g id="edge33" class="edge">
<title>vetvisit&#45;&gt;google_cal</title>
<path fill="none" stroke="#e65100" stroke-dasharray="5,2" d="M737.59,-304C824.11,-304 1047.82,-304 1047.82,-304 1047.82,-304 1047.82,-63.65 1047.82,-63.65"/>
<polygon fill="#e65100" stroke="#e65100" points="1051.32,-63.65 1047.82,-53.65 1044.32,-63.65 1051.32,-63.65"/>
<text xml:space="preserve" text-anchor="middle" x="1054.25" y="-166.5" font-family="Helvetica,sans-Serif" font-size="9.00">sync</text>
</g>
<!-- receipt -->
<g id="node36" class="node">
<title>receipt</title>
<path fill="#fff9c4" stroke="black" d="M880.99,-187.2C880.99,-187.2 815.01,-187.2 815.01,-187.2 809.01,-187.2 803.01,-181.2 803.01,-175.2 803.01,-175.2 803.01,-163.2 803.01,-163.2 803.01,-157.2 809.01,-151.2 815.01,-151.2 815.01,-151.2 880.99,-151.2 880.99,-151.2 886.99,-151.2 892.99,-157.2 892.99,-163.2 892.99,-163.2 892.99,-175.2 892.99,-175.2 892.99,-181.2 886.99,-187.2 880.99,-187.2"/>
<text xml:space="preserve" text-anchor="middle" x="848" y="-172.5" font-family="Helvetica,sans-Serif" font-size="11.00">Receipt</text>
<text xml:space="preserve" text-anchor="middle" x="848" y="-159.3" font-family="Helvetica,sans-Serif" font-size="11.00">(Comprobante)</text>
</g>
<!-- vetvisit&#45;&gt;receipt -->
<g id="edge36" class="edge">
<title>vetvisit&#45;&gt;receipt</title>
<path fill="none" stroke="#f9a825" stroke-dasharray="5,2" d="M722.89,-286.14C722.89,-249.18 722.89,-169 722.89,-169 722.89,-169 791.32,-169 791.32,-169"/>
<polygon fill="#f9a825" stroke="#f9a825" points="791.32,-172.5 801.32,-169 791.32,-165.5 791.32,-172.5"/>
<text xml:space="preserve" text-anchor="middle" x="727.27" y="-260.3" font-family="Helvetica,sans-Serif" font-size="9.00">1:1 optional</text>
</g>
<!-- medication -->
<g id="node25" class="node">
<title>medication</title>
<path fill="#e1bee7" stroke="black" d="M678.43,-52C678.43,-52 607.57,-52 607.57,-52 601.57,-52 595.57,-46 595.57,-40 595.57,-40 595.57,-28 595.57,-28 595.57,-22 601.57,-16 607.57,-16 607.57,-16 678.43,-16 678.43,-16 684.43,-16 690.43,-22 690.43,-28 690.43,-28 690.43,-40 690.43,-40 690.43,-46 684.43,-52 678.43,-52"/>
<text xml:space="preserve" text-anchor="middle" x="643" y="-37.3" font-family="Helvetica,sans-Serif" font-size="11.00">Medication</text>
<text xml:space="preserve" text-anchor="middle" x="643" y="-24.1" font-family="Helvetica,sans-Serif" font-size="11.00">(Medicamentos)</text>
</g>
<!-- vetvisitreport&#45;&gt;medication -->
<g id="edge29" class="edge">
<title>vetvisitreport&#45;&gt;medication</title>
<path fill="none" stroke="#7b1fa2" stroke-dasharray="5,2" d="M650.04,-150.97C650.04,-150.97 650.04,-63.89 650.04,-63.89"/>
<polygon fill="#7b1fa2" stroke="#7b1fa2" points="653.54,-63.89 650.04,-53.89 646.54,-63.89 653.54,-63.89"/>
<text xml:space="preserve" text-anchor="middle" x="667.26" y="-125.1" font-family="Helvetica,sans-Serif" font-size="9.00">references</text>
</g>
<!-- service -->
<g id="node12" class="node">
<title>service</title>
<path fill="#c8e6c9" stroke="black" d="M972.22,-675.6C972.22,-675.6 933.78,-675.6 933.78,-675.6 927.78,-675.6 921.78,-669.6 921.78,-663.6 921.78,-663.6 921.78,-651.6 921.78,-651.6 921.78,-645.6 927.78,-639.6 933.78,-639.6 933.78,-639.6 972.22,-639.6 972.22,-639.6 978.22,-639.6 984.22,-645.6 984.22,-651.6 984.22,-651.6 984.22,-663.6 984.22,-663.6 984.22,-669.6 978.22,-675.6 972.22,-675.6"/>
<text xml:space="preserve" text-anchor="middle" x="953" y="-660.9" font-family="Helvetica,sans-Serif" font-size="11.00">Service</text>
<text xml:space="preserve" text-anchor="middle" x="953" y="-647.7" font-family="Helvetica,sans-Serif" font-size="11.00">(Servicio)</text>
</g>
<!-- category -->
<g id="node13" class="node">
<title>category</title>
<path fill="#c8e6c9" stroke="black" d="M849.19,-540.4C849.19,-540.4 772.81,-540.4 772.81,-540.4 766.81,-540.4 760.81,-534.4 760.81,-528.4 760.81,-528.4 760.81,-516.4 760.81,-516.4 760.81,-510.4 766.81,-504.4 772.81,-504.4 772.81,-504.4 849.19,-504.4 849.19,-504.4 855.19,-504.4 861.19,-510.4 861.19,-516.4 861.19,-516.4 861.19,-528.4 861.19,-528.4 861.19,-534.4 855.19,-540.4 849.19,-540.4"/>
<text xml:space="preserve" text-anchor="middle" x="811" y="-525.7" font-family="Helvetica,sans-Serif" font-size="11.00">Category / Group</text>
<text xml:space="preserve" text-anchor="middle" x="811" y="-512.5" font-family="Helvetica,sans-Serif" font-size="11.00">(Categorias)</text>
</g>
<!-- service&#45;&gt;category -->
<g id="edge12" class="edge">
<title>service&#45;&gt;category</title>
<path fill="none" stroke="#2e7d32" d="M921.49,-652C879.65,-652 811,-652 811,-652 811,-652 811,-552.21 811,-552.21"/>
<polygon fill="#2e7d32" stroke="#2e7d32" points="814.5,-552.21 811,-542.21 807.5,-552.21 814.5,-552.21"/>
<text xml:space="preserve" text-anchor="middle" x="835" y="-613.5" font-family="Helvetica,sans-Serif" font-size="9.00">N:1</text>
</g>
<!-- prices -->
<g id="node14" class="node">
<title>prices</title>
<path fill="#c8e6c9" stroke="black" d="M927,-540.4C927,-540.4 891,-540.4 891,-540.4 885,-540.4 879,-534.4 879,-528.4 879,-528.4 879,-516.4 879,-516.4 879,-510.4 885,-504.4 891,-504.4 891,-504.4 927,-504.4 927,-504.4 933,-504.4 939,-510.4 939,-516.4 939,-516.4 939,-528.4 939,-528.4 939,-534.4 933,-540.4 927,-540.4"/>
<text xml:space="preserve" text-anchor="middle" x="909" y="-525.7" font-family="Helvetica,sans-Serif" font-size="11.00">Prices</text>
<text xml:space="preserve" text-anchor="middle" x="909" y="-512.5" font-family="Helvetica,sans-Serif" font-size="11.00">(Precios)</text>
</g>
<!-- service&#45;&gt;prices -->
<g id="edge13" class="edge">
<title>service&#45;&gt;prices</title>
<path fill="none" stroke="#2e7d32" d="M930.39,-639.37C930.39,-639.37 930.39,-552.29 930.39,-552.29"/>
<polygon fill="#2e7d32" stroke="#2e7d32" points="933.89,-552.29 930.39,-542.29 926.89,-552.29 933.89,-552.29"/>
<text xml:space="preserve" text-anchor="middle" x="929" y="-613.5" font-family="Helvetica,sans-Serif" font-size="9.00">1:N</text>
</g>
<!-- discounts -->
<g id="node15" class="node">
<title>discounts</title>
<path fill="#c8e6c9" stroke="black" d="M1026.71,-540.4C1026.71,-540.4 969.29,-540.4 969.29,-540.4 963.29,-540.4 957.29,-534.4 957.29,-528.4 957.29,-528.4 957.29,-516.4 957.29,-516.4 957.29,-510.4 963.29,-504.4 969.29,-504.4 969.29,-504.4 1026.71,-504.4 1026.71,-504.4 1032.71,-504.4 1038.71,-510.4 1038.71,-516.4 1038.71,-516.4 1038.71,-528.4 1038.71,-528.4 1038.71,-534.4 1032.71,-540.4 1026.71,-540.4"/>
<text xml:space="preserve" text-anchor="middle" x="998" y="-525.7" font-family="Helvetica,sans-Serif" font-size="11.00">Discounts</text>
<text xml:space="preserve" text-anchor="middle" x="998" y="-512.5" font-family="Helvetica,sans-Serif" font-size="11.00">(Descuentos)</text>
</g>
<!-- service&#45;&gt;discounts -->
<g id="edge14" class="edge">
<title>service&#45;&gt;discounts</title>
<path fill="none" stroke="#2e7d32" d="M970.76,-639.37C970.76,-639.37 970.76,-552.29 970.76,-552.29"/>
<polygon fill="#2e7d32" stroke="#2e7d32" points="974.26,-552.29 970.76,-542.29 967.26,-552.29 974.26,-552.29"/>
<text xml:space="preserve" text-anchor="middle" x="992" y="-613.5" font-family="Helvetica,sans-Serif" font-size="9.00">1:N</text>
</g>
<!-- cart -->
<g id="node16" class="node">
<title>cart</title>
<path fill="#c8e6c9" stroke="black" d="M1133.06,-540.4C1133.06,-540.4 1068.94,-540.4 1068.94,-540.4 1062.94,-540.4 1056.94,-534.4 1056.94,-528.4 1056.94,-528.4 1056.94,-516.4 1056.94,-516.4 1056.94,-510.4 1062.94,-504.4 1068.94,-504.4 1068.94,-504.4 1133.06,-504.4 1133.06,-504.4 1139.06,-504.4 1145.06,-510.4 1145.06,-516.4 1145.06,-516.4 1145.06,-528.4 1145.06,-528.4 1145.06,-534.4 1139.06,-540.4 1133.06,-540.4"/>
<text xml:space="preserve" text-anchor="middle" x="1101" y="-525.7" font-family="Helvetica,sans-Serif" font-size="11.00">Cart / CartItem</text>
<text xml:space="preserve" text-anchor="middle" x="1101" y="-512.5" font-family="Helvetica,sans-Serif" font-size="11.00">(Carrito)</text>
</g>
<!-- service&#45;&gt;cart -->
<g id="edge17" class="edge">
<title>service&#45;&gt;cart</title>
<path fill="none" stroke="#2e7d32" d="M984.39,-658C1027.83,-658 1101,-658 1101,-658 1101,-658 1101,-552.2 1101,-552.2"/>
<polygon fill="#2e7d32" stroke="#2e7d32" points="1104.5,-552.2 1101,-542.2 1097.5,-552.2 1104.5,-552.2"/>
<text xml:space="preserve" text-anchor="middle" x="1089.26" y="-613.5" font-family="Helvetica,sans-Serif" font-size="9.00">via CartItem</text>
</g>
<!-- cart&#45;&gt;petowner -->
<g id="edge15" class="edge">
<title>cart&#45;&gt;petowner</title>
<path fill="none" stroke="#2e7d32" d="M1064.64,-504.13C1064.64,-489.36 1064.64,-471 1064.64,-471 1064.64,-471 734.47,-471 734.47,-471 734.47,-471 734.47,-492.62 734.47,-492.62"/>
<polygon fill="#2e7d32" stroke="#2e7d32" points="730.97,-492.62 734.47,-502.62 737.97,-492.62 730.97,-492.62"/>
<text xml:space="preserve" text-anchor="middle" x="871" y="-613.5" font-family="Helvetica,sans-Serif" font-size="9.00">N:1</text>
</g>
<!-- cart&#45;&gt;veterinarian -->
<g id="edge16" class="edge">
<title>cart&#45;&gt;veterinarian</title>
<path fill="none" stroke="#2e7d32" stroke-dasharray="5,2" d="M1072.35,-504.09C1072.35,-479.26 1072.35,-438 1072.35,-438 1072.35,-438 530.06,-438 530.06,-438 530.06,-438 530.06,-417.05 530.06,-417.05"/>
<polygon fill="#2e7d32" stroke="#2e7d32" points="533.56,-417.05 530.06,-407.05 526.56,-417.05 533.56,-417.05"/>
<text xml:space="preserve" text-anchor="middle" x="886.02" y="-478.3" font-family="Helvetica,sans-Serif" font-size="9.00">N:1 optional</text>
</g>
<!-- combo -->
<g id="node17" class="node">
<title>combo</title>
<path fill="#c8e6c9" stroke="black" d="M985.07,-810.8C985.07,-810.8 920.93,-810.8 920.93,-810.8 914.93,-810.8 908.93,-804.8 908.93,-798.8 908.93,-798.8 908.93,-786.8 908.93,-786.8 908.93,-780.8 914.93,-774.8 920.93,-774.8 920.93,-774.8 985.07,-774.8 985.07,-774.8 991.07,-774.8 997.07,-780.8 997.07,-786.8 997.07,-786.8 997.07,-798.8 997.07,-798.8 997.07,-804.8 991.07,-810.8 985.07,-810.8"/>
<text xml:space="preserve" text-anchor="middle" x="953" y="-796.1" font-family="Helvetica,sans-Serif" font-size="11.00">ServiceCombo</text>
<text xml:space="preserve" text-anchor="middle" x="953" y="-782.9" font-family="Helvetica,sans-Serif" font-size="11.00">(Paquetes)</text>
</g>
<!-- combo&#45;&gt;service -->
<g id="edge18" class="edge">
<title>combo&#45;&gt;service</title>
<path fill="none" stroke="#2e7d32" d="M953,-774.57C953,-774.57 953,-687.49 953,-687.49"/>
<polygon fill="#2e7d32" stroke="#2e7d32" points="956.5,-687.49 953,-677.49 949.5,-687.49 956.5,-687.49"/>
<text xml:space="preserve" text-anchor="middle" x="969.76" y="-748.7" font-family="Helvetica,sans-Serif" font-size="9.00">contains</text>
</g>
<!-- servicerequest&#45;&gt;cart -->
<g id="edge19" class="edge">
<title>servicerequest&#45;&gt;cart</title>
<path fill="none" stroke="#c62828" d="M1113.62,-405.43C1113.62,-405.43 1113.62,-492.51 1113.62,-492.51"/>
<polygon fill="#c62828" stroke="#c62828" points="1110.12,-492.51 1113.62,-502.51 1117.12,-492.51 1110.12,-492.51"/>
<text xml:space="preserve" text-anchor="middle" x="1121.26" y="-478.3" font-family="Helvetica,sans-Serif" font-size="9.00">1:1</text>
</g>
<!-- statehistory -->
<g id="node19" class="node">
<title>statehistory</title>
<path fill="#ffcdd2" stroke="black" d="M1267.95,-322.4C1267.95,-322.4 1216.05,-322.4 1216.05,-322.4 1210.05,-322.4 1204.05,-316.4 1204.05,-310.4 1204.05,-310.4 1204.05,-298.4 1204.05,-298.4 1204.05,-292.4 1210.05,-286.4 1216.05,-286.4 1216.05,-286.4 1267.95,-286.4 1267.95,-286.4 1273.95,-286.4 1279.95,-292.4 1279.95,-298.4 1279.95,-298.4 1279.95,-310.4 1279.95,-310.4 1279.95,-316.4 1273.95,-322.4 1267.95,-322.4"/>
<text xml:space="preserve" text-anchor="middle" x="1242" y="-307.7" font-family="Helvetica,sans-Serif" font-size="11.00">StateHistory</text>
<text xml:space="preserve" text-anchor="middle" x="1242" y="-294.5" font-family="Helvetica,sans-Serif" font-size="11.00">(Historial)</text>
</g>
<!-- servicerequest&#45;&gt;statehistory -->
<g id="edge20" class="edge">
<title>servicerequest&#45;&gt;statehistory</title>
<path fill="none" stroke="#c62828" d="M1176.2,-378C1201.95,-378 1228.64,-378 1228.64,-378 1228.64,-378 1228.64,-334.11 1228.64,-334.11"/>
<polygon fill="#c62828" stroke="#c62828" points="1232.14,-334.11 1228.64,-324.11 1225.14,-334.11 1232.14,-334.11"/>
<text xml:space="preserve" text-anchor="middle" x="1212" y="-343.1" font-family="Helvetica,sans-Serif" font-size="9.00">1:N</text>
</g>
<!-- servicerequest&#45;&gt;vetasked -->
<g id="edge21" class="edge">
<title>servicerequest&#45;&gt;vetasked</title>
<path fill="none" stroke="#c62828" d="M1152.41,-368.82C1152.41,-368.82 1152.41,-334.24 1152.41,-334.24"/>
<polygon fill="#c62828" stroke="#c62828" points="1155.91,-334.24 1152.41,-324.24 1148.91,-334.24 1155.91,-334.24"/>
<text xml:space="preserve" text-anchor="middle" x="1137" y="-343.1" font-family="Helvetica,sans-Serif" font-size="9.00">1:N</text>
</g>
<!-- campaign -->
<g id="node22" class="node">
<title>campaign</title>
<path fill="#e1bee7" stroke="black" d="M436.81,-52C436.81,-52 389.19,-52 389.19,-52 383.19,-52 377.19,-46 377.19,-40 377.19,-40 377.19,-28 377.19,-28 377.19,-22 383.19,-16 389.19,-16 389.19,-16 436.81,-16 436.81,-16 442.81,-16 448.81,-22 448.81,-28 448.81,-28 448.81,-40 448.81,-40 448.81,-46 442.81,-52 436.81,-52"/>
<text xml:space="preserve" text-anchor="middle" x="413" y="-37.3" font-family="Helvetica,sans-Serif" font-size="11.00">Campaign</text>
<text xml:space="preserve" text-anchor="middle" x="413" y="-24.1" font-family="Helvetica,sans-Serif" font-size="11.00">(Marketing)</text>
</g>
<!-- servicerequest&#45;&gt;campaign -->
<g id="edge27" class="edge">
<title>servicerequest&#45;&gt;campaign</title>
<path fill="none" stroke="#7b1fa2" stroke-dasharray="5,2" d="M1105.59,-369.05C1105.59,-361.29 1105.59,-354 1105.59,-354 1105.59,-354 401.06,-354 401.06,-354 401.06,-354 401.06,-63.63 401.06,-63.63"/>
<polygon fill="#7b1fa2" stroke="#7b1fa2" points="404.56,-63.63 401.06,-53.63 397.56,-63.64 404.56,-63.63"/>
<text xml:space="preserve" text-anchor="middle" x="437.02" y="-260.3" font-family="Helvetica,sans-Serif" font-size="9.00">N:1 optional</text>
</g>
<!-- tag -->
<g id="node23" class="node">
<title>tag</title>
<path fill="#e1bee7" stroke="black" d="M765.59,-52C765.59,-52 720.41,-52 720.41,-52 714.41,-52 708.41,-46 708.41,-40 708.41,-40 708.41,-28 708.41,-28 708.41,-22 714.41,-16 720.41,-16 720.41,-16 765.59,-16 765.59,-16 771.59,-16 777.59,-22 777.59,-28 777.59,-28 777.59,-40 777.59,-40 777.59,-46 771.59,-52 765.59,-52"/>
<text xml:space="preserve" text-anchor="middle" x="743" y="-37.3" font-family="Helvetica,sans-Serif" font-size="11.00">Tag</text>
<text xml:space="preserve" text-anchor="middle" x="743" y="-24.1" font-family="Helvetica,sans-Serif" font-size="11.00">(Etiquetas)</text>
</g>
<!-- servicerequest&#45;&gt;tag -->
<g id="edge28" class="edge">
<title>servicerequest&#45;&gt;tag</title>
<path fill="none" stroke="#7b1fa2" d="M1081.96,-378C984.44,-378 769.2,-378 769.2,-378 769.2,-378 769.2,-64.01 769.2,-64.01"/>
<polygon fill="#7b1fa2" stroke="#7b1fa2" points="772.7,-64.01 769.2,-54.01 765.7,-64.01 772.7,-64.01"/>
<text xml:space="preserve" text-anchor="middle" x="783.25" y="-260.3" font-family="Helvetica,sans-Serif" font-size="9.00">N:M</text>
</g>
<!-- mercadopago -->
<g id="node29" class="node">
<title>mercadopago</title>
<path fill="#f8bbd9" stroke="black" d="M1241.24,-540.4C1241.24,-540.4 1180.76,-540.4 1180.76,-540.4 1174.76,-540.4 1168.76,-534.4 1168.76,-528.4 1168.76,-528.4 1168.76,-516.4 1168.76,-516.4 1168.76,-510.4 1174.76,-504.4 1180.76,-504.4 1180.76,-504.4 1241.24,-504.4 1241.24,-504.4 1247.24,-504.4 1253.24,-510.4 1253.24,-516.4 1253.24,-516.4 1253.24,-528.4 1253.24,-528.4 1253.24,-534.4 1247.24,-540.4 1241.24,-540.4"/>
<text xml:space="preserve" text-anchor="middle" x="1211" y="-525.7" font-family="Helvetica,sans-Serif" font-size="11.00">MercadoPago</text>
<text xml:space="preserve" text-anchor="middle" x="1211" y="-512.5" font-family="Helvetica,sans-Serif" font-size="11.00">Account</text>
</g>
<!-- servicerequest&#45;&gt;mercadopago -->
<g id="edge31" class="edge">
<title>servicerequest&#45;&gt;mercadopago</title>
<path fill="none" stroke="#ad1457" d="M1172.29,-405.43C1172.29,-405.43 1172.29,-492.51 1172.29,-492.51"/>
<polygon fill="#ad1457" stroke="#ad1457" points="1168.79,-492.51 1172.29,-502.51 1175.79,-492.51 1168.79,-492.51"/>
<text xml:space="preserve" text-anchor="middle" x="1205.26" y="-478.3" font-family="Helvetica,sans-Serif" font-size="9.00">payment</text>
</g>
<!-- mercately -->
<g id="node33" class="node">
<title>mercately</title>
<path fill="#ffe0b2" stroke="black" d="M1523.04,-52C1523.04,-52 1472.96,-52 1472.96,-52 1466.96,-52 1460.96,-46 1460.96,-40 1460.96,-40 1460.96,-28 1460.96,-28 1460.96,-22 1466.96,-16 1472.96,-16 1472.96,-16 1523.04,-16 1523.04,-16 1529.04,-16 1535.04,-22 1535.04,-28 1535.04,-28 1535.04,-40 1535.04,-40 1535.04,-46 1529.04,-52 1523.04,-52"/>
<text xml:space="preserve" text-anchor="middle" x="1498" y="-37.3" font-family="Helvetica,sans-Serif" font-size="11.00">Mercately</text>
<text xml:space="preserve" text-anchor="middle" x="1498" y="-24.1" font-family="Helvetica,sans-Serif" font-size="11.00">(WhatsApp)</text>
</g>
<!-- servicerequest&#45;&gt;mercately -->
<g id="edge34" class="edge">
<title>servicerequest&#45;&gt;mercately</title>
<path fill="none" stroke="#e65100" stroke-dasharray="5,2" d="M1176.11,-387C1275.59,-387 1498,-387 1498,-387 1498,-387 1498,-64 1498,-64"/>
<polygon fill="#e65100" stroke="#e65100" points="1501.5,-64 1498,-54 1494.5,-64 1501.5,-64"/>
<text xml:space="preserve" text-anchor="middle" x="1350.76" y="-260.3" font-family="Helvetica,sans-Serif" font-size="9.00">notify</text>
</g>
<!-- reminders -->
<g id="node21" class="node">
<title>reminders</title>
<path fill="#ffcdd2" stroke="black" d="M1249.9,-187.2C1249.9,-187.2 1182.1,-187.2 1182.1,-187.2 1176.1,-187.2 1170.1,-181.2 1170.1,-175.2 1170.1,-175.2 1170.1,-163.2 1170.1,-163.2 1170.1,-157.2 1176.1,-151.2 1182.1,-151.2 1182.1,-151.2 1249.9,-151.2 1249.9,-151.2 1255.9,-151.2 1261.9,-157.2 1261.9,-163.2 1261.9,-163.2 1261.9,-175.2 1261.9,-175.2 1261.9,-181.2 1255.9,-187.2 1249.9,-187.2"/>
<text xml:space="preserve" text-anchor="middle" x="1216" y="-172.5" font-family="Helvetica,sans-Serif" font-size="11.00">Reminders</text>
<text xml:space="preserve" text-anchor="middle" x="1216" y="-159.3" font-family="Helvetica,sans-Serif" font-size="11.00">(Recordatorios)</text>
</g>
<!-- vetasked&#45;&gt;reminders -->
<g id="edge22" class="edge">
<title>vetasked&#45;&gt;reminders</title>
<path fill="none" stroke="#c62828" d="M1178.02,-286.17C1178.02,-286.17 1178.02,-199.09 1178.02,-199.09"/>
<polygon fill="#c62828" stroke="#c62828" points="1181.52,-199.09 1178.02,-189.09 1174.52,-199.09 1181.52,-199.09"/>
<text xml:space="preserve" text-anchor="middle" x="1181" y="-260.3" font-family="Helvetica,sans-Serif" font-size="9.00">1:N</text>
</g>
<!-- celery -->
<g id="node35" class="node">
<title>celery</title>
<path fill="#ffe0b2" stroke="black" d="M1332.84,-52C1332.84,-52 1271.16,-52 1271.16,-52 1265.16,-52 1259.16,-46 1259.16,-40 1259.16,-40 1259.16,-28 1259.16,-28 1259.16,-22 1265.16,-16 1271.16,-16 1271.16,-16 1332.84,-16 1332.84,-16 1338.84,-16 1344.84,-22 1344.84,-28 1344.84,-28 1344.84,-40 1344.84,-40 1344.84,-46 1338.84,-52 1332.84,-52"/>
<text xml:space="preserve" text-anchor="middle" x="1302" y="-37.3" font-family="Helvetica,sans-Serif" font-size="11.00">Celery</text>
<text xml:space="preserve" text-anchor="middle" x="1302" y="-24.1" font-family="Helvetica,sans-Serif" font-size="11.00">(Async Tasks)</text>
</g>
<!-- reminders&#45;&gt;celery -->
<g id="edge35" class="edge">
<title>reminders&#45;&gt;celery</title>
<path fill="none" stroke="#e65100" stroke-dasharray="5,2" d="M1250.27,-150.97C1250.27,-114.06 1250.27,-34 1250.27,-34 1250.27,-34 1251.13,-34 1251.13,-34"/>
<polygon fill="#e65100" stroke="#e65100" points="1247.3,-37.5 1257.3,-34 1247.3,-30.5 1247.3,-37.5"/>
<text xml:space="preserve" text-anchor="middle" x="1270.52" y="-125.1" font-family="Helvetica,sans-Serif" font-size="9.00">scheduled</text>
</g>
<!-- breed -->
<g id="node26" class="node">
<title>breed</title>
<path fill="#e1bee7" stroke="black" d="M565.08,-52C565.08,-52 478.92,-52 478.92,-52 472.92,-52 466.92,-46 466.92,-40 466.92,-40 466.92,-28 466.92,-28 466.92,-22 472.92,-16 478.92,-16 478.92,-16 565.08,-16 565.08,-16 571.08,-16 577.08,-22 577.08,-28 577.08,-28 577.08,-40 577.08,-40 577.08,-46 571.08,-52 565.08,-52"/>
<text xml:space="preserve" text-anchor="middle" x="522" y="-37.3" font-family="Helvetica,sans-Serif" font-size="11.00">PetBreed / Vaccine</text>
<text xml:space="preserve" text-anchor="middle" x="522" y="-24.1" font-family="Helvetica,sans-Serif" font-size="11.00">/ Study</text>
</g>
<!-- mpnotification -->
<g id="node30" class="node">
<title>mpnotification</title>
<path fill="#f8bbd9" stroke="black" d="M1348.99,-540.4C1348.99,-540.4 1283.01,-540.4 1283.01,-540.4 1277.01,-540.4 1271.01,-534.4 1271.01,-528.4 1271.01,-528.4 1271.01,-516.4 1271.01,-516.4 1271.01,-510.4 1277.01,-504.4 1283.01,-504.4 1283.01,-504.4 1348.99,-504.4 1348.99,-504.4 1354.99,-504.4 1360.99,-510.4 1360.99,-516.4 1360.99,-516.4 1360.99,-528.4 1360.99,-528.4 1360.99,-534.4 1354.99,-540.4 1348.99,-540.4"/>
<text xml:space="preserve" text-anchor="middle" x="1316" y="-525.7" font-family="Helvetica,sans-Serif" font-size="11.00">MP Notification</text>
<text xml:space="preserve" text-anchor="middle" x="1316" y="-512.5" font-family="Helvetica,sans-Serif" font-size="11.00">(Webhooks)</text>
</g>
<!-- mpnotification&#45;&gt;servicerequest -->
<g id="edge32" class="edge">
<title>mpnotification&#45;&gt;servicerequest</title>
<path fill="none" stroke="#ad1457" d="M1275.48,-504.02C1275.48,-468.96 1275.48,-396 1275.48,-396 1275.48,-396 1187.67,-396 1187.67,-396"/>
<polygon fill="#ad1457" stroke="#ad1457" points="1187.67,-392.5 1177.67,-396 1187.67,-399.5 1187.67,-392.5"/>
<text xml:space="preserve" text-anchor="middle" x="1305" y="-478.3" font-family="Helvetica,sans-Serif" font-size="9.00">confirms</text>
</g>
<!-- google_sheets -->
<g id="node32" class="node">
<title>google_sheets</title>
<path fill="#ffe0b2" stroke="black" d="M1229.39,-52C1229.39,-52 1164.61,-52 1164.61,-52 1158.61,-52 1152.61,-46 1152.61,-40 1152.61,-40 1152.61,-28 1152.61,-28 1152.61,-22 1158.61,-16 1164.61,-16 1164.61,-16 1229.39,-16 1229.39,-16 1235.39,-16 1241.39,-22 1241.39,-28 1241.39,-28 1241.39,-40 1241.39,-40 1241.39,-46 1235.39,-52 1229.39,-52"/>
<text xml:space="preserve" text-anchor="middle" x="1197" y="-37.3" font-family="Helvetica,sans-Serif" font-size="11.00">Google Sheets</text>
<text xml:space="preserve" text-anchor="middle" x="1197" y="-24.1" font-family="Helvetica,sans-Serif" font-size="11.00">(Exports)</text>
</g>
<!-- afip -->
<g id="node34" class="node">
<title>afip</title>
<path fill="#ffe0b2" stroke="black" d="M1431.4,-52C1431.4,-52 1374.6,-52 1374.6,-52 1368.6,-52 1362.6,-46 1362.6,-40 1362.6,-40 1362.6,-28 1362.6,-28 1362.6,-22 1368.6,-16 1374.6,-16 1374.6,-16 1431.4,-16 1431.4,-16 1437.4,-16 1443.4,-22 1443.4,-28 1443.4,-28 1443.4,-40 1443.4,-40 1443.4,-46 1437.4,-52 1431.4,-52"/>
<text xml:space="preserve" text-anchor="middle" x="1403" y="-37.3" font-family="Helvetica,sans-Serif" font-size="11.00">AFIP</text>
<text xml:space="preserve" text-anchor="middle" x="1403" y="-24.1" font-family="Helvetica,sans-Serif" font-size="11.00">(Facturacion)</text>
</g>
<!-- taxpayer -->
<g id="node37" class="node">
<title>taxpayer</title>
<path fill="#fff9c4" stroke="black" d="M881.91,-52C881.91,-52 814.09,-52 814.09,-52 808.09,-52 802.09,-46 802.09,-40 802.09,-40 802.09,-28 802.09,-28 802.09,-22 808.09,-16 814.09,-16 814.09,-16 881.91,-16 881.91,-16 887.91,-16 893.91,-22 893.91,-28 893.91,-28 893.91,-40 893.91,-40 893.91,-46 887.91,-52 881.91,-52"/>
<text xml:space="preserve" text-anchor="middle" x="848" y="-37.3" font-family="Helvetica,sans-Serif" font-size="11.00">TaxPayer</text>
<text xml:space="preserve" text-anchor="middle" x="848" y="-24.1" font-family="Helvetica,sans-Serif" font-size="11.00">(Contribuyente)</text>
</g>
<!-- receipt&#45;&gt;taxpayer -->
<g id="edge37" class="edge">
<title>receipt&#45;&gt;taxpayer</title>
<path fill="none" stroke="#f9a825" d="M848,-150.97C848,-150.97 848,-63.89 848,-63.89"/>
<polygon fill="#f9a825" stroke="#f9a825" points="851.5,-63.89 848,-53.89 844.5,-63.89 851.5,-63.89"/>
<text xml:space="preserve" text-anchor="middle" x="855" y="-125.1" font-family="Helvetica,sans-Serif" font-size="9.00">N:1</text>
</g>
<!-- pos -->
<g id="node38" class="node">
<title>pos</title>
<path fill="#fff9c4" stroke="black" d="M998.58,-52C998.58,-52 923.42,-52 923.42,-52 917.42,-52 911.42,-46 911.42,-40 911.42,-40 911.42,-28 911.42,-28 911.42,-22 917.42,-16 923.42,-16 923.42,-16 998.58,-16 998.58,-16 1004.58,-16 1010.58,-22 1010.58,-28 1010.58,-28 1010.58,-40 1010.58,-40 1010.58,-46 1004.58,-52 998.58,-52"/>
<text xml:space="preserve" text-anchor="middle" x="961" y="-37.3" font-family="Helvetica,sans-Serif" font-size="11.00">PointOfSales</text>
<text xml:space="preserve" text-anchor="middle" x="961" y="-24.1" font-family="Helvetica,sans-Serif" font-size="11.00">(Punto de Venta)</text>
</g>
<!-- receipt&#45;&gt;pos -->
<g id="edge38" class="edge">
<title>receipt&#45;&gt;pos</title>
<path fill="none" stroke="#f9a825" d="M893.36,-169C910.17,-169 925.21,-169 925.21,-169 925.21,-169 925.21,-64 925.21,-64"/>
<polygon fill="#f9a825" stroke="#f9a825" points="928.71,-64 925.21,-54 921.71,-64 928.71,-64"/>
<text xml:space="preserve" text-anchor="middle" x="930" y="-125.1" font-family="Helvetica,sans-Serif" font-size="9.00">N:1</text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 50 KiB

View File

@@ -0,0 +1,184 @@
digraph FrontendArchitecture {
// Graph settings
rankdir=TB
compound=true
splines=ortho
node [shape=box, style="rounded,filled", fontname="Helvetica", fontsize=11]
edge [fontname="Helvetica", fontsize=9]
label="AMAR Mascotas - Frontend Architecture (Next.js)\n\n"
labelloc="t"
fontsize=16
fontname="Helvetica-Bold"
// Next.js Core Cluster
subgraph cluster_nextjs {
label="Next.js 13+ (App Router)"
style="rounded,filled"
fillcolor="#E3F2FD"
color="#1565C0"
app_router [label="App Router\n/app/*", fillcolor="#BBDEFB"]
layout [label="Layout Components\n(RootLayout, etc)", fillcolor="#BBDEFB"]
middleware [label="Middleware\n(Auth redirect)", fillcolor="#BBDEFB"]
}
// Public Frontend Cluster
subgraph cluster_public {
label="Public Frontend (/(frontend))"
style="rounded,filled"
fillcolor="#E8F5E9"
color="#2E7D32"
home [label="Home Page\n/", fillcolor="#C8E6C9"]
services_page [label="Services Catalog\n/servicios", fillcolor="#C8E6C9"]
cart_page [label="Cart\n/carrito", fillcolor="#C8E6C9"]
login_page [label="Login/Register\n/login", fillcolor="#C8E6C9"]
profile_page [label="User Profile\n/perfil", fillcolor="#C8E6C9"]
pets_page [label="My Pets\n/mascotas", fillcolor="#C8E6C9"]
requests_page [label="My Requests\n/solicitudes", fillcolor="#C8E6C9"]
}
// Backoffice Cluster
subgraph cluster_backoffice {
label="Backoffice (/(backoffice)/admin)"
style="rounded,filled"
fillcolor="#FFF3E0"
color="#E65100"
admin_dash [label="Dashboard\n/admin", fillcolor="#FFE0B2"]
admin_visits [label="Visits Management\n/admin/visits", fillcolor="#FFE0B2"]
admin_pets [label="Pets Overview\n/admin/pets", fillcolor="#FFE0B2"]
admin_requests [label="Service Requests\n/admin/solicitudes", fillcolor="#FFE0B2"]
admin_calendar [label="Calendar View\n/admin/calendario", fillcolor="#FFE0B2"]
}
// Components Cluster
subgraph cluster_components {
label="Shared Components (/components)"
style="rounded,filled"
fillcolor="#F3E5F5"
color="#7B1FA2"
sidebar [label="Sidebar\nNavigation", fillcolor="#E1BEE7"]
navbar [label="NavbarBackoffice\nTop Bar", fillcolor="#E1BEE7"]
visits_section [label="VisitsSection\n(List + Actions)", fillcolor="#E1BEE7"]
drawer [label="VisitsDrawer\n(Side Panel)", fillcolor="#E1BEE7"]
tables [label="DataTable\nComponents", fillcolor="#E1BEE7"]
forms [label="Form Components\n(Pet, Visit, etc)", fillcolor="#E1BEE7"]
}
// Services Layer Cluster
subgraph cluster_services {
label="Services Layer (/services)"
style="rounded,filled"
fillcolor="#FFEBEE"
color="#C62828"
http_service [label="HttpService\n(Axios wrapper)", fillcolor="#FFCDD2"]
auth_api [label="authAPI\n(login/register)", fillcolor="#FFCDD2"]
visits_api [label="visitsAPI\n(CRUD visits)", fillcolor="#FFCDD2"]
orders_api [label="OrdersAPI\n(service requests)", fillcolor="#FFCDD2"]
petowners_api [label="petOwnersAPI\n(clients)", fillcolor="#FFCDD2"]
vets_api [label="VeterinariansAPI\n(professionals)", fillcolor="#FFCDD2"]
services_api [label="servicesAPI\n(catalog)", fillcolor="#FFCDD2"]
cart_api [label="CartAPI\n(shopping)", fillcolor="#FFCDD2"]
}
// State Management Cluster
subgraph cluster_state {
label="State Management (/redux, /contexts)"
style="rounded,filled"
fillcolor="#E0F7FA"
color="#00838F"
redux_store [label="Redux Store\n(Global State)", fillcolor="#B2EBF2"]
auth_slice [label="Auth Slice\n(user, token)", fillcolor="#B2EBF2"]
visits_slice [label="Visits Slice\n(visit data)", fillcolor="#B2EBF2"]
cart_slice [label="Cart Slice\n(items)", fillcolor="#B2EBF2"]
auth_context [label="AuthContext\n(Provider)", fillcolor="#B2EBF2"]
}
// Backend API Cluster (external)
subgraph cluster_backend {
label="Django Backend API"
style="rounded,filled"
fillcolor="#ECEFF1"
color="#455A64"
api_mascotas [label="/mascotas/api/v1/\n(Pets, Vets, Visits)", fillcolor="#CFD8DC"]
api_productos [label="/productos/\n(Services, Prices)", fillcolor="#CFD8DC"]
api_solicitudes [label="/solicitudes/\n(Requests)", fillcolor="#CFD8DC"]
api_auth [label="/api/token/\n(JWT Auth)", fillcolor="#CFD8DC"]
api_payments [label="/payments/\n(MercadoPago)", fillcolor="#CFD8DC"]
}
// User Types
subgraph cluster_users {
label="User Types"
style="rounded,filled"
fillcolor="#FCE4EC"
color="#AD1457"
petowner_user [label="PetOwner\n(Cliente)", shape=ellipse, fillcolor="#F8BBD9"]
vet_user [label="Veterinarian\n(Profesional)", shape=ellipse, fillcolor="#F8BBD9"]
admin_user [label="Admin/Staff\n(Interno)", shape=ellipse, fillcolor="#F8BBD9"]
}
// Relationships - Router
app_router -> layout [color="#1565C0"]
app_router -> middleware [color="#1565C0"]
// Public pages
home -> services_page [color="#2E7D32"]
services_page -> cart_page [color="#2E7D32"]
cart_page -> login_page [label="if not auth", color="#2E7D32", style=dashed]
login_page -> profile_page [color="#2E7D32"]
profile_page -> pets_page [color="#2E7D32"]
profile_page -> requests_page [color="#2E7D32"]
// Backoffice pages
admin_dash -> admin_visits [color="#E65100"]
admin_dash -> admin_pets [color="#E65100"]
admin_dash -> admin_requests [color="#E65100"]
admin_dash -> admin_calendar [color="#E65100"]
// Components used by pages
admin_visits -> visits_section [color="#7B1FA2", style=dashed]
admin_visits -> drawer [color="#7B1FA2", style=dashed]
admin_dash -> sidebar [color="#7B1FA2", style=dashed]
admin_dash -> navbar [color="#7B1FA2", style=dashed]
// Services to APIs
http_service -> auth_api [color="#C62828"]
http_service -> visits_api [color="#C62828"]
http_service -> orders_api [color="#C62828"]
http_service -> petowners_api [color="#C62828"]
http_service -> vets_api [color="#C62828"]
http_service -> services_api [color="#C62828"]
http_service -> cart_api [color="#C62828"]
// Services to Backend
auth_api -> api_auth [label="POST /api/token/", color="#455A64"]
visits_api -> api_mascotas [label="CRUD /vet-visits/", color="#455A64"]
orders_api -> api_solicitudes [label="CRUD /service-requests/", color="#455A64"]
petowners_api -> api_mascotas [label="CRUD /pet-owners/", color="#455A64"]
vets_api -> api_mascotas [label="GET /veterinarians/", color="#455A64"]
services_api -> api_productos [label="GET /services/", color="#455A64"]
cart_api -> api_productos [label="CRUD /cart/", color="#455A64"]
// State connections
auth_slice -> redux_store [color="#00838F"]
visits_slice -> redux_store [color="#00838F"]
cart_slice -> redux_store [color="#00838F"]
auth_context -> redux_store [color="#00838F", style=dashed]
// User access paths
petowner_user -> home [label="public access", color="#AD1457"]
petowner_user -> profile_page [label="authenticated", color="#AD1457", style=dashed]
vet_user -> admin_dash [label="backoffice", color="#AD1457"]
admin_user -> admin_dash [label="full access", color="#AD1457"]
// API routing through HttpService
http_service -> api_auth [label="JWT refresh", color="#455A64", style=dashed]
}

View File

@@ -0,0 +1,595 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Generated by graphviz version 14.0.5 (0)
-->
<!-- Title: FrontendArchitecture Pages: 1 -->
<svg width="2391pt" height="715pt"
viewBox="0.00 0.00 2391.00 715.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 710.85)">
<title>FrontendArchitecture</title>
<polygon fill="white" stroke="none" points="-4,4 -4,-710.85 2387,-710.85 2387,4 -4,4"/>
<text xml:space="preserve" text-anchor="middle" x="1191.5" y="-687.65" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="16.00">AMAR Mascotas &#45; Frontend Architecture (Next.js)</text>
<g id="clust1" class="cluster">
<title>cluster_nextjs</title>
<path fill="#e3f2fd" stroke="#1565c0" d="M20,-452.8C20,-452.8 230,-452.8 230,-452.8 236,-452.8 242,-458.8 242,-464.8 242,-464.8 242,-634.32 242,-634.32 242,-640.32 236,-646.32 230,-646.32 230,-646.32 20,-646.32 20,-646.32 14,-646.32 8,-640.32 8,-634.32 8,-634.32 8,-464.8 8,-464.8 8,-458.8 14,-452.8 20,-452.8"/>
<text xml:space="preserve" text-anchor="middle" x="125" y="-627.12" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="16.00">Next.js 13+ (App Router)</text>
</g>
<g id="clust2" class="cluster">
<title>cluster_public</title>
<path fill="#e8f5e9" stroke="#2e7d32" d="M262,-8C262,-8 454,-8 454,-8 460,-8 466,-14 466,-20 466,-20 466,-520 466,-520 466,-526 460,-532 454,-532 454,-532 262,-532 262,-532 256,-532 250,-526 250,-520 250,-520 250,-20 250,-20 250,-14 256,-8 262,-8"/>
<text xml:space="preserve" text-anchor="middle" x="358" y="-512.8" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="16.00">Public Frontend (/(frontend))</text>
</g>
<g id="clust3" class="cluster">
<title>cluster_backoffice</title>
<path fill="#fff3e0" stroke="#e65100" d="M647,-344.8C647,-344.8 1095,-344.8 1095,-344.8 1101,-344.8 1107,-350.8 1107,-356.8 1107,-356.8 1107,-520 1107,-520 1107,-526 1101,-532 1095,-532 1095,-532 647,-532 647,-532 641,-532 635,-526 635,-520 635,-520 635,-356.8 635,-356.8 635,-350.8 641,-344.8 647,-344.8"/>
<text xml:space="preserve" text-anchor="middle" x="871" y="-512.8" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="16.00">Backoffice (/(backoffice)/admin)</text>
</g>
<g id="clust4" class="cluster">
<title>cluster_components</title>
<path fill="#f3e5f5" stroke="#7b1fa2" d="M486,-236.8C486,-236.8 1091,-236.8 1091,-236.8 1097,-236.8 1103,-242.8 1103,-248.8 1103,-248.8 1103,-304 1103,-304 1103,-310 1097,-316 1091,-316 1091,-316 486,-316 486,-316 480,-316 474,-310 474,-304 474,-304 474,-248.8 474,-248.8 474,-242.8 480,-236.8 486,-236.8"/>
<text xml:space="preserve" text-anchor="middle" x="788.5" y="-296.8" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="16.00">Shared Components (/components)</text>
</g>
<g id="clust5" class="cluster">
<title>cluster_services</title>
<path fill="#ffebee" stroke="#c62828" d="M1222,-452.8C1222,-452.8 1922,-452.8 1922,-452.8 1928,-452.8 1934,-458.8 1934,-464.8 1934,-464.8 1934,-634.32 1934,-634.32 1934,-640.32 1928,-646.32 1922,-646.32 1922,-646.32 1222,-646.32 1222,-646.32 1216,-646.32 1210,-640.32 1210,-634.32 1210,-634.32 1210,-464.8 1210,-464.8 1210,-458.8 1216,-452.8 1222,-452.8"/>
<text xml:space="preserve" text-anchor="middle" x="1572" y="-627.12" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="16.00">Services Layer (/services)</text>
</g>
<g id="clust6" class="cluster">
<title>cluster_state</title>
<path fill="#e0f7fa" stroke="#00838f" d="M2031,-452.8C2031,-452.8 2363,-452.8 2363,-452.8 2369,-452.8 2375,-458.8 2375,-464.8 2375,-464.8 2375,-634.32 2375,-634.32 2375,-640.32 2369,-646.32 2363,-646.32 2363,-646.32 2031,-646.32 2031,-646.32 2025,-646.32 2019,-640.32 2019,-634.32 2019,-634.32 2019,-464.8 2019,-464.8 2019,-458.8 2025,-452.8 2031,-452.8"/>
<text xml:space="preserve" text-anchor="middle" x="2197" y="-627.12" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="16.00">State Management (/redux, /contexts)</text>
</g>
<g id="clust7" class="cluster">
<title>cluster_backend</title>
<path fill="#eceff1" stroke="#455a64" d="M1382,-344.8C1382,-344.8 1892,-344.8 1892,-344.8 1898,-344.8 1904,-350.8 1904,-356.8 1904,-356.8 1904,-412 1904,-412 1904,-418 1898,-424 1892,-424 1892,-424 1382,-424 1382,-424 1376,-424 1370,-418 1370,-412 1370,-412 1370,-356.8 1370,-356.8 1370,-350.8 1376,-344.8 1382,-344.8"/>
<text xml:space="preserve" text-anchor="middle" x="1637" y="-404.8" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="16.00">Django Backend API</text>
</g>
<g id="clust8" class="cluster">
<title>cluster_users</title>
<path fill="#fce4ec" stroke="#ad1457" d="M824,-560.8C824,-560.8 1158,-560.8 1158,-560.8 1164,-560.8 1170,-566.8 1170,-572.8 1170,-572.8 1170,-640.65 1170,-640.65 1170,-646.65 1164,-652.65 1158,-652.65 1158,-652.65 824,-652.65 824,-652.65 818,-652.65 812,-646.65 812,-640.65 812,-640.65 812,-572.8 812,-572.8 812,-566.8 818,-560.8 824,-560.8"/>
<text xml:space="preserve" text-anchor="middle" x="991" y="-633.45" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="16.00">User Types</text>
</g>
<!-- app_router -->
<g id="node1" class="node">
<title>app_router</title>
<path fill="#bbdefb" stroke="black" d="M155.82,-611.12C155.82,-611.12 108.18,-611.12 108.18,-611.12 102.18,-611.12 96.18,-605.12 96.18,-599.12 96.18,-599.12 96.18,-587.12 96.18,-587.12 96.18,-581.12 102.18,-575.12 108.18,-575.12 108.18,-575.12 155.82,-575.12 155.82,-575.12 161.82,-575.12 167.82,-581.12 167.82,-587.12 167.82,-587.12 167.82,-599.12 167.82,-599.12 167.82,-605.12 161.82,-611.12 155.82,-611.12"/>
<text xml:space="preserve" text-anchor="middle" x="132" y="-596.42" font-family="Helvetica,sans-Serif" font-size="11.00">App Router</text>
<text xml:space="preserve" text-anchor="middle" x="132" y="-583.22" font-family="Helvetica,sans-Serif" font-size="11.00">/app/*</text>
</g>
<!-- layout -->
<g id="node2" class="node">
<title>layout</title>
<path fill="#bbdefb" stroke="black" d="M118.23,-496.8C118.23,-496.8 27.77,-496.8 27.77,-496.8 21.77,-496.8 15.77,-490.8 15.77,-484.8 15.77,-484.8 15.77,-472.8 15.77,-472.8 15.77,-466.8 21.77,-460.8 27.77,-460.8 27.77,-460.8 118.23,-460.8 118.23,-460.8 124.23,-460.8 130.23,-466.8 130.23,-472.8 130.23,-472.8 130.23,-484.8 130.23,-484.8 130.23,-490.8 124.23,-496.8 118.23,-496.8"/>
<text xml:space="preserve" text-anchor="middle" x="73" y="-482.1" font-family="Helvetica,sans-Serif" font-size="11.00">Layout Components</text>
<text xml:space="preserve" text-anchor="middle" x="73" y="-468.9" font-family="Helvetica,sans-Serif" font-size="11.00">(RootLayout, etc)</text>
</g>
<!-- app_router&#45;&gt;layout -->
<g id="edge1" class="edge">
<title>app_router&#45;&gt;layout</title>
<path fill="none" stroke="#1565c0" d="M113.2,-574.76C113.2,-574.76 113.2,-508.64 113.2,-508.64"/>
<polygon fill="#1565c0" stroke="#1565c0" points="116.7,-508.64 113.2,-498.64 109.7,-508.64 116.7,-508.64"/>
</g>
<!-- middleware -->
<g id="node3" class="node">
<title>middleware</title>
<path fill="#bbdefb" stroke="black" d="M221.84,-496.8C221.84,-496.8 160.16,-496.8 160.16,-496.8 154.16,-496.8 148.16,-490.8 148.16,-484.8 148.16,-484.8 148.16,-472.8 148.16,-472.8 148.16,-466.8 154.16,-460.8 160.16,-460.8 160.16,-460.8 221.84,-460.8 221.84,-460.8 227.84,-460.8 233.84,-466.8 233.84,-472.8 233.84,-472.8 233.84,-484.8 233.84,-484.8 233.84,-490.8 227.84,-496.8 221.84,-496.8"/>
<text xml:space="preserve" text-anchor="middle" x="191" y="-482.1" font-family="Helvetica,sans-Serif" font-size="11.00">Middleware</text>
<text xml:space="preserve" text-anchor="middle" x="191" y="-468.9" font-family="Helvetica,sans-Serif" font-size="11.00">(Auth redirect)</text>
</g>
<!-- app_router&#45;&gt;middleware -->
<g id="edge2" class="edge">
<title>app_router&#45;&gt;middleware</title>
<path fill="none" stroke="#1565c0" d="M157.99,-574.76C157.99,-574.76 157.99,-508.64 157.99,-508.64"/>
<polygon fill="#1565c0" stroke="#1565c0" points="161.49,-508.64 157.99,-498.64 154.49,-508.64 161.49,-508.64"/>
</g>
<!-- home -->
<g id="node4" class="node">
<title>home</title>
<path fill="#c8e6c9" stroke="black" d="M442.04,-496.8C442.04,-496.8 391.96,-496.8 391.96,-496.8 385.96,-496.8 379.96,-490.8 379.96,-484.8 379.96,-484.8 379.96,-472.8 379.96,-472.8 379.96,-466.8 385.96,-460.8 391.96,-460.8 391.96,-460.8 442.04,-460.8 442.04,-460.8 448.04,-460.8 454.04,-466.8 454.04,-472.8 454.04,-472.8 454.04,-484.8 454.04,-484.8 454.04,-490.8 448.04,-496.8 442.04,-496.8"/>
<text xml:space="preserve" text-anchor="middle" x="417" y="-482.1" font-family="Helvetica,sans-Serif" font-size="11.00">Home Page</text>
<text xml:space="preserve" text-anchor="middle" x="417" y="-468.9" font-family="Helvetica,sans-Serif" font-size="11.00">/</text>
</g>
<!-- services_page -->
<g id="node5" class="node">
<title>services_page</title>
<path fill="#c8e6c9" stroke="black" d="M445.57,-388.8C445.57,-388.8 370.43,-388.8 370.43,-388.8 364.43,-388.8 358.43,-382.8 358.43,-376.8 358.43,-376.8 358.43,-364.8 358.43,-364.8 358.43,-358.8 364.43,-352.8 370.43,-352.8 370.43,-352.8 445.57,-352.8 445.57,-352.8 451.57,-352.8 457.57,-358.8 457.57,-364.8 457.57,-364.8 457.57,-376.8 457.57,-376.8 457.57,-382.8 451.57,-388.8 445.57,-388.8"/>
<text xml:space="preserve" text-anchor="middle" x="408" y="-374.1" font-family="Helvetica,sans-Serif" font-size="11.00">Services Catalog</text>
<text xml:space="preserve" text-anchor="middle" x="408" y="-360.9" font-family="Helvetica,sans-Serif" font-size="11.00">/servicios</text>
</g>
<!-- home&#45;&gt;services_page -->
<g id="edge3" class="edge">
<title>home&#45;&gt;services_page</title>
<path fill="none" stroke="#2e7d32" d="M417,-460.48C417,-460.48 417,-400.72 417,-400.72"/>
<polygon fill="#2e7d32" stroke="#2e7d32" points="420.5,-400.72 417,-390.72 413.5,-400.72 420.5,-400.72"/>
</g>
<!-- cart_page -->
<g id="node6" class="node">
<title>cart_page</title>
<path fill="#c8e6c9" stroke="black" d="M425,-280.8C425,-280.8 395,-280.8 395,-280.8 389,-280.8 383,-274.8 383,-268.8 383,-268.8 383,-256.8 383,-256.8 383,-250.8 389,-244.8 395,-244.8 395,-244.8 425,-244.8 425,-244.8 431,-244.8 437,-250.8 437,-256.8 437,-256.8 437,-268.8 437,-268.8 437,-274.8 431,-280.8 425,-280.8"/>
<text xml:space="preserve" text-anchor="middle" x="410" y="-266.1" font-family="Helvetica,sans-Serif" font-size="11.00">Cart</text>
<text xml:space="preserve" text-anchor="middle" x="410" y="-252.9" font-family="Helvetica,sans-Serif" font-size="11.00">/carrito</text>
</g>
<!-- services_page&#45;&gt;cart_page -->
<g id="edge4" class="edge">
<title>services_page&#45;&gt;cart_page</title>
<path fill="none" stroke="#2e7d32" d="M410,-352.48C410,-352.48 410,-292.72 410,-292.72"/>
<polygon fill="#2e7d32" stroke="#2e7d32" points="413.5,-292.72 410,-282.72 406.5,-292.72 413.5,-292.72"/>
</g>
<!-- login_page -->
<g id="node7" class="node">
<title>login_page</title>
<path fill="#c8e6c9" stroke="black" d="M444.47,-198C444.47,-198 381.53,-198 381.53,-198 375.53,-198 369.53,-192 369.53,-186 369.53,-186 369.53,-174 369.53,-174 369.53,-168 375.53,-162 381.53,-162 381.53,-162 444.47,-162 444.47,-162 450.47,-162 456.47,-168 456.47,-174 456.47,-174 456.47,-186 456.47,-186 456.47,-192 450.47,-198 444.47,-198"/>
<text xml:space="preserve" text-anchor="middle" x="413" y="-183.3" font-family="Helvetica,sans-Serif" font-size="11.00">Login/Register</text>
<text xml:space="preserve" text-anchor="middle" x="413" y="-170.1" font-family="Helvetica,sans-Serif" font-size="11.00">/login</text>
</g>
<!-- cart_page&#45;&gt;login_page -->
<g id="edge5" class="edge">
<title>cart_page&#45;&gt;login_page</title>
<path fill="none" stroke="#2e7d32" stroke-dasharray="5,2" d="M410,-244.42C410,-244.42 410,-209.84 410,-209.84"/>
<polygon fill="#2e7d32" stroke="#2e7d32" points="413.5,-209.84 410,-199.84 406.5,-209.84 413.5,-209.84"/>
<text xml:space="preserve" text-anchor="middle" x="432.76" y="-218.7" font-family="Helvetica,sans-Serif" font-size="9.00">if not auth</text>
</g>
<!-- profile_page -->
<g id="node8" class="node">
<title>profile_page</title>
<path fill="#c8e6c9" stroke="black" d="M438.73,-125C438.73,-125 389.27,-125 389.27,-125 383.27,-125 377.27,-119 377.27,-113 377.27,-113 377.27,-101 377.27,-101 377.27,-95 383.27,-89 389.27,-89 389.27,-89 438.73,-89 438.73,-89 444.73,-89 450.73,-95 450.73,-101 450.73,-101 450.73,-113 450.73,-113 450.73,-119 444.73,-125 438.73,-125"/>
<text xml:space="preserve" text-anchor="middle" x="414" y="-110.3" font-family="Helvetica,sans-Serif" font-size="11.00">User Profile</text>
<text xml:space="preserve" text-anchor="middle" x="414" y="-97.1" font-family="Helvetica,sans-Serif" font-size="11.00">/perfil</text>
</g>
<!-- login_page&#45;&gt;profile_page -->
<g id="edge6" class="edge">
<title>login_page&#45;&gt;profile_page</title>
<path fill="none" stroke="#2e7d32" d="M414,-161.58C414,-161.58 414,-136.93 414,-136.93"/>
<polygon fill="#2e7d32" stroke="#2e7d32" points="417.5,-136.93 414,-126.93 410.5,-136.93 417.5,-136.93"/>
</g>
<!-- pets_page -->
<g id="node9" class="node">
<title>pets_page</title>
<path fill="#c8e6c9" stroke="black" d="M347.06,-52C347.06,-52 304.94,-52 304.94,-52 298.94,-52 292.94,-46 292.94,-40 292.94,-40 292.94,-28 292.94,-28 292.94,-22 298.94,-16 304.94,-16 304.94,-16 347.06,-16 347.06,-16 353.06,-16 359.06,-22 359.06,-28 359.06,-28 359.06,-40 359.06,-40 359.06,-46 353.06,-52 347.06,-52"/>
<text xml:space="preserve" text-anchor="middle" x="326" y="-37.3" font-family="Helvetica,sans-Serif" font-size="11.00">My Pets</text>
<text xml:space="preserve" text-anchor="middle" x="326" y="-24.1" font-family="Helvetica,sans-Serif" font-size="11.00">/mascotas</text>
</g>
<!-- profile_page&#45;&gt;pets_page -->
<g id="edge7" class="edge">
<title>profile_page&#45;&gt;pets_page</title>
<path fill="none" stroke="#2e7d32" d="M376.8,-107C352.66,-107 325.68,-107 325.68,-107 325.68,-107 325.68,-63.93 325.68,-63.93"/>
<polygon fill="#2e7d32" stroke="#2e7d32" points="329.18,-63.93 325.68,-53.93 322.18,-63.93 329.18,-63.93"/>
</g>
<!-- requests_page -->
<g id="node10" class="node">
<title>requests_page</title>
<path fill="#c8e6c9" stroke="black" d="M445.09,-52C445.09,-52 388.91,-52 388.91,-52 382.91,-52 376.91,-46 376.91,-40 376.91,-40 376.91,-28 376.91,-28 376.91,-22 382.91,-16 388.91,-16 388.91,-16 445.09,-16 445.09,-16 451.09,-16 457.09,-22 457.09,-28 457.09,-28 457.09,-40 457.09,-40 457.09,-46 451.09,-52 445.09,-52"/>
<text xml:space="preserve" text-anchor="middle" x="417" y="-37.3" font-family="Helvetica,sans-Serif" font-size="11.00">My Requests</text>
<text xml:space="preserve" text-anchor="middle" x="417" y="-24.1" font-family="Helvetica,sans-Serif" font-size="11.00">/solicitudes</text>
</g>
<!-- profile_page&#45;&gt;requests_page -->
<g id="edge8" class="edge">
<title>profile_page&#45;&gt;requests_page</title>
<path fill="none" stroke="#2e7d32" d="M414,-88.58C414,-88.58 414,-63.93 414,-63.93"/>
<polygon fill="#2e7d32" stroke="#2e7d32" points="417.5,-63.93 414,-53.93 410.5,-63.93 417.5,-63.93"/>
</g>
<!-- admin_dash -->
<g id="node11" class="node">
<title>admin_dash</title>
<path fill="#ffe0b2" stroke="black" d="M892.91,-496.8C892.91,-496.8 847.09,-496.8 847.09,-496.8 841.09,-496.8 835.09,-490.8 835.09,-484.8 835.09,-484.8 835.09,-472.8 835.09,-472.8 835.09,-466.8 841.09,-460.8 847.09,-460.8 847.09,-460.8 892.91,-460.8 892.91,-460.8 898.91,-460.8 904.91,-466.8 904.91,-472.8 904.91,-472.8 904.91,-484.8 904.91,-484.8 904.91,-490.8 898.91,-496.8 892.91,-496.8"/>
<text xml:space="preserve" text-anchor="middle" x="870" y="-482.1" font-family="Helvetica,sans-Serif" font-size="11.00">Dashboard</text>
<text xml:space="preserve" text-anchor="middle" x="870" y="-468.9" font-family="Helvetica,sans-Serif" font-size="11.00">/admin</text>
</g>
<!-- admin_visits -->
<g id="node12" class="node">
<title>admin_visits</title>
<path fill="#ffe0b2" stroke="black" d="M740.77,-388.8C740.77,-388.8 655.23,-388.8 655.23,-388.8 649.23,-388.8 643.23,-382.8 643.23,-376.8 643.23,-376.8 643.23,-364.8 643.23,-364.8 643.23,-358.8 649.23,-352.8 655.23,-352.8 655.23,-352.8 740.77,-352.8 740.77,-352.8 746.77,-352.8 752.77,-358.8 752.77,-364.8 752.77,-364.8 752.77,-376.8 752.77,-376.8 752.77,-382.8 746.77,-388.8 740.77,-388.8"/>
<text xml:space="preserve" text-anchor="middle" x="698" y="-374.1" font-family="Helvetica,sans-Serif" font-size="11.00">Visits Management</text>
<text xml:space="preserve" text-anchor="middle" x="698" y="-360.9" font-family="Helvetica,sans-Serif" font-size="11.00">/admin/visits</text>
</g>
<!-- admin_dash&#45;&gt;admin_visits -->
<g id="edge9" class="edge">
<title>admin_dash&#45;&gt;admin_visits</title>
<path fill="none" stroke="#e65100" d="M834.78,-468C784.46,-468 698,-468 698,-468 698,-468 698,-400.6 698,-400.6"/>
<polygon fill="#e65100" stroke="#e65100" points="701.5,-400.6 698,-390.6 694.5,-400.6 701.5,-400.6"/>
</g>
<!-- admin_pets -->
<g id="node13" class="node">
<title>admin_pets</title>
<path fill="#ffe0b2" stroke="black" d="M845.45,-388.8C845.45,-388.8 782.55,-388.8 782.55,-388.8 776.55,-388.8 770.55,-382.8 770.55,-376.8 770.55,-376.8 770.55,-364.8 770.55,-364.8 770.55,-358.8 776.55,-352.8 782.55,-352.8 782.55,-352.8 845.45,-352.8 845.45,-352.8 851.45,-352.8 857.45,-358.8 857.45,-364.8 857.45,-364.8 857.45,-376.8 857.45,-376.8 857.45,-382.8 851.45,-388.8 845.45,-388.8"/>
<text xml:space="preserve" text-anchor="middle" x="814" y="-374.1" font-family="Helvetica,sans-Serif" font-size="11.00">Pets Overview</text>
<text xml:space="preserve" text-anchor="middle" x="814" y="-360.9" font-family="Helvetica,sans-Serif" font-size="11.00">/admin/pets</text>
</g>
<!-- admin_dash&#45;&gt;admin_pets -->
<g id="edge10" class="edge">
<title>admin_dash&#45;&gt;admin_pets</title>
<path fill="none" stroke="#e65100" d="M846.27,-460.48C846.27,-460.48 846.27,-400.72 846.27,-400.72"/>
<polygon fill="#e65100" stroke="#e65100" points="849.77,-400.72 846.27,-390.72 842.77,-400.72 849.77,-400.72"/>
</g>
<!-- admin_requests -->
<g id="node14" class="node">
<title>admin_requests</title>
<path fill="#ffe0b2" stroke="black" d="M966.72,-388.8C966.72,-388.8 887.28,-388.8 887.28,-388.8 881.28,-388.8 875.28,-382.8 875.28,-376.8 875.28,-376.8 875.28,-364.8 875.28,-364.8 875.28,-358.8 881.28,-352.8 887.28,-352.8 887.28,-352.8 966.72,-352.8 966.72,-352.8 972.72,-352.8 978.72,-358.8 978.72,-364.8 978.72,-364.8 978.72,-376.8 978.72,-376.8 978.72,-382.8 972.72,-388.8 966.72,-388.8"/>
<text xml:space="preserve" text-anchor="middle" x="927" y="-374.1" font-family="Helvetica,sans-Serif" font-size="11.00">Service Requests</text>
<text xml:space="preserve" text-anchor="middle" x="927" y="-360.9" font-family="Helvetica,sans-Serif" font-size="11.00">/admin/solicitudes</text>
</g>
<!-- admin_dash&#45;&gt;admin_requests -->
<g id="edge11" class="edge">
<title>admin_dash&#45;&gt;admin_requests</title>
<path fill="none" stroke="#e65100" d="M890.1,-460.48C890.1,-460.48 890.1,-400.72 890.1,-400.72"/>
<polygon fill="#e65100" stroke="#e65100" points="893.6,-400.72 890.1,-390.72 886.6,-400.72 893.6,-400.72"/>
</g>
<!-- admin_calendar -->
<g id="node15" class="node">
<title>admin_calendar</title>
<path fill="#ffe0b2" stroke="black" d="M1087.41,-388.8C1087.41,-388.8 1008.59,-388.8 1008.59,-388.8 1002.59,-388.8 996.59,-382.8 996.59,-376.8 996.59,-376.8 996.59,-364.8 996.59,-364.8 996.59,-358.8 1002.59,-352.8 1008.59,-352.8 1008.59,-352.8 1087.41,-352.8 1087.41,-352.8 1093.41,-352.8 1099.41,-358.8 1099.41,-364.8 1099.41,-364.8 1099.41,-376.8 1099.41,-376.8 1099.41,-382.8 1093.41,-388.8 1087.41,-388.8"/>
<text xml:space="preserve" text-anchor="middle" x="1048" y="-374.1" font-family="Helvetica,sans-Serif" font-size="11.00">Calendar View</text>
<text xml:space="preserve" text-anchor="middle" x="1048" y="-360.9" font-family="Helvetica,sans-Serif" font-size="11.00">/admin/calendario</text>
</g>
<!-- admin_dash&#45;&gt;admin_calendar -->
<g id="edge12" class="edge">
<title>admin_dash&#45;&gt;admin_calendar</title>
<path fill="none" stroke="#e65100" d="M905.19,-470C949.1,-470 1018.5,-470 1018.5,-470 1018.5,-470 1018.5,-400.51 1018.5,-400.51"/>
<polygon fill="#e65100" stroke="#e65100" points="1022,-400.51 1018.5,-390.51 1015,-400.51 1022,-400.51"/>
</g>
<!-- sidebar -->
<g id="node16" class="node">
<title>sidebar</title>
<path fill="#e1bee7" stroke="black" d="M537.99,-280.8C537.99,-280.8 494.01,-280.8 494.01,-280.8 488.01,-280.8 482.01,-274.8 482.01,-268.8 482.01,-268.8 482.01,-256.8 482.01,-256.8 482.01,-250.8 488.01,-244.8 494.01,-244.8 494.01,-244.8 537.99,-244.8 537.99,-244.8 543.99,-244.8 549.99,-250.8 549.99,-256.8 549.99,-256.8 549.99,-268.8 549.99,-268.8 549.99,-274.8 543.99,-280.8 537.99,-280.8"/>
<text xml:space="preserve" text-anchor="middle" x="516" y="-266.1" font-family="Helvetica,sans-Serif" font-size="11.00">Sidebar</text>
<text xml:space="preserve" text-anchor="middle" x="516" y="-252.9" font-family="Helvetica,sans-Serif" font-size="11.00">Navigation</text>
</g>
<!-- admin_dash&#45;&gt;sidebar -->
<g id="edge15" class="edge">
<title>admin_dash&#45;&gt;sidebar</title>
<path fill="none" stroke="#7b1fa2" stroke-dasharray="5,2" d="M834.76,-482C745.22,-482 516,-482 516,-482 516,-482 516,-292.63 516,-292.63"/>
<polygon fill="#7b1fa2" stroke="#7b1fa2" points="519.5,-292.63 516,-282.63 512.5,-292.63 519.5,-292.63"/>
</g>
<!-- navbar -->
<g id="node17" class="node">
<title>navbar</title>
<path fill="#e1bee7" stroke="black" d="M658.1,-280.8C658.1,-280.8 579.9,-280.8 579.9,-280.8 573.9,-280.8 567.9,-274.8 567.9,-268.8 567.9,-268.8 567.9,-256.8 567.9,-256.8 567.9,-250.8 573.9,-244.8 579.9,-244.8 579.9,-244.8 658.1,-244.8 658.1,-244.8 664.1,-244.8 670.1,-250.8 670.1,-256.8 670.1,-256.8 670.1,-268.8 670.1,-268.8 670.1,-274.8 664.1,-280.8 658.1,-280.8"/>
<text xml:space="preserve" text-anchor="middle" x="619" y="-266.1" font-family="Helvetica,sans-Serif" font-size="11.00">NavbarBackoffice</text>
<text xml:space="preserve" text-anchor="middle" x="619" y="-252.9" font-family="Helvetica,sans-Serif" font-size="11.00">Top Bar</text>
</g>
<!-- admin_dash&#45;&gt;navbar -->
<g id="edge16" class="edge">
<title>admin_dash&#45;&gt;navbar</title>
<path fill="none" stroke="#7b1fa2" stroke-dasharray="5,2" d="M834.61,-475C762.55,-475 605.56,-475 605.56,-475 605.56,-475 605.56,-292.77 605.56,-292.77"/>
<polygon fill="#7b1fa2" stroke="#7b1fa2" points="609.06,-292.77 605.56,-282.77 602.06,-292.77 609.06,-292.77"/>
</g>
<!-- visits_section -->
<g id="node18" class="node">
<title>visits_section</title>
<path fill="#e1bee7" stroke="black" d="M765.53,-280.8C765.53,-280.8 700.47,-280.8 700.47,-280.8 694.47,-280.8 688.47,-274.8 688.47,-268.8 688.47,-268.8 688.47,-256.8 688.47,-256.8 688.47,-250.8 694.47,-244.8 700.47,-244.8 700.47,-244.8 765.53,-244.8 765.53,-244.8 771.53,-244.8 777.53,-250.8 777.53,-256.8 777.53,-256.8 777.53,-268.8 777.53,-268.8 777.53,-274.8 771.53,-280.8 765.53,-280.8"/>
<text xml:space="preserve" text-anchor="middle" x="733" y="-266.1" font-family="Helvetica,sans-Serif" font-size="11.00">VisitsSection</text>
<text xml:space="preserve" text-anchor="middle" x="733" y="-252.9" font-family="Helvetica,sans-Serif" font-size="11.00">(List + Actions)</text>
</g>
<!-- admin_visits&#45;&gt;visits_section -->
<g id="edge13" class="edge">
<title>admin_visits&#45;&gt;visits_section</title>
<path fill="none" stroke="#7b1fa2" stroke-dasharray="5,2" d="M709.91,-352.48C709.91,-352.48 709.91,-292.72 709.91,-292.72"/>
<polygon fill="#7b1fa2" stroke="#7b1fa2" points="713.41,-292.72 709.91,-282.72 706.41,-292.72 713.41,-292.72"/>
</g>
<!-- drawer -->
<g id="node19" class="node">
<title>drawer</title>
<path fill="#e1bee7" stroke="black" d="M860.86,-280.8C860.86,-280.8 807.14,-280.8 807.14,-280.8 801.14,-280.8 795.14,-274.8 795.14,-268.8 795.14,-268.8 795.14,-256.8 795.14,-256.8 795.14,-250.8 801.14,-244.8 807.14,-244.8 807.14,-244.8 860.86,-244.8 860.86,-244.8 866.86,-244.8 872.86,-250.8 872.86,-256.8 872.86,-256.8 872.86,-268.8 872.86,-268.8 872.86,-274.8 866.86,-280.8 860.86,-280.8"/>
<text xml:space="preserve" text-anchor="middle" x="834" y="-266.1" font-family="Helvetica,sans-Serif" font-size="11.00">VisitsDrawer</text>
<text xml:space="preserve" text-anchor="middle" x="834" y="-252.9" font-family="Helvetica,sans-Serif" font-size="11.00">(Side Panel)</text>
</g>
<!-- admin_visits&#45;&gt;drawer -->
<g id="edge14" class="edge">
<title>admin_visits&#45;&gt;drawer</title>
<path fill="none" stroke="#7b1fa2" stroke-dasharray="5,2" d="M731.34,-352.59C731.34,-336.98 731.34,-317 731.34,-317 731.34,-317 826.29,-317 826.29,-317 826.29,-317 826.29,-292.66 826.29,-292.66"/>
<polygon fill="#7b1fa2" stroke="#7b1fa2" points="829.79,-292.66 826.29,-282.66 822.79,-292.66 829.79,-292.66"/>
</g>
<!-- tables -->
<g id="node20" class="node">
<title>tables</title>
<path fill="#e1bee7" stroke="black" d="M957.18,-280.8C957.18,-280.8 902.82,-280.8 902.82,-280.8 896.82,-280.8 890.82,-274.8 890.82,-268.8 890.82,-268.8 890.82,-256.8 890.82,-256.8 890.82,-250.8 896.82,-244.8 902.82,-244.8 902.82,-244.8 957.18,-244.8 957.18,-244.8 963.18,-244.8 969.18,-250.8 969.18,-256.8 969.18,-256.8 969.18,-268.8 969.18,-268.8 969.18,-274.8 963.18,-280.8 957.18,-280.8"/>
<text xml:space="preserve" text-anchor="middle" x="930" y="-266.1" font-family="Helvetica,sans-Serif" font-size="11.00">DataTable</text>
<text xml:space="preserve" text-anchor="middle" x="930" y="-252.9" font-family="Helvetica,sans-Serif" font-size="11.00">Components</text>
</g>
<!-- forms -->
<g id="node21" class="node">
<title>forms</title>
<path fill="#e1bee7" stroke="black" d="M1082.54,-280.8C1082.54,-280.8 999.46,-280.8 999.46,-280.8 993.46,-280.8 987.46,-274.8 987.46,-268.8 987.46,-268.8 987.46,-256.8 987.46,-256.8 987.46,-250.8 993.46,-244.8 999.46,-244.8 999.46,-244.8 1082.54,-244.8 1082.54,-244.8 1088.54,-244.8 1094.54,-250.8 1094.54,-256.8 1094.54,-256.8 1094.54,-268.8 1094.54,-268.8 1094.54,-274.8 1088.54,-280.8 1082.54,-280.8"/>
<text xml:space="preserve" text-anchor="middle" x="1041" y="-266.1" font-family="Helvetica,sans-Serif" font-size="11.00">Form Components</text>
<text xml:space="preserve" text-anchor="middle" x="1041" y="-252.9" font-family="Helvetica,sans-Serif" font-size="11.00">(Pet, Visit, etc)</text>
</g>
<!-- http_service -->
<g id="node22" class="node">
<title>http_service</title>
<path fill="#ffcdd2" stroke="black" d="M1644.51,-611.12C1644.51,-611.12 1575.49,-611.12 1575.49,-611.12 1569.49,-611.12 1563.49,-605.12 1563.49,-599.12 1563.49,-599.12 1563.49,-587.12 1563.49,-587.12 1563.49,-581.12 1569.49,-575.12 1575.49,-575.12 1575.49,-575.12 1644.51,-575.12 1644.51,-575.12 1650.51,-575.12 1656.51,-581.12 1656.51,-587.12 1656.51,-587.12 1656.51,-599.12 1656.51,-599.12 1656.51,-605.12 1650.51,-611.12 1644.51,-611.12"/>
<text xml:space="preserve" text-anchor="middle" x="1610" y="-596.42" font-family="Helvetica,sans-Serif" font-size="11.00">HttpService</text>
<text xml:space="preserve" text-anchor="middle" x="1610" y="-583.22" font-family="Helvetica,sans-Serif" font-size="11.00">(Axios wrapper)</text>
</g>
<!-- auth_api -->
<g id="node23" class="node">
<title>auth_api</title>
<path fill="#ffcdd2" stroke="black" d="M1914.15,-496.8C1914.15,-496.8 1851.85,-496.8 1851.85,-496.8 1845.85,-496.8 1839.85,-490.8 1839.85,-484.8 1839.85,-484.8 1839.85,-472.8 1839.85,-472.8 1839.85,-466.8 1845.85,-460.8 1851.85,-460.8 1851.85,-460.8 1914.15,-460.8 1914.15,-460.8 1920.15,-460.8 1926.15,-466.8 1926.15,-472.8 1926.15,-472.8 1926.15,-484.8 1926.15,-484.8 1926.15,-490.8 1920.15,-496.8 1914.15,-496.8"/>
<text xml:space="preserve" text-anchor="middle" x="1883" y="-482.1" font-family="Helvetica,sans-Serif" font-size="11.00">authAPI</text>
<text xml:space="preserve" text-anchor="middle" x="1883" y="-468.9" font-family="Helvetica,sans-Serif" font-size="11.00">(login/register)</text>
</g>
<!-- http_service&#45;&gt;auth_api -->
<g id="edge17" class="edge">
<title>http_service&#45;&gt;auth_api</title>
<path fill="none" stroke="#c62828" d="M1656.93,-602C1734.99,-602 1883,-602 1883,-602 1883,-602 1883,-508.76 1883,-508.76"/>
<polygon fill="#c62828" stroke="#c62828" points="1886.5,-508.76 1883,-498.76 1879.5,-508.76 1886.5,-508.76"/>
</g>
<!-- visits_api -->
<g id="node24" class="node">
<title>visits_api</title>
<path fill="#ffcdd2" stroke="black" d="M1810.3,-496.8C1810.3,-496.8 1751.7,-496.8 1751.7,-496.8 1745.7,-496.8 1739.7,-490.8 1739.7,-484.8 1739.7,-484.8 1739.7,-472.8 1739.7,-472.8 1739.7,-466.8 1745.7,-460.8 1751.7,-460.8 1751.7,-460.8 1810.3,-460.8 1810.3,-460.8 1816.3,-460.8 1822.3,-466.8 1822.3,-472.8 1822.3,-472.8 1822.3,-484.8 1822.3,-484.8 1822.3,-490.8 1816.3,-496.8 1810.3,-496.8"/>
<text xml:space="preserve" text-anchor="middle" x="1781" y="-482.1" font-family="Helvetica,sans-Serif" font-size="11.00">visitsAPI</text>
<text xml:space="preserve" text-anchor="middle" x="1781" y="-468.9" font-family="Helvetica,sans-Serif" font-size="11.00">(CRUD visits)</text>
</g>
<!-- http_service&#45;&gt;visits_api -->
<g id="edge18" class="edge">
<title>http_service&#45;&gt;visits_api</title>
<path fill="none" stroke="#c62828" d="M1656.73,-593C1707.36,-593 1781,-593 1781,-593 1781,-593 1781,-508.66 1781,-508.66"/>
<polygon fill="#c62828" stroke="#c62828" points="1784.5,-508.66 1781,-498.66 1777.5,-508.66 1784.5,-508.66"/>
</g>
<!-- orders_api -->
<g id="node25" class="node">
<title>orders_api</title>
<path fill="#ffcdd2" stroke="black" d="M1309.71,-496.8C1309.71,-496.8 1230.29,-496.8 1230.29,-496.8 1224.29,-496.8 1218.29,-490.8 1218.29,-484.8 1218.29,-484.8 1218.29,-472.8 1218.29,-472.8 1218.29,-466.8 1224.29,-460.8 1230.29,-460.8 1230.29,-460.8 1309.71,-460.8 1309.71,-460.8 1315.71,-460.8 1321.71,-466.8 1321.71,-472.8 1321.71,-472.8 1321.71,-484.8 1321.71,-484.8 1321.71,-490.8 1315.71,-496.8 1309.71,-496.8"/>
<text xml:space="preserve" text-anchor="middle" x="1270" y="-482.1" font-family="Helvetica,sans-Serif" font-size="11.00">OrdersAPI</text>
<text xml:space="preserve" text-anchor="middle" x="1270" y="-468.9" font-family="Helvetica,sans-Serif" font-size="11.00">(service requests)</text>
</g>
<!-- http_service&#45;&gt;orders_api -->
<g id="edge19" class="edge">
<title>http_service&#45;&gt;orders_api</title>
<path fill="none" stroke="#c62828" d="M1563.08,-602C1469.81,-602 1270,-602 1270,-602 1270,-602 1270,-508.76 1270,-508.76"/>
<polygon fill="#c62828" stroke="#c62828" points="1273.5,-508.76 1270,-498.76 1266.5,-508.76 1273.5,-508.76"/>
</g>
<!-- petowners_api -->
<g id="node26" class="node">
<title>petowners_api</title>
<path fill="#ffcdd2" stroke="black" d="M1709.46,-496.8C1709.46,-496.8 1646.54,-496.8 1646.54,-496.8 1640.54,-496.8 1634.54,-490.8 1634.54,-484.8 1634.54,-484.8 1634.54,-472.8 1634.54,-472.8 1634.54,-466.8 1640.54,-460.8 1646.54,-460.8 1646.54,-460.8 1709.46,-460.8 1709.46,-460.8 1715.46,-460.8 1721.46,-466.8 1721.46,-472.8 1721.46,-472.8 1721.46,-484.8 1721.46,-484.8 1721.46,-490.8 1715.46,-496.8 1709.46,-496.8"/>
<text xml:space="preserve" text-anchor="middle" x="1678" y="-482.1" font-family="Helvetica,sans-Serif" font-size="11.00">petOwnersAPI</text>
<text xml:space="preserve" text-anchor="middle" x="1678" y="-468.9" font-family="Helvetica,sans-Serif" font-size="11.00">(clients)</text>
</g>
<!-- http_service&#45;&gt;petowners_api -->
<g id="edge20" class="edge">
<title>http_service&#45;&gt;petowners_api</title>
<path fill="none" stroke="#c62828" d="M1645.53,-574.76C1645.53,-574.76 1645.53,-508.64 1645.53,-508.64"/>
<polygon fill="#c62828" stroke="#c62828" points="1649.03,-508.64 1645.53,-498.64 1642.03,-508.64 1649.03,-508.64"/>
</g>
<!-- vets_api -->
<g id="node27" class="node">
<title>vets_api</title>
<path fill="#ffcdd2" stroke="black" d="M1604.27,-496.8C1604.27,-496.8 1529.73,-496.8 1529.73,-496.8 1523.73,-496.8 1517.73,-490.8 1517.73,-484.8 1517.73,-484.8 1517.73,-472.8 1517.73,-472.8 1517.73,-466.8 1523.73,-460.8 1529.73,-460.8 1529.73,-460.8 1604.27,-460.8 1604.27,-460.8 1610.27,-460.8 1616.27,-466.8 1616.27,-472.8 1616.27,-472.8 1616.27,-484.8 1616.27,-484.8 1616.27,-490.8 1610.27,-496.8 1604.27,-496.8"/>
<text xml:space="preserve" text-anchor="middle" x="1567" y="-482.1" font-family="Helvetica,sans-Serif" font-size="11.00">VeterinariansAPI</text>
<text xml:space="preserve" text-anchor="middle" x="1567" y="-468.9" font-family="Helvetica,sans-Serif" font-size="11.00">(professionals)</text>
</g>
<!-- http_service&#45;&gt;vets_api -->
<g id="edge21" class="edge">
<title>http_service&#45;&gt;vets_api</title>
<path fill="none" stroke="#c62828" d="M1589.88,-574.76C1589.88,-574.76 1589.88,-508.64 1589.88,-508.64"/>
<polygon fill="#c62828" stroke="#c62828" points="1593.38,-508.64 1589.88,-498.64 1586.38,-508.64 1593.38,-508.64"/>
</g>
<!-- services_api -->
<g id="node28" class="node">
<title>services_api</title>
<path fill="#ffcdd2" stroke="black" d="M1488.04,-496.8C1488.04,-496.8 1437.96,-496.8 1437.96,-496.8 1431.96,-496.8 1425.96,-490.8 1425.96,-484.8 1425.96,-484.8 1425.96,-472.8 1425.96,-472.8 1425.96,-466.8 1431.96,-460.8 1437.96,-460.8 1437.96,-460.8 1488.04,-460.8 1488.04,-460.8 1494.04,-460.8 1500.04,-466.8 1500.04,-472.8 1500.04,-472.8 1500.04,-484.8 1500.04,-484.8 1500.04,-490.8 1494.04,-496.8 1488.04,-496.8"/>
<text xml:space="preserve" text-anchor="middle" x="1463" y="-482.1" font-family="Helvetica,sans-Serif" font-size="11.00">servicesAPI</text>
<text xml:space="preserve" text-anchor="middle" x="1463" y="-468.9" font-family="Helvetica,sans-Serif" font-size="11.00">(catalog)</text>
</g>
<!-- http_service&#45;&gt;services_api -->
<g id="edge22" class="edge">
<title>http_service&#45;&gt;services_api</title>
<path fill="none" stroke="#c62828" d="M1563.08,-584C1520.06,-584 1463,-584 1463,-584 1463,-584 1463,-508.64 1463,-508.64"/>
<polygon fill="#c62828" stroke="#c62828" points="1466.5,-508.64 1463,-498.64 1459.5,-508.64 1466.5,-508.64"/>
</g>
<!-- cart_api -->
<g id="node29" class="node">
<title>cart_api</title>
<path fill="#ffcdd2" stroke="black" d="M1395.99,-496.8C1395.99,-496.8 1352.01,-496.8 1352.01,-496.8 1346.01,-496.8 1340.01,-490.8 1340.01,-484.8 1340.01,-484.8 1340.01,-472.8 1340.01,-472.8 1340.01,-466.8 1346.01,-460.8 1352.01,-460.8 1352.01,-460.8 1395.99,-460.8 1395.99,-460.8 1401.99,-460.8 1407.99,-466.8 1407.99,-472.8 1407.99,-472.8 1407.99,-484.8 1407.99,-484.8 1407.99,-490.8 1401.99,-496.8 1395.99,-496.8"/>
<text xml:space="preserve" text-anchor="middle" x="1374" y="-482.1" font-family="Helvetica,sans-Serif" font-size="11.00">CartAPI</text>
<text xml:space="preserve" text-anchor="middle" x="1374" y="-468.9" font-family="Helvetica,sans-Serif" font-size="11.00">(shopping)</text>
</g>
<!-- http_service&#45;&gt;cart_api -->
<g id="edge23" class="edge">
<title>http_service&#45;&gt;cart_api</title>
<path fill="none" stroke="#c62828" d="M1563.09,-593C1494.27,-593 1374,-593 1374,-593 1374,-593 1374,-508.66 1374,-508.66"/>
<polygon fill="#c62828" stroke="#c62828" points="1377.5,-508.66 1374,-498.66 1370.5,-508.66 1377.5,-508.66"/>
</g>
<!-- api_auth -->
<g id="node38" class="node">
<title>api_auth</title>
<path fill="#cfd8dc" stroke="black" d="M1773.81,-388.8C1773.81,-388.8 1726.19,-388.8 1726.19,-388.8 1720.19,-388.8 1714.19,-382.8 1714.19,-376.8 1714.19,-376.8 1714.19,-364.8 1714.19,-364.8 1714.19,-358.8 1720.19,-352.8 1726.19,-352.8 1726.19,-352.8 1773.81,-352.8 1773.81,-352.8 1779.81,-352.8 1785.81,-358.8 1785.81,-364.8 1785.81,-364.8 1785.81,-376.8 1785.81,-376.8 1785.81,-382.8 1779.81,-388.8 1773.81,-388.8"/>
<text xml:space="preserve" text-anchor="middle" x="1750" y="-374.1" font-family="Helvetica,sans-Serif" font-size="11.00">/api/token/</text>
<text xml:space="preserve" text-anchor="middle" x="1750" y="-360.9" font-family="Helvetica,sans-Serif" font-size="11.00">(JWT Auth)</text>
</g>
<!-- http_service&#45;&gt;api_auth -->
<g id="edge39" class="edge">
<title>http_service&#45;&gt;api_auth</title>
<path fill="none" stroke="#455a64" stroke-dasharray="5,2" d="M1656.96,-584C1690.82,-584 1730.58,-584 1730.58,-584 1730.58,-584 1730.58,-400.49 1730.58,-400.49"/>
<polygon fill="#455a64" stroke="#455a64" points="1734.08,-400.49 1730.58,-390.49 1727.08,-400.49 1734.08,-400.49"/>
<text xml:space="preserve" text-anchor="middle" x="1986.5" y="-476.1" font-family="Helvetica,sans-Serif" font-size="9.00">JWT refresh</text>
</g>
<!-- auth_api&#45;&gt;api_auth -->
<g id="edge24" class="edge">
<title>auth_api&#45;&gt;api_auth</title>
<path fill="none" stroke="#455a64" d="M1867.88,-460.31C1867.88,-439.04 1867.88,-407 1867.88,-407 1867.88,-407 1770.44,-407 1770.44,-407 1770.44,-407 1770.44,-400.41 1770.44,-400.41"/>
<polygon fill="#455a64" stroke="#455a64" points="1773.94,-400.41 1770.44,-390.41 1766.94,-400.41 1773.94,-400.41"/>
<text xml:space="preserve" text-anchor="middle" x="1909.27" y="-434.7" font-family="Helvetica,sans-Serif" font-size="9.00">POST /api/token/</text>
</g>
<!-- api_mascotas -->
<g id="node35" class="node">
<title>api_mascotas</title>
<path fill="#cfd8dc" stroke="black" d="M1683.93,-388.8C1683.93,-388.8 1602.07,-388.8 1602.07,-388.8 1596.07,-388.8 1590.07,-382.8 1590.07,-376.8 1590.07,-376.8 1590.07,-364.8 1590.07,-364.8 1590.07,-358.8 1596.07,-352.8 1602.07,-352.8 1602.07,-352.8 1683.93,-352.8 1683.93,-352.8 1689.93,-352.8 1695.93,-358.8 1695.93,-364.8 1695.93,-364.8 1695.93,-376.8 1695.93,-376.8 1695.93,-382.8 1689.93,-388.8 1683.93,-388.8"/>
<text xml:space="preserve" text-anchor="middle" x="1643" y="-374.1" font-family="Helvetica,sans-Serif" font-size="11.00">/mascotas/api/v1/</text>
<text xml:space="preserve" text-anchor="middle" x="1643" y="-360.9" font-family="Helvetica,sans-Serif" font-size="11.00">(Pets, Vets, Visits)</text>
</g>
<!-- visits_api&#45;&gt;api_mascotas -->
<g id="edge25" class="edge">
<title>visits_api&#45;&gt;api_mascotas</title>
<path fill="none" stroke="#455a64" d="M1755.07,-460.59C1755.07,-444.98 1755.07,-425 1755.07,-425 1755.07,-425 1675.46,-425 1675.46,-425 1675.46,-425 1675.46,-400.66 1675.46,-400.66"/>
<polygon fill="#455a64" stroke="#455a64" points="1678.96,-400.66 1675.46,-390.66 1671.96,-400.66 1678.96,-400.66"/>
<text xml:space="preserve" text-anchor="middle" x="1814.25" y="-434.7" font-family="Helvetica,sans-Serif" font-size="9.00">CRUD /vet&#45;visits/</text>
</g>
<!-- api_solicitudes -->
<g id="node37" class="node">
<title>api_solicitudes</title>
<path fill="#cfd8dc" stroke="black" d="M1439.74,-388.8C1439.74,-388.8 1390.26,-388.8 1390.26,-388.8 1384.26,-388.8 1378.26,-382.8 1378.26,-376.8 1378.26,-376.8 1378.26,-364.8 1378.26,-364.8 1378.26,-358.8 1384.26,-352.8 1390.26,-352.8 1390.26,-352.8 1439.74,-352.8 1439.74,-352.8 1445.74,-352.8 1451.74,-358.8 1451.74,-364.8 1451.74,-364.8 1451.74,-376.8 1451.74,-376.8 1451.74,-382.8 1445.74,-388.8 1439.74,-388.8"/>
<text xml:space="preserve" text-anchor="middle" x="1415" y="-374.1" font-family="Helvetica,sans-Serif" font-size="11.00">/solicitudes/</text>
<text xml:space="preserve" text-anchor="middle" x="1415" y="-360.9" font-family="Helvetica,sans-Serif" font-size="11.00">(Requests)</text>
</g>
<!-- orders_api&#45;&gt;api_solicitudes -->
<g id="edge26" class="edge">
<title>orders_api&#45;&gt;api_solicitudes</title>
<path fill="none" stroke="#455a64" d="M1270,-460.51C1270,-429.76 1270,-371 1270,-371 1270,-371 1366.52,-371 1366.52,-371"/>
<polygon fill="#455a64" stroke="#455a64" points="1366.52,-374.5 1376.52,-371 1366.52,-367.5 1366.52,-374.5"/>
<text xml:space="preserve" text-anchor="middle" x="1320.76" y="-434.7" font-family="Helvetica,sans-Serif" font-size="9.00">CRUD /service&#45;requests/</text>
</g>
<!-- petowners_api&#45;&gt;api_mascotas -->
<g id="edge27" class="edge">
<title>petowners_api&#45;&gt;api_mascotas</title>
<path fill="none" stroke="#455a64" d="M1655,-460.48C1655,-460.48 1655,-400.72 1655,-400.72"/>
<polygon fill="#455a64" stroke="#455a64" points="1658.5,-400.72 1655,-390.72 1651.5,-400.72 1658.5,-400.72"/>
<text xml:space="preserve" text-anchor="middle" x="1715.01" y="-434.7" font-family="Helvetica,sans-Serif" font-size="9.00">CRUD /pet&#45;owners/</text>
</g>
<!-- vets_api&#45;&gt;api_mascotas -->
<g id="edge28" class="edge">
<title>vets_api&#45;&gt;api_mascotas</title>
<path fill="none" stroke="#455a64" d="M1603.17,-460.48C1603.17,-460.48 1603.17,-400.72 1603.17,-400.72"/>
<polygon fill="#455a64" stroke="#455a64" points="1606.67,-400.72 1603.17,-390.72 1599.67,-400.72 1606.67,-400.72"/>
<text xml:space="preserve" text-anchor="middle" x="1609.76" y="-434.7" font-family="Helvetica,sans-Serif" font-size="9.00">GET /veterinarians/</text>
</g>
<!-- api_productos -->
<g id="node36" class="node">
<title>api_productos</title>
<path fill="#cfd8dc" stroke="black" d="M1560.09,-388.8C1560.09,-388.8 1481.91,-388.8 1481.91,-388.8 1475.91,-388.8 1469.91,-382.8 1469.91,-376.8 1469.91,-376.8 1469.91,-364.8 1469.91,-364.8 1469.91,-358.8 1475.91,-352.8 1481.91,-352.8 1481.91,-352.8 1560.09,-352.8 1560.09,-352.8 1566.09,-352.8 1572.09,-358.8 1572.09,-364.8 1572.09,-364.8 1572.09,-376.8 1572.09,-376.8 1572.09,-382.8 1566.09,-388.8 1560.09,-388.8"/>
<text xml:space="preserve" text-anchor="middle" x="1521" y="-374.1" font-family="Helvetica,sans-Serif" font-size="11.00">/productos/</text>
<text xml:space="preserve" text-anchor="middle" x="1521" y="-360.9" font-family="Helvetica,sans-Serif" font-size="11.00">(Services, Prices)</text>
</g>
<!-- services_api&#45;&gt;api_productos -->
<g id="edge29" class="edge">
<title>services_api&#45;&gt;api_productos</title>
<path fill="none" stroke="#455a64" d="M1489.99,-460.48C1489.99,-460.48 1489.99,-400.72 1489.99,-400.72"/>
<polygon fill="#455a64" stroke="#455a64" points="1493.49,-400.72 1489.99,-390.72 1486.49,-400.72 1493.49,-400.72"/>
<text xml:space="preserve" text-anchor="middle" x="1517.5" y="-434.7" font-family="Helvetica,sans-Serif" font-size="9.00">GET /services/</text>
</g>
<!-- cart_api&#45;&gt;api_productos -->
<g id="edge30" class="edge">
<title>cart_api&#45;&gt;api_productos</title>
<path fill="none" stroke="#455a64" d="M1393.13,-460.48C1393.13,-451.67 1393.13,-443 1393.13,-443 1393.13,-443 1479.95,-443 1479.95,-443 1479.95,-443 1479.95,-400.53 1479.95,-400.53"/>
<polygon fill="#455a64" stroke="#455a64" points="1483.45,-400.53 1479.95,-390.53 1476.45,-400.53 1483.45,-400.53"/>
<text xml:space="preserve" text-anchor="middle" x="1433.25" y="-434.7" font-family="Helvetica,sans-Serif" font-size="9.00">CRUD /cart/</text>
</g>
<!-- redux_store -->
<g id="node30" class="node">
<title>redux_store</title>
<path fill="#b2ebf2" stroke="black" d="M2228.93,-496.8C2228.93,-496.8 2169.07,-496.8 2169.07,-496.8 2163.07,-496.8 2157.07,-490.8 2157.07,-484.8 2157.07,-484.8 2157.07,-472.8 2157.07,-472.8 2157.07,-466.8 2163.07,-460.8 2169.07,-460.8 2169.07,-460.8 2228.93,-460.8 2228.93,-460.8 2234.93,-460.8 2240.93,-466.8 2240.93,-472.8 2240.93,-472.8 2240.93,-484.8 2240.93,-484.8 2240.93,-490.8 2234.93,-496.8 2228.93,-496.8"/>
<text xml:space="preserve" text-anchor="middle" x="2199" y="-482.1" font-family="Helvetica,sans-Serif" font-size="11.00">Redux Store</text>
<text xml:space="preserve" text-anchor="middle" x="2199" y="-468.9" font-family="Helvetica,sans-Serif" font-size="11.00">(Global State)</text>
</g>
<!-- auth_slice -->
<g id="node31" class="node">
<title>auth_slice</title>
<path fill="#b2ebf2" stroke="black" d="M2092.87,-611.12C2092.87,-611.12 2039.13,-611.12 2039.13,-611.12 2033.13,-611.12 2027.13,-605.12 2027.13,-599.12 2027.13,-599.12 2027.13,-587.12 2027.13,-587.12 2027.13,-581.12 2033.13,-575.12 2039.13,-575.12 2039.13,-575.12 2092.87,-575.12 2092.87,-575.12 2098.87,-575.12 2104.87,-581.12 2104.87,-587.12 2104.87,-587.12 2104.87,-599.12 2104.87,-599.12 2104.87,-605.12 2098.87,-611.12 2092.87,-611.12"/>
<text xml:space="preserve" text-anchor="middle" x="2066" y="-596.42" font-family="Helvetica,sans-Serif" font-size="11.00">Auth Slice</text>
<text xml:space="preserve" text-anchor="middle" x="2066" y="-583.22" font-family="Helvetica,sans-Serif" font-size="11.00">(user, token)</text>
</g>
<!-- auth_slice&#45;&gt;redux_store -->
<g id="edge31" class="edge">
<title>auth_slice&#45;&gt;redux_store</title>
<path fill="none" stroke="#00838f" d="M2066,-574.79C2066,-542.53 2066,-479 2066,-479 2066,-479 2145.4,-479 2145.4,-479"/>
<polygon fill="#00838f" stroke="#00838f" points="2145.4,-482.5 2155.4,-479 2145.4,-475.5 2145.4,-482.5"/>
</g>
<!-- visits_slice -->
<g id="node32" class="node">
<title>visits_slice</title>
<path fill="#b2ebf2" stroke="black" d="M2179.59,-611.12C2179.59,-611.12 2134.41,-611.12 2134.41,-611.12 2128.41,-611.12 2122.41,-605.12 2122.41,-599.12 2122.41,-599.12 2122.41,-587.12 2122.41,-587.12 2122.41,-581.12 2128.41,-575.12 2134.41,-575.12 2134.41,-575.12 2179.59,-575.12 2179.59,-575.12 2185.59,-575.12 2191.59,-581.12 2191.59,-587.12 2191.59,-587.12 2191.59,-599.12 2191.59,-599.12 2191.59,-605.12 2185.59,-611.12 2179.59,-611.12"/>
<text xml:space="preserve" text-anchor="middle" x="2157" y="-596.42" font-family="Helvetica,sans-Serif" font-size="11.00">Visits Slice</text>
<text xml:space="preserve" text-anchor="middle" x="2157" y="-583.22" font-family="Helvetica,sans-Serif" font-size="11.00">(visit data)</text>
</g>
<!-- visits_slice&#45;&gt;redux_store -->
<g id="edge32" class="edge">
<title>visits_slice&#45;&gt;redux_store</title>
<path fill="none" stroke="#00838f" d="M2174.33,-574.76C2174.33,-574.76 2174.33,-508.64 2174.33,-508.64"/>
<polygon fill="#00838f" stroke="#00838f" points="2177.83,-508.64 2174.33,-498.64 2170.83,-508.64 2177.83,-508.64"/>
</g>
<!-- cart_slice -->
<g id="node33" class="node">
<title>cart_slice</title>
<path fill="#b2ebf2" stroke="black" d="M2260.84,-611.12C2260.84,-611.12 2221.16,-611.12 2221.16,-611.12 2215.16,-611.12 2209.16,-605.12 2209.16,-599.12 2209.16,-599.12 2209.16,-587.12 2209.16,-587.12 2209.16,-581.12 2215.16,-575.12 2221.16,-575.12 2221.16,-575.12 2260.84,-575.12 2260.84,-575.12 2266.84,-575.12 2272.84,-581.12 2272.84,-587.12 2272.84,-587.12 2272.84,-599.12 2272.84,-599.12 2272.84,-605.12 2266.84,-611.12 2260.84,-611.12"/>
<text xml:space="preserve" text-anchor="middle" x="2241" y="-596.42" font-family="Helvetica,sans-Serif" font-size="11.00">Cart Slice</text>
<text xml:space="preserve" text-anchor="middle" x="2241" y="-583.22" font-family="Helvetica,sans-Serif" font-size="11.00">(items)</text>
</g>
<!-- cart_slice&#45;&gt;redux_store -->
<g id="edge33" class="edge">
<title>cart_slice&#45;&gt;redux_store</title>
<path fill="none" stroke="#00838f" d="M2225.05,-574.76C2225.05,-574.76 2225.05,-508.64 2225.05,-508.64"/>
<polygon fill="#00838f" stroke="#00838f" points="2228.55,-508.64 2225.05,-498.64 2221.55,-508.64 2228.55,-508.64"/>
</g>
<!-- auth_context -->
<g id="node34" class="node">
<title>auth_context</title>
<path fill="#b2ebf2" stroke="black" d="M2355.27,-611.12C2355.27,-611.12 2302.73,-611.12 2302.73,-611.12 2296.73,-611.12 2290.73,-605.12 2290.73,-599.12 2290.73,-599.12 2290.73,-587.12 2290.73,-587.12 2290.73,-581.12 2296.73,-575.12 2302.73,-575.12 2302.73,-575.12 2355.27,-575.12 2355.27,-575.12 2361.27,-575.12 2367.27,-581.12 2367.27,-587.12 2367.27,-587.12 2367.27,-599.12 2367.27,-599.12 2367.27,-605.12 2361.27,-611.12 2355.27,-611.12"/>
<text xml:space="preserve" text-anchor="middle" x="2329" y="-596.42" font-family="Helvetica,sans-Serif" font-size="11.00">AuthContext</text>
<text xml:space="preserve" text-anchor="middle" x="2329" y="-583.22" font-family="Helvetica,sans-Serif" font-size="11.00">(Provider)</text>
</g>
<!-- auth_context&#45;&gt;redux_store -->
<g id="edge34" class="edge">
<title>auth_context&#45;&gt;redux_store</title>
<path fill="none" stroke="#00838f" stroke-dasharray="5,2" d="M2329,-574.79C2329,-542.53 2329,-479 2329,-479 2329,-479 2252.72,-479 2252.72,-479"/>
<polygon fill="#00838f" stroke="#00838f" points="2252.72,-475.5 2242.72,-479 2252.72,-482.5 2252.72,-475.5"/>
</g>
<!-- api_payments -->
<g id="node39" class="node">
<title>api_payments</title>
<path fill="#cfd8dc" stroke="black" d="M1883.91,-388.8C1883.91,-388.8 1816.09,-388.8 1816.09,-388.8 1810.09,-388.8 1804.09,-382.8 1804.09,-376.8 1804.09,-376.8 1804.09,-364.8 1804.09,-364.8 1804.09,-358.8 1810.09,-352.8 1816.09,-352.8 1816.09,-352.8 1883.91,-352.8 1883.91,-352.8 1889.91,-352.8 1895.91,-358.8 1895.91,-364.8 1895.91,-364.8 1895.91,-376.8 1895.91,-376.8 1895.91,-382.8 1889.91,-388.8 1883.91,-388.8"/>
<text xml:space="preserve" text-anchor="middle" x="1850" y="-374.1" font-family="Helvetica,sans-Serif" font-size="11.00">/payments/</text>
<text xml:space="preserve" text-anchor="middle" x="1850" y="-360.9" font-family="Helvetica,sans-Serif" font-size="11.00">(MercadoPago)</text>
</g>
<!-- petowner_user -->
<g id="node40" class="node">
<title>petowner_user</title>
<ellipse fill="#f8bbd9" stroke="black" cx="866" cy="-593.12" rx="45.9" ry="24.32"/>
<text xml:space="preserve" text-anchor="middle" x="866" y="-596.42" font-family="Helvetica,sans-Serif" font-size="11.00">PetOwner</text>
<text xml:space="preserve" text-anchor="middle" x="866" y="-583.22" font-family="Helvetica,sans-Serif" font-size="11.00">(Cliente)</text>
</g>
<!-- petowner_user&#45;&gt;home -->
<g id="edge35" class="edge">
<title>petowner_user&#45;&gt;home</title>
<path fill="none" stroke="#ad1457" d="M827.6,-579.22C827.6,-551.07 827.6,-490 827.6,-490 827.6,-490 466.04,-490 466.04,-490"/>
<polygon fill="#ad1457" stroke="#ad1457" points="466.04,-486.5 456.04,-490 466.04,-493.5 466.04,-486.5"/>
<text xml:space="preserve" text-anchor="middle" x="830.01" y="-542.7" font-family="Helvetica,sans-Serif" font-size="9.00">public access</text>
</g>
<!-- petowner_user&#45;&gt;profile_page -->
<g id="edge36" class="edge">
<title>petowner_user&#45;&gt;profile_page</title>
<path fill="none" stroke="#ad1457" stroke-dasharray="5,2" d="M819.7,-593C733.92,-593 558.94,-593 558.94,-593 558.94,-593 558.94,-107 558.94,-107 558.94,-107 462.46,-107 462.46,-107"/>
<polygon fill="#ad1457" stroke="#ad1457" points="462.46,-103.5 452.46,-107 462.46,-110.5 462.46,-103.5"/>
<text xml:space="preserve" text-anchor="middle" x="1154.02" y="-326.7" font-family="Helvetica,sans-Serif" font-size="9.00">authenticated</text>
</g>
<!-- vet_user -->
<g id="node41" class="node">
<title>vet_user</title>
<ellipse fill="#f8bbd9" stroke="black" cx="985" cy="-593.12" rx="55.41" ry="24.32"/>
<text xml:space="preserve" text-anchor="middle" x="985" y="-596.42" font-family="Helvetica,sans-Serif" font-size="11.00">Veterinarian</text>
<text xml:space="preserve" text-anchor="middle" x="985" y="-583.22" font-family="Helvetica,sans-Serif" font-size="11.00">(Profesional)</text>
</g>
<!-- vet_user&#45;&gt;admin_dash -->
<g id="edge37" class="edge">
<title>vet_user&#45;&gt;admin_dash</title>
<path fill="none" stroke="#ad1457" d="M954.15,-572.61C954.15,-542.01 954.15,-488 954.15,-488 954.15,-488 916.71,-488 916.71,-488"/>
<polygon fill="#ad1457" stroke="#ad1457" points="916.71,-484.5 906.71,-488 916.71,-491.5 916.71,-484.5"/>
<text xml:space="preserve" text-anchor="middle" x="921.26" y="-542.7" font-family="Helvetica,sans-Serif" font-size="9.00">backoffice</text>
</g>
<!-- admin_user -->
<g id="node42" class="node">
<title>admin_user</title>
<ellipse fill="#f8bbd9" stroke="black" cx="1110" cy="-593.12" rx="51.52" ry="24.32"/>
<text xml:space="preserve" text-anchor="middle" x="1110" y="-596.42" font-family="Helvetica,sans-Serif" font-size="11.00">Admin/Staff</text>
<text xml:space="preserve" text-anchor="middle" x="1110" y="-583.22" font-family="Helvetica,sans-Serif" font-size="11.00">(Interno)</text>
</g>
<!-- admin_user&#45;&gt;admin_dash -->
<g id="edge38" class="edge">
<title>admin_user&#45;&gt;admin_dash</title>
<path fill="none" stroke="#ad1457" d="M1078.95,-573.25C1078.95,-540.55 1078.95,-479 1078.95,-479 1078.95,-479 916.49,-479 916.49,-479"/>
<polygon fill="#ad1457" stroke="#ad1457" points="916.49,-475.5 906.49,-479 916.49,-482.5 916.49,-475.5"/>
<text xml:space="preserve" text-anchor="middle" x="1181.01" y="-542.7" font-family="Helvetica,sans-Serif" font-size="9.00">full access</text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 51 KiB

View File

@@ -0,0 +1,234 @@
digraph DataModel {
// Graph settings
rankdir=TB
compound=true
splines=ortho
node [shape=record, style="filled", fontname="Helvetica", fontsize=10]
edge [fontname="Helvetica", fontsize=8]
label="AMAR Mascotas - Data Model (Entity Relationships)\n\n"
labelloc="t"
fontsize=16
fontname="Helvetica-Bold"
// === USERS & AUTHENTICATION ===
subgraph cluster_auth {
label="Users & Authentication"
style="rounded,filled"
fillcolor="#E8F5E9"
color="#2E7D32"
auth_user [label="{auth.User|id: PK\luser name: str\lemail: str\lis_staff: bool\lis_superuser: bool\l}", fillcolor="#C8E6C9"]
}
// === PET OWNERS & PETS ===
subgraph cluster_mascotas_owners {
label="Pet Owners & Pets"
style="rounded,filled"
fillcolor="#E3F2FD"
color="#1565C0"
petowner [label="{PetOwner|id: PK\lemail: str (unique)\lphone: str\lfirst_name: str\llast_name: str\lneighborhood_id: FK\lcampaign_id: FK (opt)\lgeo_latitude: float\lgeo_longitude: float\laddress: str\l}", fillcolor="#BBDEFB"]
pet [label="{Pet|id: PK\lowner_id: FK\lname: str\lpet_type: DOG/CAT\lbreed_id: FK (opt)\lgender: M/F\lage: int\lweight: decimal\lheight: decimal\lbirth_date: date\lallergies: text\lneutered: bool\lis_deceased: bool\lstate: puppy/adult/...\lprofile_picture: file\l}", fillcolor="#BBDEFB"]
petvaccine [label="{PetVaccine|id: PK\lpet_id: FK\lvaccine_id: FK\lapplication_date: date\lnext_application: date\l}", fillcolor="#90CAF9"]
petstudy [label="{PetStudy|id: PK\lpet_id: FK\lstudy_id: FK\ldate: date\lresult: text\limages: files\l}", fillcolor="#90CAF9"]
}
// === VETERINARIANS ===
subgraph cluster_veterinarians {
label="Veterinarians"
style="rounded,filled"
fillcolor="#FFF3E0"
color="#E65100"
veterinarian [label="{Veterinarian|id: PK\luser_id: FK\llicense: str\lemail: str\lphone: str\l}", fillcolor="#FFE0B2"]
availability [label="{Availability|id: PK\lveterinarian_id: FK\lstart_day: 0-6\lend_day: 0-6\lstart_time: time\lend_time: time\l}", fillcolor="#FFCC80"]
unavailability [label="{Unavailability|id: PK\lveterinarian_id: FK\lstart_date: date\lend_date: date\lstart_time: time\lend_time: time\lreason: str\l}", fillcolor="#FFCC80"]
vet_specialty [label="{M2M: Vet-Specialty|veterinarian_id: FK\lspecialty_id: FK\l}", shape=diamond, fillcolor="#FFB74D"]
vet_neighborhood [label="{M2M: Vet-Neighborhood|veterinarian_id: FK\lneighborhood_id: FK\l}", shape=diamond, fillcolor="#FFB74D"]
}
// === SERVICES & PRICING ===
subgraph cluster_productos {
label="Services & Pricing"
style="rounded,filled"
fillcolor="#F3E5F5"
color="#7B1FA2"
grupo [label="{Group|id: PK\lname: str\ldescription: text\l}", fillcolor="#E1BEE7"]
category [label="{Category|id: PK\lgroup_id: FK\lname: str\ldescription: text\lvalue: int (order)\l}", fillcolor="#E1BEE7"]
service [label="{Service|id: PK\lname: str\ldescription: text\lspecialty_id: FK\lcategory_id: FK\lduration: int (min)\lmodality: onsite/online\lpayment_sign_req: bool\lpet_type_filter: str\lage_filter: str\lweight_range: str\l}", fillcolor="#CE93D8"]
prices [label="{Prices|id: PK\lservice_id: FK\lveterinarian_id: FK (opt)\lprice: decimal\lprofessional_fee: decimal\lpayment_sign: decimal\lfrom_date: date\lto_date: date\lactive: bool\l}", fillcolor="#BA68C8"]
discounts [label="{Discounts|id: PK\lservice_id: FK\ldiscount: decimal (%)\lfrom_date: date\lto_date: date\lactive: bool\l}", fillcolor="#BA68C8"]
servicecombo [label="{ServiceCombo|id: PK\lname: str\ldescription: text\ldiscount_percent: decimal\ldiscount_fixed: decimal\l}", fillcolor="#E1BEE7"]
}
// === CART & CHECKOUT ===
subgraph cluster_cart {
label="Cart & Checkout"
style="rounded,filled"
fillcolor="#E0F7FA"
color="#00838F"
cart [label="{Cart|id: PK\lpetowner_id: FK\lveterinarian_id: FK (opt)\luse_vet_prices: bool\lapply_turn_fee: bool\l}", fillcolor="#B2EBF2"]
cartitem [label="{CartItem|id: PK\lcart_id: FK\lpet_id: FK (opt)\lservice_id: FK\lprice: decimal\lquantity: int\ltotal: decimal (calc)\l}", fillcolor="#80DEEA"]
cartresumeitem [label="{CartResumeItem|id: PK\lcart_id: FK\lconcept: SUBTOTAL/\l DESCUENTO/\l ADELANTO/\l TOTAL/\l COSTO_SERVICIO\lamount: decimal\lorder: int\l}", fillcolor="#80DEEA"]
cartpetreason [label="{CartPetReason|id: PK\lcart_id: FK\lpet_id: FK\lreason: text\l}", fillcolor="#80DEEA"]
}
// === SERVICE REQUESTS ===
subgraph cluster_solicitudes {
label="Service Requests (Workflow)"
style="rounded,filled"
fillcolor="#FFEBEE"
color="#C62828"
servicerequest [label="{ServiceRequest|id: PK\lpetowner_id: FK\lcart_id: FK\lveterinarian_id: FK (opt)\lstate: pending/vet_asked/\l vet_accepted/coordinated/\l payed/Confirmado/...\lreason: text\ldays_requested: JSON\ldate_coordinated: datetime\lhour_coordinated: time\lpay_number: str\lcampaign_id: FK (opt)\lattended_by_id: FK (opt)\l}", fillcolor="#FFCDD2"]
statehistory [label="{StateHistory|id: PK\lservice_request_id: FK\lstate: str\ladditional_data: JSON\lcreated_at: datetime\luser_id: FK\l}", fillcolor="#EF9A9A"]
vetasked [label="{VeterinarianAsked|id: PK\lservice_request_id: FK\lveterinarian_id: FK\ldate_asked: datetime\ldate_answered: datetime\laccepted: bool\l}", fillcolor="#EF9A9A"]
vetreminder [label="{ScheduledVetReminder|id: PK\lvet_asked_id: FK\lscheduled_for: datetime\lprocessed_at: datetime\lstatus: pending/sent/...\lcelery_task_id: str\l}", fillcolor="#E57373"]
payreminder [label="{PaymentReminder|id: PK\lservice_request_id: FK\lscheduled_for: datetime\lprocessed_at: datetime\lstatus: pending/sent/...\l}", fillcolor="#E57373"]
}
// === VET VISITS ===
subgraph cluster_vetvisits {
label="Veterinary Visits"
style="rounded,filled"
fillcolor="#FFFDE7"
color="#F9A825"
vetvisit [label="{VetVisit|id: PK\lservice_request_id: FK (opt)\lowner_id: FK\lveterinarian_id: FK\ldate: date\lhour: time\lvisit_type: clinical/\l vaccination/\l telemedicina\lvisit_state: PENDING/\l IN_PROGRESS/\l COMPLETED/\l NO_REPORT/\l CANCELLED\lreason: text\lobservations: text\lprice: decimal\ldeposit: decimal\lvet_fee: decimal\lpay_transaction: str\lgoogle_event_id: str\lafip_receipt_id: FK (opt)\l}", fillcolor="#FFF9C4"]
vetvisitreport [label="{VetVisitReport|id: PK\lvisit_id: FK\lpet_id: FK\lreason: text\lphysical_exam: text\ldiagnosis: text\ltreatment: text\lpdf_file: file\l}", fillcolor="#FFF59D"]
vetvisitfollowup [label="{VetVisitFollowUp|id: PK\lreport_id: FK\ldate: date\ldescription: text\l}", fillcolor="#FFF176"]
vetvisitpetreason [label="{VetVisitPetReason|id: PK\lvisit_id: FK\lpet_id: FK\lreason: text\l}", fillcolor="#FFF176"]
visit_pets [label="{M2M: Visit-Pets|vetvisit_id: FK\lpet_id: FK\l}", shape=diamond, fillcolor="#FFEE58"]
}
// === REFERENCE DATA ===
subgraph cluster_reference {
label="Reference Data"
style="rounded,filled"
fillcolor="#ECEFF1"
color="#455A64"
specialty [label="{Specialty|id: PK\lname: str\l}", fillcolor="#CFD8DC"]
neighborhood [label="{Neighborhood|id: PK\lname: str\ldistance_coefficient: decimal\lcoverage_area: GIS Polygon\l}", fillcolor="#CFD8DC"]
province [label="{Province|id: PK\lname: str\l}", fillcolor="#CFD8DC"]
locality [label="{Locality|id: PK\lprovince_id: FK\lname: str\l}", fillcolor="#CFD8DC"]
petbreed [label="{PetBreed|id: PK\lname: str\lpet_type: DOG/CAT\l}", fillcolor="#CFD8DC"]
vaccine [label="{Vaccine|id: PK\lname: str\lpet_type: DOG/CAT\lperiodicity: int (months)\l}", fillcolor="#CFD8DC"]
study [label="{Study|id: PK\lname: str\lpet_type: DOG/CAT\lgroup_id: FK (opt)\l}", fillcolor="#CFD8DC"]
campaign [label="{Campaign|id: PK\lname: str\lutm_source: str\lutm_medium: str\lis_active: bool\l}", fillcolor="#CFD8DC"]
tag [label="{Tag|id: PK\lname: str (unique)\l}", fillcolor="#CFD8DC"]
medication [label="{Medication|id: PK\lname: str\lgeneral_name_id: FK\ltype_id: FK\lpresentation_id: FK\lpet_type: str\l}", fillcolor="#CFD8DC"]
turnfeegroup [label="{IndividualTurnFeeGroup|id: PK\lname: str\lfee_percentage: decimal\l}", fillcolor="#CFD8DC"]
}
// === AFIP/INVOICING ===
subgraph cluster_afip {
label="AFIP Invoicing"
style="rounded,filled"
fillcolor="#FCE4EC"
color="#AD1457"
receipt [label="{Receipt|id: PK\ldocument_number: bigint\lreceipt_number: int\lissued_date: date\ltotal_amount: decimal\lnet_taxed: decimal\lcae: str\lcae_expiration: date\l}", fillcolor="#F8BBD9"]
}
// === RELATIONSHIPS ===
// Auth -> PetOwner/Vet
auth_user -> petowner [label="1:1 opt", style=dashed, color="#2E7D32"]
auth_user -> veterinarian [label="1:1", color="#2E7D32"]
// PetOwner relationships
petowner -> pet [label="1:N owns", color="#1565C0"]
petowner -> neighborhood [label="N:1 lives in", color="#1565C0"]
petowner -> campaign [label="N:1 opt", style=dashed, color="#666"]
petowner -> cart [label="1:N", color="#00838F"]
petowner -> servicerequest [label="1:N requests", color="#C62828"]
petowner -> vetvisit [label="1:N as owner", color="#F9A825"]
// Pet relationships
pet -> petbreed [label="N:1 opt", style=dashed, color="#666"]
pet -> petvaccine [label="1:N", color="#1565C0"]
pet -> petstudy [label="1:N", color="#1565C0"]
petvaccine -> vaccine [label="N:1", color="#666"]
petstudy -> study [label="N:1", color="#666"]
// Veterinarian relationships
veterinarian -> vet_specialty [label="1:N", color="#E65100"]
vet_specialty -> specialty [label="N:1", color="#E65100"]
veterinarian -> vet_neighborhood [label="1:N coverage", color="#E65100"]
vet_neighborhood -> neighborhood [label="N:1", color="#E65100"]
veterinarian -> availability [label="1:N", color="#E65100"]
veterinarian -> unavailability [label="1:N", color="#E65100"]
veterinarian -> turnfeegroup [label="N:M", color="#E65100", style=dashed]
// Service/Pricing relationships
grupo -> category [label="1:N", color="#7B1FA2"]
category -> service [label="1:N", color="#7B1FA2"]
service -> specialty [label="N:1 opt", style=dashed, color="#7B1FA2"]
service -> prices [label="1:N", color="#7B1FA2"]
service -> discounts [label="1:N", color="#7B1FA2"]
prices -> veterinarian [label="N:1 opt\n(vet-specific)", style=dashed, color="#7B1FA2"]
// Cart relationships
cart -> veterinarian [label="N:1 opt\n(assigned vet)", style=dashed, color="#00838F"]
cart -> cartitem [label="1:N", color="#00838F"]
cart -> cartresumeitem [label="1:N", color="#00838F"]
cart -> cartpetreason [label="1:N", color="#00838F"]
cartitem -> service [label="N:1", color="#00838F"]
cartitem -> pet [label="N:1 opt", style=dashed, color="#00838F"]
cartpetreason -> pet [label="N:1", color="#00838F"]
// ServiceRequest relationships
servicerequest -> cart [label="1:1", color="#C62828"]
servicerequest -> veterinarian [label="N:1 opt\n(assigned)", style=dashed, color="#C62828"]
servicerequest -> statehistory [label="1:N audit", color="#C62828"]
servicerequest -> vetasked [label="1:N", color="#C62828"]
servicerequest -> payreminder [label="1:N", color="#C62828"]
servicerequest -> campaign [label="N:1 opt", style=dashed, color="#666"]
servicerequest -> tag [label="N:M", color="#666"]
vetasked -> veterinarian [label="N:1", color="#C62828"]
vetasked -> vetreminder [label="1:N", color="#C62828"]
// VetVisit relationships
vetvisit -> servicerequest [label="1:1 opt\n(from request)", style=dashed, color="#F9A825"]
vetvisit -> veterinarian [label="N:1", color="#F9A825"]
vetvisit -> visit_pets [label="1:N", color="#F9A825"]
visit_pets -> pet [label="N:1", color="#F9A825"]
vetvisit -> vetvisitreport [label="1:N", color="#F9A825"]
vetvisit -> vetvisitpetreason [label="1:N", color="#F9A825"]
vetvisitreport -> pet [label="N:1", color="#F9A825"]
vetvisitreport -> vetvisitfollowup [label="1:N", color="#F9A825"]
vetvisitpetreason -> pet [label="N:1", color="#F9A825"]
// AFIP
vetvisit -> receipt [label="1:1 opt\n(invoice)", style=dashed, color="#AD1457"]
// Geography
province -> locality [label="1:N", color="#666"]
}

View File

@@ -0,0 +1,976 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Generated by graphviz version 14.0.5 (0)
-->
<!-- Title: DataModel Pages: 1 -->
<svg width="2547pt" height="1691pt"
viewBox="0.00 0.00 2547.00 1691.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 1687.2)">
<title>DataModel</title>
<polygon fill="white" stroke="none" points="-4,4 -4,-1687.2 2543,-1687.2 2543,4 -4,4"/>
<text xml:space="preserve" text-anchor="middle" x="1269.5" y="-1664" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="16.00">AMAR Mascotas &#45; Data Model (Entity Relationships)</text>
<g id="clust1" class="cluster">
<title>cluster_auth</title>
<path fill="#e8f5e9" stroke="#2e7d32" d="M550,-1367.6C550,-1367.6 703,-1367.6 703,-1367.6 709,-1367.6 715,-1373.6 715,-1379.6 715,-1379.6 715,-1515 715,-1515 715,-1521 709,-1527 703,-1527 703,-1527 550,-1527 550,-1527 544,-1527 538,-1521 538,-1515 538,-1515 538,-1379.6 538,-1379.6 538,-1373.6 544,-1367.6 550,-1367.6"/>
<text xml:space="preserve" text-anchor="middle" x="626.5" y="-1507.8" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="16.00">Users &amp; Authentication</text>
</g>
<g id="clust2" class="cluster">
<title>cluster_mascotas_owners</title>
<path fill="#e3f2fd" stroke="#1565c0" d="M482,-494.2C482,-494.2 678,-494.2 678,-494.2 684,-494.2 690,-500.2 690,-506.2 690,-506.2 690,-1192.4 690,-1192.4 690,-1198.4 684,-1204.4 678,-1204.4 678,-1204.4 482,-1204.4 482,-1204.4 476,-1204.4 470,-1198.4 470,-1192.4 470,-1192.4 470,-506.2 470,-506.2 470,-500.2 476,-494.2 482,-494.2"/>
<text xml:space="preserve" text-anchor="middle" x="580" y="-1185.2" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="16.00">Pet Owners &amp; Pets</text>
</g>
<g id="clust3" class="cluster">
<title>cluster_veterinarians</title>
<path fill="#fff3e0" stroke="#e65100" d="M806,-317.6C806,-317.6 1867,-317.6 1867,-317.6 1873,-317.6 1879,-323.6 1879,-329.6 1879,-329.6 1879,-647.6 1879,-647.6 1879,-653.6 1873,-659.6 1867,-659.6 1867,-659.6 806,-659.6 806,-659.6 800,-659.6 794,-653.6 794,-647.6 794,-647.6 794,-329.6 794,-329.6 794,-323.6 800,-317.6 806,-317.6"/>
<text xml:space="preserve" text-anchor="middle" x="1336.5" y="-640.4" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="16.00">Veterinarians</text>
</g>
<g id="clust4" class="cluster">
<title>cluster_productos</title>
<path fill="#f3e5f5" stroke="#7b1fa2" d="M67,-476.2C67,-476.2 318,-476.2 318,-476.2 324,-476.2 330,-482.2 330,-488.2 330,-488.2 330,-1515 330,-1515 330,-1521 324,-1527 318,-1527 318,-1527 67,-1527 67,-1527 61,-1527 55,-1521 55,-1515 55,-1515 55,-488.2 55,-488.2 55,-482.2 61,-476.2 67,-476.2"/>
<text xml:space="preserve" text-anchor="middle" x="192.5" y="-1507.8" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="16.00">Services &amp; Pricing</text>
</g>
<g id="clust5" class="cluster">
<title>cluster_cart</title>
<path fill="#e0f7fa" stroke="#00838f" d="M740,-991C740,-991 1066,-991 1066,-991 1072,-991 1078,-997 1078,-1003 1078,-1003 1078,-1515 1078,-1515 1078,-1521 1072,-1527 1066,-1527 1066,-1527 740,-1527 740,-1527 734,-1527 728,-1521 728,-1515 728,-1515 728,-1003 728,-1003 728,-997 734,-991 740,-991"/>
<text xml:space="preserve" text-anchor="middle" x="903" y="-1507.8" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="16.00">Cart &amp; Checkout</text>
</g>
<g id="clust6" class="cluster">
<title>cluster_solicitudes</title>
<path fill="#ffebee" stroke="#c62828" d="M1899,-494.2C1899,-494.2 2297,-494.2 2297,-494.2 2303,-494.2 2309,-500.2 2309,-506.2 2309,-506.2 2309,-1216.4 2309,-1216.4 2309,-1222.4 2303,-1228.4 2297,-1228.4 2297,-1228.4 1899,-1228.4 1899,-1228.4 1893,-1228.4 1887,-1222.4 1887,-1216.4 1887,-1216.4 1887,-506.2 1887,-506.2 1887,-500.2 1893,-494.2 1899,-494.2"/>
<text xml:space="preserve" text-anchor="middle" x="2098" y="-1209.2" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="16.00">Service Requests (Workflow)</text>
</g>
<g id="clust7" class="cluster">
<title>cluster_vetvisits</title>
<path fill="#fffde7" stroke="#f9a825" d="M1186,-762.8C1186,-762.8 1726,-762.8 1726,-762.8 1732,-762.8 1738,-768.8 1738,-774.8 1738,-774.8 1738,-1617 1738,-1617 1738,-1623 1732,-1629 1726,-1629 1726,-1629 1186,-1629 1186,-1629 1180,-1629 1174,-1623 1174,-1617 1174,-1617 1174,-774.8 1174,-774.8 1174,-768.8 1180,-762.8 1186,-762.8"/>
<text xml:space="preserve" text-anchor="middle" x="1456" y="-1609.8" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="16.00">Veterinary Visits</text>
</g>
<g id="clust8" class="cluster">
<title>cluster_reference</title>
<path fill="#eceff1" stroke="#455a64" d="M20,-8C20,-8 1189,-8 1189,-8 1195,-8 1201,-14 1201,-20 1201,-20 1201,-278 1201,-278 1201,-284 1195,-290 1189,-290 1189,-290 20,-290 20,-290 14,-290 8,-284 8,-278 8,-278 8,-20 8,-20 8,-14 14,-8 20,-8"/>
<text xml:space="preserve" text-anchor="middle" x="604.5" y="-270.8" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="16.00">Reference Data</text>
</g>
<g id="clust9" class="cluster">
<title>cluster_afip</title>
<path fill="#fce4ec" stroke="#ad1457" d="M2399,-997C2399,-997 2519,-997 2519,-997 2525,-997 2531,-1003 2531,-1009 2531,-1009 2531,-1180.4 2531,-1180.4 2531,-1186.4 2525,-1192.4 2519,-1192.4 2519,-1192.4 2399,-1192.4 2399,-1192.4 2393,-1192.4 2387,-1186.4 2387,-1180.4 2387,-1180.4 2387,-1009 2387,-1009 2387,-1003 2393,-997 2399,-997"/>
<text xml:space="preserve" text-anchor="middle" x="2459" y="-1173.2" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="16.00">AFIP Invoicing</text>
</g>
<!-- auth_user -->
<g id="node1" class="node">
<title>auth_user</title>
<polygon fill="#c8e6c9" stroke="black" points="604.14,-1376.1 604.14,-1464.1 701.86,-1464.1 701.86,-1376.1 604.14,-1376.1"/>
<text xml:space="preserve" text-anchor="middle" x="653" y="-1451.1" font-family="Helvetica,sans-Serif" font-size="10.00">auth.User</text>
<polyline fill="none" stroke="black" points="604.14,-1444.1 701.86,-1444.1"/>
<text xml:space="preserve" text-anchor="start" x="612.14" y="-1431.1" font-family="Helvetica,sans-Serif" font-size="10.00">id: PK</text>
<text xml:space="preserve" text-anchor="start" x="612.14" y="-1419.1" font-family="Helvetica,sans-Serif" font-size="10.00">user name: str</text>
<text xml:space="preserve" text-anchor="start" x="612.14" y="-1407.1" font-family="Helvetica,sans-Serif" font-size="10.00">email: str</text>
<text xml:space="preserve" text-anchor="start" x="612.14" y="-1395.1" font-family="Helvetica,sans-Serif" font-size="10.00">is_staff: bool</text>
<text xml:space="preserve" text-anchor="start" x="612.14" y="-1383.1" font-family="Helvetica,sans-Serif" font-size="10.00">is_superuser: bool</text>
</g>
<!-- petowner -->
<g id="node2" class="node">
<title>petowner</title>
<polygon fill="#bbdefb" stroke="black" points="565.81,-993.5 565.81,-1141.5 680.19,-1141.5 680.19,-993.5 565.81,-993.5"/>
<text xml:space="preserve" text-anchor="middle" x="623" y="-1128.5" font-family="Helvetica,sans-Serif" font-size="10.00">PetOwner</text>
<polyline fill="none" stroke="black" points="565.81,-1121.5 680.19,-1121.5"/>
<text xml:space="preserve" text-anchor="start" x="573.81" y="-1108.5" font-family="Helvetica,sans-Serif" font-size="10.00">id: PK</text>
<text xml:space="preserve" text-anchor="start" x="573.81" y="-1096.5" font-family="Helvetica,sans-Serif" font-size="10.00">email: str (unique)</text>
<text xml:space="preserve" text-anchor="start" x="573.81" y="-1084.5" font-family="Helvetica,sans-Serif" font-size="10.00">phone: str</text>
<text xml:space="preserve" text-anchor="start" x="573.81" y="-1072.5" font-family="Helvetica,sans-Serif" font-size="10.00">first_name: str</text>
<text xml:space="preserve" text-anchor="start" x="573.81" y="-1060.5" font-family="Helvetica,sans-Serif" font-size="10.00">last_name: str</text>
<text xml:space="preserve" text-anchor="start" x="573.81" y="-1048.5" font-family="Helvetica,sans-Serif" font-size="10.00">neighborhood_id: FK</text>
<text xml:space="preserve" text-anchor="start" x="573.81" y="-1036.5" font-family="Helvetica,sans-Serif" font-size="10.00">campaign_id: FK (opt)</text>
<text xml:space="preserve" text-anchor="start" x="573.81" y="-1024.5" font-family="Helvetica,sans-Serif" font-size="10.00">geo_latitude: float</text>
<text xml:space="preserve" text-anchor="start" x="573.81" y="-1012.5" font-family="Helvetica,sans-Serif" font-size="10.00">geo_longitude: float</text>
<text xml:space="preserve" text-anchor="start" x="573.81" y="-1000.5" font-family="Helvetica,sans-Serif" font-size="10.00">address: str</text>
</g>
<!-- auth_user&#45;&gt;petowner -->
<g id="edge1" class="edge">
<title>auth_user&#45;&gt;petowner</title>
<path fill="none" stroke="#2e7d32" stroke-dasharray="5,2" d="M642.17,-1375.8C642.17,-1375.8 642.17,-1153.26 642.17,-1153.26"/>
<polygon fill="#2e7d32" stroke="#2e7d32" points="645.67,-1153.26 642.17,-1143.26 638.67,-1153.26 645.67,-1153.26"/>
<text xml:space="preserve" text-anchor="middle" x="645.23" y="-1243.6" font-family="Helvetica,sans-Serif" font-size="8.00">1:1 opt</text>
</g>
<!-- veterinarian -->
<g id="node6" class="node">
<title>veterinarian</title>
<polygon fill="#ffe0b2" stroke="black" points="1538.04,-508.7 1538.04,-596.7 1607.96,-596.7 1607.96,-508.7 1538.04,-508.7"/>
<text xml:space="preserve" text-anchor="middle" x="1573" y="-583.7" font-family="Helvetica,sans-Serif" font-size="10.00">Veterinarian</text>
<polyline fill="none" stroke="black" points="1538.04,-576.7 1607.96,-576.7"/>
<text xml:space="preserve" text-anchor="start" x="1546.04" y="-563.7" font-family="Helvetica,sans-Serif" font-size="10.00">id: PK</text>
<text xml:space="preserve" text-anchor="start" x="1546.04" y="-551.7" font-family="Helvetica,sans-Serif" font-size="10.00">user_id: FK</text>
<text xml:space="preserve" text-anchor="start" x="1546.04" y="-539.7" font-family="Helvetica,sans-Serif" font-size="10.00">license: str</text>
<text xml:space="preserve" text-anchor="start" x="1546.04" y="-527.7" font-family="Helvetica,sans-Serif" font-size="10.00">email: str</text>
<text xml:space="preserve" text-anchor="start" x="1546.04" y="-515.7" font-family="Helvetica,sans-Serif" font-size="10.00">phone: str</text>
</g>
<!-- auth_user&#45;&gt;veterinarian -->
<g id="edge2" class="edge">
<title>auth_user&#45;&gt;veterinarian</title>
<path fill="none" stroke="#2e7d32" d="M695.21,-1375.77C695.21,-1200.06 695.21,-562 695.21,-562 695.21,-562 1526.26,-562 1526.26,-562"/>
<polygon fill="#2e7d32" stroke="#2e7d32" points="1526.26,-565.5 1536.26,-562 1526.26,-558.5 1526.26,-565.5"/>
<text xml:space="preserve" text-anchor="middle" x="787.56" y="-939" font-family="Helvetica,sans-Serif" font-size="8.00">1:1</text>
</g>
<!-- pet -->
<g id="node3" class="node">
<title>pet</title>
<polygon fill="#bbdefb" stroke="black" points="574.97,-705.3 574.97,-913.3 681.03,-913.3 681.03,-705.3 574.97,-705.3"/>
<text xml:space="preserve" text-anchor="middle" x="628" y="-900.3" font-family="Helvetica,sans-Serif" font-size="10.00">Pet</text>
<polyline fill="none" stroke="black" points="574.97,-893.3 681.03,-893.3"/>
<text xml:space="preserve" text-anchor="start" x="582.97" y="-880.3" font-family="Helvetica,sans-Serif" font-size="10.00">id: PK</text>
<text xml:space="preserve" text-anchor="start" x="582.97" y="-868.3" font-family="Helvetica,sans-Serif" font-size="10.00">owner_id: FK</text>
<text xml:space="preserve" text-anchor="start" x="582.97" y="-856.3" font-family="Helvetica,sans-Serif" font-size="10.00">name: str</text>
<text xml:space="preserve" text-anchor="start" x="582.97" y="-844.3" font-family="Helvetica,sans-Serif" font-size="10.00">pet_type: DOG/CAT</text>
<text xml:space="preserve" text-anchor="start" x="582.97" y="-832.3" font-family="Helvetica,sans-Serif" font-size="10.00">breed_id: FK (opt)</text>
<text xml:space="preserve" text-anchor="start" x="582.97" y="-820.3" font-family="Helvetica,sans-Serif" font-size="10.00">gender: M/F</text>
<text xml:space="preserve" text-anchor="start" x="582.97" y="-808.3" font-family="Helvetica,sans-Serif" font-size="10.00">age: int</text>
<text xml:space="preserve" text-anchor="start" x="582.97" y="-796.3" font-family="Helvetica,sans-Serif" font-size="10.00">weight: decimal</text>
<text xml:space="preserve" text-anchor="start" x="582.97" y="-784.3" font-family="Helvetica,sans-Serif" font-size="10.00">height: decimal</text>
<text xml:space="preserve" text-anchor="start" x="582.97" y="-772.3" font-family="Helvetica,sans-Serif" font-size="10.00">birth_date: date</text>
<text xml:space="preserve" text-anchor="start" x="582.97" y="-760.3" font-family="Helvetica,sans-Serif" font-size="10.00">allergies: text</text>
<text xml:space="preserve" text-anchor="start" x="582.97" y="-748.3" font-family="Helvetica,sans-Serif" font-size="10.00">neutered: bool</text>
<text xml:space="preserve" text-anchor="start" x="582.97" y="-736.3" font-family="Helvetica,sans-Serif" font-size="10.00">is_deceased: bool</text>
<text xml:space="preserve" text-anchor="start" x="582.97" y="-724.3" font-family="Helvetica,sans-Serif" font-size="10.00">state: puppy/adult/...</text>
<text xml:space="preserve" text-anchor="start" x="582.97" y="-712.3" font-family="Helvetica,sans-Serif" font-size="10.00">profile_picture: file</text>
</g>
<!-- petowner&#45;&gt;pet -->
<g id="edge3" class="edge">
<title>petowner&#45;&gt;pet</title>
<path fill="none" stroke="#1565c0" d="M627.58,-993.16C627.58,-993.16 627.58,-924.87 627.58,-924.87"/>
<polygon fill="#1565c0" stroke="#1565c0" points="631.08,-924.87 627.58,-914.87 624.08,-924.87 631.08,-924.87"/>
<text xml:space="preserve" text-anchor="middle" x="588.67" y="-939" font-family="Helvetica,sans-Serif" font-size="8.00">1:N owns</text>
</g>
<!-- cart -->
<g id="node17" class="node">
<title>cart</title>
<polygon fill="#b2ebf2" stroke="black" points="906.36,-1376.1 906.36,-1464.1 1029.64,-1464.1 1029.64,-1376.1 906.36,-1376.1"/>
<text xml:space="preserve" text-anchor="middle" x="968" y="-1451.1" font-family="Helvetica,sans-Serif" font-size="10.00">Cart</text>
<polyline fill="none" stroke="black" points="906.36,-1444.1 1029.64,-1444.1"/>
<text xml:space="preserve" text-anchor="start" x="914.36" y="-1431.1" font-family="Helvetica,sans-Serif" font-size="10.00">id: PK</text>
<text xml:space="preserve" text-anchor="start" x="914.36" y="-1419.1" font-family="Helvetica,sans-Serif" font-size="10.00">petowner_id: FK</text>
<text xml:space="preserve" text-anchor="start" x="914.36" y="-1407.1" font-family="Helvetica,sans-Serif" font-size="10.00">veterinarian_id: FK (opt)</text>
<text xml:space="preserve" text-anchor="start" x="914.36" y="-1395.1" font-family="Helvetica,sans-Serif" font-size="10.00">use_vet_prices: bool</text>
<text xml:space="preserve" text-anchor="start" x="914.36" y="-1383.1" font-family="Helvetica,sans-Serif" font-size="10.00">apply_turn_fee: bool</text>
</g>
<!-- petowner&#45;&gt;cart -->
<g id="edge6" class="edge">
<title>petowner&#45;&gt;cart</title>
<path fill="none" stroke="#00838f" d="M680.65,-1130C765.7,-1130 914.82,-1130 914.82,-1130 914.82,-1130 914.82,-1364.21 914.82,-1364.21"/>
<polygon fill="#00838f" stroke="#00838f" points="911.32,-1364.21 914.82,-1374.21 918.32,-1364.21 911.32,-1364.21"/>
<text xml:space="preserve" text-anchor="middle" x="790.22" y="-1243.6" font-family="Helvetica,sans-Serif" font-size="8.00">1:N</text>
</g>
<!-- servicerequest -->
<g id="node21" class="node">
<title>servicerequest</title>
<polygon fill="#ffcdd2" stroke="black" points="1895.4,-969.5 1895.4,-1165.5 2032.6,-1165.5 2032.6,-969.5 1895.4,-969.5"/>
<text xml:space="preserve" text-anchor="middle" x="1964" y="-1152.5" font-family="Helvetica,sans-Serif" font-size="10.00">ServiceRequest</text>
<polyline fill="none" stroke="black" points="1895.4,-1145.5 2032.6,-1145.5"/>
<text xml:space="preserve" text-anchor="start" x="1903.4" y="-1132.5" font-family="Helvetica,sans-Serif" font-size="10.00">id: PK</text>
<text xml:space="preserve" text-anchor="start" x="1903.4" y="-1120.5" font-family="Helvetica,sans-Serif" font-size="10.00">petowner_id: FK</text>
<text xml:space="preserve" text-anchor="start" x="1903.4" y="-1108.5" font-family="Helvetica,sans-Serif" font-size="10.00">cart_id: FK</text>
<text xml:space="preserve" text-anchor="start" x="1903.4" y="-1096.5" font-family="Helvetica,sans-Serif" font-size="10.00">veterinarian_id: FK (opt)</text>
<text xml:space="preserve" text-anchor="start" x="1903.4" y="-1084.5" font-family="Helvetica,sans-Serif" font-size="10.00">state: pending/vet_asked/</text>
<text xml:space="preserve" text-anchor="start" x="1903.4" y="-1072.5" font-family="Helvetica,sans-Serif" font-size="10.00"> vet_accepted/coordinated/</text>
<text xml:space="preserve" text-anchor="start" x="1903.4" y="-1060.5" font-family="Helvetica,sans-Serif" font-size="10.00"> payed/Confirmado/...</text>
<text xml:space="preserve" text-anchor="start" x="1903.4" y="-1048.5" font-family="Helvetica,sans-Serif" font-size="10.00">reason: text</text>
<text xml:space="preserve" text-anchor="start" x="1903.4" y="-1036.5" font-family="Helvetica,sans-Serif" font-size="10.00">days_requested: JSON</text>
<text xml:space="preserve" text-anchor="start" x="1903.4" y="-1024.5" font-family="Helvetica,sans-Serif" font-size="10.00">date_coordinated: datetime</text>
<text xml:space="preserve" text-anchor="start" x="1903.4" y="-1012.5" font-family="Helvetica,sans-Serif" font-size="10.00">hour_coordinated: time</text>
<text xml:space="preserve" text-anchor="start" x="1903.4" y="-1000.5" font-family="Helvetica,sans-Serif" font-size="10.00">pay_number: str</text>
<text xml:space="preserve" text-anchor="start" x="1903.4" y="-988.5" font-family="Helvetica,sans-Serif" font-size="10.00">campaign_id: FK (opt)</text>
<text xml:space="preserve" text-anchor="start" x="1903.4" y="-976.5" font-family="Helvetica,sans-Serif" font-size="10.00">attended_by_id: FK (opt)</text>
</g>
<!-- petowner&#45;&gt;servicerequest -->
<g id="edge7" class="edge">
<title>petowner&#45;&gt;servicerequest</title>
<path fill="none" stroke="#c62828" d="M623.15,-1141.95C623.15,-1145.73 623.15,-1148 623.15,-1148 623.15,-1148 1883.69,-1148 1883.69,-1148"/>
<polygon fill="#c62828" stroke="#c62828" points="1883.69,-1151.5 1893.69,-1148 1883.69,-1144.5 1883.69,-1151.5"/>
<text xml:space="preserve" text-anchor="middle" x="1109" y="-1243.6" font-family="Helvetica,sans-Serif" font-size="8.00">1:N requests</text>
</g>
<!-- vetvisit -->
<g id="node26" class="node">
<title>vetvisit</title>
<polygon fill="#fff9c4" stroke="black" points="1588.19,-1274.1 1588.19,-1566.1 1729.81,-1566.1 1729.81,-1274.1 1588.19,-1274.1"/>
<text xml:space="preserve" text-anchor="middle" x="1659" y="-1553.1" font-family="Helvetica,sans-Serif" font-size="10.00">VetVisit</text>
<polyline fill="none" stroke="black" points="1588.19,-1546.1 1729.81,-1546.1"/>
<text xml:space="preserve" text-anchor="start" x="1596.19" y="-1533.1" font-family="Helvetica,sans-Serif" font-size="10.00">id: PK</text>
<text xml:space="preserve" text-anchor="start" x="1596.19" y="-1521.1" font-family="Helvetica,sans-Serif" font-size="10.00">service_request_id: FK (opt)</text>
<text xml:space="preserve" text-anchor="start" x="1596.19" y="-1509.1" font-family="Helvetica,sans-Serif" font-size="10.00">owner_id: FK</text>
<text xml:space="preserve" text-anchor="start" x="1596.19" y="-1497.1" font-family="Helvetica,sans-Serif" font-size="10.00">veterinarian_id: FK</text>
<text xml:space="preserve" text-anchor="start" x="1596.19" y="-1485.1" font-family="Helvetica,sans-Serif" font-size="10.00">date: date</text>
<text xml:space="preserve" text-anchor="start" x="1596.19" y="-1473.1" font-family="Helvetica,sans-Serif" font-size="10.00">hour: time</text>
<text xml:space="preserve" text-anchor="start" x="1596.19" y="-1461.1" font-family="Helvetica,sans-Serif" font-size="10.00">visit_type: clinical/</text>
<text xml:space="preserve" text-anchor="start" x="1596.19" y="-1449.1" font-family="Helvetica,sans-Serif" font-size="10.00"> vaccination/</text>
<text xml:space="preserve" text-anchor="start" x="1596.19" y="-1437.1" font-family="Helvetica,sans-Serif" font-size="10.00"> telemedicina</text>
<text xml:space="preserve" text-anchor="start" x="1596.19" y="-1425.1" font-family="Helvetica,sans-Serif" font-size="10.00">visit_state: PENDING/</text>
<text xml:space="preserve" text-anchor="start" x="1596.19" y="-1413.1" font-family="Helvetica,sans-Serif" font-size="10.00"> IN_PROGRESS/</text>
<text xml:space="preserve" text-anchor="start" x="1596.19" y="-1401.1" font-family="Helvetica,sans-Serif" font-size="10.00"> COMPLETED/</text>
<text xml:space="preserve" text-anchor="start" x="1596.19" y="-1389.1" font-family="Helvetica,sans-Serif" font-size="10.00"> NO_REPORT/</text>
<text xml:space="preserve" text-anchor="start" x="1596.19" y="-1377.1" font-family="Helvetica,sans-Serif" font-size="10.00"> CANCELLED</text>
<text xml:space="preserve" text-anchor="start" x="1596.19" y="-1365.1" font-family="Helvetica,sans-Serif" font-size="10.00">reason: text</text>
<text xml:space="preserve" text-anchor="start" x="1596.19" y="-1353.1" font-family="Helvetica,sans-Serif" font-size="10.00">observations: text</text>
<text xml:space="preserve" text-anchor="start" x="1596.19" y="-1341.1" font-family="Helvetica,sans-Serif" font-size="10.00">price: decimal</text>
<text xml:space="preserve" text-anchor="start" x="1596.19" y="-1329.1" font-family="Helvetica,sans-Serif" font-size="10.00">deposit: decimal</text>
<text xml:space="preserve" text-anchor="start" x="1596.19" y="-1317.1" font-family="Helvetica,sans-Serif" font-size="10.00">vet_fee: decimal</text>
<text xml:space="preserve" text-anchor="start" x="1596.19" y="-1305.1" font-family="Helvetica,sans-Serif" font-size="10.00">pay_transaction: str</text>
<text xml:space="preserve" text-anchor="start" x="1596.19" y="-1293.1" font-family="Helvetica,sans-Serif" font-size="10.00">google_event_id: str</text>
<text xml:space="preserve" text-anchor="start" x="1596.19" y="-1281.1" font-family="Helvetica,sans-Serif" font-size="10.00">afip_receipt_id: FK (opt)</text>
</g>
<!-- petowner&#45;&gt;vetvisit -->
<g id="edge8" class="edge">
<title>petowner&#45;&gt;vetvisit</title>
<path fill="none" stroke="#f9a825" d="M661.18,-1141.78C661.18,-1223.08 661.18,-1342 661.18,-1342 661.18,-1342 1576.53,-1342 1576.53,-1342"/>
<polygon fill="#f9a825" stroke="#f9a825" points="1576.53,-1345.5 1586.53,-1342 1576.53,-1338.5 1576.53,-1345.5"/>
<text xml:space="preserve" text-anchor="middle" x="1249.57" y="-1243.6" font-family="Helvetica,sans-Serif" font-size="8.00">1:N as owner</text>
</g>
<!-- neighborhood -->
<g id="node32" class="node">
<title>neighborhood</title>
<polygon fill="#cfd8dc" stroke="black" points="139.79,-139.1 139.79,-215.1 284.21,-215.1 284.21,-139.1 139.79,-139.1"/>
<text xml:space="preserve" text-anchor="middle" x="212" y="-202.1" font-family="Helvetica,sans-Serif" font-size="10.00">Neighborhood</text>
<polyline fill="none" stroke="black" points="139.79,-195.1 284.21,-195.1"/>
<text xml:space="preserve" text-anchor="start" x="147.79" y="-182.1" font-family="Helvetica,sans-Serif" font-size="10.00">id: PK</text>
<text xml:space="preserve" text-anchor="start" x="147.79" y="-170.1" font-family="Helvetica,sans-Serif" font-size="10.00">name: str</text>
<text xml:space="preserve" text-anchor="start" x="147.79" y="-158.1" font-family="Helvetica,sans-Serif" font-size="10.00">distance_coefficient: decimal</text>
<text xml:space="preserve" text-anchor="start" x="147.79" y="-146.1" font-family="Helvetica,sans-Serif" font-size="10.00">coverage_area: GIS Polygon</text>
</g>
<!-- petowner&#45;&gt;neighborhood -->
<g id="edge4" class="edge">
<title>petowner&#45;&gt;neighborhood</title>
<path fill="none" stroke="#1565c0" d="M565.5,-1008C489.96,-1008 366.79,-1008 366.79,-1008 366.79,-1008 366.79,-210 366.79,-210 366.79,-210 295.92,-210 295.92,-210"/>
<polygon fill="#1565c0" stroke="#1565c0" points="295.92,-206.5 285.92,-210 295.92,-213.5 295.92,-206.5"/>
<text xml:space="preserve" text-anchor="middle" x="25.56" y="-550.3" font-family="Helvetica,sans-Serif" font-size="8.00">N:1 lives in</text>
</g>
<!-- campaign -->
<g id="node38" class="node">
<title>campaign</title>
<polygon fill="#cfd8dc" stroke="black" points="828.77,-133.1 828.77,-221.1 919.23,-221.1 919.23,-133.1 828.77,-133.1"/>
<text xml:space="preserve" text-anchor="middle" x="874" y="-208.1" font-family="Helvetica,sans-Serif" font-size="10.00">Campaign</text>
<polyline fill="none" stroke="black" points="828.77,-201.1 919.23,-201.1"/>
<text xml:space="preserve" text-anchor="start" x="836.77" y="-188.1" font-family="Helvetica,sans-Serif" font-size="10.00">id: PK</text>
<text xml:space="preserve" text-anchor="start" x="836.77" y="-176.1" font-family="Helvetica,sans-Serif" font-size="10.00">name: str</text>
<text xml:space="preserve" text-anchor="start" x="836.77" y="-164.1" font-family="Helvetica,sans-Serif" font-size="10.00">utm_source: str</text>
<text xml:space="preserve" text-anchor="start" x="836.77" y="-152.1" font-family="Helvetica,sans-Serif" font-size="10.00">utm_medium: str</text>
<text xml:space="preserve" text-anchor="start" x="836.77" y="-140.1" font-family="Helvetica,sans-Serif" font-size="10.00">is_active: bool</text>
</g>
<!-- petowner&#45;&gt;campaign -->
<g id="edge5" class="edge">
<title>petowner&#45;&gt;campaign</title>
<path fill="none" stroke="#666666" stroke-dasharray="5,2" d="M680.53,-1005C685.43,-1005 688.57,-1005 688.57,-1005 688.57,-1005 688.57,-210 688.57,-210 688.57,-210 817.13,-210 817.13,-210"/>
<polygon fill="#666666" stroke="#666666" points="817.13,-213.5 827.13,-210 817.13,-206.5 817.13,-213.5"/>
<text xml:space="preserve" text-anchor="middle" x="408.9" y="-550.3" font-family="Helvetica,sans-Serif" font-size="8.00">N:1 opt</text>
</g>
<!-- petvaccine -->
<g id="node4" class="node">
<title>petvaccine</title>
<polygon fill="#90caf9" stroke="black" points="568.07,-508.7 568.07,-596.7 681.93,-596.7 681.93,-508.7 568.07,-508.7"/>
<text xml:space="preserve" text-anchor="middle" x="625" y="-583.7" font-family="Helvetica,sans-Serif" font-size="10.00">PetVaccine</text>
<polyline fill="none" stroke="black" points="568.07,-576.7 681.93,-576.7"/>
<text xml:space="preserve" text-anchor="start" x="576.07" y="-563.7" font-family="Helvetica,sans-Serif" font-size="10.00">id: PK</text>
<text xml:space="preserve" text-anchor="start" x="576.07" y="-551.7" font-family="Helvetica,sans-Serif" font-size="10.00">pet_id: FK</text>
<text xml:space="preserve" text-anchor="start" x="576.07" y="-539.7" font-family="Helvetica,sans-Serif" font-size="10.00">vaccine_id: FK</text>
<text xml:space="preserve" text-anchor="start" x="576.07" y="-527.7" font-family="Helvetica,sans-Serif" font-size="10.00">application_date: date</text>
<text xml:space="preserve" text-anchor="start" x="576.07" y="-515.7" font-family="Helvetica,sans-Serif" font-size="10.00">next_application: date</text>
</g>
<!-- pet&#45;&gt;petvaccine -->
<g id="edge10" class="edge">
<title>pet&#45;&gt;petvaccine</title>
<path fill="none" stroke="#1565c0" d="M628,-704.93C628,-704.93 628,-608.65 628,-608.65"/>
<polygon fill="#1565c0" stroke="#1565c0" points="631.5,-608.65 628,-598.65 624.5,-608.65 631.5,-608.65"/>
<text xml:space="preserve" text-anchor="middle" x="633.22" y="-674.8" font-family="Helvetica,sans-Serif" font-size="8.00">1:N</text>
</g>
<!-- petstudy -->
<g id="node5" class="node">
<title>petstudy</title>
<polygon fill="#90caf9" stroke="black" points="478.21,-502.7 478.21,-602.7 549.79,-602.7 549.79,-502.7 478.21,-502.7"/>
<text xml:space="preserve" text-anchor="middle" x="514" y="-589.7" font-family="Helvetica,sans-Serif" font-size="10.00">PetStudy</text>
<polyline fill="none" stroke="black" points="478.21,-582.7 549.79,-582.7"/>
<text xml:space="preserve" text-anchor="start" x="486.21" y="-569.7" font-family="Helvetica,sans-Serif" font-size="10.00">id: PK</text>
<text xml:space="preserve" text-anchor="start" x="486.21" y="-557.7" font-family="Helvetica,sans-Serif" font-size="10.00">pet_id: FK</text>
<text xml:space="preserve" text-anchor="start" x="486.21" y="-545.7" font-family="Helvetica,sans-Serif" font-size="10.00">study_id: FK</text>
<text xml:space="preserve" text-anchor="start" x="486.21" y="-533.7" font-family="Helvetica,sans-Serif" font-size="10.00">date: date</text>
<text xml:space="preserve" text-anchor="start" x="486.21" y="-521.7" font-family="Helvetica,sans-Serif" font-size="10.00">result: text</text>
<text xml:space="preserve" text-anchor="start" x="486.21" y="-509.7" font-family="Helvetica,sans-Serif" font-size="10.00">images: files</text>
</g>
<!-- pet&#45;&gt;petstudy -->
<g id="edge11" class="edge">
<title>pet&#45;&gt;petstudy</title>
<path fill="none" stroke="#1565c0" d="M574.67,-713C545,-713 514,-713 514,-713 514,-713 514,-614.49 514,-614.49"/>
<polygon fill="#1565c0" stroke="#1565c0" points="517.5,-614.49 514,-604.49 510.5,-614.49 517.5,-614.49"/>
<text xml:space="preserve" text-anchor="middle" x="558.22" y="-674.8" font-family="Helvetica,sans-Serif" font-size="8.00">1:N</text>
</g>
<!-- petbreed -->
<g id="node35" class="node">
<title>petbreed</title>
<polygon fill="#cfd8dc" stroke="black" points="16.26,-145.1 16.26,-209.1 121.74,-209.1 121.74,-145.1 16.26,-145.1"/>
<text xml:space="preserve" text-anchor="middle" x="69" y="-196.1" font-family="Helvetica,sans-Serif" font-size="10.00">PetBreed</text>
<polyline fill="none" stroke="black" points="16.26,-189.1 121.74,-189.1"/>
<text xml:space="preserve" text-anchor="start" x="24.26" y="-176.1" font-family="Helvetica,sans-Serif" font-size="10.00">id: PK</text>
<text xml:space="preserve" text-anchor="start" x="24.26" y="-164.1" font-family="Helvetica,sans-Serif" font-size="10.00">name: str</text>
<text xml:space="preserve" text-anchor="start" x="24.26" y="-152.1" font-family="Helvetica,sans-Serif" font-size="10.00">pet_type: DOG/CAT</text>
</g>
<!-- pet&#45;&gt;petbreed -->
<g id="edge9" class="edge">
<title>pet&#45;&gt;petbreed</title>
<path fill="none" stroke="#666666" stroke-dasharray="5,2" d="M574.5,-721C429.5,-721 39.78,-721 39.78,-721 39.78,-721 39.78,-221.01 39.78,-221.01"/>
<polygon fill="#666666" stroke="#666666" points="43.28,-221.01 39.78,-211.01 36.28,-221.01 43.28,-221.01"/>
<text xml:space="preserve" text-anchor="middle" x="457.9" y="-459" font-family="Helvetica,sans-Serif" font-size="8.00">N:1 opt</text>
</g>
<!-- vaccine -->
<g id="node36" class="node">
<title>vaccine</title>
<polygon fill="#cfd8dc" stroke="black" points="501.32,-139.1 501.32,-215.1 620.68,-215.1 620.68,-139.1 501.32,-139.1"/>
<text xml:space="preserve" text-anchor="middle" x="561" y="-202.1" font-family="Helvetica,sans-Serif" font-size="10.00">Vaccine</text>
<polyline fill="none" stroke="black" points="501.32,-195.1 620.68,-195.1"/>
<text xml:space="preserve" text-anchor="start" x="509.32" y="-182.1" font-family="Helvetica,sans-Serif" font-size="10.00">id: PK</text>
<text xml:space="preserve" text-anchor="start" x="509.32" y="-170.1" font-family="Helvetica,sans-Serif" font-size="10.00">name: str</text>
<text xml:space="preserve" text-anchor="start" x="509.32" y="-158.1" font-family="Helvetica,sans-Serif" font-size="10.00">pet_type: DOG/CAT</text>
<text xml:space="preserve" text-anchor="start" x="509.32" y="-146.1" font-family="Helvetica,sans-Serif" font-size="10.00">periodicity: int (months)</text>
</g>
<!-- petvaccine&#45;&gt;vaccine -->
<g id="edge12" class="edge">
<title>petvaccine&#45;&gt;vaccine</title>
<path fill="none" stroke="#666666" d="M594.38,-508.51C594.38,-508.51 594.38,-226.98 594.38,-226.98"/>
<polygon fill="#666666" stroke="#666666" points="597.88,-226.98 594.38,-216.98 590.88,-226.98 597.88,-226.98"/>
<text xml:space="preserve" text-anchor="middle" x="581.22" y="-379.7" font-family="Helvetica,sans-Serif" font-size="8.00">N:1</text>
</g>
<!-- study -->
<g id="node37" class="node">
<title>study</title>
<polygon fill="#cfd8dc" stroke="black" points="378.26,-139.1 378.26,-215.1 483.74,-215.1 483.74,-139.1 378.26,-139.1"/>
<text xml:space="preserve" text-anchor="middle" x="431" y="-202.1" font-family="Helvetica,sans-Serif" font-size="10.00">Study</text>
<polyline fill="none" stroke="black" points="378.26,-195.1 483.74,-195.1"/>
<text xml:space="preserve" text-anchor="start" x="386.26" y="-182.1" font-family="Helvetica,sans-Serif" font-size="10.00">id: PK</text>
<text xml:space="preserve" text-anchor="start" x="386.26" y="-170.1" font-family="Helvetica,sans-Serif" font-size="10.00">name: str</text>
<text xml:space="preserve" text-anchor="start" x="386.26" y="-158.1" font-family="Helvetica,sans-Serif" font-size="10.00">pet_type: DOG/CAT</text>
<text xml:space="preserve" text-anchor="start" x="386.26" y="-146.1" font-family="Helvetica,sans-Serif" font-size="10.00">group_id: FK (opt)</text>
</g>
<!-- petstudy&#45;&gt;study -->
<g id="edge13" class="edge">
<title>petstudy&#45;&gt;study</title>
<path fill="none" stroke="#666666" d="M492.53,-502.42C492.53,-400.05 492.53,-177 492.53,-177 492.53,-177 491.7,-177 491.7,-177"/>
<polygon fill="#666666" stroke="#666666" points="495.74,-173.5 485.74,-177 495.74,-180.5 495.74,-173.5"/>
<text xml:space="preserve" text-anchor="middle" x="495.22" y="-379.7" font-family="Helvetica,sans-Serif" font-size="8.00">N:1</text>
</g>
<!-- availability -->
<g id="node7" class="node">
<title>availability</title>
<polygon fill="#ffcc80" stroke="black" points="1653.03,-332.1 1653.03,-432.1 1752.97,-432.1 1752.97,-332.1 1653.03,-332.1"/>
<text xml:space="preserve" text-anchor="middle" x="1703" y="-419.1" font-family="Helvetica,sans-Serif" font-size="10.00">Availability</text>
<polyline fill="none" stroke="black" points="1653.03,-412.1 1752.97,-412.1"/>
<text xml:space="preserve" text-anchor="start" x="1661.03" y="-399.1" font-family="Helvetica,sans-Serif" font-size="10.00">id: PK</text>
<text xml:space="preserve" text-anchor="start" x="1661.03" y="-387.1" font-family="Helvetica,sans-Serif" font-size="10.00">veterinarian_id: FK</text>
<text xml:space="preserve" text-anchor="start" x="1661.03" y="-375.1" font-family="Helvetica,sans-Serif" font-size="10.00">start_day: 0&#45;6</text>
<text xml:space="preserve" text-anchor="start" x="1661.03" y="-363.1" font-family="Helvetica,sans-Serif" font-size="10.00">end_day: 0&#45;6</text>
<text xml:space="preserve" text-anchor="start" x="1661.03" y="-351.1" font-family="Helvetica,sans-Serif" font-size="10.00">start_time: time</text>
<text xml:space="preserve" text-anchor="start" x="1661.03" y="-339.1" font-family="Helvetica,sans-Serif" font-size="10.00">end_time: time</text>
</g>
<!-- veterinarian&#45;&gt;availability -->
<g id="edge18" class="edge">
<title>veterinarian&#45;&gt;availability</title>
<path fill="none" stroke="#e65100" d="M1608.25,-530C1642.2,-530 1688.41,-530 1688.41,-530 1688.41,-530 1688.41,-444.1 1688.41,-444.1"/>
<polygon fill="#e65100" stroke="#e65100" points="1691.91,-444.1 1688.41,-434.1 1684.91,-444.1 1691.91,-444.1"/>
<text xml:space="preserve" text-anchor="middle" x="1653.22" y="-459" font-family="Helvetica,sans-Serif" font-size="8.00">1:N</text>
</g>
<!-- unavailability -->
<g id="node8" class="node">
<title>unavailability</title>
<polygon fill="#ffcc80" stroke="black" points="1771.03,-326.1 1771.03,-438.1 1870.97,-438.1 1870.97,-326.1 1771.03,-326.1"/>
<text xml:space="preserve" text-anchor="middle" x="1821" y="-425.1" font-family="Helvetica,sans-Serif" font-size="10.00">Unavailability</text>
<polyline fill="none" stroke="black" points="1771.03,-418.1 1870.97,-418.1"/>
<text xml:space="preserve" text-anchor="start" x="1779.03" y="-405.1" font-family="Helvetica,sans-Serif" font-size="10.00">id: PK</text>
<text xml:space="preserve" text-anchor="start" x="1779.03" y="-393.1" font-family="Helvetica,sans-Serif" font-size="10.00">veterinarian_id: FK</text>
<text xml:space="preserve" text-anchor="start" x="1779.03" y="-381.1" font-family="Helvetica,sans-Serif" font-size="10.00">start_date: date</text>
<text xml:space="preserve" text-anchor="start" x="1779.03" y="-369.1" font-family="Helvetica,sans-Serif" font-size="10.00">end_date: date</text>
<text xml:space="preserve" text-anchor="start" x="1779.03" y="-357.1" font-family="Helvetica,sans-Serif" font-size="10.00">start_time: time</text>
<text xml:space="preserve" text-anchor="start" x="1779.03" y="-345.1" font-family="Helvetica,sans-Serif" font-size="10.00">end_time: time</text>
<text xml:space="preserve" text-anchor="start" x="1779.03" y="-333.1" font-family="Helvetica,sans-Serif" font-size="10.00">reason: str</text>
</g>
<!-- veterinarian&#45;&gt;unavailability -->
<g id="edge19" class="edge">
<title>veterinarian&#45;&gt;unavailability</title>
<path fill="none" stroke="#e65100" d="M1608.27,-553C1676.74,-553 1821,-553 1821,-553 1821,-553 1821,-450.05 1821,-450.05"/>
<polygon fill="#e65100" stroke="#e65100" points="1824.5,-450.05 1821,-440.05 1817.5,-450.05 1824.5,-450.05"/>
<text xml:space="preserve" text-anchor="middle" x="1729.22" y="-459" font-family="Helvetica,sans-Serif" font-size="8.00">1:N</text>
</g>
<!-- vet_specialty -->
<g id="node9" class="node">
<title>vet_specialty</title>
<polygon fill="#ffb74d" stroke="black" points="1442,-426.1 1249.44,-382.1 1442,-338.1 1634.56,-382.1 1442,-426.1"/>
<text xml:space="preserve" text-anchor="start" x="1283.24" y="-391.1" font-family="Helvetica,sans-Serif" font-size="10.00">{M2M: Vet&#45;Specialty|veterinarian_id: FK</text>
<text xml:space="preserve" text-anchor="start" x="1283.24" y="-379.1" font-family="Helvetica,sans-Serif" font-size="10.00">specialty_id: FK</text>
<text xml:space="preserve" text-anchor="middle" x="1442" y="-367.1" font-family="Helvetica,sans-Serif" font-size="10.00">}</text>
</g>
<!-- veterinarian&#45;&gt;vet_specialty -->
<g id="edge14" class="edge">
<title>veterinarian&#45;&gt;vet_specialty</title>
<path fill="none" stroke="#e65100" d="M1584.65,-508.32C1584.65,-508.32 1584.65,-405.5 1584.65,-405.5"/>
<polygon fill="#e65100" stroke="#e65100" points="1588.15,-405.5 1584.65,-395.5 1581.15,-405.5 1588.15,-405.5"/>
<text xml:space="preserve" text-anchor="middle" x="1505.22" y="-459" font-family="Helvetica,sans-Serif" font-size="8.00">1:N</text>
</g>
<!-- vet_neighborhood -->
<g id="node10" class="node">
<title>vet_neighborhood</title>
<polygon fill="#ffb74d" stroke="black" points="1017,-426.1 802.19,-382.1 1017,-338.1 1231.81,-382.1 1017,-426.1"/>
<text xml:space="preserve" text-anchor="start" x="838.97" y="-391.1" font-family="Helvetica,sans-Serif" font-size="10.00">{M2M: Vet&#45;Neighborhood|veterinarian_id: FK</text>
<text xml:space="preserve" text-anchor="start" x="838.97" y="-379.1" font-family="Helvetica,sans-Serif" font-size="10.00">neighborhood_id: FK</text>
<text xml:space="preserve" text-anchor="middle" x="1017" y="-367.1" font-family="Helvetica,sans-Serif" font-size="10.00">}</text>
</g>
<!-- veterinarian&#45;&gt;vet_neighborhood -->
<g id="edge16" class="edge">
<title>veterinarian&#45;&gt;vet_neighborhood</title>
<path fill="none" stroke="#e65100" d="M1537.71,-544C1447.43,-544 1215.15,-544 1215.15,-544 1215.15,-544 1215.15,-397.3 1215.15,-397.3"/>
<polygon fill="#e65100" stroke="#e65100" points="1218.65,-397.3 1215.15,-387.3 1211.65,-397.3 1218.65,-397.3"/>
<text xml:space="preserve" text-anchor="middle" x="1273.79" y="-459" font-family="Helvetica,sans-Serif" font-size="8.00">1:N coverage</text>
</g>
<!-- turnfeegroup -->
<g id="node41" class="node">
<title>turnfeegroup</title>
<polygon fill="#cfd8dc" stroke="black" points="1067.24,-145.1 1067.24,-209.1 1192.76,-209.1 1192.76,-145.1 1067.24,-145.1"/>
<text xml:space="preserve" text-anchor="middle" x="1130" y="-196.1" font-family="Helvetica,sans-Serif" font-size="10.00">IndividualTurnFeeGroup</text>
<polyline fill="none" stroke="black" points="1067.24,-189.1 1192.76,-189.1"/>
<text xml:space="preserve" text-anchor="start" x="1075.24" y="-176.1" font-family="Helvetica,sans-Serif" font-size="10.00">id: PK</text>
<text xml:space="preserve" text-anchor="start" x="1075.24" y="-164.1" font-family="Helvetica,sans-Serif" font-size="10.00">name: str</text>
<text xml:space="preserve" text-anchor="start" x="1075.24" y="-152.1" font-family="Helvetica,sans-Serif" font-size="10.00">fee_percentage: decimal</text>
</g>
<!-- veterinarian&#45;&gt;turnfeegroup -->
<g id="edge20" class="edge">
<title>veterinarian&#45;&gt;turnfeegroup</title>
<path fill="none" stroke="#e65100" stroke-dasharray="5,2" d="M1537.74,-526C1452.2,-526 1240.63,-526 1240.63,-526 1240.63,-526 1240.63,-177 1240.63,-177 1240.63,-177 1204.58,-177 1204.58,-177"/>
<polygon fill="#e65100" stroke="#e65100" points="1204.58,-173.5 1194.58,-177 1204.58,-180.5 1204.58,-173.5"/>
<text xml:space="preserve" text-anchor="middle" x="777.33" y="-379.7" font-family="Helvetica,sans-Serif" font-size="8.00">N:M</text>
</g>
<!-- specialty -->
<g id="node31" class="node">
<title>specialty</title>
<polygon fill="#cfd8dc" stroke="black" points="753.16,-151.1 753.16,-203.1 810.84,-203.1 810.84,-151.1 753.16,-151.1"/>
<text xml:space="preserve" text-anchor="middle" x="782" y="-190.1" font-family="Helvetica,sans-Serif" font-size="10.00">Specialty</text>
<polyline fill="none" stroke="black" points="753.16,-183.1 810.84,-183.1"/>
<text xml:space="preserve" text-anchor="start" x="761.16" y="-170.1" font-family="Helvetica,sans-Serif" font-size="10.00">id: PK</text>
<text xml:space="preserve" text-anchor="start" x="761.16" y="-158.1" font-family="Helvetica,sans-Serif" font-size="10.00">name: str</text>
</g>
<!-- vet_specialty&#45;&gt;specialty -->
<g id="edge15" class="edge">
<title>vet_specialty&#45;&gt;specialty</title>
<path fill="none" stroke="#e65100" d="M1442,-337.81C1442,-315.45 1442,-293 1442,-293 1442,-293 806.51,-293 806.51,-293 806.51,-293 806.51,-214.91 806.51,-214.91"/>
<polygon fill="#e65100" stroke="#e65100" points="810.01,-214.91 806.51,-204.91 803.01,-214.91 810.01,-214.91"/>
<text xml:space="preserve" text-anchor="middle" x="751.22" y="-300.4" font-family="Helvetica,sans-Serif" font-size="8.00">N:1</text>
</g>
<!-- vet_neighborhood&#45;&gt;neighborhood -->
<g id="edge17" class="edge">
<title>vet_neighborhood&#45;&gt;neighborhood</title>
<path fill="none" stroke="#e65100" d="M800.26,-382C572.59,-382 247.21,-382 247.21,-382 247.21,-382 247.21,-226.67 247.21,-226.67"/>
<polygon fill="#e65100" stroke="#e65100" points="250.71,-226.67 247.21,-216.67 243.71,-226.67 250.71,-226.67"/>
<text xml:space="preserve" text-anchor="middle" x="657.22" y="-300.4" font-family="Helvetica,sans-Serif" font-size="8.00">N:1</text>
</g>
<!-- grupo -->
<g id="node11" class="node">
<title>grupo</title>
<polygon fill="#e1bee7" stroke="black" points="85.98,-1388.1 85.98,-1452.1 172.02,-1452.1 172.02,-1388.1 85.98,-1388.1"/>
<text xml:space="preserve" text-anchor="middle" x="129" y="-1439.1" font-family="Helvetica,sans-Serif" font-size="10.00">Group</text>
<polyline fill="none" stroke="black" points="85.98,-1432.1 172.02,-1432.1"/>
<text xml:space="preserve" text-anchor="start" x="93.98" y="-1419.1" font-family="Helvetica,sans-Serif" font-size="10.00">id: PK</text>
<text xml:space="preserve" text-anchor="start" x="93.98" y="-1407.1" font-family="Helvetica,sans-Serif" font-size="10.00">name: str</text>
<text xml:space="preserve" text-anchor="start" x="93.98" y="-1395.1" font-family="Helvetica,sans-Serif" font-size="10.00">description: text</text>
</g>
<!-- category -->
<g id="node12" class="node">
<title>category</title>
<polygon fill="#e1bee7" stroke="black" points="166.6,-1023.5 166.6,-1111.5 255.4,-1111.5 255.4,-1023.5 166.6,-1023.5"/>
<text xml:space="preserve" text-anchor="middle" x="211" y="-1098.5" font-family="Helvetica,sans-Serif" font-size="10.00">Category</text>
<polyline fill="none" stroke="black" points="166.6,-1091.5 255.4,-1091.5"/>
<text xml:space="preserve" text-anchor="start" x="174.6" y="-1078.5" font-family="Helvetica,sans-Serif" font-size="10.00">id: PK</text>
<text xml:space="preserve" text-anchor="start" x="174.6" y="-1066.5" font-family="Helvetica,sans-Serif" font-size="10.00">group_id: FK</text>
<text xml:space="preserve" text-anchor="start" x="174.6" y="-1054.5" font-family="Helvetica,sans-Serif" font-size="10.00">name: str</text>
<text xml:space="preserve" text-anchor="start" x="174.6" y="-1042.5" font-family="Helvetica,sans-Serif" font-size="10.00">description: text</text>
<text xml:space="preserve" text-anchor="start" x="174.6" y="-1030.5" font-family="Helvetica,sans-Serif" font-size="10.00">value: int (order)</text>
</g>
<!-- grupo&#45;&gt;category -->
<g id="edge21" class="edge">
<title>grupo&#45;&gt;category</title>
<path fill="none" stroke="#7b1fa2" d="M172.38,-1420C177.51,-1420 180.96,-1420 180.96,-1420 180.96,-1420 180.96,-1123.3 180.96,-1123.3"/>
<polygon fill="#7b1fa2" stroke="#7b1fa2" points="184.46,-1123.3 180.96,-1113.3 177.46,-1123.3 184.46,-1123.3"/>
<text xml:space="preserve" text-anchor="middle" x="151.22" y="-1243.6" font-family="Helvetica,sans-Serif" font-size="8.00">1:N</text>
</g>
<!-- service -->
<g id="node13" class="node">
<title>service</title>
<polygon fill="#ce93d8" stroke="black" points="199.63,-729.3 199.63,-889.3 322.37,-889.3 322.37,-729.3 199.63,-729.3"/>
<text xml:space="preserve" text-anchor="middle" x="261" y="-876.3" font-family="Helvetica,sans-Serif" font-size="10.00">Service</text>
<polyline fill="none" stroke="black" points="199.63,-869.3 322.37,-869.3"/>
<text xml:space="preserve" text-anchor="start" x="207.63" y="-856.3" font-family="Helvetica,sans-Serif" font-size="10.00">id: PK</text>
<text xml:space="preserve" text-anchor="start" x="207.63" y="-844.3" font-family="Helvetica,sans-Serif" font-size="10.00">name: str</text>
<text xml:space="preserve" text-anchor="start" x="207.63" y="-832.3" font-family="Helvetica,sans-Serif" font-size="10.00">description: text</text>
<text xml:space="preserve" text-anchor="start" x="207.63" y="-820.3" font-family="Helvetica,sans-Serif" font-size="10.00">specialty_id: FK</text>
<text xml:space="preserve" text-anchor="start" x="207.63" y="-808.3" font-family="Helvetica,sans-Serif" font-size="10.00">category_id: FK</text>
<text xml:space="preserve" text-anchor="start" x="207.63" y="-796.3" font-family="Helvetica,sans-Serif" font-size="10.00">duration: int (min)</text>
<text xml:space="preserve" text-anchor="start" x="207.63" y="-784.3" font-family="Helvetica,sans-Serif" font-size="10.00">modality: onsite/online</text>
<text xml:space="preserve" text-anchor="start" x="207.63" y="-772.3" font-family="Helvetica,sans-Serif" font-size="10.00">payment_sign_req: bool</text>
<text xml:space="preserve" text-anchor="start" x="207.63" y="-760.3" font-family="Helvetica,sans-Serif" font-size="10.00">pet_type_filter: str</text>
<text xml:space="preserve" text-anchor="start" x="207.63" y="-748.3" font-family="Helvetica,sans-Serif" font-size="10.00">age_filter: str</text>
<text xml:space="preserve" text-anchor="start" x="207.63" y="-736.3" font-family="Helvetica,sans-Serif" font-size="10.00">weight_range: str</text>
</g>
<!-- category&#45;&gt;service -->
<g id="edge22" class="edge">
<title>category&#45;&gt;service</title>
<path fill="none" stroke="#7b1fa2" d="M227.52,-1023.11C227.52,-1023.11 227.52,-901.07 227.52,-901.07"/>
<polygon fill="#7b1fa2" stroke="#7b1fa2" points="231.02,-901.07 227.52,-891.07 224.02,-901.08 231.02,-901.07"/>
<text xml:space="preserve" text-anchor="middle" x="250.22" y="-939" font-family="Helvetica,sans-Serif" font-size="8.00">1:N</text>
</g>
<!-- prices -->
<g id="node14" class="node">
<title>prices</title>
<polygon fill="#ba68c8" stroke="black" points="63.3,-484.7 63.3,-620.7 192.7,-620.7 192.7,-484.7 63.3,-484.7"/>
<text xml:space="preserve" text-anchor="middle" x="128" y="-607.7" font-family="Helvetica,sans-Serif" font-size="10.00">Prices</text>
<polyline fill="none" stroke="black" points="63.3,-600.7 192.7,-600.7"/>
<text xml:space="preserve" text-anchor="start" x="71.3" y="-587.7" font-family="Helvetica,sans-Serif" font-size="10.00">id: PK</text>
<text xml:space="preserve" text-anchor="start" x="71.3" y="-575.7" font-family="Helvetica,sans-Serif" font-size="10.00">service_id: FK</text>
<text xml:space="preserve" text-anchor="start" x="71.3" y="-563.7" font-family="Helvetica,sans-Serif" font-size="10.00">veterinarian_id: FK (opt)</text>
<text xml:space="preserve" text-anchor="start" x="71.3" y="-551.7" font-family="Helvetica,sans-Serif" font-size="10.00">price: decimal</text>
<text xml:space="preserve" text-anchor="start" x="71.3" y="-539.7" font-family="Helvetica,sans-Serif" font-size="10.00">professional_fee: decimal</text>
<text xml:space="preserve" text-anchor="start" x="71.3" y="-527.7" font-family="Helvetica,sans-Serif" font-size="10.00">payment_sign: decimal</text>
<text xml:space="preserve" text-anchor="start" x="71.3" y="-515.7" font-family="Helvetica,sans-Serif" font-size="10.00">from_date: date</text>
<text xml:space="preserve" text-anchor="start" x="71.3" y="-503.7" font-family="Helvetica,sans-Serif" font-size="10.00">to_date: date</text>
<text xml:space="preserve" text-anchor="start" x="71.3" y="-491.7" font-family="Helvetica,sans-Serif" font-size="10.00">active: bool</text>
</g>
<!-- service&#45;&gt;prices -->
<g id="edge24" class="edge">
<title>service&#45;&gt;prices</title>
<path fill="none" stroke="#7b1fa2" d="M204.92,-728.98C204.92,-673.26 204.92,-609 204.92,-609 204.92,-609 203.72,-609 203.72,-609"/>
<polygon fill="#7b1fa2" stroke="#7b1fa2" points="204.41,-605.5 194.41,-609 204.41,-612.5 204.41,-605.5"/>
<text xml:space="preserve" text-anchor="middle" x="184.22" y="-674.8" font-family="Helvetica,sans-Serif" font-size="8.00">1:N</text>
</g>
<!-- discounts -->
<g id="node15" class="node">
<title>discounts</title>
<polygon fill="#ba68c8" stroke="black" points="210.2,-502.7 210.2,-602.7 321.8,-602.7 321.8,-502.7 210.2,-502.7"/>
<text xml:space="preserve" text-anchor="middle" x="266" y="-589.7" font-family="Helvetica,sans-Serif" font-size="10.00">Discounts</text>
<polyline fill="none" stroke="black" points="210.2,-582.7 321.8,-582.7"/>
<text xml:space="preserve" text-anchor="start" x="218.2" y="-569.7" font-family="Helvetica,sans-Serif" font-size="10.00">id: PK</text>
<text xml:space="preserve" text-anchor="start" x="218.2" y="-557.7" font-family="Helvetica,sans-Serif" font-size="10.00">service_id: FK</text>
<text xml:space="preserve" text-anchor="start" x="218.2" y="-545.7" font-family="Helvetica,sans-Serif" font-size="10.00">discount: decimal (%)</text>
<text xml:space="preserve" text-anchor="start" x="218.2" y="-533.7" font-family="Helvetica,sans-Serif" font-size="10.00">from_date: date</text>
<text xml:space="preserve" text-anchor="start" x="218.2" y="-521.7" font-family="Helvetica,sans-Serif" font-size="10.00">to_date: date</text>
<text xml:space="preserve" text-anchor="start" x="218.2" y="-509.7" font-family="Helvetica,sans-Serif" font-size="10.00">active: bool</text>
</g>
<!-- service&#45;&gt;discounts -->
<g id="edge25" class="edge">
<title>service&#45;&gt;discounts</title>
<path fill="none" stroke="#7b1fa2" d="M247.4,-728.81C247.4,-728.81 247.4,-614.57 247.4,-614.57"/>
<polygon fill="#7b1fa2" stroke="#7b1fa2" points="250.9,-614.57 247.4,-604.57 243.9,-614.57 250.9,-614.57"/>
<text xml:space="preserve" text-anchor="middle" x="270.22" y="-674.8" font-family="Helvetica,sans-Serif" font-size="8.00">1:N</text>
</g>
<!-- service&#45;&gt;specialty -->
<g id="edge23" class="edge">
<title>service&#45;&gt;specialty</title>
<path fill="none" stroke="#7b1fa2" stroke-dasharray="5,2" d="M284.6,-728.87C284.6,-675.42 284.6,-615 284.6,-615 284.6,-615 777.67,-615 777.67,-615 777.67,-615 777.67,-215.02 777.67,-215.02"/>
<polygon fill="#7b1fa2" stroke="#7b1fa2" points="781.17,-215.02 777.67,-205.02 774.17,-215.02 781.17,-215.02"/>
<text xml:space="preserve" text-anchor="middle" x="538.9" y="-459" font-family="Helvetica,sans-Serif" font-size="8.00">N:1 opt</text>
</g>
<!-- prices&#45;&gt;veterinarian -->
<g id="edge26" class="edge">
<title>prices&#45;&gt;veterinarian</title>
<path fill="none" stroke="#7b1fa2" stroke-dasharray="5,2" d="M192.84,-493C472.5,-493 1561.35,-493 1561.35,-493 1561.35,-493 1561.35,-496.86 1561.35,-496.86"/>
<polygon fill="#7b1fa2" stroke="#7b1fa2" points="1557.85,-496.86 1561.35,-506.86 1564.85,-496.86 1557.85,-496.86"/>
<text xml:space="preserve" text-anchor="middle" x="434" y="-679.6" font-family="Helvetica,sans-Serif" font-size="8.00">N:1 opt</text>
<text xml:space="preserve" text-anchor="middle" x="434" y="-670" font-family="Helvetica,sans-Serif" font-size="8.00">(vet&#45;specific)</text>
</g>
<!-- servicecombo -->
<g id="node16" class="node">
<title>servicecombo</title>
<polygon fill="#e1bee7" stroke="black" points="189.91,-1376.1 189.91,-1464.1 322.09,-1464.1 322.09,-1376.1 189.91,-1376.1"/>
<text xml:space="preserve" text-anchor="middle" x="256" y="-1451.1" font-family="Helvetica,sans-Serif" font-size="10.00">ServiceCombo</text>
<polyline fill="none" stroke="black" points="189.91,-1444.1 322.09,-1444.1"/>
<text xml:space="preserve" text-anchor="start" x="197.91" y="-1431.1" font-family="Helvetica,sans-Serif" font-size="10.00">id: PK</text>
<text xml:space="preserve" text-anchor="start" x="197.91" y="-1419.1" font-family="Helvetica,sans-Serif" font-size="10.00">name: str</text>
<text xml:space="preserve" text-anchor="start" x="197.91" y="-1407.1" font-family="Helvetica,sans-Serif" font-size="10.00">description: text</text>
<text xml:space="preserve" text-anchor="start" x="197.91" y="-1395.1" font-family="Helvetica,sans-Serif" font-size="10.00">discount_percent: decimal</text>
<text xml:space="preserve" text-anchor="start" x="197.91" y="-1383.1" font-family="Helvetica,sans-Serif" font-size="10.00">discount_fixed: decimal</text>
</g>
<!-- cart&#45;&gt;veterinarian -->
<g id="edge27" class="edge">
<title>cart&#45;&gt;veterinarian</title>
<path fill="none" stroke="#00838f" stroke-dasharray="5,2" d="M949.06,-1375.77C949.06,-1202.29 949.06,-579 949.06,-579 949.06,-579 1526.15,-579 1526.15,-579"/>
<polygon fill="#00838f" stroke="#00838f" points="1526.15,-582.5 1536.15,-579 1526.15,-575.5 1526.15,-582.5"/>
<text xml:space="preserve" text-anchor="middle" x="1789.12" y="-943.8" font-family="Helvetica,sans-Serif" font-size="8.00">N:1 opt</text>
<text xml:space="preserve" text-anchor="middle" x="1789.12" y="-934.2" font-family="Helvetica,sans-Serif" font-size="8.00">(assigned vet)</text>
</g>
<!-- cartitem -->
<g id="node18" class="node">
<title>cartitem</title>
<polygon fill="#80deea" stroke="black" points="735.93,-1011.5 735.93,-1123.5 838.07,-1123.5 838.07,-1011.5 735.93,-1011.5"/>
<text xml:space="preserve" text-anchor="middle" x="787" y="-1110.5" font-family="Helvetica,sans-Serif" font-size="10.00">CartItem</text>
<polyline fill="none" stroke="black" points="735.93,-1103.5 838.07,-1103.5"/>
<text xml:space="preserve" text-anchor="start" x="743.93" y="-1090.5" font-family="Helvetica,sans-Serif" font-size="10.00">id: PK</text>
<text xml:space="preserve" text-anchor="start" x="743.93" y="-1078.5" font-family="Helvetica,sans-Serif" font-size="10.00">cart_id: FK</text>
<text xml:space="preserve" text-anchor="start" x="743.93" y="-1066.5" font-family="Helvetica,sans-Serif" font-size="10.00">pet_id: FK (opt)</text>
<text xml:space="preserve" text-anchor="start" x="743.93" y="-1054.5" font-family="Helvetica,sans-Serif" font-size="10.00">service_id: FK</text>
<text xml:space="preserve" text-anchor="start" x="743.93" y="-1042.5" font-family="Helvetica,sans-Serif" font-size="10.00">price: decimal</text>
<text xml:space="preserve" text-anchor="start" x="743.93" y="-1030.5" font-family="Helvetica,sans-Serif" font-size="10.00">quantity: int</text>
<text xml:space="preserve" text-anchor="start" x="743.93" y="-1018.5" font-family="Helvetica,sans-Serif" font-size="10.00">total: decimal (calc)</text>
</g>
<!-- cart&#45;&gt;cartitem -->
<g id="edge28" class="edge">
<title>cart&#45;&gt;cartitem</title>
<path fill="none" stroke="#00838f" d="M923.27,-1375.74C923.27,-1291.12 923.27,-1115 923.27,-1115 923.27,-1115 850.03,-1115 850.03,-1115"/>
<polygon fill="#00838f" stroke="#00838f" points="850.03,-1111.5 840.03,-1115 850.03,-1118.5 850.03,-1111.5"/>
<text xml:space="preserve" text-anchor="middle" x="862.22" y="-1243.6" font-family="Helvetica,sans-Serif" font-size="8.00">1:N</text>
</g>
<!-- cartresumeitem -->
<g id="node19" class="node">
<title>cartresumeitem</title>
<polygon fill="#80deea" stroke="black" points="957.93,-999.5 957.93,-1135.5 1070.07,-1135.5 1070.07,-999.5 957.93,-999.5"/>
<text xml:space="preserve" text-anchor="middle" x="1014" y="-1122.5" font-family="Helvetica,sans-Serif" font-size="10.00">CartResumeItem</text>
<polyline fill="none" stroke="black" points="957.93,-1115.5 1070.07,-1115.5"/>
<text xml:space="preserve" text-anchor="start" x="965.93" y="-1102.5" font-family="Helvetica,sans-Serif" font-size="10.00">id: PK</text>
<text xml:space="preserve" text-anchor="start" x="965.93" y="-1090.5" font-family="Helvetica,sans-Serif" font-size="10.00">cart_id: FK</text>
<text xml:space="preserve" text-anchor="start" x="965.93" y="-1078.5" font-family="Helvetica,sans-Serif" font-size="10.00">concept: SUBTOTAL/</text>
<text xml:space="preserve" text-anchor="start" x="965.93" y="-1066.5" font-family="Helvetica,sans-Serif" font-size="10.00"> DESCUENTO/</text>
<text xml:space="preserve" text-anchor="start" x="965.93" y="-1054.5" font-family="Helvetica,sans-Serif" font-size="10.00"> ADELANTO/</text>
<text xml:space="preserve" text-anchor="start" x="965.93" y="-1042.5" font-family="Helvetica,sans-Serif" font-size="10.00"> TOTAL/</text>
<text xml:space="preserve" text-anchor="start" x="965.93" y="-1030.5" font-family="Helvetica,sans-Serif" font-size="10.00"> COSTO_SERVICIO</text>
<text xml:space="preserve" text-anchor="start" x="965.93" y="-1018.5" font-family="Helvetica,sans-Serif" font-size="10.00">amount: decimal</text>
<text xml:space="preserve" text-anchor="start" x="965.93" y="-1006.5" font-family="Helvetica,sans-Serif" font-size="10.00">order: int</text>
</g>
<!-- cart&#45;&gt;cartresumeitem -->
<g id="edge29" class="edge">
<title>cart&#45;&gt;cartresumeitem</title>
<path fill="none" stroke="#00838f" d="M981.83,-1375.8C981.83,-1375.8 981.83,-1147.31 981.83,-1147.31"/>
<polygon fill="#00838f" stroke="#00838f" points="985.33,-1147.31 981.83,-1137.31 978.33,-1147.31 985.33,-1147.31"/>
<text xml:space="preserve" text-anchor="middle" x="990.22" y="-1243.6" font-family="Helvetica,sans-Serif" font-size="8.00">1:N</text>
</g>
<!-- cartpetreason -->
<g id="node20" class="node">
<title>cartpetreason</title>
<polygon fill="#80deea" stroke="black" points="855.82,-1029.5 855.82,-1105.5 940.18,-1105.5 940.18,-1029.5 855.82,-1029.5"/>
<text xml:space="preserve" text-anchor="middle" x="898" y="-1092.5" font-family="Helvetica,sans-Serif" font-size="10.00">CartPetReason</text>
<polyline fill="none" stroke="black" points="855.82,-1085.5 940.18,-1085.5"/>
<text xml:space="preserve" text-anchor="start" x="863.82" y="-1072.5" font-family="Helvetica,sans-Serif" font-size="10.00">id: PK</text>
<text xml:space="preserve" text-anchor="start" x="863.82" y="-1060.5" font-family="Helvetica,sans-Serif" font-size="10.00">cart_id: FK</text>
<text xml:space="preserve" text-anchor="start" x="863.82" y="-1048.5" font-family="Helvetica,sans-Serif" font-size="10.00">pet_id: FK</text>
<text xml:space="preserve" text-anchor="start" x="863.82" y="-1036.5" font-family="Helvetica,sans-Serif" font-size="10.00">reason: text</text>
</g>
<!-- cart&#45;&gt;cartpetreason -->
<g id="edge30" class="edge">
<title>cart&#45;&gt;cartpetreason</title>
<path fill="none" stroke="#00838f" d="M931.73,-1375.8C931.73,-1375.8 931.73,-1117.47 931.73,-1117.47"/>
<polygon fill="#00838f" stroke="#00838f" points="935.23,-1117.47 931.73,-1107.47 928.23,-1117.47 935.23,-1117.47"/>
<text xml:space="preserve" text-anchor="middle" x="919.22" y="-1243.6" font-family="Helvetica,sans-Serif" font-size="8.00">1:N</text>
</g>
<!-- cartitem&#45;&gt;pet -->
<g id="edge32" class="edge">
<title>cartitem&#45;&gt;pet</title>
<path fill="none" stroke="#00838f" stroke-dasharray="5,2" d="M747.42,-1011.12C747.42,-964.19 747.42,-905 747.42,-905 747.42,-905 692.94,-905 692.94,-905"/>
<polygon fill="#00838f" stroke="#00838f" points="692.94,-901.5 682.94,-905 692.94,-908.5 692.94,-901.5"/>
<text xml:space="preserve" text-anchor="middle" x="640.9" y="-939" font-family="Helvetica,sans-Serif" font-size="8.00">N:1 opt</text>
</g>
<!-- cartitem&#45;&gt;service -->
<g id="edge31" class="edge">
<title>cartitem&#45;&gt;service</title>
<path fill="none" stroke="#00838f" d="M741.67,-1011.15C741.67,-997.62 741.67,-987 741.67,-987 741.67,-987 288.75,-987 288.75,-987 288.75,-987 288.75,-901.09 288.75,-901.09"/>
<polygon fill="#00838f" stroke="#00838f" points="292.25,-901.09 288.75,-891.09 285.25,-901.09 292.25,-901.09"/>
<text xml:space="preserve" text-anchor="middle" x="446.22" y="-939" font-family="Helvetica,sans-Serif" font-size="8.00">N:1</text>
</g>
<!-- cartpetreason&#45;&gt;pet -->
<g id="edge33" class="edge">
<title>cartpetreason&#45;&gt;pet</title>
<path fill="none" stroke="#00838f" d="M898,-1029.01C898,-978.45 898,-896 898,-896 898,-896 692.86,-896 692.86,-896"/>
<polygon fill="#00838f" stroke="#00838f" points="692.86,-892.5 682.86,-896 692.86,-899.5 692.86,-892.5"/>
<text xml:space="preserve" text-anchor="middle" x="683.22" y="-939" font-family="Helvetica,sans-Serif" font-size="8.00">N:1</text>
</g>
<!-- servicerequest&#45;&gt;veterinarian -->
<g id="edge35" class="edge">
<title>servicerequest&#45;&gt;veterinarian</title>
<path fill="none" stroke="#c62828" stroke-dasharray="5,2" d="M1895.22,-975C1780.2,-975 1561.26,-975 1561.26,-975 1561.26,-975 1561.26,-608.61 1561.26,-608.61"/>
<polygon fill="#c62828" stroke="#c62828" points="1564.76,-608.61 1561.26,-598.61 1557.76,-608.61 1564.76,-608.61"/>
<text xml:space="preserve" text-anchor="middle" x="1858.68" y="-811.7" font-family="Helvetica,sans-Serif" font-size="8.00">N:1 opt</text>
<text xml:space="preserve" text-anchor="middle" x="1858.68" y="-802.1" font-family="Helvetica,sans-Serif" font-size="8.00">(assigned)</text>
</g>
<!-- servicerequest&#45;&gt;cart -->
<g id="edge34" class="edge">
<title>servicerequest&#45;&gt;cart</title>
<path fill="none" stroke="#c62828" d="M1895.08,-1154C1675.36,-1154 1005.73,-1154 1005.73,-1154 1005.73,-1154 1005.73,-1364.35 1005.73,-1364.35"/>
<polygon fill="#c62828" stroke="#c62828" points="1002.23,-1364.35 1005.73,-1374.35 1009.23,-1364.35 1002.23,-1364.35"/>
<text xml:space="preserve" text-anchor="middle" x="1160.56" y="-1243.6" font-family="Helvetica,sans-Serif" font-size="8.00">1:1</text>
</g>
<!-- statehistory -->
<g id="node22" class="node">
<title>statehistory</title>
<polygon fill="#ef9a9a" stroke="black" points="2182.86,-759.3 2182.86,-859.3 2301.14,-859.3 2301.14,-759.3 2182.86,-759.3"/>
<text xml:space="preserve" text-anchor="middle" x="2242" y="-846.3" font-family="Helvetica,sans-Serif" font-size="10.00">StateHistory</text>
<polyline fill="none" stroke="black" points="2182.86,-839.3 2301.14,-839.3"/>
<text xml:space="preserve" text-anchor="start" x="2190.86" y="-826.3" font-family="Helvetica,sans-Serif" font-size="10.00">id: PK</text>
<text xml:space="preserve" text-anchor="start" x="2190.86" y="-814.3" font-family="Helvetica,sans-Serif" font-size="10.00">service_request_id: FK</text>
<text xml:space="preserve" text-anchor="start" x="2190.86" y="-802.3" font-family="Helvetica,sans-Serif" font-size="10.00">state: str</text>
<text xml:space="preserve" text-anchor="start" x="2190.86" y="-790.3" font-family="Helvetica,sans-Serif" font-size="10.00">additional_data: JSON</text>
<text xml:space="preserve" text-anchor="start" x="2190.86" y="-778.3" font-family="Helvetica,sans-Serif" font-size="10.00">created_at: datetime</text>
<text xml:space="preserve" text-anchor="start" x="2190.86" y="-766.3" font-family="Helvetica,sans-Serif" font-size="10.00">user_id: FK</text>
</g>
<!-- servicerequest&#45;&gt;statehistory -->
<g id="edge36" class="edge">
<title>servicerequest&#45;&gt;statehistory</title>
<path fill="none" stroke="#c62828" d="M2033.08,-987C2115.41,-987 2242,-987 2242,-987 2242,-987 2242,-871.26 2242,-871.26"/>
<polygon fill="#c62828" stroke="#c62828" points="2245.5,-871.26 2242,-861.26 2238.5,-871.26 2245.5,-871.26"/>
<text xml:space="preserve" text-anchor="middle" x="2179.01" y="-939" font-family="Helvetica,sans-Serif" font-size="8.00">1:N audit</text>
</g>
<!-- vetasked -->
<g id="node23" class="node">
<title>vetasked</title>
<polygon fill="#ef9a9a" stroke="black" points="1894.85,-759.3 1894.85,-859.3 2023.15,-859.3 2023.15,-759.3 1894.85,-759.3"/>
<text xml:space="preserve" text-anchor="middle" x="1959" y="-846.3" font-family="Helvetica,sans-Serif" font-size="10.00">VeterinarianAsked</text>
<polyline fill="none" stroke="black" points="1894.85,-839.3 2023.15,-839.3"/>
<text xml:space="preserve" text-anchor="start" x="1902.85" y="-826.3" font-family="Helvetica,sans-Serif" font-size="10.00">id: PK</text>
<text xml:space="preserve" text-anchor="start" x="1902.85" y="-814.3" font-family="Helvetica,sans-Serif" font-size="10.00">service_request_id: FK</text>
<text xml:space="preserve" text-anchor="start" x="1902.85" y="-802.3" font-family="Helvetica,sans-Serif" font-size="10.00">veterinarian_id: FK</text>
<text xml:space="preserve" text-anchor="start" x="1902.85" y="-790.3" font-family="Helvetica,sans-Serif" font-size="10.00">date_asked: datetime</text>
<text xml:space="preserve" text-anchor="start" x="1902.85" y="-778.3" font-family="Helvetica,sans-Serif" font-size="10.00">date_answered: datetime</text>
<text xml:space="preserve" text-anchor="start" x="1902.85" y="-766.3" font-family="Helvetica,sans-Serif" font-size="10.00">accepted: bool</text>
</g>
<!-- servicerequest&#45;&gt;vetasked -->
<g id="edge37" class="edge">
<title>servicerequest&#45;&gt;vetasked</title>
<path fill="none" stroke="#c62828" d="M1959.27,-969.15C1959.27,-969.15 1959.27,-871.22 1959.27,-871.22"/>
<polygon fill="#c62828" stroke="#c62828" points="1962.77,-871.22 1959.27,-861.22 1955.77,-871.22 1962.77,-871.22"/>
<text xml:space="preserve" text-anchor="middle" x="1967.22" y="-939" font-family="Helvetica,sans-Serif" font-size="8.00">1:N</text>
</g>
<!-- payreminder -->
<g id="node25" class="node">
<title>payreminder</title>
<polygon fill="#e57373" stroke="black" points="2041.63,-765.3 2041.63,-853.3 2164.37,-853.3 2164.37,-765.3 2041.63,-765.3"/>
<text xml:space="preserve" text-anchor="middle" x="2103" y="-840.3" font-family="Helvetica,sans-Serif" font-size="10.00">PaymentReminder</text>
<polyline fill="none" stroke="black" points="2041.63,-833.3 2164.37,-833.3"/>
<text xml:space="preserve" text-anchor="start" x="2049.63" y="-820.3" font-family="Helvetica,sans-Serif" font-size="10.00">id: PK</text>
<text xml:space="preserve" text-anchor="start" x="2049.63" y="-808.3" font-family="Helvetica,sans-Serif" font-size="10.00">service_request_id: FK</text>
<text xml:space="preserve" text-anchor="start" x="2049.63" y="-796.3" font-family="Helvetica,sans-Serif" font-size="10.00">scheduled_for: datetime</text>
<text xml:space="preserve" text-anchor="start" x="2049.63" y="-784.3" font-family="Helvetica,sans-Serif" font-size="10.00">processed_at: datetime</text>
<text xml:space="preserve" text-anchor="start" x="2049.63" y="-772.3" font-family="Helvetica,sans-Serif" font-size="10.00">status: pending/sent/...</text>
</g>
<!-- servicerequest&#45;&gt;payreminder -->
<g id="edge38" class="edge">
<title>servicerequest&#45;&gt;payreminder</title>
<path fill="none" stroke="#c62828" d="M2029.45,-969.03C2029.45,-895.98 2029.45,-809 2029.45,-809 2029.45,-809 2030.63,-809 2030.63,-809"/>
<polygon fill="#c62828" stroke="#c62828" points="2029.75,-812.5 2039.75,-809 2029.75,-805.5 2029.75,-812.5"/>
<text xml:space="preserve" text-anchor="middle" x="2055.22" y="-939" font-family="Helvetica,sans-Serif" font-size="8.00">1:N</text>
</g>
<!-- servicerequest&#45;&gt;campaign -->
<g id="edge39" class="edge">
<title>servicerequest&#45;&gt;campaign</title>
<path fill="none" stroke="#666666" stroke-dasharray="5,2" d="M2026.3,-969.29C2026.3,-754.09 2026.3,-260 2026.3,-260 2026.3,-260 874,-260 874,-260 874,-260 874,-232.98 874,-232.98"/>
<polygon fill="#666666" stroke="#666666" points="877.5,-232.98 874,-222.98 870.5,-232.98 877.5,-232.98"/>
<text xml:space="preserve" text-anchor="middle" x="2341.9" y="-550.3" font-family="Helvetica,sans-Serif" font-size="8.00">N:1 opt</text>
</g>
<!-- tag -->
<g id="node39" class="node">
<title>tag</title>
<polygon fill="#cfd8dc" stroke="black" points="638.43,-151.1 638.43,-203.1 735.57,-203.1 735.57,-151.1 638.43,-151.1"/>
<text xml:space="preserve" text-anchor="middle" x="687" y="-190.1" font-family="Helvetica,sans-Serif" font-size="10.00">Tag</text>
<polyline fill="none" stroke="black" points="638.43,-183.1 735.57,-183.1"/>
<text xml:space="preserve" text-anchor="start" x="646.43" y="-170.1" font-family="Helvetica,sans-Serif" font-size="10.00">id: PK</text>
<text xml:space="preserve" text-anchor="start" x="646.43" y="-158.1" font-family="Helvetica,sans-Serif" font-size="10.00">name: str (unique)</text>
</g>
<!-- servicerequest&#45;&gt;tag -->
<g id="edge40" class="edge">
<title>servicerequest&#45;&gt;tag</title>
<path fill="none" stroke="#666666" d="M1895.27,-981C1634.21,-981 718.72,-981 718.72,-981 718.72,-981 718.72,-214.9 718.72,-214.9"/>
<polygon fill="#666666" stroke="#666666" points="722.22,-214.9 718.72,-204.9 715.22,-214.9 722.22,-214.9"/>
<text xml:space="preserve" text-anchor="middle" x="756.33" y="-550.3" font-family="Helvetica,sans-Serif" font-size="8.00">N:M</text>
</g>
<!-- vetasked&#45;&gt;veterinarian -->
<g id="edge41" class="edge">
<title>vetasked&#45;&gt;veterinarian</title>
<path fill="none" stroke="#c62828" d="M1894.62,-765C1787.53,-765 1584.47,-765 1584.47,-765 1584.47,-765 1584.47,-608.46 1584.47,-608.46"/>
<polygon fill="#c62828" stroke="#c62828" points="1587.97,-608.46 1584.47,-598.46 1580.97,-608.46 1587.97,-608.46"/>
<text xml:space="preserve" text-anchor="middle" x="1848.22" y="-674.8" font-family="Helvetica,sans-Serif" font-size="8.00">N:1</text>
</g>
<!-- vetreminder -->
<g id="node24" class="node">
<title>vetreminder</title>
<polygon fill="#e57373" stroke="black" points="1897.63,-502.7 1897.63,-602.7 2020.37,-602.7 2020.37,-502.7 1897.63,-502.7"/>
<text xml:space="preserve" text-anchor="middle" x="1959" y="-589.7" font-family="Helvetica,sans-Serif" font-size="10.00">ScheduledVetReminder</text>
<polyline fill="none" stroke="black" points="1897.63,-582.7 2020.37,-582.7"/>
<text xml:space="preserve" text-anchor="start" x="1905.63" y="-569.7" font-family="Helvetica,sans-Serif" font-size="10.00">id: PK</text>
<text xml:space="preserve" text-anchor="start" x="1905.63" y="-557.7" font-family="Helvetica,sans-Serif" font-size="10.00">vet_asked_id: FK</text>
<text xml:space="preserve" text-anchor="start" x="1905.63" y="-545.7" font-family="Helvetica,sans-Serif" font-size="10.00">scheduled_for: datetime</text>
<text xml:space="preserve" text-anchor="start" x="1905.63" y="-533.7" font-family="Helvetica,sans-Serif" font-size="10.00">processed_at: datetime</text>
<text xml:space="preserve" text-anchor="start" x="1905.63" y="-521.7" font-family="Helvetica,sans-Serif" font-size="10.00">status: pending/sent/...</text>
<text xml:space="preserve" text-anchor="start" x="1905.63" y="-509.7" font-family="Helvetica,sans-Serif" font-size="10.00">celery_task_id: str</text>
</g>
<!-- vetasked&#45;&gt;vetreminder -->
<g id="edge42" class="edge">
<title>vetasked&#45;&gt;vetreminder</title>
<path fill="none" stroke="#c62828" d="M1959,-758.91C1959,-758.91 1959,-614.49 1959,-614.49"/>
<polygon fill="#c62828" stroke="#c62828" points="1962.5,-614.49 1959,-604.49 1955.5,-614.49 1962.5,-614.49"/>
<text xml:space="preserve" text-anchor="middle" x="1965.22" y="-674.8" font-family="Helvetica,sans-Serif" font-size="8.00">1:N</text>
</g>
<!-- vetvisit&#45;&gt;veterinarian -->
<g id="edge44" class="edge">
<title>vetvisit&#45;&gt;veterinarian</title>
<path fill="none" stroke="#f9a825" d="M1617.03,-1273.86C1617.03,-1031.98 1617.03,-575 1617.03,-575 1617.03,-575 1616.13,-575 1616.13,-575"/>
<polygon fill="#f9a825" stroke="#f9a825" points="1619.63,-571.5 1609.63,-575 1619.63,-578.5 1619.63,-571.5"/>
<text xml:space="preserve" text-anchor="middle" x="2373.22" y="-939" font-family="Helvetica,sans-Serif" font-size="8.00">N:1</text>
</g>
<!-- vetvisit&#45;&gt;servicerequest -->
<g id="edge43" class="edge">
<title>vetvisit&#45;&gt;servicerequest</title>
<path fill="none" stroke="#f9a825" stroke-dasharray="5,2" d="M1695.23,-1273.71C1695.23,-1214.56 1695.23,-1160 1695.23,-1160 1695.23,-1160 1883.62,-1160 1883.62,-1160"/>
<polygon fill="#f9a825" stroke="#f9a825" points="1883.62,-1163.5 1893.62,-1160 1883.62,-1156.5 1883.62,-1163.5"/>
<text xml:space="preserve" text-anchor="middle" x="1864.12" y="-1248.4" font-family="Helvetica,sans-Serif" font-size="8.00">1:1 opt</text>
<text xml:space="preserve" text-anchor="middle" x="1864.12" y="-1238.8" font-family="Helvetica,sans-Serif" font-size="8.00">(from request)</text>
</g>
<!-- vetvisitreport -->
<g id="node27" class="node">
<title>vetvisitreport</title>
<polygon fill="#fff59d" stroke="black" points="1626.09,-1005.5 1626.09,-1129.5 1729.91,-1129.5 1729.91,-1005.5 1626.09,-1005.5"/>
<text xml:space="preserve" text-anchor="middle" x="1678" y="-1116.5" font-family="Helvetica,sans-Serif" font-size="10.00">VetVisitReport</text>
<polyline fill="none" stroke="black" points="1626.09,-1109.5 1729.91,-1109.5"/>
<text xml:space="preserve" text-anchor="start" x="1634.09" y="-1096.5" font-family="Helvetica,sans-Serif" font-size="10.00">id: PK</text>
<text xml:space="preserve" text-anchor="start" x="1634.09" y="-1084.5" font-family="Helvetica,sans-Serif" font-size="10.00">visit_id: FK</text>
<text xml:space="preserve" text-anchor="start" x="1634.09" y="-1072.5" font-family="Helvetica,sans-Serif" font-size="10.00">pet_id: FK</text>
<text xml:space="preserve" text-anchor="start" x="1634.09" y="-1060.5" font-family="Helvetica,sans-Serif" font-size="10.00">reason: text</text>
<text xml:space="preserve" text-anchor="start" x="1634.09" y="-1048.5" font-family="Helvetica,sans-Serif" font-size="10.00">physical_exam: text</text>
<text xml:space="preserve" text-anchor="start" x="1634.09" y="-1036.5" font-family="Helvetica,sans-Serif" font-size="10.00">diagnosis: text</text>
<text xml:space="preserve" text-anchor="start" x="1634.09" y="-1024.5" font-family="Helvetica,sans-Serif" font-size="10.00">treatment: text</text>
<text xml:space="preserve" text-anchor="start" x="1634.09" y="-1012.5" font-family="Helvetica,sans-Serif" font-size="10.00">pdf_file: file</text>
</g>
<!-- vetvisit&#45;&gt;vetvisitreport -->
<g id="edge47" class="edge">
<title>vetvisit&#45;&gt;vetvisitreport</title>
<path fill="none" stroke="#f9a825" d="M1660.66,-1273.63C1660.66,-1273.63 1660.66,-1141.19 1660.66,-1141.19"/>
<polygon fill="#f9a825" stroke="#f9a825" points="1664.16,-1141.19 1660.66,-1131.19 1657.16,-1141.19 1664.16,-1141.19"/>
<text xml:space="preserve" text-anchor="middle" x="1675.22" y="-1243.6" font-family="Helvetica,sans-Serif" font-size="8.00">1:N</text>
</g>
<!-- vetvisitpetreason -->
<g id="node29" class="node">
<title>vetvisitpetreason</title>
<polygon fill="#fff176" stroke="black" points="1508.31,-1029.5 1508.31,-1105.5 1607.69,-1105.5 1607.69,-1029.5 1508.31,-1029.5"/>
<text xml:space="preserve" text-anchor="middle" x="1558" y="-1092.5" font-family="Helvetica,sans-Serif" font-size="10.00">VetVisitPetReason</text>
<polyline fill="none" stroke="black" points="1508.31,-1085.5 1607.69,-1085.5"/>
<text xml:space="preserve" text-anchor="start" x="1516.31" y="-1072.5" font-family="Helvetica,sans-Serif" font-size="10.00">id: PK</text>
<text xml:space="preserve" text-anchor="start" x="1516.31" y="-1060.5" font-family="Helvetica,sans-Serif" font-size="10.00">visit_id: FK</text>
<text xml:space="preserve" text-anchor="start" x="1516.31" y="-1048.5" font-family="Helvetica,sans-Serif" font-size="10.00">pet_id: FK</text>
<text xml:space="preserve" text-anchor="start" x="1516.31" y="-1036.5" font-family="Helvetica,sans-Serif" font-size="10.00">reason: text</text>
</g>
<!-- vetvisit&#45;&gt;vetvisitpetreason -->
<g id="edge48" class="edge">
<title>vetvisit&#45;&gt;vetvisitpetreason</title>
<path fill="none" stroke="#f9a825" d="M1597.94,-1273.63C1597.94,-1273.63 1597.94,-1117.18 1597.94,-1117.18"/>
<polygon fill="#f9a825" stroke="#f9a825" points="1601.44,-1117.18 1597.94,-1107.18 1594.44,-1117.18 1601.44,-1117.18"/>
<text xml:space="preserve" text-anchor="middle" x="1598.22" y="-1243.6" font-family="Helvetica,sans-Serif" font-size="8.00">1:N</text>
</g>
<!-- visit_pets -->
<g id="node30" class="node">
<title>visit_pets</title>
<polygon fill="#ffee58" stroke="black" points="1336,-1111.5 1181.81,-1067.5 1336,-1023.5 1490.19,-1067.5 1336,-1111.5"/>
<text xml:space="preserve" text-anchor="start" x="1210.47" y="-1076.5" font-family="Helvetica,sans-Serif" font-size="10.00">{M2M: Visit&#45;Pets|vetvisit_id: FK</text>
<text xml:space="preserve" text-anchor="start" x="1210.47" y="-1064.5" font-family="Helvetica,sans-Serif" font-size="10.00">pet_id: FK</text>
<text xml:space="preserve" text-anchor="middle" x="1336" y="-1052.5" font-family="Helvetica,sans-Serif" font-size="10.00">}</text>
</g>
<!-- vetvisit&#45;&gt;visit_pets -->
<g id="edge45" class="edge">
<title>vetvisit&#45;&gt;visit_pets</title>
<path fill="none" stroke="#f9a825" d="M1587.72,-1308C1492.6,-1308 1336,-1308 1336,-1308 1336,-1308 1336,-1123.41 1336,-1123.41"/>
<polygon fill="#f9a825" stroke="#f9a825" points="1339.5,-1123.41 1336,-1113.41 1332.5,-1123.41 1339.5,-1123.41"/>
<text xml:space="preserve" text-anchor="middle" x="1476.22" y="-1243.6" font-family="Helvetica,sans-Serif" font-size="8.00">1:N</text>
</g>
<!-- receipt -->
<g id="node42" class="node">
<title>receipt</title>
<polygon fill="#f8bbd9" stroke="black" points="2394.58,-1005.5 2394.58,-1129.5 2523.42,-1129.5 2523.42,-1005.5 2394.58,-1005.5"/>
<text xml:space="preserve" text-anchor="middle" x="2459" y="-1116.5" font-family="Helvetica,sans-Serif" font-size="10.00">Receipt</text>
<polyline fill="none" stroke="black" points="2394.58,-1109.5 2523.42,-1109.5"/>
<text xml:space="preserve" text-anchor="start" x="2402.58" y="-1096.5" font-family="Helvetica,sans-Serif" font-size="10.00">id: PK</text>
<text xml:space="preserve" text-anchor="start" x="2402.58" y="-1084.5" font-family="Helvetica,sans-Serif" font-size="10.00">document_number: bigint</text>
<text xml:space="preserve" text-anchor="start" x="2402.58" y="-1072.5" font-family="Helvetica,sans-Serif" font-size="10.00">receipt_number: int</text>
<text xml:space="preserve" text-anchor="start" x="2402.58" y="-1060.5" font-family="Helvetica,sans-Serif" font-size="10.00">issued_date: date</text>
<text xml:space="preserve" text-anchor="start" x="2402.58" y="-1048.5" font-family="Helvetica,sans-Serif" font-size="10.00">total_amount: decimal</text>
<text xml:space="preserve" text-anchor="start" x="2402.58" y="-1036.5" font-family="Helvetica,sans-Serif" font-size="10.00">net_taxed: decimal</text>
<text xml:space="preserve" text-anchor="start" x="2402.58" y="-1024.5" font-family="Helvetica,sans-Serif" font-size="10.00">cae: str</text>
<text xml:space="preserve" text-anchor="start" x="2402.58" y="-1012.5" font-family="Helvetica,sans-Serif" font-size="10.00">cae_expiration: date</text>
</g>
<!-- vetvisit&#45;&gt;receipt -->
<g id="edge52" class="edge">
<title>vetvisit&#45;&gt;receipt</title>
<path fill="none" stroke="#ad1457" stroke-dasharray="5,2" d="M1729.99,-1420C1925.78,-1420 2459,-1420 2459,-1420 2459,-1420 2459,-1141.22 2459,-1141.22"/>
<polygon fill="#ad1457" stroke="#ad1457" points="2462.5,-1141.22 2459,-1131.22 2455.5,-1141.22 2462.5,-1141.22"/>
<text xml:space="preserve" text-anchor="middle" x="2444.12" y="-1248.4" font-family="Helvetica,sans-Serif" font-size="8.00">1:1 opt</text>
<text xml:space="preserve" text-anchor="middle" x="2444.12" y="-1238.8" font-family="Helvetica,sans-Serif" font-size="8.00">(invoice)</text>
</g>
<!-- vetvisitreport&#45;&gt;pet -->
<g id="edge49" class="edge">
<title>vetvisitreport&#45;&gt;pet</title>
<path fill="none" stroke="#f9a825" d="M1662.74,-1005.24C1662.74,-947.02 1662.74,-869 1662.74,-869 1662.74,-869 692.97,-869 692.97,-869"/>
<polygon fill="#f9a825" stroke="#f9a825" points="692.97,-865.5 682.97,-869 692.97,-872.5 692.97,-865.5"/>
<text xml:space="preserve" text-anchor="middle" x="1237.22" y="-939" font-family="Helvetica,sans-Serif" font-size="8.00">N:1</text>
</g>
<!-- vetvisitfollowup -->
<g id="node28" class="node">
<title>vetvisitfollowup</title>
<polygon fill="#fff176" stroke="black" points="1632.21,-771.3 1632.21,-847.3 1723.79,-847.3 1723.79,-771.3 1632.21,-771.3"/>
<text xml:space="preserve" text-anchor="middle" x="1678" y="-834.3" font-family="Helvetica,sans-Serif" font-size="10.00">VetVisitFollowUp</text>
<polyline fill="none" stroke="black" points="1632.21,-827.3 1723.79,-827.3"/>
<text xml:space="preserve" text-anchor="start" x="1640.21" y="-814.3" font-family="Helvetica,sans-Serif" font-size="10.00">id: PK</text>
<text xml:space="preserve" text-anchor="start" x="1640.21" y="-802.3" font-family="Helvetica,sans-Serif" font-size="10.00">report_id: FK</text>
<text xml:space="preserve" text-anchor="start" x="1640.21" y="-790.3" font-family="Helvetica,sans-Serif" font-size="10.00">date: date</text>
<text xml:space="preserve" text-anchor="start" x="1640.21" y="-778.3" font-family="Helvetica,sans-Serif" font-size="10.00">description: text</text>
</g>
<!-- vetvisitreport&#45;&gt;vetvisitfollowup -->
<g id="edge50" class="edge">
<title>vetvisitreport&#45;&gt;vetvisitfollowup</title>
<path fill="none" stroke="#f9a825" d="M1693.26,-1005.33C1693.26,-1005.33 1693.26,-859.22 1693.26,-859.22"/>
<polygon fill="#f9a825" stroke="#f9a825" points="1696.76,-859.22 1693.26,-849.22 1689.76,-859.22 1696.76,-859.22"/>
<text xml:space="preserve" text-anchor="middle" x="1684.22" y="-939" font-family="Helvetica,sans-Serif" font-size="8.00">1:N</text>
</g>
<!-- vetvisitpetreason&#45;&gt;pet -->
<g id="edge51" class="edge">
<title>vetvisitpetreason&#45;&gt;pet</title>
<path fill="none" stroke="#f9a825" d="M1523.18,-1029.15C1523.18,-973.78 1523.18,-878 1523.18,-878 1523.18,-878 692.83,-878 692.83,-878"/>
<polygon fill="#f9a825" stroke="#f9a825" points="692.83,-874.5 682.83,-878 692.83,-881.5 692.83,-874.5"/>
<text xml:space="preserve" text-anchor="middle" x="753.22" y="-939" font-family="Helvetica,sans-Serif" font-size="8.00">N:1</text>
</g>
<!-- visit_pets&#45;&gt;pet -->
<g id="edge46" class="edge">
<title>visit_pets&#45;&gt;pet</title>
<path fill="none" stroke="#f9a825" d="M1198.48,-1062.29C1198.48,-1032.7 1198.48,-887 1198.48,-887 1198.48,-887 692.93,-887 692.93,-887"/>
<polygon fill="#f9a825" stroke="#f9a825" points="692.93,-883.5 682.93,-887 692.93,-890.5 692.93,-883.5"/>
<text xml:space="preserve" text-anchor="middle" x="718.22" y="-939" font-family="Helvetica,sans-Serif" font-size="8.00">N:1</text>
</g>
<!-- province -->
<g id="node33" class="node">
<title>province</title>
<polygon fill="#cfd8dc" stroke="black" points="302.16,-151.1 302.16,-203.1 359.84,-203.1 359.84,-151.1 302.16,-151.1"/>
<text xml:space="preserve" text-anchor="middle" x="331" y="-190.1" font-family="Helvetica,sans-Serif" font-size="10.00">Province</text>
<polyline fill="none" stroke="black" points="302.16,-183.1 359.84,-183.1"/>
<text xml:space="preserve" text-anchor="start" x="310.16" y="-170.1" font-family="Helvetica,sans-Serif" font-size="10.00">id: PK</text>
<text xml:space="preserve" text-anchor="start" x="310.16" y="-158.1" font-family="Helvetica,sans-Serif" font-size="10.00">name: str</text>
</g>
<!-- locality -->
<g id="node34" class="node">
<title>locality</title>
<polygon fill="#cfd8dc" stroke="black" points="288.26,-16.5 288.26,-80.5 373.74,-80.5 373.74,-16.5 288.26,-16.5"/>
<text xml:space="preserve" text-anchor="middle" x="331" y="-67.5" font-family="Helvetica,sans-Serif" font-size="10.00">Locality</text>
<polyline fill="none" stroke="black" points="288.26,-60.5 373.74,-60.5"/>
<text xml:space="preserve" text-anchor="start" x="296.26" y="-47.5" font-family="Helvetica,sans-Serif" font-size="10.00">id: PK</text>
<text xml:space="preserve" text-anchor="start" x="296.26" y="-35.5" font-family="Helvetica,sans-Serif" font-size="10.00">province_id: FK</text>
<text xml:space="preserve" text-anchor="start" x="296.26" y="-23.5" font-family="Helvetica,sans-Serif" font-size="10.00">name: str</text>
</g>
<!-- province&#45;&gt;locality -->
<g id="edge53" class="edge">
<title>province&#45;&gt;locality</title>
<path fill="none" stroke="#666666" d="M331,-150.77C331,-150.77 331,-92.24 331,-92.24"/>
<polygon fill="#666666" stroke="#666666" points="334.5,-92.24 331,-82.24 327.5,-92.24 334.5,-92.24"/>
<text xml:space="preserve" text-anchor="middle" x="337.22" y="-101.4" font-family="Helvetica,sans-Serif" font-size="8.00">1:N</text>
</g>
<!-- medication -->
<g id="node40" class="node">
<title>medication</title>
<polygon fill="#cfd8dc" stroke="black" points="937.19,-127.1 937.19,-227.1 1048.81,-227.1 1048.81,-127.1 937.19,-127.1"/>
<text xml:space="preserve" text-anchor="middle" x="993" y="-214.1" font-family="Helvetica,sans-Serif" font-size="10.00">Medication</text>
<polyline fill="none" stroke="black" points="937.19,-207.1 1048.81,-207.1"/>
<text xml:space="preserve" text-anchor="start" x="945.19" y="-194.1" font-family="Helvetica,sans-Serif" font-size="10.00">id: PK</text>
<text xml:space="preserve" text-anchor="start" x="945.19" y="-182.1" font-family="Helvetica,sans-Serif" font-size="10.00">name: str</text>
<text xml:space="preserve" text-anchor="start" x="945.19" y="-170.1" font-family="Helvetica,sans-Serif" font-size="10.00">general_name_id: FK</text>
<text xml:space="preserve" text-anchor="start" x="945.19" y="-158.1" font-family="Helvetica,sans-Serif" font-size="10.00">type_id: FK</text>
<text xml:space="preserve" text-anchor="start" x="945.19" y="-146.1" font-family="Helvetica,sans-Serif" font-size="10.00">presentation_id: FK</text>
<text xml:space="preserve" text-anchor="start" x="945.19" y="-134.1" font-family="Helvetica,sans-Serif" font-size="10.00">pet_type: str</text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 84 KiB

View File

@@ -0,0 +1,195 @@
digraph DataModelSimple {
rankdir=TB
compound=true
splines=ortho
node [shape=box, style="rounded,filled", fontname="Helvetica", fontsize=10]
edge [fontname="Helvetica", fontsize=8]
nodesep=0.3
ranksep=1.2
newrank=true
label="AMAR Mascotas - Data Model Overview"
labelloc="t"
fontsize=16
fontname="Helvetica-Bold"
// === ROW 1: AUTH (center) ===
subgraph cluster_auth {
label="Users & Auth"
style="rounded,filled"
fillcolor="#E8F5E9"
color="#2E7D32"
auth_user [label="auth.User", fillcolor="#C8E6C9"]
}
// === ROW 2: LEFT COLUMN - Pet Owners ===
subgraph cluster_mascotas {
label="Pet Owners & Pets"
style="rounded,filled"
fillcolor="#E3F2FD"
color="#1565C0"
petowner [label="PetOwner", fillcolor="#BBDEFB"]
pet [label="Pet", fillcolor="#BBDEFB"]
petvaccine [label="PetVaccine", fillcolor="#90CAF9"]
petstudy [label="PetStudy", fillcolor="#90CAF9"]
}
// === ROW 2: CENTER COLUMN - Veterinarians ===
subgraph cluster_vets {
label="Veterinarians"
style="rounded,filled"
fillcolor="#FFF3E0"
color="#E65100"
veterinarian [label="Veterinarian", fillcolor="#FFE0B2"]
availability [label="Availability", fillcolor="#FFCC80"]
unavailability [label="Unavailability", fillcolor="#FFCC80"]
vet_specialty [label="Vet-Specialty", shape=diamond, fillcolor="#FFB74D"]
vet_neighborhood [label="Vet-Neighborhood", shape=diamond, fillcolor="#FFB74D"]
}
// === ROW 2: RIGHT COLUMN - Services ===
subgraph cluster_productos {
label="Services & Pricing"
style="rounded,filled"
fillcolor="#F3E5F5"
color="#7B1FA2"
grupo [label="Group", fillcolor="#E1BEE7"]
category [label="Category", fillcolor="#E1BEE7"]
service [label="Service", fillcolor="#CE93D8"]
prices [label="Prices", fillcolor="#BA68C8"]
discounts [label="Discounts", fillcolor="#BA68C8"]
}
// === ROW 3: LEFT - Cart ===
subgraph cluster_cart {
label="Cart & Checkout"
style="rounded,filled"
fillcolor="#E0F7FA"
color="#00838F"
cart [label="Cart", fillcolor="#B2EBF2"]
cartitem [label="CartItem", fillcolor="#80DEEA"]
cartresumeitem [label="CartResumeItem", fillcolor="#80DEEA"]
cartpetreason [label="CartPetReason", fillcolor="#80DEEA"]
}
// === ROW 3: CENTER - Requests ===
subgraph cluster_solicitudes {
label="Service Requests"
style="rounded,filled"
fillcolor="#FFEBEE"
color="#C62828"
servicerequest [label="ServiceRequest", fillcolor="#FFCDD2"]
statehistory [label="StateHistory", fillcolor="#EF9A9A"]
vetasked [label="VeterinarianAsked", fillcolor="#EF9A9A"]
vetreminder [label="VetReminder", fillcolor="#E57373"]
payreminder [label="PayReminder", fillcolor="#E57373"]
}
// === ROW 3: RIGHT - Visits ===
subgraph cluster_visits {
label="Veterinary Visits"
style="rounded,filled"
fillcolor="#FFFDE7"
color="#F9A825"
vetvisit [label="VetVisit", fillcolor="#FFF9C4"]
vetvisitreport [label="VetVisitReport", fillcolor="#FFF59D"]
vetvisitfollowup [label="FollowUp", fillcolor="#FFF176"]
vetvisitpetreason [label="VisitPetReason", fillcolor="#FFF176"]
visit_pets [label="Visit-Pets", shape=diamond, fillcolor="#FFEE58"]
receipt [label="Receipt\n(AFIP)", fillcolor="#F8BBD9"]
}
// === ROW 4: REFERENCE DATA (bottom, full width) ===
subgraph cluster_reference {
label="Reference Data"
style="rounded,filled"
fillcolor="#ECEFF1"
color="#455A64"
subgraph {
rank=same
specialty [label="Specialty", fillcolor="#CFD8DC"]
neighborhood [label="Neighborhood", fillcolor="#CFD8DC"]
province [label="Province", fillcolor="#CFD8DC"]
locality [label="Locality", fillcolor="#CFD8DC"]
petbreed [label="PetBreed", fillcolor="#CFD8DC"]
vaccine [label="Vaccine", fillcolor="#CFD8DC"]
}
subgraph {
rank=same
study [label="Study", fillcolor="#CFD8DC"]
campaign [label="Campaign", fillcolor="#CFD8DC"]
tag [label="Tag", fillcolor="#CFD8DC"]
medication [label="Medication", fillcolor="#CFD8DC"]
turnfeegroup [label="TurnFeeGroup", fillcolor="#CFD8DC"]
}
specialty -> study [style=invis]
province -> locality
}
// === FORCE COLUMN ALIGNMENT WITH INVISIBLE EDGES ===
// Column 1: Pets -> Cart
petowner -> cart [style=invis, weight=10]
// Column 2: Vets -> Requests
veterinarian -> servicerequest [style=invis, weight=10]
// Column 3: Services -> Visits
service -> vetvisit [style=invis, weight=10]
// Force Reference Data to bottom
cart -> specialty [style=invis, weight=10]
servicerequest -> campaign [style=invis, weight=10]
vetvisit -> turnfeegroup [style=invis, weight=10]
// === INTERNAL CLUSTER EDGES ===
petowner -> pet [label="1:N"]
pet -> petvaccine [label="1:N"]
pet -> petstudy [label="1:N"]
veterinarian -> availability
veterinarian -> unavailability
veterinarian -> vet_specialty
veterinarian -> vet_neighborhood
grupo -> category [label="1:N"]
category -> service [label="1:N"]
service -> prices [label="1:N"]
service -> discounts [label="1:N"]
cart -> cartitem [label="1:N"]
cart -> cartresumeitem
cart -> cartpetreason
servicerequest -> statehistory [label="1:N"]
servicerequest -> vetasked [label="1:N"]
servicerequest -> payreminder
vetasked -> vetreminder
vetvisit -> visit_pets
vetvisit -> vetvisitreport [label="1:N"]
vetvisit -> vetvisitpetreason
vetvisitreport -> vetvisitfollowup
vetvisit -> receipt [style=dashed]
// === CROSS-CLUSTER RELATIONSHIPS ===
auth_user -> petowner [label="1:1 opt", style=dashed]
auth_user -> veterinarian [label="1:1"]
petowner -> cart [label="1:N"]
petowner -> servicerequest [label="1:N"]
servicerequest -> cart [label="1:1", constraint=false]
vetasked -> veterinarian [constraint=false]
vetvisit -> servicerequest [style=dashed, constraint=false]
// Reference links
petvaccine -> vaccine [constraint=false]
petstudy -> study [constraint=false]
vet_specialty -> specialty [constraint=false]
vet_neighborhood -> neighborhood [constraint=false]
cartitem -> service [constraint=false]
}

View File

@@ -0,0 +1,540 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Generated by graphviz version 14.0.5 (0)
-->
<!-- Title: DataModelSimple Pages: 1 -->
<svg width="2433pt" height="780pt"
viewBox="0.00 0.00 2433.00 780.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 776.4)">
<title>DataModelSimple</title>
<polygon fill="white" stroke="none" points="-4,4 -4,-776.4 2429,-776.4 2429,4 -4,4"/>
<text xml:space="preserve" text-anchor="middle" x="1212.5" y="-753.2" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="16.00">AMAR Mascotas &#45; Data Model Overview</text>
<g id="clust1" class="cluster">
<title>cluster_auth</title>
<path fill="#e8f5e9" stroke="#2e7d32" d="M890,-658C890,-658 975,-658 975,-658 981,-658 987,-664 987,-670 987,-670 987,-725.2 987,-725.2 987,-731.2 981,-737.2 975,-737.2 975,-737.2 890,-737.2 890,-737.2 884,-737.2 878,-731.2 878,-725.2 878,-725.2 878,-670 878,-670 878,-664 884,-658 890,-658"/>
<text xml:space="preserve" text-anchor="middle" x="932.5" y="-718" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="16.00">Users &amp; Auth</text>
</g>
<g id="clust2" class="cluster">
<title>cluster_mascotas</title>
<path fill="#e3f2fd" stroke="#1565c0" d="M1662,-262.6C1662,-262.6 1799,-262.6 1799,-262.6 1805,-262.6 1811,-268.6 1811,-274.6 1811,-274.6 1811,-593.4 1811,-593.4 1811,-599.4 1805,-605.4 1799,-605.4 1799,-605.4 1662,-605.4 1662,-605.4 1656,-605.4 1650,-599.4 1650,-593.4 1650,-593.4 1650,-274.6 1650,-274.6 1650,-268.6 1656,-262.6 1662,-262.6"/>
<text xml:space="preserve" text-anchor="middle" x="1730.5" y="-586.2" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="16.00">Pet Owners &amp; Pets</text>
</g>
<g id="clust3" class="cluster">
<title>cluster_vets</title>
<path fill="#fff3e0" stroke="#e65100" d="M20,-262.6C20,-262.6 509,-262.6 509,-262.6 515,-262.6 521,-268.6 521,-274.6 521,-274.6 521,-461.6 521,-461.6 521,-467.6 515,-473.6 509,-473.6 509,-473.6 20,-473.6 20,-473.6 14,-473.6 8,-467.6 8,-461.6 8,-461.6 8,-274.6 8,-274.6 8,-268.6 14,-262.6 20,-262.6"/>
<text xml:space="preserve" text-anchor="middle" x="264.5" y="-454.4" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="16.00">Veterinarians</text>
</g>
<g id="clust4" class="cluster">
<title>cluster_productos</title>
<path fill="#f3e5f5" stroke="#7b1fa2" d="M2277,-262.6C2277,-262.6 2405,-262.6 2405,-262.6 2411,-262.6 2417,-268.6 2417,-274.6 2417,-274.6 2417,-725.2 2417,-725.2 2417,-731.2 2411,-737.2 2405,-737.2 2405,-737.2 2277,-737.2 2277,-737.2 2271,-737.2 2265,-731.2 2265,-725.2 2265,-725.2 2265,-274.6 2265,-274.6 2265,-268.6 2271,-262.6 2277,-262.6"/>
<text xml:space="preserve" text-anchor="middle" x="2341" y="-718" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="16.00">Services &amp; Pricing</text>
</g>
<g id="clust5" class="cluster">
<title>cluster_cart</title>
<path fill="#e0f7fa" stroke="#00838f" d="M853,-262.6C853,-262.6 1119,-262.6 1119,-262.6 1125,-262.6 1131,-268.6 1131,-274.6 1131,-274.6 1131,-461.6 1131,-461.6 1131,-467.6 1125,-473.6 1119,-473.6 1119,-473.6 853,-473.6 853,-473.6 847,-473.6 841,-467.6 841,-461.6 841,-461.6 841,-274.6 841,-274.6 841,-268.6 847,-262.6 853,-262.6"/>
<text xml:space="preserve" text-anchor="middle" x="986" y="-454.4" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="16.00">Cart &amp; Checkout</text>
</g>
<g id="clust6" class="cluster">
<title>cluster_solicitudes</title>
<path fill="#ffebee" stroke="#c62828" d="M541,-8C541,-8 821,-8 821,-8 827,-8 833,-14 833,-20 833,-20 833,-329.8 833,-329.8 833,-335.8 827,-341.8 821,-341.8 821,-341.8 541,-341.8 541,-341.8 535,-341.8 529,-335.8 529,-329.8 529,-329.8 529,-20 529,-20 529,-14 535,-8 541,-8"/>
<text xml:space="preserve" text-anchor="middle" x="681" y="-322.6" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="16.00">Service Requests</text>
</g>
<g id="clust7" class="cluster">
<title>cluster_visits</title>
<path fill="#fffde7" stroke="#f9a825" d="M1869,-8C1869,-8 2245,-8 2245,-8 2251,-8 2257,-14 2257,-20 2257,-20 2257,-329.8 2257,-329.8 2257,-335.8 2251,-341.8 2245,-341.8 2245,-341.8 1869,-341.8 1869,-341.8 1863,-341.8 1857,-335.8 1857,-329.8 1857,-329.8 1857,-20 1857,-20 1857,-14 1863,-8 1869,-8"/>
<text xml:space="preserve" text-anchor="middle" x="2057" y="-322.6" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="16.00">Veterinary Visits</text>
</g>
<g id="clust8" class="cluster">
<title>cluster_reference</title>
<path fill="#eceff1" stroke="#455a64" d="M1151,-131C1151,-131 1630,-131 1630,-131 1636,-131 1642,-137 1642,-143 1642,-143 1642,-329.8 1642,-329.8 1642,-335.8 1636,-341.8 1630,-341.8 1630,-341.8 1151,-341.8 1151,-341.8 1145,-341.8 1139,-335.8 1139,-329.8 1139,-329.8 1139,-143 1139,-143 1139,-137 1145,-131 1151,-131"/>
<text xml:space="preserve" text-anchor="middle" x="1390.5" y="-322.6" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="16.00">Reference Data</text>
</g>
<!-- auth_user -->
<g id="node1" class="node">
<title>auth_user</title>
<path fill="#c8e6c9" stroke="black" d="M949.68,-702C949.68,-702 914.32,-702 914.32,-702 908.32,-702 902.32,-696 902.32,-690 902.32,-690 902.32,-678 902.32,-678 902.32,-672 908.32,-666 914.32,-666 914.32,-666 949.68,-666 949.68,-666 955.68,-666 961.68,-672 961.68,-678 961.68,-678 961.68,-690 961.68,-690 961.68,-696 955.68,-702 949.68,-702"/>
<text xml:space="preserve" text-anchor="middle" x="932" y="-681" font-family="Helvetica,sans-Serif" font-size="10.00">auth.User</text>
</g>
<!-- petowner -->
<g id="node2" class="node">
<title>petowner</title>
<path fill="#bbdefb" stroke="black" d="M1706.23,-570.2C1706.23,-570.2 1669.77,-570.2 1669.77,-570.2 1663.77,-570.2 1657.77,-564.2 1657.77,-558.2 1657.77,-558.2 1657.77,-546.2 1657.77,-546.2 1657.77,-540.2 1663.77,-534.2 1669.77,-534.2 1669.77,-534.2 1706.23,-534.2 1706.23,-534.2 1712.23,-534.2 1718.23,-540.2 1718.23,-546.2 1718.23,-546.2 1718.23,-558.2 1718.23,-558.2 1718.23,-564.2 1712.23,-570.2 1706.23,-570.2"/>
<text xml:space="preserve" text-anchor="middle" x="1688" y="-549.2" font-family="Helvetica,sans-Serif" font-size="10.00">PetOwner</text>
</g>
<!-- auth_user&#45;&gt;petowner -->
<g id="edge32" class="edge">
<title>auth_user&#45;&gt;petowner</title>
<path fill="none" stroke="black" stroke-dasharray="5,2" d="M943.44,-665.62C943.44,-631.77 943.44,-563 943.44,-563 943.44,-563 1645.91,-563 1645.91,-563"/>
<polygon fill="black" stroke="black" points="1645.91,-566.5 1655.91,-563 1645.91,-559.5 1645.91,-566.5"/>
<text xml:space="preserve" text-anchor="middle" x="1213.23" y="-615.8" font-family="Helvetica,sans-Serif" font-size="8.00">1:1 opt</text>
</g>
<!-- veterinarian -->
<g id="node6" class="node">
<title>veterinarian</title>
<path fill="#ffe0b2" stroke="black" d="M500.96,-438.4C500.96,-438.4 455.04,-438.4 455.04,-438.4 449.04,-438.4 443.04,-432.4 443.04,-426.4 443.04,-426.4 443.04,-414.4 443.04,-414.4 443.04,-408.4 449.04,-402.4 455.04,-402.4 455.04,-402.4 500.96,-402.4 500.96,-402.4 506.96,-402.4 512.96,-408.4 512.96,-414.4 512.96,-414.4 512.96,-426.4 512.96,-426.4 512.96,-432.4 506.96,-438.4 500.96,-438.4"/>
<text xml:space="preserve" text-anchor="middle" x="478" y="-417.4" font-family="Helvetica,sans-Serif" font-size="10.00">Veterinarian</text>
</g>
<!-- auth_user&#45;&gt;veterinarian -->
<g id="edge33" class="edge">
<title>auth_user&#45;&gt;veterinarian</title>
<path fill="none" stroke="black" d="M914.19,-665.54C914.19,-607.58 914.19,-433 914.19,-433 914.19,-433 524.84,-433 524.84,-433"/>
<polygon fill="black" stroke="black" points="524.84,-429.5 514.84,-433 524.84,-436.5 524.84,-429.5"/>
<text xml:space="preserve" text-anchor="middle" x="667.56" y="-549.8" font-family="Helvetica,sans-Serif" font-size="8.00">1:1</text>
</g>
<!-- pet -->
<g id="node3" class="node">
<title>pet</title>
<path fill="#bbdefb" stroke="black" d="M1706,-438.4C1706,-438.4 1676,-438.4 1676,-438.4 1670,-438.4 1664,-432.4 1664,-426.4 1664,-426.4 1664,-414.4 1664,-414.4 1664,-408.4 1670,-402.4 1676,-402.4 1676,-402.4 1706,-402.4 1706,-402.4 1712,-402.4 1718,-408.4 1718,-414.4 1718,-414.4 1718,-426.4 1718,-426.4 1718,-432.4 1712,-438.4 1706,-438.4"/>
<text xml:space="preserve" text-anchor="middle" x="1691" y="-417.4" font-family="Helvetica,sans-Serif" font-size="10.00">Pet</text>
</g>
<!-- petowner&#45;&gt;pet -->
<g id="edge9" class="edge">
<title>petowner&#45;&gt;pet</title>
<path fill="none" stroke="black" d="M1691,-533.87C1691,-533.87 1691,-450.08 1691,-450.08"/>
<polygon fill="black" stroke="black" points="1694.5,-450.08 1691,-440.08 1687.5,-450.08 1694.5,-450.08"/>
<text xml:space="preserve" text-anchor="middle" x="1696.22" y="-484" font-family="Helvetica,sans-Serif" font-size="8.00">1:N</text>
</g>
<!-- cart -->
<g id="node16" class="node">
<title>cart</title>
<path fill="#b2ebf2" stroke="black" d="M1111,-438.4C1111,-438.4 1081,-438.4 1081,-438.4 1075,-438.4 1069,-432.4 1069,-426.4 1069,-426.4 1069,-414.4 1069,-414.4 1069,-408.4 1075,-402.4 1081,-402.4 1081,-402.4 1111,-402.4 1111,-402.4 1117,-402.4 1123,-408.4 1123,-414.4 1123,-414.4 1123,-426.4 1123,-426.4 1123,-432.4 1117,-438.4 1111,-438.4"/>
<text xml:space="preserve" text-anchor="middle" x="1096" y="-417.4" font-family="Helvetica,sans-Serif" font-size="10.00">Cart</text>
</g>
<!-- petowner&#45;&gt;cart -->
<!-- petowner&#45;&gt;cart -->
<g id="edge34" class="edge">
<title>petowner&#45;&gt;cart</title>
<path fill="none" stroke="black" d="M1657.48,-541C1537.52,-541 1105,-541 1105,-541 1105,-541 1105,-450.23 1105,-450.23"/>
<polygon fill="black" stroke="black" points="1108.5,-450.23 1105,-440.23 1101.5,-450.23 1108.5,-450.23"/>
<text xml:space="preserve" text-anchor="middle" x="1233.22" y="-484" font-family="Helvetica,sans-Serif" font-size="8.00">1:N</text>
</g>
<!-- servicerequest -->
<g id="node20" class="node">
<title>servicerequest</title>
<path fill="#ffcdd2" stroke="black" d="M763.3,-306.6C763.3,-306.6 700.7,-306.6 700.7,-306.6 694.7,-306.6 688.7,-300.6 688.7,-294.6 688.7,-294.6 688.7,-282.6 688.7,-282.6 688.7,-276.6 694.7,-270.6 700.7,-270.6 700.7,-270.6 763.3,-270.6 763.3,-270.6 769.3,-270.6 775.3,-276.6 775.3,-282.6 775.3,-282.6 775.3,-294.6 775.3,-294.6 775.3,-300.6 769.3,-306.6 763.3,-306.6"/>
<text xml:space="preserve" text-anchor="middle" x="732" y="-285.6" font-family="Helvetica,sans-Serif" font-size="10.00">ServiceRequest</text>
</g>
<!-- petowner&#45;&gt;servicerequest -->
<g id="edge35" class="edge">
<title>petowner&#45;&gt;servicerequest</title>
<path fill="none" stroke="black" d="M1657.27,-556C1493.64,-556 732,-556 732,-556 732,-556 732,-318.49 732,-318.49"/>
<polygon fill="black" stroke="black" points="735.5,-318.49 732,-308.49 728.5,-318.49 735.5,-318.49"/>
<text xml:space="preserve" text-anchor="middle" x="782.22" y="-418" font-family="Helvetica,sans-Serif" font-size="8.00">1:N</text>
</g>
<!-- petvaccine -->
<g id="node4" class="node">
<title>petvaccine</title>
<path fill="#90caf9" stroke="black" d="M1712.29,-306.6C1712.29,-306.6 1669.71,-306.6 1669.71,-306.6 1663.71,-306.6 1657.71,-300.6 1657.71,-294.6 1657.71,-294.6 1657.71,-282.6 1657.71,-282.6 1657.71,-276.6 1663.71,-270.6 1669.71,-270.6 1669.71,-270.6 1712.29,-270.6 1712.29,-270.6 1718.29,-270.6 1724.29,-276.6 1724.29,-282.6 1724.29,-282.6 1724.29,-294.6 1724.29,-294.6 1724.29,-300.6 1718.29,-306.6 1712.29,-306.6"/>
<text xml:space="preserve" text-anchor="middle" x="1691" y="-285.6" font-family="Helvetica,sans-Serif" font-size="10.00">PetVaccine</text>
</g>
<!-- pet&#45;&gt;petvaccine -->
<g id="edge10" class="edge">
<title>pet&#45;&gt;petvaccine</title>
<path fill="none" stroke="black" d="M1691,-402.07C1691,-402.07 1691,-318.28 1691,-318.28"/>
<polygon fill="black" stroke="black" points="1694.5,-318.28 1691,-308.28 1687.5,-318.28 1694.5,-318.28"/>
<text xml:space="preserve" text-anchor="middle" x="1697.22" y="-352.2" font-family="Helvetica,sans-Serif" font-size="8.00">1:N</text>
</g>
<!-- petstudy -->
<g id="node5" class="node">
<title>petstudy</title>
<path fill="#90caf9" stroke="black" d="M1791.29,-306.6C1791.29,-306.6 1758.71,-306.6 1758.71,-306.6 1752.71,-306.6 1746.71,-300.6 1746.71,-294.6 1746.71,-294.6 1746.71,-282.6 1746.71,-282.6 1746.71,-276.6 1752.71,-270.6 1758.71,-270.6 1758.71,-270.6 1791.29,-270.6 1791.29,-270.6 1797.29,-270.6 1803.29,-276.6 1803.29,-282.6 1803.29,-282.6 1803.29,-294.6 1803.29,-294.6 1803.29,-300.6 1797.29,-306.6 1791.29,-306.6"/>
<text xml:space="preserve" text-anchor="middle" x="1775" y="-285.6" font-family="Helvetica,sans-Serif" font-size="10.00">PetStudy</text>
</g>
<!-- pet&#45;&gt;petstudy -->
<g id="edge11" class="edge">
<title>pet&#45;&gt;petstudy</title>
<path fill="none" stroke="black" d="M1718.04,-414C1742.6,-414 1775,-414 1775,-414 1775,-414 1775,-318.35 1775,-318.35"/>
<polygon fill="black" stroke="black" points="1778.5,-318.35 1775,-308.35 1771.5,-318.35 1778.5,-318.35"/>
<text xml:space="preserve" text-anchor="middle" x="1758.22" y="-352.2" font-family="Helvetica,sans-Serif" font-size="8.00">1:N</text>
</g>
<!-- vaccine -->
<g id="node36" class="node">
<title>vaccine</title>
<path fill="#cfd8dc" stroke="black" d="M1189,-306.6C1189,-306.6 1159,-306.6 1159,-306.6 1153,-306.6 1147,-300.6 1147,-294.6 1147,-294.6 1147,-282.6 1147,-282.6 1147,-276.6 1153,-270.6 1159,-270.6 1159,-270.6 1189,-270.6 1189,-270.6 1195,-270.6 1201,-276.6 1201,-282.6 1201,-282.6 1201,-294.6 1201,-294.6 1201,-300.6 1195,-306.6 1189,-306.6"/>
<text xml:space="preserve" text-anchor="middle" x="1174" y="-285.6" font-family="Helvetica,sans-Serif" font-size="10.00">Vaccine</text>
</g>
<!-- petvaccine&#45;&gt;vaccine -->
<g id="edge39" class="edge">
<title>petvaccine&#45;&gt;vaccine</title>
<path fill="none" stroke="black" d="M1691,-270.32C1691,-257.63 1691,-243 1691,-243 1691,-243 1174.1,-243 1174.1,-243 1174.1,-243 1174.1,-258.8 1174.1,-258.8"/>
<polygon fill="black" stroke="black" points="1170.6,-258.8 1174.1,-268.8 1177.6,-258.8 1170.6,-258.8"/>
</g>
<!-- study -->
<g id="node37" class="node">
<title>study</title>
<path fill="#cfd8dc" stroke="black" d="M1356,-175C1356,-175 1326,-175 1326,-175 1320,-175 1314,-169 1314,-163 1314,-163 1314,-151 1314,-151 1314,-145 1320,-139 1326,-139 1326,-139 1356,-139 1356,-139 1362,-139 1368,-145 1368,-151 1368,-151 1368,-163 1368,-163 1368,-169 1362,-175 1356,-175"/>
<text xml:space="preserve" text-anchor="middle" x="1341" y="-154" font-family="Helvetica,sans-Serif" font-size="10.00">Study</text>
</g>
<!-- petstudy&#45;&gt;study -->
<g id="edge40" class="edge">
<title>petstudy&#45;&gt;study</title>
<path fill="none" stroke="black" d="M1775,-270.23C1775,-244.81 1775,-202 1775,-202 1775,-202 1353.18,-202 1353.18,-202 1353.18,-202 1353.18,-186.82 1353.18,-186.82"/>
<polygon fill="black" stroke="black" points="1356.68,-186.82 1353.18,-176.82 1349.68,-186.82 1356.68,-186.82"/>
</g>
<!-- availability -->
<g id="node7" class="node">
<title>availability</title>
<path fill="#ffcc80" stroke="black" d="M67.62,-306.6C67.62,-306.6 28.38,-306.6 28.38,-306.6 22.38,-306.6 16.38,-300.6 16.38,-294.6 16.38,-294.6 16.38,-282.6 16.38,-282.6 16.38,-276.6 22.38,-270.6 28.38,-270.6 28.38,-270.6 67.62,-270.6 67.62,-270.6 73.62,-270.6 79.62,-276.6 79.62,-282.6 79.62,-282.6 79.62,-294.6 79.62,-294.6 79.62,-300.6 73.62,-306.6 67.62,-306.6"/>
<text xml:space="preserve" text-anchor="middle" x="48" y="-285.6" font-family="Helvetica,sans-Serif" font-size="10.00">Availability</text>
</g>
<!-- veterinarian&#45;&gt;availability -->
<g id="edge12" class="edge">
<title>veterinarian&#45;&gt;availability</title>
<path fill="none" stroke="black" d="M442.81,-429C340.11,-429 48,-429 48,-429 48,-429 48,-318.47 48,-318.47"/>
<polygon fill="black" stroke="black" points="51.5,-318.47 48,-308.47 44.5,-318.47 51.5,-318.47"/>
</g>
<!-- unavailability -->
<g id="node8" class="node">
<title>unavailability</title>
<path fill="#ffcc80" stroke="black" d="M164.46,-306.6C164.46,-306.6 113.54,-306.6 113.54,-306.6 107.54,-306.6 101.54,-300.6 101.54,-294.6 101.54,-294.6 101.54,-282.6 101.54,-282.6 101.54,-276.6 107.54,-270.6 113.54,-270.6 113.54,-270.6 164.46,-270.6 164.46,-270.6 170.46,-270.6 176.46,-276.6 176.46,-282.6 176.46,-282.6 176.46,-294.6 176.46,-294.6 176.46,-300.6 170.46,-306.6 164.46,-306.6"/>
<text xml:space="preserve" text-anchor="middle" x="139" y="-285.6" font-family="Helvetica,sans-Serif" font-size="10.00">Unavailability</text>
</g>
<!-- veterinarian&#45;&gt;unavailability -->
<g id="edge13" class="edge">
<title>veterinarian&#45;&gt;unavailability</title>
<path fill="none" stroke="black" d="M442.68,-420C355.85,-420 139,-420 139,-420 139,-420 139,-318.38 139,-318.38"/>
<polygon fill="black" stroke="black" points="142.5,-318.38 139,-308.38 135.5,-318.38 142.5,-318.38"/>
</g>
<!-- vet_specialty -->
<g id="node9" class="node">
<title>vet_specialty</title>
<path fill="#ffb74d" stroke="black" d="M250.45,-303.34C250.45,-303.34 209.84,-291.86 209.84,-291.86 204.06,-290.23 204.06,-286.97 209.84,-285.34 209.84,-285.34 250.45,-273.86 250.45,-273.86 256.23,-272.23 267.77,-272.23 273.55,-273.86 273.55,-273.86 314.16,-285.34 314.16,-285.34 319.94,-286.97 319.94,-290.23 314.16,-291.86 314.16,-291.86 273.55,-303.34 273.55,-303.34 267.77,-304.97 256.23,-304.97 250.45,-303.34"/>
<text xml:space="preserve" text-anchor="middle" x="262" y="-285.6" font-family="Helvetica,sans-Serif" font-size="10.00">Vet&#45;Specialty</text>
</g>
<!-- veterinarian&#45;&gt;vet_specialty -->
<g id="edge14" class="edge">
<title>veterinarian&#45;&gt;vet_specialty</title>
<path fill="none" stroke="black" d="M442.82,-411C381.61,-411 262,-411 262,-411 262,-411 262,-318.44 262,-318.44"/>
<polygon fill="black" stroke="black" points="265.5,-318.44 262,-308.44 258.5,-318.44 265.5,-318.44"/>
</g>
<!-- vet_neighborhood -->
<g id="node10" class="node">
<title>vet_neighborhood</title>
<path fill="#ffb74d" stroke="black" d="M418.27,-304.05C418.27,-304.05 359.09,-291.15 359.09,-291.15 353.23,-289.88 353.23,-287.32 359.09,-286.05 359.09,-286.05 418.27,-273.15 418.27,-273.15 424.14,-271.88 435.86,-271.88 441.73,-273.15 441.73,-273.15 500.91,-286.05 500.91,-286.05 506.77,-287.32 506.77,-289.88 500.91,-291.15 500.91,-291.15 441.73,-304.05 441.73,-304.05 435.86,-305.32 424.14,-305.32 418.27,-304.05"/>
<text xml:space="preserve" text-anchor="middle" x="430" y="-285.6" font-family="Helvetica,sans-Serif" font-size="10.00">Vet&#45;Neighborhood</text>
</g>
<!-- veterinarian&#45;&gt;vet_neighborhood -->
<g id="edge15" class="edge">
<title>veterinarian&#45;&gt;vet_neighborhood</title>
<path fill="none" stroke="black" d="M466.24,-402.07C466.24,-402.07 466.24,-310.63 466.24,-310.63"/>
<polygon fill="black" stroke="black" points="469.74,-310.63 466.24,-300.63 462.74,-310.63 469.74,-310.63"/>
</g>
<!-- veterinarian&#45;&gt;servicerequest -->
<!-- specialty -->
<g id="node31" class="node">
<title>specialty</title>
<path fill="#cfd8dc" stroke="black" d="M1622.29,-306.6C1622.29,-306.6 1589.71,-306.6 1589.71,-306.6 1583.71,-306.6 1577.71,-300.6 1577.71,-294.6 1577.71,-294.6 1577.71,-282.6 1577.71,-282.6 1577.71,-276.6 1583.71,-270.6 1589.71,-270.6 1589.71,-270.6 1622.29,-270.6 1622.29,-270.6 1628.29,-270.6 1634.29,-276.6 1634.29,-282.6 1634.29,-282.6 1634.29,-294.6 1634.29,-294.6 1634.29,-300.6 1628.29,-306.6 1622.29,-306.6"/>
<text xml:space="preserve" text-anchor="middle" x="1606" y="-285.6" font-family="Helvetica,sans-Serif" font-size="10.00">Specialty</text>
</g>
<!-- vet_specialty&#45;&gt;specialty -->
<g id="edge41" class="edge">
<title>vet_specialty&#45;&gt;specialty</title>
<path fill="none" stroke="black" d="M262,-270.22C262,-263.27 262,-257 262,-257 262,-257 1591.73,-257 1591.73,-257 1591.73,-257 1591.73,-258.7 1591.73,-258.7"/>
<polygon fill="black" stroke="black" points="1588.23,-258.7 1591.73,-268.7 1595.23,-258.7 1588.23,-258.7"/>
</g>
<!-- neighborhood -->
<g id="node32" class="node">
<title>neighborhood</title>
<path fill="#cfd8dc" stroke="black" d="M1289.41,-306.6C1289.41,-306.6 1234.59,-306.6 1234.59,-306.6 1228.59,-306.6 1222.59,-300.6 1222.59,-294.6 1222.59,-294.6 1222.59,-282.6 1222.59,-282.6 1222.59,-276.6 1228.59,-270.6 1234.59,-270.6 1234.59,-270.6 1289.41,-270.6 1289.41,-270.6 1295.41,-270.6 1301.41,-276.6 1301.41,-282.6 1301.41,-282.6 1301.41,-294.6 1301.41,-294.6 1301.41,-300.6 1295.41,-306.6 1289.41,-306.6"/>
<text xml:space="preserve" text-anchor="middle" x="1262" y="-285.6" font-family="Helvetica,sans-Serif" font-size="10.00">Neighborhood</text>
</g>
<!-- vet_neighborhood&#45;&gt;neighborhood -->
<g id="edge42" class="edge">
<title>vet_neighborhood&#45;&gt;neighborhood</title>
<path fill="none" stroke="black" d="M489.44,-293.99C489.44,-307 489.44,-339 489.44,-339 489.44,-339 1262,-339 1262,-339 1262,-339 1262,-318.59 1262,-318.59"/>
<polygon fill="black" stroke="black" points="1265.5,-318.59 1262,-308.59 1258.5,-318.59 1265.5,-318.59"/>
</g>
<!-- grupo -->
<g id="node11" class="node">
<title>grupo</title>
<path fill="#e1bee7" stroke="black" d="M2316,-702C2316,-702 2286,-702 2286,-702 2280,-702 2274,-696 2274,-690 2274,-690 2274,-678 2274,-678 2274,-672 2280,-666 2286,-666 2286,-666 2316,-666 2316,-666 2322,-666 2328,-672 2328,-678 2328,-678 2328,-690 2328,-690 2328,-696 2322,-702 2316,-702"/>
<text xml:space="preserve" text-anchor="middle" x="2301" y="-681" font-family="Helvetica,sans-Serif" font-size="10.00">Group</text>
</g>
<!-- category -->
<g id="node12" class="node">
<title>category</title>
<path fill="#e1bee7" stroke="black" d="M2317.29,-570.2C2317.29,-570.2 2284.71,-570.2 2284.71,-570.2 2278.71,-570.2 2272.71,-564.2 2272.71,-558.2 2272.71,-558.2 2272.71,-546.2 2272.71,-546.2 2272.71,-540.2 2278.71,-534.2 2284.71,-534.2 2284.71,-534.2 2317.29,-534.2 2317.29,-534.2 2323.29,-534.2 2329.29,-540.2 2329.29,-546.2 2329.29,-546.2 2329.29,-558.2 2329.29,-558.2 2329.29,-564.2 2323.29,-570.2 2317.29,-570.2"/>
<text xml:space="preserve" text-anchor="middle" x="2301" y="-549.2" font-family="Helvetica,sans-Serif" font-size="10.00">Category</text>
</g>
<!-- grupo&#45;&gt;category -->
<g id="edge16" class="edge">
<title>grupo&#45;&gt;category</title>
<path fill="none" stroke="black" d="M2301,-665.67C2301,-665.67 2301,-581.88 2301,-581.88"/>
<polygon fill="black" stroke="black" points="2304.5,-581.88 2301,-571.88 2297.5,-581.88 2304.5,-581.88"/>
<text xml:space="preserve" text-anchor="middle" x="2307.22" y="-615.8" font-family="Helvetica,sans-Serif" font-size="8.00">1:N</text>
</g>
<!-- service -->
<g id="node13" class="node">
<title>service</title>
<path fill="#ce93d8" stroke="black" d="M2315,-438.4C2315,-438.4 2285,-438.4 2285,-438.4 2279,-438.4 2273,-432.4 2273,-426.4 2273,-426.4 2273,-414.4 2273,-414.4 2273,-408.4 2279,-402.4 2285,-402.4 2285,-402.4 2315,-402.4 2315,-402.4 2321,-402.4 2327,-408.4 2327,-414.4 2327,-414.4 2327,-426.4 2327,-426.4 2327,-432.4 2321,-438.4 2315,-438.4"/>
<text xml:space="preserve" text-anchor="middle" x="2300" y="-417.4" font-family="Helvetica,sans-Serif" font-size="10.00">Service</text>
</g>
<!-- category&#45;&gt;service -->
<g id="edge17" class="edge">
<title>category&#45;&gt;service</title>
<path fill="none" stroke="black" d="M2300,-533.87C2300,-533.87 2300,-450.08 2300,-450.08"/>
<polygon fill="black" stroke="black" points="2303.5,-450.08 2300,-440.08 2296.5,-450.08 2303.5,-450.08"/>
<text xml:space="preserve" text-anchor="middle" x="2306.22" y="-484" font-family="Helvetica,sans-Serif" font-size="8.00">1:N</text>
</g>
<!-- prices -->
<g id="node14" class="node">
<title>prices</title>
<path fill="#ba68c8" stroke="black" d="M2315,-306.6C2315,-306.6 2285,-306.6 2285,-306.6 2279,-306.6 2273,-300.6 2273,-294.6 2273,-294.6 2273,-282.6 2273,-282.6 2273,-276.6 2279,-270.6 2285,-270.6 2285,-270.6 2315,-270.6 2315,-270.6 2321,-270.6 2327,-276.6 2327,-282.6 2327,-282.6 2327,-294.6 2327,-294.6 2327,-300.6 2321,-306.6 2315,-306.6"/>
<text xml:space="preserve" text-anchor="middle" x="2300" y="-285.6" font-family="Helvetica,sans-Serif" font-size="10.00">Prices</text>
</g>
<!-- service&#45;&gt;prices -->
<g id="edge18" class="edge">
<title>service&#45;&gt;prices</title>
<path fill="none" stroke="black" d="M2309,-402.07C2309,-402.07 2309,-318.28 2309,-318.28"/>
<polygon fill="black" stroke="black" points="2312.5,-318.28 2309,-308.28 2305.5,-318.28 2312.5,-318.28"/>
<text xml:space="preserve" text-anchor="middle" x="2306.22" y="-352.2" font-family="Helvetica,sans-Serif" font-size="8.00">1:N</text>
</g>
<!-- discounts -->
<g id="node15" class="node">
<title>discounts</title>
<path fill="#ba68c8" stroke="black" d="M2396.95,-306.6C2396.95,-306.6 2361.05,-306.6 2361.05,-306.6 2355.05,-306.6 2349.05,-300.6 2349.05,-294.6 2349.05,-294.6 2349.05,-282.6 2349.05,-282.6 2349.05,-276.6 2355.05,-270.6 2361.05,-270.6 2361.05,-270.6 2396.95,-270.6 2396.95,-270.6 2402.95,-270.6 2408.95,-276.6 2408.95,-282.6 2408.95,-282.6 2408.95,-294.6 2408.95,-294.6 2408.95,-300.6 2402.95,-306.6 2396.95,-306.6"/>
<text xml:space="preserve" text-anchor="middle" x="2379" y="-285.6" font-family="Helvetica,sans-Serif" font-size="10.00">Discounts</text>
</g>
<!-- service&#45;&gt;discounts -->
<g id="edge19" class="edge">
<title>service&#45;&gt;discounts</title>
<path fill="none" stroke="black" d="M2327.19,-420C2350.04,-420 2379,-420 2379,-420 2379,-420 2379,-318.38 2379,-318.38"/>
<polygon fill="black" stroke="black" points="2382.5,-318.38 2379,-308.38 2375.5,-318.38 2382.5,-318.38"/>
<text xml:space="preserve" text-anchor="middle" x="2365.22" y="-352.2" font-family="Helvetica,sans-Serif" font-size="8.00">1:N</text>
</g>
<!-- vetvisit -->
<g id="node25" class="node">
<title>vetvisit</title>
<path fill="#fff9c4" stroke="black" d="M2081,-306.6C2081,-306.6 2051,-306.6 2051,-306.6 2045,-306.6 2039,-300.6 2039,-294.6 2039,-294.6 2039,-282.6 2039,-282.6 2039,-276.6 2045,-270.6 2051,-270.6 2051,-270.6 2081,-270.6 2081,-270.6 2087,-270.6 2093,-276.6 2093,-282.6 2093,-282.6 2093,-294.6 2093,-294.6 2093,-300.6 2087,-306.6 2081,-306.6"/>
<text xml:space="preserve" text-anchor="middle" x="2066" y="-285.6" font-family="Helvetica,sans-Serif" font-size="10.00">VetVisit</text>
</g>
<!-- service&#45;&gt;vetvisit -->
<!-- cartitem -->
<g id="node17" class="node">
<title>cartitem</title>
<path fill="#80deea" stroke="black" d="M891.17,-306.6C891.17,-306.6 860.83,-306.6 860.83,-306.6 854.83,-306.6 848.83,-300.6 848.83,-294.6 848.83,-294.6 848.83,-282.6 848.83,-282.6 848.83,-276.6 854.83,-270.6 860.83,-270.6 860.83,-270.6 891.17,-270.6 891.17,-270.6 897.17,-270.6 903.17,-276.6 903.17,-282.6 903.17,-282.6 903.17,-294.6 903.17,-294.6 903.17,-300.6 897.17,-306.6 891.17,-306.6"/>
<text xml:space="preserve" text-anchor="middle" x="876" y="-285.6" font-family="Helvetica,sans-Serif" font-size="10.00">CartItem</text>
</g>
<!-- cart&#45;&gt;cartitem -->
<g id="edge20" class="edge">
<title>cart&#45;&gt;cartitem</title>
<path fill="none" stroke="black" d="M1068.78,-413C1011.66,-413 884.49,-413 884.49,-413 884.49,-413 884.49,-318.47 884.49,-318.47"/>
<polygon fill="black" stroke="black" points="887.99,-318.47 884.49,-308.47 880.99,-318.47 887.99,-318.47"/>
<text xml:space="preserve" text-anchor="middle" x="924.22" y="-352.2" font-family="Helvetica,sans-Serif" font-size="8.00">1:N</text>
</g>
<!-- cartresumeitem -->
<g id="node18" class="node">
<title>cartresumeitem</title>
<path fill="#80deea" stroke="black" d="M1004.79,-306.6C1004.79,-306.6 937.21,-306.6 937.21,-306.6 931.21,-306.6 925.21,-300.6 925.21,-294.6 925.21,-294.6 925.21,-282.6 925.21,-282.6 925.21,-276.6 931.21,-270.6 937.21,-270.6 937.21,-270.6 1004.79,-270.6 1004.79,-270.6 1010.79,-270.6 1016.79,-276.6 1016.79,-282.6 1016.79,-282.6 1016.79,-294.6 1016.79,-294.6 1016.79,-300.6 1010.79,-306.6 1004.79,-306.6"/>
<text xml:space="preserve" text-anchor="middle" x="971" y="-285.6" font-family="Helvetica,sans-Serif" font-size="10.00">CartResumeItem</text>
</g>
<!-- cart&#45;&gt;cartresumeitem -->
<g id="edge21" class="edge">
<title>cart&#45;&gt;cartresumeitem</title>
<path fill="none" stroke="black" d="M1068.5,-408C1036.87,-408 989.23,-408 989.23,-408 989.23,-408 989.23,-318.51 989.23,-318.51"/>
<polygon fill="black" stroke="black" points="992.73,-318.51 989.23,-308.51 985.73,-318.51 992.73,-318.51"/>
</g>
<!-- cartpetreason -->
<g id="node19" class="node">
<title>cartpetreason</title>
<path fill="#80deea" stroke="black" d="M1111.18,-306.6C1111.18,-306.6 1050.82,-306.6 1050.82,-306.6 1044.82,-306.6 1038.82,-300.6 1038.82,-294.6 1038.82,-294.6 1038.82,-282.6 1038.82,-282.6 1038.82,-276.6 1044.82,-270.6 1050.82,-270.6 1050.82,-270.6 1111.18,-270.6 1111.18,-270.6 1117.18,-270.6 1123.18,-276.6 1123.18,-282.6 1123.18,-282.6 1123.18,-294.6 1123.18,-294.6 1123.18,-300.6 1117.18,-306.6 1111.18,-306.6"/>
<text xml:space="preserve" text-anchor="middle" x="1081" y="-285.6" font-family="Helvetica,sans-Serif" font-size="10.00">CartPetReason</text>
</g>
<!-- cart&#45;&gt;cartpetreason -->
<g id="edge22" class="edge">
<title>cart&#45;&gt;cartpetreason</title>
<path fill="none" stroke="black" d="M1096,-402.07C1096,-402.07 1096,-318.28 1096,-318.28"/>
<polygon fill="black" stroke="black" points="1099.5,-318.28 1096,-308.28 1092.5,-318.28 1099.5,-318.28"/>
</g>
<!-- cart&#45;&gt;specialty -->
<!-- cartitem&#45;&gt;service -->
<g id="edge43" class="edge">
<title>cartitem&#45;&gt;service</title>
<path fill="none" stroke="black" d="M866.66,-307.07C866.66,-331.09 866.66,-370 866.66,-370 866.66,-370 2291,-370 2291,-370 2291,-370 2291,-390.41 2291,-390.41"/>
<polygon fill="black" stroke="black" points="2287.5,-390.41 2291,-400.41 2294.5,-390.41 2287.5,-390.41"/>
</g>
<!-- servicerequest&#45;&gt;cart -->
<g id="edge36" class="edge">
<title>servicerequest&#45;&gt;cart</title>
<path fill="none" stroke="black" d="M753.65,-306.82C753.65,-344.61 753.65,-428 753.65,-428 753.65,-428 1057.05,-428 1057.05,-428"/>
<polygon fill="black" stroke="black" points="1057.05,-431.5 1067.05,-428 1057.05,-424.5 1057.05,-431.5"/>
<text xml:space="preserve" text-anchor="middle" x="1349.56" y="-352.2" font-family="Helvetica,sans-Serif" font-size="8.00">1:1</text>
</g>
<!-- statehistory -->
<g id="node21" class="node">
<title>statehistory</title>
<path fill="#ef9a9a" stroke="black" d="M595.23,-175C595.23,-175 548.77,-175 548.77,-175 542.77,-175 536.77,-169 536.77,-163 536.77,-163 536.77,-151 536.77,-151 536.77,-145 542.77,-139 548.77,-139 548.77,-139 595.23,-139 595.23,-139 601.23,-139 607.23,-145 607.23,-151 607.23,-151 607.23,-163 607.23,-163 607.23,-169 601.23,-175 595.23,-175"/>
<text xml:space="preserve" text-anchor="middle" x="572" y="-154" font-family="Helvetica,sans-Serif" font-size="10.00">StateHistory</text>
</g>
<!-- servicerequest&#45;&gt;statehistory -->
<g id="edge23" class="edge">
<title>servicerequest&#45;&gt;statehistory</title>
<path fill="none" stroke="black" d="M688.28,-289C640.91,-289 572,-289 572,-289 572,-289 572,-186.87 572,-186.87"/>
<polygon fill="black" stroke="black" points="575.5,-186.87 572,-176.87 568.5,-186.87 575.5,-186.87"/>
<text xml:space="preserve" text-anchor="middle" x="635.22" y="-220.4" font-family="Helvetica,sans-Serif" font-size="8.00">1:N</text>
</g>
<!-- vetasked -->
<g id="node22" class="node">
<title>vetasked</title>
<path fill="#ef9a9a" stroke="black" d="M714.86,-175C714.86,-175 641.14,-175 641.14,-175 635.14,-175 629.14,-169 629.14,-163 629.14,-163 629.14,-151 629.14,-151 629.14,-145 635.14,-139 641.14,-139 641.14,-139 714.86,-139 714.86,-139 720.86,-139 726.86,-145 726.86,-151 726.86,-151 726.86,-163 726.86,-163 726.86,-169 720.86,-175 714.86,-175"/>
<text xml:space="preserve" text-anchor="middle" x="678" y="-154" font-family="Helvetica,sans-Serif" font-size="10.00">VeterinarianAsked</text>
</g>
<!-- servicerequest&#45;&gt;vetasked -->
<g id="edge24" class="edge">
<title>servicerequest&#45;&gt;vetasked</title>
<path fill="none" stroke="black" d="M707.78,-270.3C707.78,-270.3 707.78,-186.66 707.78,-186.66"/>
<polygon fill="black" stroke="black" points="711.28,-186.66 707.78,-176.66 704.28,-186.66 711.28,-186.66"/>
<text xml:space="preserve" text-anchor="middle" x="707.22" y="-220.4" font-family="Helvetica,sans-Serif" font-size="8.00">1:N</text>
</g>
<!-- payreminder -->
<g id="node24" class="node">
<title>payreminder</title>
<path fill="#e57373" stroke="black" d="M813.29,-175C813.29,-175 760.71,-175 760.71,-175 754.71,-175 748.71,-169 748.71,-163 748.71,-163 748.71,-151 748.71,-151 748.71,-145 754.71,-139 760.71,-139 760.71,-139 813.29,-139 813.29,-139 819.29,-139 825.29,-145 825.29,-151 825.29,-151 825.29,-163 825.29,-163 825.29,-169 819.29,-175 813.29,-175"/>
<text xml:space="preserve" text-anchor="middle" x="787" y="-154" font-family="Helvetica,sans-Serif" font-size="10.00">PayReminder</text>
</g>
<!-- servicerequest&#45;&gt;payreminder -->
<g id="edge25" class="edge">
<title>servicerequest&#45;&gt;payreminder</title>
<path fill="none" stroke="black" d="M757.57,-270.3C757.57,-270.3 757.57,-186.66 757.57,-186.66"/>
<polygon fill="black" stroke="black" points="761.07,-186.66 757.57,-176.66 754.07,-186.66 761.07,-186.66"/>
</g>
<!-- campaign -->
<g id="node38" class="node">
<title>campaign</title>
<path fill="#cfd8dc" stroke="black" d="M1196.79,-175C1196.79,-175 1159.21,-175 1159.21,-175 1153.21,-175 1147.21,-169 1147.21,-163 1147.21,-163 1147.21,-151 1147.21,-151 1147.21,-145 1153.21,-139 1159.21,-139 1159.21,-139 1196.79,-139 1196.79,-139 1202.79,-139 1208.79,-145 1208.79,-151 1208.79,-151 1208.79,-163 1208.79,-163 1208.79,-169 1202.79,-175 1196.79,-175"/>
<text xml:space="preserve" text-anchor="middle" x="1178" y="-154" font-family="Helvetica,sans-Serif" font-size="10.00">Campaign</text>
</g>
<!-- servicerequest&#45;&gt;campaign -->
<!-- vetasked&#45;&gt;veterinarian -->
<g id="edge37" class="edge">
<title>vetasked&#45;&gt;veterinarian</title>
<path fill="none" stroke="black" d="M658.92,-175.35C658.92,-234.76 658.92,-418 658.92,-418 658.92,-418 524.83,-418 524.83,-418"/>
<polygon fill="black" stroke="black" points="524.83,-414.5 514.83,-418 524.83,-421.5 524.83,-414.5"/>
</g>
<!-- vetreminder -->
<g id="node23" class="node">
<title>vetreminder</title>
<path fill="#e57373" stroke="black" d="M703.18,-52C703.18,-52 652.82,-52 652.82,-52 646.82,-52 640.82,-46 640.82,-40 640.82,-40 640.82,-28 640.82,-28 640.82,-22 646.82,-16 652.82,-16 652.82,-16 703.18,-16 703.18,-16 709.18,-16 715.18,-22 715.18,-28 715.18,-28 715.18,-40 715.18,-40 715.18,-46 709.18,-52 703.18,-52"/>
<text xml:space="preserve" text-anchor="middle" x="678" y="-31" font-family="Helvetica,sans-Serif" font-size="10.00">VetReminder</text>
</g>
<!-- vetasked&#45;&gt;vetreminder -->
<g id="edge26" class="edge">
<title>vetasked&#45;&gt;vetreminder</title>
<path fill="none" stroke="black" d="M678,-138.59C678,-138.59 678,-63.65 678,-63.65"/>
<polygon fill="black" stroke="black" points="681.5,-63.65 678,-53.65 674.5,-63.65 681.5,-63.65"/>
</g>
<!-- vetvisit&#45;&gt;servicerequest -->
<g id="edge38" class="edge">
<title>vetvisit&#45;&gt;servicerequest</title>
<path fill="none" stroke="black" stroke-dasharray="5,2" d="M2042.05,-270.38C2042.05,-253.18 2042.05,-230 2042.05,-230 2042.05,-230 766.43,-230 766.43,-230 766.43,-230 766.43,-258.87 766.43,-258.87"/>
<polygon fill="black" stroke="black" points="762.93,-258.87 766.43,-268.87 769.93,-258.87 762.93,-258.87"/>
</g>
<!-- vetvisitreport -->
<g id="node26" class="node">
<title>vetvisitreport</title>
<path fill="#fff59d" stroke="black" d="M1932.96,-175C1932.96,-175 1877.04,-175 1877.04,-175 1871.04,-175 1865.04,-169 1865.04,-163 1865.04,-163 1865.04,-151 1865.04,-151 1865.04,-145 1871.04,-139 1877.04,-139 1877.04,-139 1932.96,-139 1932.96,-139 1938.96,-139 1944.96,-145 1944.96,-151 1944.96,-151 1944.96,-163 1944.96,-163 1944.96,-169 1938.96,-175 1932.96,-175"/>
<text xml:space="preserve" text-anchor="middle" x="1905" y="-154" font-family="Helvetica,sans-Serif" font-size="10.00">VetVisitReport</text>
</g>
<!-- vetvisit&#45;&gt;vetvisitreport -->
<g id="edge28" class="edge">
<title>vetvisit&#45;&gt;vetvisitreport</title>
<path fill="none" stroke="black" d="M2038.69,-289C1992.75,-289 1905,-289 1905,-289 1905,-289 1905,-186.87 1905,-186.87"/>
<polygon fill="black" stroke="black" points="1908.5,-186.87 1905,-176.87 1901.5,-186.87 1908.5,-186.87"/>
<text xml:space="preserve" text-anchor="middle" x="1981.22" y="-220.4" font-family="Helvetica,sans-Serif" font-size="8.00">1:N</text>
</g>
<!-- vetvisitpetreason -->
<g id="node28" class="node">
<title>vetvisitpetreason</title>
<path fill="#fff176" stroke="black" d="M2039.18,-175C2039.18,-175 1978.82,-175 1978.82,-175 1972.82,-175 1966.82,-169 1966.82,-163 1966.82,-163 1966.82,-151 1966.82,-151 1966.82,-145 1972.82,-139 1978.82,-139 1978.82,-139 2039.18,-139 2039.18,-139 2045.18,-139 2051.18,-145 2051.18,-151 2051.18,-151 2051.18,-163 2051.18,-163 2051.18,-169 2045.18,-175 2039.18,-175"/>
<text xml:space="preserve" text-anchor="middle" x="2009" y="-154" font-family="Helvetica,sans-Serif" font-size="10.00">VisitPetReason</text>
</g>
<!-- vetvisit&#45;&gt;vetvisitpetreason -->
<g id="edge29" class="edge">
<title>vetvisit&#45;&gt;vetvisitpetreason</title>
<path fill="none" stroke="black" d="M2048.14,-270.3C2048.14,-270.3 2048.14,-186.66 2048.14,-186.66"/>
<polygon fill="black" stroke="black" points="2051.64,-186.66 2048.14,-176.66 2044.64,-186.66 2051.64,-186.66"/>
</g>
<!-- visit_pets -->
<g id="node29" class="node">
<title>visit_pets</title>
<path fill="#ffee58" stroke="black" d="M2111.72,-170.9C2111.72,-170.9 2084.76,-161.1 2084.76,-161.1 2079.12,-159.05 2079.12,-154.95 2084.76,-152.9 2084.76,-152.9 2111.72,-143.1 2111.72,-143.1 2117.36,-141.05 2128.64,-141.05 2134.28,-143.1 2134.28,-143.1 2161.24,-152.9 2161.24,-152.9 2166.88,-154.95 2166.88,-159.05 2161.24,-161.1 2161.24,-161.1 2134.28,-170.9 2134.28,-170.9 2128.64,-172.95 2117.36,-172.95 2111.72,-170.9"/>
<text xml:space="preserve" text-anchor="middle" x="2123" y="-154" font-family="Helvetica,sans-Serif" font-size="10.00">Visit&#45;Pets</text>
</g>
<!-- vetvisit&#45;&gt;visit_pets -->
<g id="edge27" class="edge">
<title>vetvisit&#45;&gt;visit_pets</title>
<path fill="none" stroke="black" d="M2083.24,-270.3C2083.24,-270.3 2083.24,-172.32 2083.24,-172.32"/>
<polygon fill="black" stroke="black" points="2086.74,-172.32 2083.24,-162.32 2079.74,-172.32 2086.74,-172.32"/>
</g>
<!-- receipt -->
<g id="node30" class="node">
<title>receipt</title>
<path fill="#f8bbd9" stroke="black" d="M2237,-175C2237,-175 2207,-175 2207,-175 2201,-175 2195,-169 2195,-163 2195,-163 2195,-151 2195,-151 2195,-145 2201,-139 2207,-139 2207,-139 2237,-139 2237,-139 2243,-139 2249,-145 2249,-151 2249,-151 2249,-163 2249,-163 2249,-169 2243,-175 2237,-175"/>
<text xml:space="preserve" text-anchor="middle" x="2222" y="-160" font-family="Helvetica,sans-Serif" font-size="10.00">Receipt</text>
<text xml:space="preserve" text-anchor="middle" x="2222" y="-148" font-family="Helvetica,sans-Serif" font-size="10.00">(AFIP)</text>
</g>
<!-- vetvisit&#45;&gt;receipt -->
<g id="edge31" class="edge">
<title>vetvisit&#45;&gt;receipt</title>
<path fill="none" stroke="black" stroke-dasharray="5,2" d="M2093.17,-289C2137.87,-289 2222,-289 2222,-289 2222,-289 2222,-186.87 2222,-186.87"/>
<polygon fill="black" stroke="black" points="2225.5,-186.87 2222,-176.87 2218.5,-186.87 2225.5,-186.87"/>
</g>
<!-- turnfeegroup -->
<g id="node41" class="node">
<title>turnfeegroup</title>
<path fill="#cfd8dc" stroke="black" d="M1621.79,-175C1621.79,-175 1564.21,-175 1564.21,-175 1558.21,-175 1552.21,-169 1552.21,-163 1552.21,-163 1552.21,-151 1552.21,-151 1552.21,-145 1558.21,-139 1564.21,-139 1564.21,-139 1621.79,-139 1621.79,-139 1627.79,-139 1633.79,-145 1633.79,-151 1633.79,-151 1633.79,-163 1633.79,-163 1633.79,-169 1627.79,-175 1621.79,-175"/>
<text xml:space="preserve" text-anchor="middle" x="1593" y="-154" font-family="Helvetica,sans-Serif" font-size="10.00">TurnFeeGroup</text>
</g>
<!-- vetvisit&#45;&gt;turnfeegroup -->
<!-- vetvisitfollowup -->
<g id="node27" class="node">
<title>vetvisitfollowup</title>
<path fill="#fff176" stroke="black" d="M1921.84,-52C1921.84,-52 1888.16,-52 1888.16,-52 1882.16,-52 1876.16,-46 1876.16,-40 1876.16,-40 1876.16,-28 1876.16,-28 1876.16,-22 1882.16,-16 1888.16,-16 1888.16,-16 1921.84,-16 1921.84,-16 1927.84,-16 1933.84,-22 1933.84,-28 1933.84,-28 1933.84,-40 1933.84,-40 1933.84,-46 1927.84,-52 1921.84,-52"/>
<text xml:space="preserve" text-anchor="middle" x="1905" y="-31" font-family="Helvetica,sans-Serif" font-size="10.00">FollowUp</text>
</g>
<!-- vetvisitreport&#45;&gt;vetvisitfollowup -->
<g id="edge30" class="edge">
<title>vetvisitreport&#45;&gt;vetvisitfollowup</title>
<path fill="none" stroke="black" d="M1905,-138.59C1905,-138.59 1905,-63.65 1905,-63.65"/>
<polygon fill="black" stroke="black" points="1908.5,-63.65 1905,-53.65 1901.5,-63.65 1908.5,-63.65"/>
</g>
<!-- specialty&#45;&gt;study -->
<!-- province -->
<g id="node33" class="node">
<title>province</title>
<path fill="#cfd8dc" stroke="black" d="M1366.45,-306.6C1366.45,-306.6 1335.55,-306.6 1335.55,-306.6 1329.55,-306.6 1323.55,-300.6 1323.55,-294.6 1323.55,-294.6 1323.55,-282.6 1323.55,-282.6 1323.55,-276.6 1329.55,-270.6 1335.55,-270.6 1335.55,-270.6 1366.45,-270.6 1366.45,-270.6 1372.45,-270.6 1378.45,-276.6 1378.45,-282.6 1378.45,-282.6 1378.45,-294.6 1378.45,-294.6 1378.45,-300.6 1372.45,-306.6 1366.45,-306.6"/>
<text xml:space="preserve" text-anchor="middle" x="1351" y="-285.6" font-family="Helvetica,sans-Serif" font-size="10.00">Province</text>
</g>
<!-- locality -->
<g id="node34" class="node">
<title>locality</title>
<path fill="#cfd8dc" stroke="black" d="M1464,-306.6C1464,-306.6 1434,-306.6 1434,-306.6 1428,-306.6 1422,-300.6 1422,-294.6 1422,-294.6 1422,-282.6 1422,-282.6 1422,-276.6 1428,-270.6 1434,-270.6 1434,-270.6 1464,-270.6 1464,-270.6 1470,-270.6 1476,-276.6 1476,-282.6 1476,-282.6 1476,-294.6 1476,-294.6 1476,-300.6 1470,-306.6 1464,-306.6"/>
<text xml:space="preserve" text-anchor="middle" x="1449" y="-285.6" font-family="Helvetica,sans-Serif" font-size="10.00">Locality</text>
</g>
<!-- province&#45;&gt;locality -->
<g id="edge2" class="edge">
<title>province&#45;&gt;locality</title>
<path fill="none" stroke="black" d="M1378.82,-289C1378.82,-289 1410.27,-289 1410.27,-289"/>
<polygon fill="black" stroke="black" points="1410.27,-292.5 1420.27,-289 1410.27,-285.5 1410.27,-292.5"/>
</g>
<!-- petbreed -->
<g id="node35" class="node">
<title>petbreed</title>
<path fill="#cfd8dc" stroke="black" d="M1543.85,-306.6C1543.85,-306.6 1510.15,-306.6 1510.15,-306.6 1504.15,-306.6 1498.15,-300.6 1498.15,-294.6 1498.15,-294.6 1498.15,-282.6 1498.15,-282.6 1498.15,-276.6 1504.15,-270.6 1510.15,-270.6 1510.15,-270.6 1543.85,-270.6 1543.85,-270.6 1549.85,-270.6 1555.85,-276.6 1555.85,-282.6 1555.85,-282.6 1555.85,-294.6 1555.85,-294.6 1555.85,-300.6 1549.85,-306.6 1543.85,-306.6"/>
<text xml:space="preserve" text-anchor="middle" x="1527" y="-285.6" font-family="Helvetica,sans-Serif" font-size="10.00">PetBreed</text>
</g>
<!-- tag -->
<g id="node39" class="node">
<title>tag</title>
<path fill="#cfd8dc" stroke="black" d="M1432,-175C1432,-175 1402,-175 1402,-175 1396,-175 1390,-169 1390,-163 1390,-163 1390,-151 1390,-151 1390,-145 1396,-139 1402,-139 1402,-139 1432,-139 1432,-139 1438,-139 1444,-145 1444,-151 1444,-151 1444,-163 1444,-163 1444,-169 1438,-175 1432,-175"/>
<text xml:space="preserve" text-anchor="middle" x="1417" y="-154" font-family="Helvetica,sans-Serif" font-size="10.00">Tag</text>
</g>
<!-- medication -->
<g id="node40" class="node">
<title>medication</title>
<path fill="#cfd8dc" stroke="black" d="M1518.18,-175C1518.18,-175 1477.82,-175 1477.82,-175 1471.82,-175 1465.82,-169 1465.82,-163 1465.82,-163 1465.82,-151 1465.82,-151 1465.82,-145 1471.82,-139 1477.82,-139 1477.82,-139 1518.18,-139 1518.18,-139 1524.18,-139 1530.18,-145 1530.18,-151 1530.18,-151 1530.18,-163 1530.18,-163 1530.18,-169 1524.18,-175 1518.18,-175"/>
<text xml:space="preserve" text-anchor="middle" x="1498" y="-154" font-family="Helvetica,sans-Serif" font-size="10.00">Medication</text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 43 KiB

View File

@@ -0,0 +1,120 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Graph Viewer - AMAR Mascotas</title>
<link rel="stylesheet" href="styles.css">
</head>
<body class="graph-viewer">
<header class="graph-header">
<a href="index.html" class="back-link">← Index</a>
<div class="nav-controls">
<button onclick="navigate(-1)" id="btn-prev" title="Previous (←)"></button>
<span id="nav-position">1 / 4</span>
<button onclick="navigate(1)" id="btn-next" title="Next (→)"></button>
</div>
<h1 id="graph-title">Loading...</h1>
<div class="graph-controls">
<button onclick="setMode('fit')">Fit</button>
<button onclick="setMode('fit-width')">Width</button>
<button onclick="setMode('fit-height')">Height</button>
<button onclick="setMode('actual-size')">100%</button>
<button onclick="downloadSvg()">↓ SVG</button>
</div>
</header>
<div class="graph-container" id="graph-container">
<img id="graph-img" src="" alt="Graph">
</div>
<script>
const graphOrder = [
'04-data-model-simple',
'01-backend-architecture',
'02-frontend-architecture',
'03-data-model'
];
const graphs = {
'04-data-model-simple': {
title: 'Data Model Overview',
file: '04-data-model-simple.svg'
},
'01-backend-architecture': {
title: 'Backend Architecture',
file: '01-backend-architecture.svg'
},
'02-frontend-architecture': {
title: 'Frontend Architecture',
file: '02-frontend-architecture.svg'
},
'03-data-model': {
title: 'Detailed Data Model',
file: '03-data-model.svg'
}
};
const params = new URLSearchParams(window.location.search);
let graphKey = params.get('g') || '04-data-model-simple';
let currentIndex = graphOrder.indexOf(graphKey);
if (currentIndex === -1) currentIndex = 0;
function loadGraph(key) {
const graph = graphs[key];
document.getElementById('graph-title').textContent = graph.title;
document.getElementById('graph-img').src = graph.file;
document.title = graph.title + ' - AMAR Mascotas';
history.replaceState(null, '', '?g=' + key);
graphKey = key;
updateNavHints();
}
function updateNavHints() {
const idx = graphOrder.indexOf(graphKey);
const prevBtn = document.getElementById('btn-prev');
const nextBtn = document.getElementById('btn-next');
prevBtn.disabled = idx === 0;
nextBtn.disabled = idx === graphOrder.length - 1;
document.getElementById('nav-position').textContent = (idx + 1) + ' / ' + graphOrder.length;
}
function navigate(direction) {
const idx = graphOrder.indexOf(graphKey);
const newIdx = idx + direction;
if (newIdx >= 0 && newIdx < graphOrder.length) {
currentIndex = newIdx;
loadGraph(graphOrder[newIdx]);
}
}
function setMode(mode) {
const container = document.getElementById('graph-container');
container.className = 'graph-container ' + mode;
}
function downloadSvg() {
const graph = graphs[graphKey];
const link = document.createElement('a');
link.href = graph.file;
link.download = graph.file;
link.click();
}
// Keyboard navigation
document.addEventListener('keydown', (e) => {
if (e.key === 'ArrowLeft') {
navigate(-1);
} else if (e.key === 'ArrowRight') {
navigate(1);
} else if (e.key === 'Escape') {
window.location.href = 'index.html';
}
});
// Initialize
loadGraph(graphOrder[currentIndex]);
setMode('fit');
</script>
</body>
</html>

View File

@@ -0,0 +1,245 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AMAR Mascotas - Architecture & Data Models</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<header>
<h1>AMAR Mascotas</h1>
<p class="subtitle">Architecture & Data Model Documentation</p>
</header>
<main>
<!-- Graph Sections -->
<section class="graph-section" id="data-model-simple">
<div class="graph-header-row">
<h2>Data Model Overview</h2>
<a href="graph.html?g=04-data-model-simple" class="view-btn">View Full</a>
</div>
<a href="graph.html?g=04-data-model-simple" class="graph-preview">
<img src="04-data-model-simple.svg" alt="Data Model Overview">
</a>
<div class="graph-details">
<p>High-level entity relationships without field details. Shows the main actors, workflow, and data flow.</p>
<h4>Clusters</h4>
<ul>
<li><strong>Users & Auth</strong>: Django auth.User as central identity</li>
<li><strong>Pet Owners & Pets</strong>: Clients, their pets, vaccines, studies</li>
<li><strong>Veterinarians</strong>: Vets with availability, specialties, coverage areas</li>
<li><strong>Services & Pricing</strong>: Service catalog with dynamic pricing</li>
<li><strong>Cart & Checkout</strong>: Shopping cart workflow</li>
<li><strong>Service Requests</strong>: Order lifecycle with state machine</li>
<li><strong>Veterinary Visits</strong>: Scheduled visits, reports, AFIP invoicing</li>
<li><strong>Reference Data</strong>: Lookups (specialties, neighborhoods, vaccines, etc.)</li>
</ul>
</div>
</section>
<section class="graph-section" id="backend">
<div class="graph-header-row">
<h2>Backend Architecture</h2>
<a href="graph.html?g=01-backend-architecture" class="view-btn">View Full</a>
</div>
<a href="graph.html?g=01-backend-architecture" class="graph-preview">
<img src="01-backend-architecture.svg" alt="Backend Architecture">
</a>
<div class="graph-details">
<p>Django apps structure: mascotas, productos, solicitudes, common, payments, and external integrations.</p>
<h4>Celery Tasks</h4>
<table class="details-table">
<thead>
<tr><th>Task</th><th>App</th><th>Purpose</th></tr>
</thead>
<tbody>
<tr>
<td><code>send_veterinarian_followup</code></td>
<td>solicitudes</td>
<td>Re-sends availability request to vet if still pending</td>
</tr>
<tr>
<td><code>run_payment_reminder_cron</code></td>
<td>solicitudes</td>
<td>Cron job to send payment reminders</td>
</tr>
<tr>
<td><code>create_vetvisit_in_sheet</code></td>
<td>mascotas</td>
<td>Creates row in Google Sheets for visit</td>
</tr>
<tr>
<td><code>update_vetvisit_in_sheet</code></td>
<td>mascotas</td>
<td>Updates Google Sheets row</td>
</tr>
<tr>
<td><code>create_event_calendar_vetvisit</code></td>
<td>mascotas</td>
<td>Creates Google Calendar event</td>
</tr>
<tr>
<td><code>update_event_calendar_vetvisit</code></td>
<td>mascotas</td>
<td>Updates Google Calendar event</td>
</tr>
<tr>
<td><code>create_user_owner</code></td>
<td>mascotas</td>
<td>Creates Django user for PetOwner + welcome email</td>
</tr>
<tr>
<td><code>generate_vetvisit_invoice</code></td>
<td>mascotas</td>
<td>Generates AFIP invoice and PDF</td>
</tr>
<tr>
<td><code>fetch_mp_notification_details</code></td>
<td>payments</td>
<td>Fetches MercadoPago webhook details</td>
</tr>
</tbody>
</table>
<p class="note">Celery handles async operations: external APIs (Google, MercadoPago, AFIP), scheduled reminders, and heavy processing (invoices, emails).</p>
</div>
</section>
<section class="graph-section" id="frontend">
<div class="graph-header-row">
<h2>Frontend Architecture</h2>
<a href="graph.html?g=02-frontend-architecture" class="view-btn">View Full</a>
</div>
<a href="graph.html?g=02-frontend-architecture" class="graph-preview">
<img src="02-frontend-architecture.svg" alt="Frontend Architecture">
</a>
<div class="graph-details">
<p>Next.js 13+ App Router structure with public pages, backoffice, shared components, and services layer.</p>
<h4>Key Areas</h4>
<ul>
<li><strong>Public Pages</strong>: Landing, service catalog, booking flow</li>
<li><strong>Backoffice</strong>: Role-based dashboards (admin, vet, petowner)</li>
<li><strong>Services Layer</strong>: API clients for backend communication</li>
<li><strong>State Management</strong>: Redux store for cart, auth, UI state</li>
<li><strong>Shared Components</strong>: Forms, tables, modals, navigation</li>
</ul>
</div>
</section>
<section class="graph-section" id="data-model-detailed">
<div class="graph-header-row">
<h2>Detailed Data Model</h2>
<a href="graph.html?g=03-data-model" class="view-btn">View Full</a>
</div>
<a href="graph.html?g=03-data-model" class="graph-preview">
<img src="03-data-model.svg" alt="Detailed Data Model">
</a>
<div class="graph-details">
<p>Complete entity-relationship diagram with all fields, types, and relationships.</p>
<h4>Data Patterns</h4>
<ul>
<li><strong>Soft delete</strong>: <code>deleted</code> flag on most models</li>
<li><strong>Audit trail</strong>: <code>StateHistory</code> for service requests</li>
<li><strong>Geographic</strong>: PostGIS polygons for coverage areas</li>
<li><strong>Versioned pricing</strong>: Date ranges on <code>Prices</code></li>
</ul>
</div>
</section>
<!-- Findings Section -->
<section class="findings-section">
<h2>Key Findings</h2>
<div class="findings-grid">
<article class="finding-card">
<h3>User Types</h3>
<p>All users connect to <code>auth.User</code>:</p>
<ul>
<li><strong>PetOwner</strong>: Optional 1:1 link (created lazily)</li>
<li><strong>Veterinarian</strong>: Required 1:1 link to User</li>
<li><strong>Staff</strong>: Direct Django users with <code>is_staff=True</code></li>
</ul>
</article>
<article class="finding-card">
<h3>Core Workflow</h3>
<div class="workflow-diagram">
<code>PetOwner → Cart → ServiceRequest → VeterinarianAsked → VetVisit → Report</code>
</div>
<p><strong>State Machine:</strong></p>
<div class="state-flow">
pending → vet_asked → vet_accepted → coordinated → payed → Confirmado
</div>
</article>
<article class="finding-card">
<h3>Pricing Logic</h3>
<ul>
<li>Base price × <code>neighborhood.distance_coefficient</code></li>
<li>Optional vet-specific pricing via <code>Prices.veterinarian_id</code></li>
<li>Turn fee surcharge via <code>IndividualTurnFeeGroup</code></li>
<li>Time-based discounts via <code>Discounts</code> model</li>
</ul>
</article>
<article class="finding-card">
<h3>External Integrations</h3>
<ul>
<li><strong>MercadoPago</strong>: Payment processing</li>
<li><strong>Google Calendar</strong>: Visit synchronization</li>
<li><strong>Google Sheets</strong>: Visit tracking spreadsheet</li>
<li><strong>Mercately</strong>: WhatsApp notifications</li>
<li><strong>AFIP</strong>: Argentine tax invoicing</li>
</ul>
</article>
</div>
</section>
<!-- Tech Stack Section -->
<section class="tech-section">
<h2>Technology Stack</h2>
<div class="tech-grid">
<div class="tech-column">
<h3>Backend</h3>
<ul>
<li>Django 4.x</li>
<li>Django REST Framework</li>
<li>PostgreSQL + PostGIS</li>
<li>Celery (Redis)</li>
<li>JWT Authentication</li>
<li>django-afip</li>
</ul>
</div>
<div class="tech-column">
<h3>Frontend</h3>
<ul>
<li>Next.js 13+ (App Router)</li>
<li>React 18+</li>
<li>TypeScript</li>
<li>Redux</li>
<li>Axios</li>
<li>Tailwind CSS</li>
</ul>
</div>
<div class="tech-column">
<h3>Infrastructure</h3>
<ul>
<li>Docker</li>
<li>Nginx</li>
<li>AWS S3 (storage)</li>
<li>MercadoPago API</li>
<li>Google APIs</li>
<li>WhatsApp Business</li>
</ul>
</div>
</div>
</section>
</main>
<footer>
<p>AMAR Mascotas Architecture Documentation</p>
<p class="date">Generated: <time datetime="2024-12-14">December 2024</time></p>
</footer>
</body>
</html>

View File

@@ -0,0 +1,565 @@
/* Reset and base */
*, *::before, *::after {
box-sizing: border-box;
}
:root {
--color-bg: #0f0f0f;
--color-surface: #1a1a1a;
--color-surface-hover: #252525;
--color-border: #333;
--color-text: #e0e0e0;
--color-text-muted: #888;
--color-accent: #4A90D9;
--color-accent-light: #6BA3E0;
--radius: 8px;
--shadow: 0 2px 8px rgba(0,0,0,0.3);
}
html {
font-size: 16px;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, sans-serif;
background: var(--color-bg);
color: var(--color-text);
line-height: 1.6;
margin: 0;
padding: 0;
min-height: 100vh;
}
/* Header */
header {
background: var(--color-surface);
border-bottom: 1px solid var(--color-border);
padding: 2rem;
text-align: center;
}
header h1 {
margin: 0;
font-size: 2rem;
font-weight: 600;
color: var(--color-text);
}
header .subtitle {
margin: 0.5rem 0 0;
color: var(--color-text-muted);
font-size: 1rem;
}
/* Main content */
main {
max-width: 1400px;
margin: 0 auto;
padding: 2rem;
}
section {
margin-bottom: 3rem;
}
section h2 {
font-size: 1.5rem;
font-weight: 600;
margin: 0 0 1.5rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid var(--color-border);
}
/* Card Grid - max 3 columns */
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1.5rem;
}
@media (min-width: 1000px) {
.card-grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media (min-width: 700px) and (max-width: 999px) {
.card-grid {
grid-template-columns: repeat(2, 1fr);
}
}
/* Graph Cards */
.card {
display: block;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius);
overflow: hidden;
text-decoration: none;
color: inherit;
transition: transform 0.2s, box-shadow 0.2s, border-color 0.2s;
}
.card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow);
border-color: var(--color-accent);
}
.card-preview {
aspect-ratio: 4/3;
background: #fff;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
padding: 1rem;
}
.card-preview img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.card-content {
padding: 1rem;
}
.card-content h3 {
margin: 0 0 0.5rem;
font-size: 1.1rem;
font-weight: 600;
color: var(--color-accent-light);
}
.card-content p {
margin: 0;
font-size: 0.9rem;
color: var(--color-text-muted);
}
/* Findings Grid */
.findings-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1.5rem;
}
@media (min-width: 1000px) {
.findings-grid {
grid-template-columns: repeat(3, 1fr);
}
}
.finding-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius);
padding: 1.25rem;
}
.finding-card h3 {
margin: 0 0 0.75rem;
font-size: 1rem;
font-weight: 600;
color: var(--color-accent-light);
}
.finding-card p,
.finding-card ul {
margin: 0 0 0.75rem;
font-size: 0.9rem;
}
.finding-card ul {
padding-left: 1.25rem;
}
.finding-card li {
margin-bottom: 0.25rem;
}
.finding-card code {
background: #2a2a2a;
padding: 0.1em 0.4em;
border-radius: 3px;
font-size: 0.85em;
color: #f0f0f0;
}
.workflow-diagram {
background: #2a2a2a;
padding: 0.75rem;
border-radius: 4px;
margin-bottom: 0.75rem;
overflow-x: auto;
}
.workflow-diagram code {
background: none;
padding: 0;
font-size: 0.8rem;
white-space: nowrap;
}
.state-flow {
font-family: monospace;
font-size: 0.8rem;
color: var(--color-text-muted);
word-break: break-all;
}
.stats-table {
width: 100%;
font-size: 0.85rem;
border-collapse: collapse;
}
.stats-table td {
padding: 0.35rem 0;
border-bottom: 1px solid var(--color-border);
}
.stats-table td:first-child {
font-family: monospace;
color: var(--color-accent-light);
}
.stats-table td:last-child {
color: var(--color-text-muted);
text-align: right;
}
/* Tech Stack Grid */
.tech-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1.5rem;
}
@media (min-width: 700px) {
.tech-grid {
grid-template-columns: repeat(3, 1fr);
}
}
.tech-column {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius);
padding: 1.25rem;
}
.tech-column h3 {
margin: 0 0 0.75rem;
font-size: 1rem;
font-weight: 600;
color: var(--color-accent-light);
}
.tech-column ul {
margin: 0;
padding-left: 1.25rem;
font-size: 0.9rem;
}
.tech-column li {
margin-bottom: 0.25rem;
}
/* Footer */
footer {
background: var(--color-surface);
border-top: 1px solid var(--color-border);
padding: 1.5rem 2rem;
text-align: center;
color: var(--color-text-muted);
font-size: 0.9rem;
}
footer p {
margin: 0.25rem 0;
}
/* Graph Viewer Page */
.graph-viewer {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.graph-header {
background: var(--color-surface);
border-bottom: 1px solid var(--color-border);
padding: 1rem 2rem;
display: flex;
align-items: center;
gap: 1rem;
flex-shrink: 0;
}
.graph-header .back-link {
color: var(--color-accent);
text-decoration: none;
font-size: 0.9rem;
}
.graph-header .back-link:hover {
text-decoration: underline;
}
.nav-controls {
display: flex;
align-items: center;
gap: 0.5rem;
}
.nav-controls button {
background: var(--color-surface-hover);
border: 1px solid var(--color-border);
color: var(--color-text);
padding: 0.4rem 0.7rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
}
.nav-controls button:hover:not(:disabled) {
background: var(--color-border);
}
.nav-controls button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.nav-controls span {
font-size: 0.85rem;
color: var(--color-text-muted);
min-width: 3rem;
text-align: center;
}
.graph-header h1 {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
flex: 1;
}
.graph-controls {
display: flex;
gap: 0.5rem;
}
.graph-controls button {
background: var(--color-surface-hover);
border: 1px solid var(--color-border);
color: var(--color-text);
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.85rem;
}
.graph-controls button:hover {
background: var(--color-border);
}
.graph-container {
flex: 1;
overflow: auto;
background: #fff;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
}
.graph-container img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.graph-container.fit-width img {
width: 100%;
height: auto;
max-height: none;
}
.graph-container.fit-height img {
height: calc(100vh - 80px);
width: auto;
max-width: none;
}
.graph-container.actual-size img {
max-width: none;
max-height: none;
}
/* Graph Sections (index page) */
.graph-section {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius);
margin-bottom: 2rem;
overflow: hidden;
}
.graph-header-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--color-border);
}
.graph-header-row h2 {
margin: 0;
padding: 0;
border: none;
font-size: 1.25rem;
}
.view-btn {
background: var(--color-accent);
color: #fff;
text-decoration: none;
padding: 0.5rem 1rem;
border-radius: 4px;
font-size: 0.85rem;
font-weight: 500;
}
.view-btn:hover {
background: var(--color-accent-light);
}
.graph-section .graph-preview {
display: block;
background: #fff;
max-height: 400px;
overflow: hidden;
}
.graph-section .graph-preview img {
width: 100%;
height: auto;
object-fit: contain;
object-position: top left;
}
.graph-details {
padding: 1.5rem;
border-top: 1px solid var(--color-border);
}
.graph-details p {
margin: 0 0 1rem;
color: var(--color-text-muted);
}
.graph-details h4 {
margin: 1.5rem 0 0.75rem;
font-size: 0.95rem;
font-weight: 600;
color: var(--color-accent-light);
}
.graph-details h4:first-child {
margin-top: 0;
}
.graph-details ul {
margin: 0;
padding-left: 1.25rem;
font-size: 0.9rem;
}
.graph-details li {
margin-bottom: 0.35rem;
}
.graph-details code {
background: #2a2a2a;
padding: 0.1em 0.4em;
border-radius: 3px;
font-size: 0.85em;
}
/* Details Table */
.details-table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
margin: 0.5rem 0 1rem;
}
.details-table th {
text-align: left;
padding: 0.5rem;
background: #2a2a2a;
border-bottom: 1px solid var(--color-border);
font-weight: 600;
color: var(--color-text);
}
.details-table td {
padding: 0.5rem;
border-bottom: 1px solid var(--color-border);
vertical-align: top;
}
.details-table code {
background: #2a2a2a;
padding: 0.15em 0.4em;
border-radius: 3px;
font-size: 0.85em;
white-space: nowrap;
}
.note {
font-size: 0.85rem;
color: var(--color-text-muted);
font-style: italic;
margin-top: 0.5rem;
}
/* Mobile adjustments */
@media (max-width: 600px) {
main {
padding: 1rem;
}
header {
padding: 1.5rem 1rem;
}
header h1 {
font-size: 1.5rem;
}
.card-grid,
.findings-grid {
grid-template-columns: 1fr;
}
.graph-header {
flex-wrap: wrap;
padding: 1rem;
}
.graph-header h1 {
order: -1;
width: 100%;
margin-bottom: 0.5rem;
}
.graph-controls {
flex-wrap: wrap;
}
}

View File

@@ -0,0 +1,300 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Drive Index - Amar Mascotas</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #f5f5f5;
color: #333;
line-height: 1.5;
padding: 2rem;
}
.container { max-width: 900px; margin: 0 auto; }
.back { font-size: 0.85rem; margin-bottom: 1rem; }
.back a { color: #6366f1; text-decoration: none; }
.back a:hover { text-decoration: underline; }
header {
background: linear-gradient(135deg, #6366f1, #8b5cf6);
color: white;
padding: 1.5rem;
border-radius: 12px;
margin-bottom: 1.5rem;
}
header h1 { font-size: 1.5rem; margin-bottom: 0.25rem; }
header p { opacity: 0.9; font-size: 0.85rem; }
.stats { display: flex; gap: 0.75rem; margin-top: 0.75rem; flex-wrap: wrap; }
.stat { background: rgba(255,255,255,0.2); padding: 0.35rem 0.75rem; border-radius: 6px; font-size: 0.8rem; }
.notice {
background: #fef3c7;
border: 1px solid #fcd34d;
border-radius: 8px;
padding: 1rem;
margin-bottom: 1.5rem;
font-size: 0.85rem;
color: #92400e;
}
.notice strong { display: block; margin-bottom: 0.5rem; }
.notice ul { margin: 0.5rem 0 0 1.25rem; }
.notice li { margin-bottom: 0.25rem; }
details {
background: white;
border-radius: 8px;
margin-bottom: 0.5rem;
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
}
summary {
padding: 0.75rem 1rem;
cursor: pointer;
display: flex;
align-items: center;
gap: 0.5rem;
list-style: none;
}
summary::-webkit-details-marker { display: none; }
summary:hover { background: #f9fafb; border-radius: 8px; }
summary .icon {
font-size: 0.7rem;
color: #6366f1;
transition: transform 0.15s;
}
details[open] summary .icon { transform: rotate(90deg); }
summary .name { font-weight: 500; font-size: 0.9rem; flex: 1; }
summary .count {
background: #e0e7ff;
color: #4f46e5;
padding: 0.15rem 0.5rem;
border-radius: 10px;
font-size: 0.7rem;
font-weight: 500;
}
.folder-content {
padding: 0.5rem 1rem 0.75rem 2rem;
border-top: 1px solid #f0f0f0;
}
.folder-desc {
font-size: 0.8rem;
color: #666;
font-style: italic;
margin-bottom: 0.5rem;
}
.file-list {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.file {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8rem;
color: #555;
}
.file-ext {
font-size: 0.6rem;
font-weight: 600;
text-transform: uppercase;
padding: 0.15rem 0.35rem;
border-radius: 3px;
min-width: 32px;
text-align: center;
}
.file-ext.xlsx { background: #dcfce7; color: #166534; }
.file-ext.docx { background: #dbeafe; color: #1e40af; }
.file-ext.pdf { background: #fee2e2; color: #991b1b; }
.file-ext.png, .file-ext.jpg { background: #fef3c7; color: #92400e; }
.file-ext.mov, .file-ext.mp4 { background: #f3e8ff; color: #7c3aed; }
.file-ext.pptx { background: #ffedd5; color: #c2410c; }
.file-ext.ttf { background: #f1f5f9; color: #475569; }
footer { text-align: center; padding: 2rem; color: #999; font-size: 0.75rem; }
</style>
</head>
<body>
<div class="container">
<div class="back"><a href="/">&larr; Album</a></div>
<header>
<h1>Drive Index</h1>
<p>Amar Mascotas - Company Drive Structure</p>
<div class="stats">
<span class="stat">12 folders</span>
<span class="stat">~235 files</span>
</div>
</header>
<div class="notice">
<strong>Preview Mode</strong>
This shows the Drive folder structure with placeholder filenames. Real file data will be available once <strong>Google Vein</strong> (Google Drive sync) is implemented in Artery:
<ul>
<li>Auto-sync folder structure and file metadata</li>
<li>Direct links to files (requires Google Workspace login)</li>
<li>Live updates when Drive contents change</li>
</ul>
</div>
<details>
<summary><span class="icon"></span><span class="name">01. Identidad</span><span class="count">3 files</span></summary>
<div class="folder-content">
<div class="folder-desc">Brand assets, logos, typography</div>
<div class="file-list">
<div class="file"><span class="file-ext png">png</span> logo_main.png</div>
<div class="file"><span class="file-ext png">png</span> logo_alt.png</div>
<div class="file"><span class="file-ext ttf">ttf</span> brand_font.ttf</div>
</div>
</div>
</details>
<details>
<summary><span class="icon"></span><span class="name">02. Marketing Contenidos</span><span class="count">8 files</span></summary>
<div class="folder-content">
<div class="folder-desc">Social media content, templates, testimonials</div>
<div class="file-list">
<div class="file"><span class="file-ext docx">docx</span> social_template.docx</div>
<div class="file"><span class="file-ext png">png</span> post_image1.png</div>
<div class="file"><span class="file-ext png">png</span> post_image2.png</div>
<div class="file"><span class="file-ext mov">mov</span> event_video.mov</div>
<div class="file"><span class="file-ext png">png</span> testimonials.png</div>
</div>
</div>
</details>
<details>
<summary><span class="icon"></span><span class="name">03. Marketing Growth</span><span class="count">8 files</span></summary>
<div class="folder-content">
<div class="folder-desc">Paid media campaigns, performance dashboards</div>
<div class="file-list">
<div class="file"><span class="file-ext xlsx">xlsx</span> dashboard_report.xlsx</div>
<div class="file"><span class="file-ext png">png</span> ad_1080x1080.png</div>
<div class="file"><span class="file-ext png">png</span> ad_1200x628.png</div>
<div class="file"><span class="file-ext png">png</span> ad_stories.png</div>
</div>
</div>
</details>
<details>
<summary><span class="icon"></span><span class="name">05. ATC - Operaciones</span><span class="count">12 files</span></summary>
<div class="folder-content">
<div class="folder-desc">Process manuals, workflows, support docs</div>
<div class="file-list">
<div class="file"><span class="file-ext docx">docx</span> manual_procesos.docx</div>
<div class="file"><span class="file-ext jpg">jpg</span> workflow_diagram.jpg</div>
<div class="file"><span class="file-ext xlsx">xlsx</span> contacts_list.xlsx</div>
<div class="file"><span class="file-ext xlsx">xlsx</span> postal_codes.xlsx</div>
<div class="file"><span class="file-ext xlsx">xlsx</span> tasks_tracker.xlsx</div>
</div>
</div>
</details>
<details>
<summary><span class="icon"></span><span class="name">06. Supply</span><span class="count">7 files</span></summary>
<div class="folder-content">
<div class="folder-desc">Vet network, lab partnerships, onboarding</div>
<div class="file-list">
<div class="file"><span class="file-ext pdf">pdf</span> lab_prices.pdf</div>
<div class="file"><span class="file-ext xlsx">xlsx</span> vet_onboarding.xlsx</div>
<div class="file"><span class="file-ext pdf">pdf</span> vet_cv_sample.pdf</div>
<div class="file"><span class="file-ext mp4">mp4</span> meeting_recording.mp4</div>
</div>
</div>
</details>
<details>
<summary><span class="icon"></span><span class="name">07. Finanzas</span><span class="count">7 files</span></summary>
<div class="folder-content">
<div class="folder-desc">Invoices, accounting, finance tracking</div>
<div class="file-list">
<div class="file"><span class="file-ext xlsx">xlsx</span> finance_tracker.xlsx</div>
<div class="file"><span class="file-ext pdf">pdf</span> invoice_may.pdf</div>
<div class="file"><span class="file-ext pdf">pdf</span> invoice_jun.pdf</div>
<div class="file"><span class="file-ext pdf">pdf</span> invoice_jul.pdf</div>
</div>
</div>
</details>
<details>
<summary><span class="icon"></span><span class="name">08. IT y Producto</span><span class="count">57 files</span></summary>
<div class="folder-content">
<div class="folder-desc">Product specs, feature docs, references</div>
<div class="file-list">
<div class="file"><span class="file-ext docx">docx</span> product_index.docx</div>
<div class="file"><span class="file-ext docx">docx</span> feature_specs.docx</div>
<div class="file"><span class="file-ext xlsx">xlsx</span> platform_states.xlsx</div>
<div class="file"><span class="file-ext xlsx">xlsx</span> medications_db.xlsx</div>
<div class="file"><span class="file-ext xlsx">xlsx</span> events_mapping.xlsx</div>
<div class="file"><span class="file-ext pptx">pptx</span> benchmark.pptx</div>
</div>
</div>
</details>
<details>
<summary><span class="icon"></span><span class="name">09. DATA</span><span class="count">2 files</span></summary>
<div class="folder-content">
<div class="folder-desc">Database exports, data backups</div>
<div class="file-list">
<div class="file"><span class="file-ext xlsx">xlsx</span> pets_database.xlsx</div>
<div class="file"><span class="file-ext xlsx">xlsx</span> pets_backup.xlsx</div>
</div>
</div>
</details>
<details>
<summary><span class="icon"></span><span class="name">Clientes - ventas</span><span class="count">22 files</span></summary>
<div class="folder-content">
<div class="folder-desc">CRM exports, sales data, client info</div>
<div class="file-list">
<div class="file"><span class="file-ext xlsx">xlsx</span> crm_export.xlsx</div>
<div class="file"><span class="file-ext xlsx">xlsx</span> churn_analysis.xlsx</div>
<div class="file"><span class="file-ext xlsx">xlsx</span> leads_2023.xlsx</div>
<div class="file"><span class="file-ext xlsx">xlsx</span> leads_2024.xlsx</div>
<div class="file"><span class="file-ext pdf">pdf</span> monthly_report.pdf</div>
</div>
</div>
</details>
<details>
<summary><span class="icon"></span><span class="name">HR</span><span class="count">17 files</span></summary>
<div class="folder-content">
<div class="folder-desc">Onboarding, job profiles, team docs</div>
<div class="file-list">
<div class="file"><span class="file-ext pptx">pptx</span> onboarding_deck.pptx</div>
<div class="file"><span class="file-ext docx">docx</span> job_profile_sales.docx</div>
<div class="file"><span class="file-ext pdf">pdf</span> job_profile_growth.pdf</div>
<div class="file"><span class="file-ext jpg">jpg</span> team_photo.jpg</div>
</div>
</div>
</details>
<details>
<summary><span class="icon"></span><span class="name">Pitch Decks</span><span class="count">24 files</span></summary>
<div class="folder-content">
<div class="folder-desc">Investor presentations, company decks</div>
<div class="file-list">
<div class="file"><span class="file-ext pptx">pptx</span> pitch_deck_latest.pptx</div>
<div class="file"><span class="file-ext docx">docx</span> video_script.docx</div>
<div class="file"><span class="file-ext pptx">pptx</span> product_timeline.pptx</div>
<div class="file"><span class="file-ext pptx">pptx</span> investor_update.pptx</div>
</div>
</div>
</details>
<details>
<summary><span class="icon"></span><span class="name">z.Backup archivos viejos</span><span class="count">46 files</span></summary>
<div class="folder-content">
<div class="folder-desc">Legacy files, old exports</div>
<div class="file-list">
<div class="file"><span class="file-ext xlsx">xlsx</span> old_vets_list.xlsx</div>
<div class="file"><span class="file-ext xlsx">xlsx</span> org_chart.xlsx</div>
<div class="file"><span class="file-ext xlsx">xlsx</span> data_room_2023.xlsx</div>
<div class="file"><span class="file-ext xlsx">xlsx</span> legacy_export.xlsx</div>
</div>
</div>
</details>
<footer>pawprint/album</footer>
</div>
</body>
</html>

View File

@@ -0,0 +1,217 @@
# Drive Index - Amar Mascotas
> vault source: `vault/drive/`
> 48 files | Last indexed: 2025-12-08
## Overview by Area
| Area | Focus | Key Files | Workflow Potential |
|------|-------|-----------|-------------------|
| 01. Identidad | Brand assets | Illustrations, Typography | Design handoff |
| 02. Marketing Contenidos | Social/Content | Posts, Event media, Templates | Content calendar |
| 03. Marketing Growth | Paid/Performance | Ad creatives, Dashboards | Campaign tracking |
| 05. ATC - Operaciones | Ops/Support | Process manual, Workflow diagram | Onboarding, SOP |
| 06. Supply | Vets/Labs | Vet tracking, Lab prices, CVs | Vet onboarding |
| 07. Finanzas | Accounting | Invoices, Finance sheet | Reconciliation |
| Clientes | Sales/CRM | Fitsales export | Client tracking |
| Pitch Decks | Presentations | Company pitch | Investor/partner |
---
## 01. Identidad Amar Mascotas
Brand identity assets.
```
Ilustraciones amar PNG/
├── Asset 4@4x.png
└── Asset 11@4x.png
Tipografia/Open_Sans/static/
└── OpenSans_Condensed-Italic.ttf
```
**Workflow relevance**: Design system, brand consistency checks.
---
## 02. Marketing Contenidos
Social media content and production assets.
```
Backup archivos viejos/
├── Contenido evento amar 10 ago 2023/
│ ├── IMG_7309.MOV
│ └── video-output-(...).mov
└── RRSS MSJ PREDETERMINADOS.docx ← Predefined social messages
Produccion audiovisual Jul 24/
└── Fotos Produccion Audiovisual JUL24/
└── Vacunacion Fotos Produccion JUL 24/
└── IMG_4620.JPG
Redes sociales Amar/
├── 02. FEBRERO 25 Finales/12-2/cambios/247.png
├── 08. AGOSTO 24 Finales Amar/
│ ├── 13.8/1.png
│ └── 21.8/3.png
└── Comentarios clientes felices JUN-JUL24/
└── Captura de pantalla (...).png ← Testimonials
```
**Workflow relevance**:
- `RRSS MSJ PREDETERMINADOS.docx` = template for ATC/social responses
- Monthly folders suggest content calendar exists
- Testimonials capture = social proof workflow
---
## 03. Marketing Growth
Paid media campaigns and performance tracking.
```
Paid Media - Pauta digital/
├── 05.MAY24 - ADS Testimonios/
├── 08.AGO24 - ADS 10 OFF/
│ ├── 1080 x 1080-17.png
│ ├── 1200 x 628-32.png
│ ├── 1200 x 628-33.png
│ ├── 1200 x 628-38.png
│ ├── 1200 x 628-39.png
│ └── 960 x 1200-30.png
├── Amar Mascotas - Reporte_Dash general(...)(2).xlsx
└── Amar Mascotas - Reporte_Dash general(...)(3).xlsx
```
**Workflow relevance**:
- Ad size variants = multi-platform campaigns (Meta, Google)
- Dashboard reports = performance tracking exists
- Monthly campaign folders = structured campaign workflow
---
## 05. ATC - Operaciones
Operations, customer service, and legacy data.
```
Operaciones backup viejos/
├── ATC - Manual de Procesos.docx ← KEY: Process documentation
├── work Flow servicio veterinario.jpg ← KEY: Service workflow diagram
├── Clientes a contactar.xlsx
├── codigos_postales_y_barrios (1).xlsx ← Coverage/delivery zones
├── domesticables ultimo.docx
├── domesticables ultimo.pdf
├── INFO.xlsx
├── Reporte DOMESTICABLES 11_5.xlsx
├── Tareas de Leila.xlsx
└── entrega/
├── prestashop/
│ ├── clientes a escribir febrero 2023.csv
│ └── datos web vieja/
│ └── reembolsos por falta de stock.csv
└── vendedores/somos pam/
└── articulos desactivados en marzo 2023.csv
```
**Workflow relevance**:
- `ATC - Manual de Procesos.docx` = existing SOP (Gherkin source?)
- `work Flow servicio veterinario.jpg` = visual process (BDD source?)
- Legacy CSV = migration/history context
- Task tracking = ops workflow exists
---
## 06. Supply (vetes-labo-clinicas)
Veterinarian network and lab partnerships.
```
Comunciacion/
└── Veterinarios-46.jpg
Formularios- Viejos - no usar/
└── Reuniones Matias/
├── Grabación de pantalla 2024-08-20(...).mp4
└── Grabación de pantalla 2024-08-21(...).mp4
Laboratorios/DiagnoTest/
└── Diagnotest-ListaPrecios-Junio2025 (1).pdf ← Current pricing
Veterinarios postulantes/
├── Aldana Becker - Colaboracion Amar Mascotas.docx
├── Ana Christophersen/
│ └── CV - Ana Christophersen.pdf
└── Ingreso Veterinarios.xlsx ← Vet onboarding tracker
```
**Workflow relevance**:
- `Ingreso Veterinarios.xlsx` = vet recruitment/onboarding flow
- Lab price list = service pricing reference
- Meeting recordings = training/knowledge transfer
---
## 07. Finanzas y contabilidad
Invoices and financial tracking.
```
Facturas/
├── May 23/Recibido/
│ ├── FacturaAMARMASCOTASSA(...).pdf
│ └── FacturaAMARMASCOTASSA(...).pdf
├── Jun 23/Recibido/
│ ├── nc 4-29.pdf
│ └── nc 4-30.pdf
└── Jul 23/Recibido/
├── FAC 567 ZUCCONI ALAN EDGARDO.pdf
└── Mercaba carlos roberto cozak.pdf
Finanzas Amar mascotas.xlsx ← Main finance tracker
```
**Workflow relevance**:
- Monthly invoice organization = accounting workflow
- Finance xlsx = reconciliation process
---
## Clientes - ventas - devoluciones
CRM and sales data.
```
Excels Clientes - ventas - devoluciones/
└── Fitsales CRM vinculados hasta el 06 dic.xlsx
```
**Workflow relevance**:
- CRM export = client tracking, sales funnel
---
## Pitch Decks - Presentaciones
Company presentations.
```
Copias Pitch Deck Cata/
└── Copia de Pitch Deck - ABR 2025.pptx
```
**Workflow relevance**:
- Investor/partner communications
---
## Workflow Discovery Summary
### Documents to Review for BDD/Gherkin
1. `05. ATC/ATC - Manual de Procesos.docx` - existing process documentation
2. `05. ATC/work Flow servicio veterinario.jpg` - visual workflow
3. `02. Marketing/RRSS MSJ PREDETERMINADOS.docx` - response templates
### Active Tracking Sheets
- `06. Supply/Ingreso Veterinarios.xlsx` - vet onboarding
- `Clientes/Fitsales CRM vinculados.xlsx` - client tracking
- `07. Finanzas/Finanzas Amar mascotas.xlsx` - finance
### Potential Artery Connections
- Fitsales CRM → pulse for client data
- Finance xlsx → pulse for accounting
- Vet tracker → pulse for supply management

View File

@@ -0,0 +1,79 @@
# Ops Templates Sample Book
## Purpose
**SAMPLE DATA** - Example filled ops/support templates for demonstration and testing purposes. These are realistic examples to work with while actual definitions are pending from ops/support team.
Use these to:
- Understand the template structure
- Test tooling and workflows
- Demo the feature-flow pipeline
Real templates will follow the same structure but with validated content.
## Structure
```
ops-templates-sample/
├── pet-owner/ # 5 flows for pet owners
│ ├── 01-registro.md
│ ├── 02-reservar-turno.md
│ ├── 03-gestion-mascotas.md
│ ├── 04-pago-turno.md
│ └── 05-historial-medico.md
├── veterinarian/ # 5 flows for vets
│ ├── 01-aceptar-solicitud.md
│ ├── 02-gestion-agenda.md
│ ├── 03-realizar-visita.md
│ ├── 04-zonas-cobertura.md
│ └── 05-historial-pacientes.md
└── backoffice/ # 5 flows for admins
├── 01-gestion-solicitudes.md
├── 02-gestion-usuarios.md
├── 03-gestion-servicios.md
├── 04-reembolsos.md
└── 05-reportes.md
```
## Template Format
Each file follows the standard ops template:
```markdown
# [Nombre del Flujo]
## Tipo de usuario
## Donde empieza
## Que quiere hacer el usuario
## Pasos
## Que deberia pasar
## Problemas comunes
## Casos especiales
## Flujos relacionados
## Notas tecnicas
```
## Corresponding Gherkin
Each template has corresponding `.feature` files in:
- `album/book/gherkin-sample/es/` - Spanish keywords (Dado/Cuando/Entonces)
- `album/book/gherkin-sample/en/` - English keywords (Given/When/Then)
## Usage
1. **Support/Ops**: Use templates to document new flows
2. **Dev**: Convert templates to Gherkin specs
3. **QA**: Use Gherkin to derive test cases
4. **Product**: Review templates for completeness
## Source Template
The base template is at: `album/template/ops-flow/plantilla-flujo.md`
## Coverage
| Area | Flows | Status |
|------|-------|--------|
| Pet Owner | 5 | Complete |
| Veterinarian | 5 | Complete |
| Backoffice | 5 | Complete |
| **Total** | **15** | |

View File

@@ -0,0 +1,335 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ filename }} - Ops Template</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, -apple-system, sans-serif;
background: #f8fafc;
color: #1e293b;
line-height: 1.6;
}
.container { max-width: 800px; margin: 0 auto; padding: 2rem 1rem; }
header { margin-bottom: 1.5rem; }
.breadcrumb { font-size: 0.9rem; color: #64748b; margin-bottom: 0.5rem; }
.breadcrumb a { color: #15803d; text-decoration: none; }
.breadcrumb a:hover { text-decoration: underline; }
h1 { font-size: 1.5rem; color: #15803d; }
.meta {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
font-size: 0.8rem;
}
.meta span {
background: #f1f5f9;
padding: 0.2rem 0.5rem;
border-radius: 4px;
color: #64748b;
}
.meta .sample { background: #fef3c7; color: #92400e; }
/* Form-like styling */
.form-card {
background: white;
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
overflow: hidden;
}
.form-header {
background: linear-gradient(135deg, #15803d, #22c55e);
color: white;
padding: 1rem 1.5rem;
}
.form-header h2 { font-size: 1.1rem; font-weight: 600; }
.form-body { padding: 1.5rem; }
.field {
margin-bottom: 1.25rem;
}
.field:last-child { margin-bottom: 0; }
.field-label {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #64748b;
margin-bottom: 0.35rem;
}
.field-label .edit-icon {
cursor: pointer;
opacity: 0.5;
font-size: 0.85rem;
}
.field-label .edit-icon:hover {
opacity: 1;
}
.field-value {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 6px;
padding: 0.75rem;
font-size: 0.95rem;
color: #1e293b;
min-height: 2.5rem;
}
.field-value:focus-within {
border-color: #15803d;
outline: none;
box-shadow: 0 0 0 3px rgba(21, 128, 61, 0.1);
}
.field-value.multiline {
min-height: 4rem;
}
.field-value ul, .field-value ol {
margin: 0;
padding-left: 1.25rem;
}
.field-value li {
margin-bottom: 0.25rem;
}
.field-value p {
margin: 0 0 0.5rem 0;
}
.field-value p:last-child { margin-bottom: 0; }
.field-textarea {
display: none;
width: 100%;
min-height: 100px;
padding: 0.75rem;
font-size: 0.95rem;
font-family: inherit;
border: 1px solid #15803d;
border-radius: 6px;
resize: vertical;
box-shadow: 0 0 0 3px rgba(21, 128, 61, 0.1);
}
.field.editing .field-value { display: none; }
.field.editing .field-textarea { display: block; }
/* Special field styles */
.field-steps .field-value {
counter-reset: step;
padding-left: 0.5rem;
}
.field-steps ol {
list-style: none;
padding-left: 0;
}
.field-steps li {
position: relative;
padding-left: 2rem;
margin-bottom: 0.5rem;
}
.field-steps li::before {
counter-increment: step;
content: counter(step);
position: absolute;
left: 0;
width: 1.5rem;
height: 1.5rem;
background: #15803d;
color: white;
border-radius: 50%;
font-size: 0.75rem;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
}
.field-problems .field-value {
background: #fef2f2;
border-color: #fecaca;
}
.field-problems li::before {
content: '!';
color: #dc2626;
font-weight: bold;
margin-right: 0.5rem;
}
.field-special .field-value {
background: #fffbeb;
border-color: #fde68a;
}
.field-technical .field-value {
background: #f0fdf4;
border-color: #bbf7d0;
font-family: 'JetBrains Mono', monospace;
font-size: 0.85rem;
}
.field-technical code {
background: #dcfce7;
padding: 0.1rem 0.35rem;
border-radius: 3px;
font-size: 0.85em;
}
.sidebar {
margin-top: 1.5rem;
padding: 1rem;
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-radius: 8px;
}
.sidebar h3 { font-size: 0.8rem; color: #15803d; margin-bottom: 0.75rem; text-transform: uppercase; }
.sidebar a {
display: block;
color: #15803d;
text-decoration: none;
padding: 0.35rem 0;
font-size: 0.9rem;
}
.sidebar a:hover { text-decoration: underline; }
footer {
margin-top: 2rem;
padding-top: 1rem;
border-top: 1px solid #e2e8f0;
font-size: 0.85rem;
}
footer a { color: #15803d; text-decoration: none; }
footer a:hover { text-decoration: underline; }
</style>
</head>
<body>
<div class="container">
<header>
<div class="breadcrumb">
<a href="/">Album</a> / <a href="/book/ops-templates/">Ops Templates</a> / {{ user_type }}
</div>
<h1>{{ filename.replace('.md', '').replace('-', ' ').title() }}</h1>
<div class="meta">
<span>{{ user_type }}</span>
<span class="sample">Sample</span>
</div>
</header>
<div class="form-card">
<div class="form-header">
<h2>User Flow Template</h2>
</div>
<div class="form-body" id="form-content">
<!-- Content will be parsed and inserted here -->
</div>
</div>
<div class="sidebar">
<h3>Related</h3>
<a href="/book/gherkin/es/{{ user_type }}/{{ filename.replace('.md', '.feature') }}">Gherkin (Spanish)</a>
<a href="/book/gherkin/en/{{ user_type }}/{{ filename.replace('.md', '.feature') }}">Gherkin (English)</a>
<a href="/book/feature-flow/">Feature Flow Pipeline</a>
</div>
<footer>
<a href="/book/ops-templates/">&larr; All Templates</a>
</footer>
</div>
<script>
const rawContent = {{ content | tojson }};
function parseMarkdown(md) {
const sections = {};
let currentSection = null;
let currentContent = [];
const lines = md.split('\n');
for (const line of lines) {
if (line.startsWith('## ')) {
if (currentSection) {
sections[currentSection] = currentContent.join('\n').trim();
}
currentSection = line.replace('## ', '').trim();
currentContent = [];
} else if (line.startsWith('# ')) {
sections['_title'] = line.replace('# ', '').trim();
} else if (currentSection) {
currentContent.push(line);
}
}
if (currentSection) {
sections[currentSection] = currentContent.join('\n').trim();
}
return sections;
}
function formatContent(content) {
if (!content) return '';
// Convert markdown lists to HTML
let html = content
.replace(/^(\d+)\.\s+(.*)$/gm, '<li>$2</li>')
.replace(/^-\s+(.*)$/gm, '<li>$1</li>')
.replace(/`([^`]+)`/g, '<code>$1</code>');
// Wrap consecutive li elements in ul/ol
if (html.includes('<li>')) {
if (content.match(/^\d+\./m)) {
html = '<ol>' + html + '</ol>';
} else {
html = '<ul>' + html + '</ul>';
}
}
// Convert line breaks to paragraphs for non-list content
if (!html.includes('<li>')) {
html = html.split('\n').filter(l => l.trim()).map(l => '<p>' + l + '</p>').join('');
}
return html;
}
function createField(label, content, className = '') {
if (!content) return '';
return `
<div class="field ${className}">
<label class="field-label">${label} <span class="edit-icon" onclick="toggleEdit(this)" title="Edit field">&#9998;</span></label>
<div class="field-value ${content.includes('\n') ? 'multiline' : ''}">${formatContent(content)}</div>
<textarea class="field-textarea">${content}</textarea>
</div>
`;
}
function toggleEdit(icon) {
const field = icon.closest('.field');
field.classList.toggle('editing');
if (field.classList.contains('editing')) {
field.querySelector('.field-textarea').focus();
}
}
const sections = parseMarkdown(rawContent);
const container = document.getElementById('form-content');
const fieldMap = [
{ key: 'Tipo de usuario', label: 'Tipo de Usuario', class: '' },
{ key: 'Donde empieza', label: 'Punto de Entrada', class: '' },
{ key: 'Que quiere hacer el usuario', label: 'Objetivo del Usuario', class: '' },
{ key: 'Pasos', label: 'Pasos', class: 'field-steps' },
{ key: 'Que deberia pasar', label: 'Resultado Esperado', class: '' },
{ key: 'Problemas comunes', label: 'Problemas Comunes', class: 'field-problems' },
{ key: 'Casos especiales', label: 'Casos Especiales', class: 'field-special' },
{ key: 'Flujos relacionados', label: 'Flujos Relacionados', class: '' },
{ key: 'Notas tecnicas', label: 'Notas Tecnicas', class: 'field-technical' },
];
let html = '';
for (const field of fieldMap) {
if (sections[field.key]) {
html += createField(field.label, sections[field.key], field.class);
}
}
container.innerHTML = html;
</script>
</body>
</html>

View File

@@ -0,0 +1,92 @@
# Gestion de Solicitudes de Servicio
## Tipo de usuario
Administrador / Operaciones
## Donde empieza
Backoffice `/admin/` -> Dashboard o Seccion "Solicitudes"
## Que quiere hacer el usuario
Ver, filtrar y gestionar todas las solicitudes de servicio del sistema
## Pasos - Ver solicitudes
1. Acceder al backoffice
2. Ir a seccion "Solicitudes"
3. Ver listado con todas las solicitudes
4. Usar filtros para encontrar solicitudes especificas:
- Por estado (Pendiente, Coordinado, Pagado, Completado, Cancelado)
- Por fecha
- Por barrio/zona
- Por veterinario
- Por dueno
## Pasos - Ver detalle de solicitud
1. Click en una solicitud del listado
2. Ver toda la informacion:
- Datos del dueno
- Datos de la mascota
- Servicios solicitados
- Fechas preferidas
- Veterinario asignado (si hay)
- Estado de pago
- Historial de cambios de estado
## Pasos - Asignar veterinario manualmente
1. Abrir solicitud en estado "Pendiente"
2. Click en "Asignar veterinario"
3. Ver lista de vets disponibles en la zona
4. Seleccionar veterinario
5. Seleccionar fecha y hora
6. Confirmar asignacion
7. Sistema notifica al vet y al dueno
## Pasos - Cambiar estado manualmente
1. Abrir solicitud
2. Click en "Cambiar estado"
3. Seleccionar nuevo estado
4. Ingresar motivo (si aplica)
5. Confirmar
6. Se registra en el historial
## Que deberia pasar
- Vision completa de todas las solicitudes
- Capacidad de intervenir cuando algo falla
- Trazabilidad de cambios
- Notificaciones a las partes afectadas
## Problemas comunes
- Solicitud sin vet disponible en la zona
- Usuario pago pero webhook fallo
- Vet no puede asistir y hay que reasignar
- Dueno quiere cancelar pero ya pago
- Solicitudes duplicadas del mismo dueno
## Casos especiales
- Solicitud urgente sin vets disponibles
- Reasignacion a ultimo momento
- Cancelacion con reembolso
- Solicitud con datos incompletos
- Dueno no responde a confirmaciones
## Flujos relacionados
- Proceso de reembolso
- Gestion de usuarios
- Reportes
## Notas tecnicas
- APIs:
- `GET /solicitudes/api/v1/service-requests/` (listado con filtros)
- `GET /solicitudes/api/v1/service-requests/{id}/` (detalle)
- `PATCH /solicitudes/api/v1/service-requests/{id}/` (asignar vet)
- `PATCH /solicitudes/api/v1/change-service-request-state/` (cambiar estado)
- Estados validos: pending, coordinated, payed, completed, cancelled
- Permisos: solo staff puede ver todas las solicitudes

View File

@@ -0,0 +1,106 @@
# Gestion de Usuarios
## Tipo de usuario
Administrador / Operaciones
## Donde empieza
Backoffice `/admin/` -> Seccion "Usuarios" o "Duenos" / "Veterinarios"
## Que quiere hacer el usuario
Administrar duenos de mascotas y veterinarios del sistema
## Pasos - Ver/Buscar duenos
1. Ir a "Duenos de mascotas"
2. Ver listado de todos los duenos
3. Usar buscador (nombre, email, telefono)
4. Filtrar por:
- Tipo (Registrado / Invitado)
- Fecha de registro
- Con/sin turnos activos
## Pasos - Ver detalle de dueno
1. Click en un dueno
2. Ver informacion completa:
- Datos personales
- Direcciones registradas
- Mascotas
- Historial de solicitudes
- Historial de pagos
## Pasos - Editar dueno
1. En el detalle, click "Editar"
2. Modificar campos permitidos:
- Nombre
- Telefono
- Email
- Direccion
3. Guardar cambios
## Pasos - Gestionar veterinarios
1. Ir a "Veterinarios"
2. Ver listado con:
- Nombre y matricula
- Zonas de cobertura
- Estado (Activo/Inactivo)
- Turnos del mes
## Pasos - Agregar nuevo veterinario
1. Click en "Agregar veterinario"
2. Completar datos:
- Nombre completo
- Numero de matricula
- Email
- Telefono
- Especialidades
- Zonas de cobertura inicial
3. Crear credenciales de acceso
4. Guardar
## Pasos - Desactivar veterinario
1. Abrir perfil del vet
2. Click en "Desactivar"
3. Confirmar
4. Vet deja de recibir solicitudes
5. Visitas ya aceptadas se mantienen o se reasignan
## Que deberia pasar
- Gestion completa de usuarios del sistema
- Capacidad de corregir datos erroneos
- Alta/baja de veterinarios
- Vision del estado de cada usuario
## Problemas comunes
- Duenos duplicados (se registraron con email diferente)
- Vet quiere darse de baja pero tiene citas pendientes
- Email incorrecto y no reciben notificaciones
- Cuenta de invitado que quiere convertirse en registrada
## Casos especiales
- Fusionar cuentas duplicadas
- Transferir mascota a otro dueno
- Vet de vacaciones temporalmente
- Cambio de numero de matricula
## Flujos relacionados
- Gestion de solicitudes (ver historial por usuario)
- Reportes (filtrar por usuario)
## Notas tecnicas
- APIs:
- `GET /mascotas/api/v1/pet-owners/` (duenos)
- `GET /mascotas/api/v1/veterinarians/` (vets)
- `POST /mascotas/api/v1/veterinarians/` (crear vet)
- `PATCH /mascotas/api/v1/veterinarians/{id}/` (editar/desactivar)
- Permisos: solo staff puede modificar usuarios
- Soft delete para mantener historial

View File

@@ -0,0 +1,98 @@
# Gestion de Servicios y Precios
## Tipo de usuario
Administrador
## Donde empieza
Backoffice `/admin/` -> Seccion "Servicios" o Django Admin -> Productos
## Que quiere hacer el usuario
Administrar el catalogo de servicios veterinarios y sus precios
## Pasos - Ver servicios
1. Ir a "Servicios"
2. Ver listado de todos los servicios
3. Filtrar por:
- Categoria (Vacunacion, Consulta, Cirugia, etc.)
- Tipo de mascota (Perro, Gato, Ambos)
- Estado (Activo/Inactivo)
## Pasos - Agregar nuevo servicio
1. Click en "Agregar servicio"
2. Completar datos:
- Nombre del servicio
- Descripcion
- Categoria
- Tipo de mascota aplicable
- Precio base
- Duracion estimada
- Requiere otros servicios (dependencias)
3. Guardar
## Pasos - Editar servicio
1. Abrir servicio existente
2. Modificar campos
3. Guardar
4. Cambios aplican a nuevas solicitudes
## Pasos - Gestionar precios
1. Abrir servicio
2. Ir a seccion "Precios"
3. Ver precio actual
4. Para cambiar precio:
- Click "Agregar precio"
- Ingresar monto
- Definir fecha de vigencia
- Guardar
5. Precio anterior queda en historial
## Pasos - Configurar combos
1. Ir a "Combos" o "Paquetes"
2. Crear nuevo combo:
- Nombre del combo
- Servicios incluidos
- Precio del combo (con descuento)
- Condiciones de aplicacion
3. Guardar
## Que deberia pasar
- Catalogo actualizado de servicios
- Precios correctos en el turnero
- Combos aplicados automaticamente
- Historial de precios para facturacion
## Problemas comunes
- Cambio de precio afecta solicitudes ya creadas
- Servicio deprecado pero con solicitudes pendientes
- Combo no se aplica automaticamente
- Precio diferente por zona/vet
## Casos especiales
- Servicio con precio variable (depende de peso mascota)
- Servicio estacional (solo ciertas epocas)
- Promocion temporal
- Precio especial por vet
## Flujos relacionados
- Turnero (muestra servicios disponibles)
- Facturacion (usa precios)
## Notas tecnicas
- APIs:
- `GET /productos/api/v1/services/` (servicios)
- `POST /productos/api/v1/services/` (crear)
- `PATCH /productos/api/v1/services/{id}/` (editar)
- `GET /productos/api/v1/prices/` (precios)
- `POST /productos/api/v1/prices/` (nuevo precio)
- Modelo Price tiene fecha de vigencia
- Precio se congela al momento de crear solicitud

View File

@@ -0,0 +1,82 @@
# Proceso de Reembolso
## Tipo de usuario
Administrador / Operaciones
## Donde empieza
Backoffice -> Solicitud pagada -> "Procesar reembolso" o Seccion "Pagos"
## Que quiere hacer el usuario
Procesar un reembolso para un turno que fue cancelado despues del pago
## Pasos - Identificar solicitud a reembolsar
1. Buscar la solicitud por:
- ID de solicitud
- Nombre del dueno
- Numero de operacion de Mercado Pago
2. Verificar que esta en estado "Pagado"
3. Verificar que el pago fue exitoso
## Pasos - Procesar reembolso
1. Abrir detalle de la solicitud
2. Click en "Procesar reembolso"
3. Ver informacion del pago:
- Monto pagado
- Fecha de pago
- Metodo de pago
- ID de operacion MP
4. Seleccionar tipo de reembolso:
- Total
- Parcial (ingresar monto)
5. Ingresar motivo del reembolso
6. Confirmar
7. Sistema procesa reembolso en Mercado Pago
8. Actualizar estado de la solicitud
## Pasos - Verificar reembolso
1. Ir a seccion "Pagos" o historial de la solicitud
2. Verificar que el reembolso fue procesado
3. Ver estado del reembolso en Mercado Pago
## Que deberia pasar
- Reembolso procesado en Mercado Pago
- Dueno recibe el dinero (segun tiempos de MP)
- Solicitud actualiza estado
- Registro en el historial
- Dueno notificado por email
## Problemas comunes
- Reembolso parcial no soportado por el metodo de pago
- Plazo de reembolso vencido (politica de MP)
- Dueno reclama que no recibio el dinero
- Reembolso duplicado por error
- Pago original en disputa
## Casos especiales
- Reembolso por mala atencion
- Reembolso parcial (solo algunos servicios)
- Visita incompleta (reembolso proporcional)
- Dueno quiere credito en vez de reembolso
- Reembolso fuera de plazo (solucion alternativa)
## Flujos relacionados
- Gestion de solicitudes
- Cancelacion de turno
- Atencion al cliente
## Notas tecnicas
- API Mercado Pago: `POST /v1/payments/{id}/refunds`
- Tiempos de acreditacion:
- Tarjeta credito: 1-2 estados de cuenta
- Tarjeta debito: 5-10 dias habiles
- Dinero en cuenta MP: inmediato
- Plazo maximo para reembolso: 180 dias desde el pago
- Se debe guardar log de todos los reembolsos

View File

@@ -0,0 +1,99 @@
# Reportes y Dashboard
## Tipo de usuario
Administrador / Gerencia
## Donde empieza
Backoffice `/admin/` -> Dashboard o Seccion "Reportes"
## Que quiere hacer el usuario
Ver metricas del negocio, generar reportes y analizar datos
## Pasos - Ver dashboard
1. Acceder al backoffice
2. Ver dashboard principal con:
- Solicitudes del dia/semana/mes
- Ingresos del periodo
- Visitas completadas
- Conversion (solicitudes -> pagos)
- Vets activos
## Pasos - Generar reporte de solicitudes
1. Ir a "Reportes" -> "Solicitudes"
2. Seleccionar rango de fechas
3. Filtrar por:
- Estado
- Zona
- Veterinario
- Tipo de servicio
4. Click "Generar"
5. Ver tabla con resultados
6. Opcion de exportar (CSV/Excel)
## Pasos - Generar reporte de ingresos
1. Ir a "Reportes" -> "Ingresos"
2. Seleccionar periodo
3. Agrupar por:
- Dia/Semana/Mes
- Veterinario
- Servicio
- Zona
4. Ver grafico y tabla
5. Exportar si es necesario
## Pasos - Reporte de veterinarios
1. Ir a "Reportes" -> "Veterinarios"
2. Ver para cada vet:
- Visitas completadas
- Calificacion promedio
- Ingresos generados
- Tasa de cancelacion
3. Filtrar por periodo
4. Ordenar por metrica
## Pasos - Exportar datos
1. En cualquier reporte, click "Exportar"
2. Seleccionar formato (CSV, Excel)
3. Descargar archivo
4. Usar para analisis externo o contabilidad
## Que deberia pasar
- Vision clara del estado del negocio
- Datos para toma de decisiones
- Reportes exportables para contabilidad
- Identificacion de tendencias
## Problemas comunes
- Datos no actualizados en tiempo real
- Filtros que no devuelven lo esperado
- Discrepancia entre reportes y realidad
- Exportacion con formato incorrecto
## Casos especiales
- Reporte de prueba vs produccion
- Datos historicos antes de implementar filtro
- Comparativa año vs año
- Reporte personalizado para inversores
## Flujos relacionados
- Gestion de solicitudes (fuente de datos)
- Facturacion AFIP
- Google Sheets sync
## Notas tecnicas
- API:
- `GET /mascotas/api/v1/stats-summary` (metricas principales)
- `GET /solicitudes/api/v1/service-requests/?date_from=X&date_to=Y` (datos crudos)
- Google Sheets: sincronizacion automatica via Celery
- Dashboard actualiza cada 5 minutos
- Reportes pesados: generar async y notificar cuando estan listos

View File

@@ -0,0 +1,55 @@
# Registro de Usuario
## Tipo de usuario
Dueno de mascota (nuevo)
## Donde empieza
Pagina principal -> Boton "Registrarse" o `/register`
## Que quiere hacer el usuario
Crear una cuenta nueva para poder reservar turnos y gestionar sus mascotas
## Pasos
1. Click en "Registrarse" en la pagina principal
2. Ingresar email
3. Ingresar contraseña
4. Confirmar contraseña
5. Aceptar terminos y condiciones
6. Click en "Crear cuenta"
7. Recibir email de verificacion
8. Click en el link de verificacion
9. Cuenta activada, redirige al dashboard
## Que deberia pasar
- Usuario creado en el sistema
- Email de bienvenida enviado
- Puede iniciar sesion inmediatamente
- Ve su dashboard vacio (sin mascotas, sin turnos)
## Problemas comunes
- El email ya esta registrado pero el usuario no lo recuerda
- El email de verificacion llega a spam
- La contraseña no cumple los requisitos minimos
- El usuario cierra el browser antes de verificar y no encuentra el email despues
- Usuarios intentan registrarse con email de otra persona
## Casos especiales
- Si el email existe como usuario invitado (del turnero), deberia linkear las cuentas
- Si el usuario ya tiene cuenta pero no verificada, reenviar email de verificacion
- Registro desde el flujo de turnero (ya ingreso datos, solo falta contraseña)
## Flujos relacionados
- Login (despues de registrarse)
- Turnero (puede disparar registro)
- Recuperar contraseña (si ya tenia cuenta y no recuerda)
## Notas tecnicas
- API: `POST /common/users/create/`
- Validacion de contraseña: minimo 8 caracteres, 1 numero, 1 mayuscula
- Verificacion expira en 24 horas

View File

@@ -0,0 +1,77 @@
# Reservar Turno (Turnero)
## Tipo de usuario
Dueno de mascota (invitado, registrado, o recurrente)
## Donde empieza
Pagina principal -> Boton "Agendar visita" o enlace directo `/turnero`
## Que quiere hacer el usuario
Reservar un turno veterinario a domicilio para su mascota
## Pasos
1. Click en "Agendar visita"
2. Ingresar direccion en el popup de cobertura
3. Sistema verifica si hay cobertura en esa zona
4. Si hay cobertura, click "Siguiente"
5. Completar datos de la mascota:
- Nombre
- Tipo (Perro/Gato)
- Raza (opcional)
- Edad aproximada
- Peso aproximado
- Si esta castrado/a
6. Seleccionar categoria de servicio (Vacunacion, Consulta, etc.)
7. Seleccionar servicios especificos
8. Ver que algunos servicios se agregan automaticamente (ej: consulta clinica)
9. Seleccionar fechas preferidas (hasta 3)
10. Seleccionar franja horaria preferida
11. Ingresar datos de contacto:
- Nombre completo
- Telefono
- Email
12. Revisar resumen
13. Enviar solicitud
## Que deberia pasar
- Solicitud creada con estado "Pendiente"
- Email de confirmacion al usuario
- Notificacion a veterinarios de la zona
- Aparece en "Mis turnos" del usuario
## Problemas comunes
- No hay cobertura en la zona del usuario
- El autocompletado de direcciones no encuentra la calle
- Usuario no puede quitar "Consulta clinica" cuando selecciona vacunacion
- Confunden "fecha preferida" con "fecha confirmada"
- No entienden por que no pueden elegir hora exacta
- El total no se muestra hasta el final y sorprende al usuario
## Casos especiales
- Usuario invitado: se crea como guest, puede registrarse despues
- Usuario logueado: datos pre-llenados, mascota puede ser existente
- Usuario recurrente (mismo email): sistema detecta y sugiere linkear
- Mascota ya castrada: no mostrar servicio de castracion
- Servicios combo: algunos incluyen otros automaticamente
- Zona sin cobertura: mostrar formulario para avisar cuando haya
## Flujos relacionados
- Ver mis turnos (despues de reservar)
- Pagar turno (cuando se coordina)
- Cancelar turno
- Agregar mascota (si ya tiene cuenta)
## Notas tecnicas
- APIs:
- `POST /mascotas/api/v1/coverage-check/` (verificar cobertura)
- `POST /mascotas/api/v1/pet-owners/` (crear dueno invitado)
- `POST /mascotas/api/v1/pets/` (crear mascota)
- `GET /productos/api/v1/services/` (obtener servicios)
- `POST /solicitudes/api/v1/service-requests/` (crear solicitud)
- Estados de solicitud: pending -> coordinated -> payed -> completed

Some files were not shown because too many files have changed in this diff Show More