Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
82e8f10
Added WebhookCredential model, migrations, and notified flag to wishl…
DredBaron Sep 12, 2026
29616d0
Extracted shared `compute_collection_stats` and added `color_identity…
DredBaron Sep 12, 2026
921d40f
Added `services/webhooks.py` implementation for Home Assistant push e…
DredBaron Sep 12, 2026
7ebf1dc
Added `/webhooks` router for managing webhook credentials
DredBaron Sep 12, 2026
b02c10b
Added `/webhook` pull endpoint for Home Assistant stats
DredBaron Sep 12, 2026
0998ee1
Registered webhook routers
DredBaron Sep 12, 2026
8a254ff
Added `home_assistant_integration_enabled` and per-user credential ca…
DredBaron Sep 12, 2026
ff8b1f3
Wired trade events to Home Assistant webhook service
DredBaron Sep 12, 2026
88ea948
Added wishlist `target-price` webhook check to price refresh
DredBaron Sep 12, 2026
7d04305
Reset wishlist notified flag when target price, card, or foil changes
DredBaron Sep 12, 2026
352be2c
Fixed settings cache leaking between tests
DredBaron Sep 12, 2026
dda9177
Added test coverage for webhook credential management and the pull en…
DredBaron Sep 12, 2026
3816ea7
Added test coverage for the webhook push service
DredBaron Sep 12, 2026
d8e43dc
Added test coverage for wishlist `target-price` webhook checks
DredBaron Sep 12, 2026
b918636
Added Webhooks page for managing credentials
DredBaron Sep 12, 2026
043cc5d
Added Webhooks nav link and route
DredBaron Sep 12, 2026
e4f35c5
Added webhook credential list and secret-reveal modal status
DredBaron Sep 12, 2026
0ec4ba7
Added Home Assistant Integration toggle to Settings page
DredBaron Sep 12, 2026
378bb34
Updated application versioning to 1.10.0
DredBaron Sep 12, 2026
dedab97
Updated CHANGELOG for v1.10.0
DredBaron Sep 12, 2026
48bc3f8
Updated README for Home Assistant Integration
DredBaron Sep 12, 2026
6397abc
Migrated and updated Scryfall and Frankfurter accreditation
DredBaron Sep 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,21 @@
# Changelog

## v1.10.0

### Added

- **Home Assistant Integration** - Per-user webhook credentials for pushing trade and wishlist-price alerts to Home Assistant, and pulling live collection stats.
- New Webhooks page where any user can generate credentials, each pairing a Home Assistant target URL with a bearer secret shown once at creation.
- Push events for trade proposals, updates, engagement, acceptance, and rejection/cancellation, plus a wishlist target-price-met alert.
- A new authenticated pull endpoint for Home Assistant to fetch a user's collection stats on its own schedule.
- Per-credential TLS verification toggle for Home Assistant instances behind a self-signed certificate or internal CA, with a visible "Insecure" indicator when disabled.
- Admin controls in Settings to enable/disable the integration and cap credentials per user (default 3, off by default).
- See the wiki for setup and Home Assistant configuration details.

### Changed

- Version bumped from 1.9.2 to 1.10.0 within `constants.py`.

## v1.9.2

### Fixed
Expand Down
4 changes: 4 additions & 0 deletions CREDITS.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
## Credits

Favicon icon by [Faithtoken](https://game-icons.net/1x1/faithtoken/card-pick.html), licensed under [CC BY 3.0](https://creativecommons.org/licenses/by/3.0/).

Card data, imagery, and pricing provided by [Scryfall](https://scryfall.com), used in accordance with their [API Terms of Service](https://scryfall.com/docs/api). Scryfall is not affiliated with or endorsed by Wizards of the Coast.

Currency exchange rates provided by [Frankfurter](https://frankfurter.dev), a free, open-source exchange rate API sourced from the European Central Bank and other central banks.
32 changes: 10 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
![Arch](https://img.shields.io/badge/arch-AMD64%20%7C%20ARM64-informational)
![Scryfall](https://img.shields.io/badge/data-Scryfall-E35B2A)

Self-hosted MTG card inventory server with multi-account support, collection tracking, deck building, statistics, wishlist, public Showroom display, card trading, loan tracking, card photos, and import/export. Built with FastAPI and React, deployed with Docker.
Self-hosted MTG card inventory server with multi-account support, collection tracking, deck building, statistics, wishlist, public Showroom display, card trading, loan tracking, card photos, Home Assistant integration, and import/export. Built with FastAPI and React, deployed with Docker.

---

Expand Down Expand Up @@ -40,6 +40,7 @@ Self-hosted MTG card inventory server with multi-account support, collection tra
- **Card Trading** - Propose and complete card trades with other users on the same instance. Both sides confirm before cards auto-transfer between collections. Trade history stored separately.
- **Loan Tracking** - Mark cards in your collection as on loan with a recipient name and date. Loaned cards display a badge in the Collection view.
- **Card Photos** - Upload front and back photos for individual cards in your collection. Photos are viewable by trade counterparts when reviewing a trade offer.
- **Home Assistant Integration** - Per-user webhook credentials push trade and wishlist price-target alerts to Home Assistant, and a bearer-authenticated pull endpoint serves live collection stats to a Home Assistant sensor. Admin-gated and off by default.

---

Expand All @@ -51,17 +52,8 @@ For those moving up from versions previous to v1.9.0, there are extra steps invo

## Roadmap

### Release Plan

- **v1.10** - Home Assistant Integration: webhooks for custom dashboards, price alerts, and watchlist notifications

### Short-term

- **Set Completion** - Appending the statistics page to include per-set completion for the collectors.
- **Expand Import/Export** - Expand accepted import formats beyond Moxfield/MTGO/Arena, and add collection export format options.

### Long-term

- **Bulk Data Download** - Scryfall allows for users to download the full catalog of card information. An option is planned to allow users to download the entire database at once for faster card lookups.

### Not Planned
Expand All @@ -77,7 +69,7 @@ For those moving up from versions previous to v1.9.0, there are extra steps invo
| Layer | Technology |
|---|---|
| Application | Python 3.14, FastAPI, SQLAlchemy, Alembic, React, Vite, TanStack Query |
| Database | PostgreSQL 16 |
| Database | PostgreSQL 16 / SQLite |
| Reverse Proxy | Nginx |
| Container | Docker + Docker Compose |

Expand Down Expand Up @@ -166,11 +158,14 @@ All configuration is done via the `.env` file or the admin **Settings** panel in
| `DB_PASSWORD` | Database password | *(required)* |
| `JWT_SECRET` | Secret key for auth tokens | *(required)* |
| `DATA_PATH` | Path for PostgreSQL data volume | `./data` |
| `SQLITE_PATH` | Path for the SQLite database file, if using `docker-compose.sqlite.yml` instead of PostgreSQL | `./sqlite-data` |
| `CONFIG_PATH` | Path for app config volume | `./config` |
| `UPLOADS_PATH` | Path for card photo uploads | `./uploads` |
| `TRADES_PATH` | Path for trade history database | `./trades` |
| `NOTEL` | Option to disable telemetry settings | Not present by default |

> Database backend (PostgreSQL vs SQLite) is chosen by which compose file you run, not by an environment variable: `docker-compose.yml` uses `DATA_PATH`, `docker-compose.sqlite.yml` uses `SQLITE_PATH`. This is a permanent, per-instance choice locked in at first-time setup. See the [Install Guide](https://github.com/DredBaron/OpenMTG/wiki/Installation).

### Price Refresh Settings (Admin UI)

| Setting | Description | Default |
Expand All @@ -185,12 +180,13 @@ All configuration is done via the `.env` file or the admin **Settings** panel in
| Showroom | Enables the public Showroom display page, nav link, and per-card/per-deck visibility toggles | Enabled |
| Card Search | Enables the Card Search page and nav link | Enabled |
| Trades | Enables the Trades page, nav link, and trade proposal workflow between users | Enabled |
| Home Assistant Integration | Enables the Webhooks page and per-user webhook credentials for pushing trade/wishlist events to Home Assistant and pulling live stats | Disabled |

---

## Ports

By default, OpenMTG listens on port **8080**. To change it, edit the `nginx` service in `docker-compose.yml`:
By default, OpenMTG listens on port **8080**. To change it, edit the `app` service's port mapping in `docker-compose.yml`:

```yaml
ports:
Expand Down Expand Up @@ -219,15 +215,9 @@ You are free to use, modify, and self-host OpenMTG. If you distribute a modified

---

## Acknowledgements

Card data and pricing provided by [Scryfall](https://scryfall.com). Please respect their [API guidelines](https://scryfall.com/docs/api) and rate limits.

---

## Development History

This project was initially conceived with AI reference (Claude by Anthropic)
This project was initially conceived with AI reference (Claude by Anthropic)
as a learning exercise in building self-hosted MTG collection tools, as well
as understanding Docker image development processes. Active development is
now entirely human-driven.
Expand All @@ -242,9 +232,7 @@ Contributions are welcome and reviewed by human maintainer(s) only.

## Credits

Favicon icon by [Faithtoken](https://game-icons.net/1x1/faithtoken/card-pick.html), licensed under [CC BY 3.0](https://creativecommons.org/licenses/by/3.0/).

Card data, imagery, and pricing provided by [Scryfall](https://scryfall.com), used in accordance with their [API Terms of Service](https://scryfall.com/docs/api). Scryfall is not affiliated with or endorsed by Wizards of the Coast.
See [CREDITS.md](CREDITS.md) for third-party data, API, and asset attributions.

## Notes

Expand Down
4 changes: 2 additions & 2 deletions backend/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@


VERSIONS = {
"API_VERSION": "1.9.2",
"APPLICATION_VERSION": "1.9.2",
"API_VERSION": "1.10.0",
"APPLICATION_VERSION": "1.10.0",
}

API_RATES = {
Expand Down
4 changes: 3 additions & 1 deletion backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from fastapi import FastAPI
from slowapi import _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from routers import auth, cards, collections, currencies, converted_currencies, decks, export, admin, settings, telemetry, wishlist, showroom, scanner, trades
from routers import auth, cards, collections, currencies, converted_currencies, decks, export, admin, settings, telemetry, wishlist, showroom, scanner, trades, webhook_credentials, webhook_inbound
from limiter import limiter
from services.price_refresh import start_scheduler as start_price_scheduler
from services.telemetry import start_scheduler as start_telemetry_scheduler
Expand Down Expand Up @@ -36,6 +36,8 @@ async def lifespan(app: FastAPI):
app.include_router(showroom.router)
app.include_router(scanner.router)
app.include_router(trades.router)
app.include_router(webhook_credentials.router)
app.include_router(webhook_inbound.router)

@app.get("/health")
def health():
Expand Down
11 changes: 11 additions & 0 deletions backend/markets.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,14 @@
"adapter": ScryfallMarket,
},
}


def resolve_base_currency_and_rate(db, currency: str) -> tuple[str, float, str]:
if currency in MARKETS:
return currency, 1.0, currency

from models import ConvertedCurrency
db_curr = db.query(ConvertedCurrency).filter_by(code=currency.upper()).first()
if db_curr and db_curr.rate:
return "usd", db_curr.rate, currency
return "usd", 1.0, "usd"
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"""add webhook_credentials table and wishlist notified flag

Revision ID: a3b4c5d6e7f8
Revises: c3d4e5f6a7b8
Create Date: 2026-09-11

"""
from alembic import op
import sqlalchemy as sa

revision = 'a3b4c5d6e7f8'
down_revision = 'c3d4e5f6a7b8'
branch_labels = None
depends_on = None


def upgrade() -> None:
op.create_table(
'webhook_credentials',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=False),
sa.Column('label', sa.String(length=100), nullable=False),
sa.Column('webhook_id', sa.String(length=64), nullable=False),
sa.Column('secret_hash', sa.String(length=64), nullable=False),
sa.Column('target_url', sa.Text(), nullable=True),
sa.Column('enabled', sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column('last_used_at', sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('id'),
)
op.create_index(
op.f('ix_webhook_credentials_id'), 'webhook_credentials', ['id'], unique=False
)
op.create_index(
op.f('ix_webhook_credentials_webhook_id'), 'webhook_credentials', ['webhook_id'], unique=True
)

op.add_column(
'wishlist_entries',
sa.Column('notified', sa.Boolean(), nullable=False, server_default=sa.false())
)


def downgrade() -> None:
op.drop_column('wishlist_entries', 'notified')
op.drop_index('ix_webhook_credentials_id', table_name='webhook_credentials')
op.drop_index('ix_webhook_credentials_webhook_id', table_name='webhook_credentials')
op.drop_table('webhook_credentials')
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
"""add verify_tls to webhook_credentials

Revision ID: b4c5d6e7f8a9
Revises: a3b4c5d6e7f8
Create Date: 2026-09-11

"""
from alembic import op
import sqlalchemy as sa

revision = 'b4c5d6e7f8a9'
down_revision = 'a3b4c5d6e7f8'
branch_labels = None
depends_on = None


def upgrade() -> None:
op.add_column(
'webhook_credentials',
sa.Column('verify_tls', sa.Boolean(), nullable=False, server_default=sa.true())
)


def downgrade() -> None:
op.drop_column('webhook_credentials', 'verify_tls')
2 changes: 2 additions & 0 deletions backend/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from models.wishlist import WishlistEntry
from models.currency import ConvertedCurrency
from models.price_history import PriceHistory
from models.webhook_credential import WebhookCredential

__all__ = [
"User",
Expand All @@ -19,4 +20,5 @@
"WishlistEntry",
"ConvertedCurrency",
"PriceHistory",
"WebhookCredential",
]
7 changes: 4 additions & 3 deletions backend/models/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ class User(Base):
created_at = Column(DateTime(timezone=True), server_default=func.now(), default=lambda: datetime.now(timezone.utc))
preferred_currency = Column(String, default="usd", nullable=False)

collections = relationship("CollectionEntry", back_populates="owner", cascade="all, delete-orphan")
decks = relationship("Deck", back_populates="owner", cascade="all, delete-orphan")
wishlist = relationship("WishlistEntry", back_populates="owner", cascade="all, delete-orphan")
collections = relationship("CollectionEntry", back_populates="owner", cascade="all, delete-orphan")
decks = relationship("Deck", back_populates="owner", cascade="all, delete-orphan")
wishlist = relationship("WishlistEntry", back_populates="owner", cascade="all, delete-orphan")
webhooks = relationship("WebhookCredential", back_populates="owner", cascade="all, delete-orphan")
21 changes: 21 additions & 0 deletions backend/models/webhook_credential.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text, ForeignKey
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from database import Base


class WebhookCredential(Base):
__tablename__ = "webhook_credentials"

id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
label = Column(String(100), nullable=False)
webhook_id = Column(String(64), unique=True, nullable=False, index=True)
secret_hash = Column(String(64), nullable=False)
target_url = Column(Text, nullable=True)
enabled = Column(Boolean, default=True, server_default='true', nullable=False)
verify_tls = Column(Boolean, default=True, server_default='true', nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
last_used_at = Column(DateTime(timezone=True), nullable=True)

owner = relationship("User", back_populates="webhooks")
1 change: 1 addition & 0 deletions backend/models/wishlist.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ class WishlistEntry(Base):
target_price = Column(Float, nullable=True)
foil = Column(Boolean, default=False)
notes = Column(Text, nullable=True)
notified = Column(Boolean, default=False, server_default='false', nullable=False)
added_at = Column(DateTime(timezone=True), server_default=func.now())

__table_args__ = (
Expand Down
27 changes: 12 additions & 15 deletions backend/routers/collections.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import re
from schemas import AddCardRequest, UpdateCardRequest, ImportResult, ImportRequest
from constants import CONDITION_MULTIPLIERS
from markets import MARKETS
from markets import MARKETS, resolve_base_currency_and_rate

import models
import schemas
Expand Down Expand Up @@ -180,21 +180,14 @@ def get_stats(
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
uid = current_user.id
currency = current_user.preferred_currency
return compute_collection_stats(db, current_user)

if currency in MARKETS:
base_currency = currency
rate = 1.0
else:
db_curr = db.query(models.ConvertedCurrency).filter_by(code=currency.upper()).first()
if db_curr and db_curr.rate:
base_currency = "usd"
rate = db_curr.rate
else:
currency = "usd"
base_currency = "usd"
rate = 1.0

def compute_collection_stats(db: Session, user: models.User) -> dict:
uid = user.id
currency = user.preferred_currency

base_currency, rate, currency = resolve_base_currency_and_rate(db, currency)

normal_col_name = f"price_{base_currency}"
foil_col_name = f"price_{base_currency}_foil"
Expand Down Expand Up @@ -408,6 +401,10 @@ def row_unit_price(r):
},
"rarity": [{"name": r.rarity or "unknown", "count": r.count, "value": round(r.value or 0, 2)} for r in sorted(rarity_rows, key=lambda r: r.rarity or "")],
"colors": [{"name": k, "count": v} for k, v in sorted(color_count.items(), key=lambda x: x[1], reverse=True)],
"color_identity_pct": {
k: round(v / sum(color_count.values()) * 100, 1)
for k, v in color_count.items()
} if color_count else {},
"types": [{"name": k, "count": v} for k, v in sorted(type_count.items(), key=lambda x: x[1], reverse=True)],
"conditions": [{"name": r.condition or "Unknown", "count": r.count, "value": round(r.value or 0, 2)} for r in sorted(condition_rows, key=lambda r: r.condition or "")],
"top_cards": top_cards,
Expand Down
14 changes: 14 additions & 0 deletions backend/routers/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,20 @@ def update_settings(payload: SettingsUpdate, db: Session = Depends(get_db)):
if payload.trades_enabled is not None:
settings_service.set_value(db, "trades_enabled", "true" if payload.trades_enabled else "false")

if payload.home_assistant_integration_enabled is not None:
settings_service.set_value(
db, "home_assistant_integration_enabled",
"true" if payload.home_assistant_integration_enabled else "false",
)

if payload.home_assistant_max_credentials_per_user is not None:
if not 1 <= payload.home_assistant_max_credentials_per_user <= 20:
raise HTTPException(status_code=400, detail="Max credentials per user must be between 1 and 20")
settings_service.set_value(
db, "home_assistant_max_credentials_per_user",
str(payload.home_assistant_max_credentials_per_user),
)

return settings_service.get_all(db)


Expand Down
Loading
Loading