generated from westfarn/web_django_template
Stand up Print Forge as a 3D-printed toy shop with color variants and printer photography.
Replaces the client_site template branding, adds shop/shipping, and points beta CI at master for easy deploy. Closes #1
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from pos_sync.models import POSConnection, SyncEvent
|
||||
|
||||
|
||||
@admin.register(POSConnection)
|
||||
class POSConnectionAdmin(admin.ModelAdmin):
|
||||
list_display = ("name", "provider", "is_active")
|
||||
list_filter = ("provider", "is_active")
|
||||
|
||||
|
||||
@admin.register(SyncEvent)
|
||||
class SyncEventAdmin(admin.ModelAdmin):
|
||||
list_display = ("direction", "sku", "quantity", "status", "created_at")
|
||||
list_filter = ("direction", "status")
|
||||
search_fields = ("sku", "external_id")
|
||||
@@ -0,0 +1,12 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class PosSyncConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "pos_sync"
|
||||
verbose_name = "POS API sync"
|
||||
|
||||
def ready(self):
|
||||
from pos_sync import hooks
|
||||
|
||||
hooks.register()
|
||||
@@ -0,0 +1,35 @@
|
||||
from core.registry import (
|
||||
register_dashboard_collector,
|
||||
register_dispatcher,
|
||||
register_feature,
|
||||
register_portal_nav,
|
||||
)
|
||||
|
||||
|
||||
def register() -> None:
|
||||
register_feature("pos_sync")
|
||||
register_portal_nav(
|
||||
section="pos_sync",
|
||||
label="POS sync",
|
||||
url_name="pos_sync:event_list",
|
||||
group="Retail",
|
||||
order=40,
|
||||
)
|
||||
register_dashboard_collector(_dashboard)
|
||||
register_dispatcher(_dispatch)
|
||||
|
||||
|
||||
def _dashboard(request) -> dict:
|
||||
from pos_sync.models import SyncEvent
|
||||
|
||||
return {
|
||||
"pos_failed_syncs": SyncEvent.objects.filter(
|
||||
status=SyncEvent.Status.FAILED
|
||||
).count()
|
||||
}
|
||||
|
||||
|
||||
def _dispatch() -> int:
|
||||
from pos_sync.services import dispatch_pending_outbound
|
||||
|
||||
return dispatch_pending_outbound()
|
||||
@@ -0,0 +1,52 @@
|
||||
# Generated by Django 6.1 on 2026-09-06 11:17
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='POSConnection',
|
||||
fields=[
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('name', models.CharField(max_length=120)),
|
||||
('provider', models.CharField(choices=[('generic', 'Generic REST'), ('square', 'Square'), ('lightspeed', 'Lightspeed'), ('binderpos', 'BinderPOS'), ('crystalcommerce', 'CrystalCommerce')], default='generic', max_length=32)),
|
||||
('api_base_url', models.URLField(blank=True)),
|
||||
('api_token', models.CharField(blank=True, max_length=255)),
|
||||
('webhook_secret', models.CharField(blank=True, max_length=255)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='SyncEvent',
|
||||
fields=[
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('direction', models.CharField(choices=[('inbound', 'POS → site'), ('outbound', 'Site → POS')], max_length=16)),
|
||||
('status', models.CharField(choices=[('pending', 'Pending'), ('done', 'Done'), ('failed', 'Failed')], default='pending', max_length=16)),
|
||||
('sku', models.CharField(max_length=64)),
|
||||
('quantity', models.IntegerField(default=0)),
|
||||
('external_id', models.CharField(blank=True, max_length=255)),
|
||||
('payload', models.JSONField(blank=True, default=dict)),
|
||||
('error', models.TextField(blank=True)),
|
||||
('connection', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='events', to='pos_sync.posconnection')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,61 @@
|
||||
from django.db import models
|
||||
|
||||
from core.models import TimeStampedModel, UUIDPrimaryKeyModel
|
||||
|
||||
|
||||
class POSConnection(UUIDPrimaryKeyModel, TimeStampedModel):
|
||||
class Provider(models.TextChoices):
|
||||
GENERIC = "generic", "Generic REST"
|
||||
SQUARE = "square", "Square"
|
||||
LIGHTSPEED = "lightspeed", "Lightspeed"
|
||||
BINDERPOS = "binderpos", "BinderPOS"
|
||||
CRYSTALCOMMERCE = "crystalcommerce", "CrystalCommerce"
|
||||
|
||||
name = models.CharField(max_length=120)
|
||||
provider = models.CharField(
|
||||
max_length=32, choices=Provider.choices, default=Provider.GENERIC
|
||||
)
|
||||
api_base_url = models.URLField(blank=True)
|
||||
api_token = models.CharField(max_length=255, blank=True)
|
||||
webhook_secret = models.CharField(max_length=255, blank=True)
|
||||
is_active = models.BooleanField(default=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["name"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name
|
||||
|
||||
|
||||
class SyncEvent(UUIDPrimaryKeyModel, TimeStampedModel):
|
||||
class Direction(models.TextChoices):
|
||||
INBOUND = "inbound", "POS → site"
|
||||
OUTBOUND = "outbound", "Site → POS"
|
||||
|
||||
class Status(models.TextChoices):
|
||||
PENDING = "pending", "Pending"
|
||||
DONE = "done", "Done"
|
||||
FAILED = "failed", "Failed"
|
||||
|
||||
connection = models.ForeignKey(
|
||||
POSConnection,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="events",
|
||||
)
|
||||
direction = models.CharField(max_length=16, choices=Direction.choices)
|
||||
status = models.CharField(
|
||||
max_length=16, choices=Status.choices, default=Status.PENDING
|
||||
)
|
||||
sku = models.CharField(max_length=64)
|
||||
quantity = models.IntegerField(default=0)
|
||||
external_id = models.CharField(max_length=255, blank=True)
|
||||
payload = models.JSONField(default=dict, blank=True)
|
||||
error = models.TextField(blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.direction} {self.sku} × {self.quantity} · {self.status}"
|
||||
@@ -0,0 +1,131 @@
|
||||
"""POS inventory sync. Inbound webhooks decrement shop stock; outbound queues reserve POS."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import requests
|
||||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
|
||||
from pos_sync.models import POSConnection, SyncEvent
|
||||
from shop.models import Product
|
||||
from shop.services import ShopError, adjust_stock
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class POSSyncError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def active_connection() -> POSConnection | None:
|
||||
return POSConnection.objects.filter(is_active=True).order_by("created_at").first()
|
||||
|
||||
|
||||
def apply_inbound_sale(
|
||||
*,
|
||||
sku: str,
|
||||
quantity: int,
|
||||
external_id: str = "",
|
||||
payload: dict | None = None,
|
||||
connection: POSConnection | None = None,
|
||||
) -> SyncEvent:
|
||||
sku = (sku or "").strip()
|
||||
if not sku:
|
||||
raise POSSyncError("SKU is required.")
|
||||
if quantity < 1:
|
||||
raise POSSyncError("Quantity must be at least 1.")
|
||||
product = Product.objects.filter(sku__iexact=sku).first()
|
||||
if product is None:
|
||||
raise POSSyncError(f"Unknown SKU {sku}.")
|
||||
event = SyncEvent.objects.create(
|
||||
connection=connection or active_connection(),
|
||||
direction=SyncEvent.Direction.INBOUND,
|
||||
status=SyncEvent.Status.PENDING,
|
||||
sku=product.sku,
|
||||
quantity=quantity,
|
||||
external_id=external_id,
|
||||
payload=payload or {},
|
||||
)
|
||||
try:
|
||||
with transaction.atomic():
|
||||
locked = Product.objects.select_for_update().get(pk=product.pk)
|
||||
if locked.track_inventory and locked.fulfillment == Product.Fulfillment.STOCKED:
|
||||
adjust_stock(locked, -quantity)
|
||||
event.status = SyncEvent.Status.DONE
|
||||
event.save(update_fields=["status", "updated_at"])
|
||||
except ShopError as exc:
|
||||
event.status = SyncEvent.Status.FAILED
|
||||
event.error = str(exc)
|
||||
event.save(update_fields=["status", "error", "updated_at"])
|
||||
raise POSSyncError(str(exc)) from exc
|
||||
return event
|
||||
|
||||
|
||||
def enqueue_online_sale(order) -> list[SyncEvent]:
|
||||
"""Queue outbound POS reserves after an online shop sale."""
|
||||
events = []
|
||||
connection = active_connection()
|
||||
for item in order.items.all():
|
||||
sku = item.sku
|
||||
if not sku:
|
||||
continue
|
||||
events.append(
|
||||
SyncEvent.objects.create(
|
||||
connection=connection,
|
||||
direction=SyncEvent.Direction.OUTBOUND,
|
||||
status=SyncEvent.Status.PENDING,
|
||||
sku=sku,
|
||||
quantity=item.quantity,
|
||||
external_id=order.number,
|
||||
payload={"order_id": str(order.pk), "order_number": order.number},
|
||||
)
|
||||
)
|
||||
return events
|
||||
|
||||
|
||||
def _post_reserve(connection: POSConnection, event: SyncEvent) -> None:
|
||||
base = (connection.api_base_url or settings.POS_API_BASE_URL or "").rstrip("/")
|
||||
token = connection.api_token or (settings.POS_API_TOKEN or "")
|
||||
if not base:
|
||||
raise POSSyncError("POS API base URL is not configured.")
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
response = requests.post(
|
||||
f"{base}/inventory/reserve",
|
||||
json={
|
||||
"sku": event.sku,
|
||||
"quantity": event.quantity,
|
||||
"external_id": event.external_id,
|
||||
"source": "online",
|
||||
},
|
||||
headers=headers,
|
||||
timeout=15,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
|
||||
def dispatch_pending_outbound() -> int:
|
||||
sent = 0
|
||||
for event in SyncEvent.objects.filter(
|
||||
direction=SyncEvent.Direction.OUTBOUND,
|
||||
status=SyncEvent.Status.PENDING,
|
||||
)[:50]:
|
||||
connection = event.connection or active_connection()
|
||||
try:
|
||||
if connection is None:
|
||||
raise POSSyncError("No active POS connection.")
|
||||
_post_reserve(connection, event)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.exception("POS outbound failed for %s", event.sku)
|
||||
event.status = SyncEvent.Status.FAILED
|
||||
event.error = str(exc)
|
||||
event.save(update_fields=["status", "error", "updated_at"])
|
||||
else:
|
||||
event.status = SyncEvent.Status.DONE
|
||||
event.error = ""
|
||||
event.save(update_fields=["status", "error", "updated_at"])
|
||||
sent += 1
|
||||
return sent
|
||||
@@ -0,0 +1,22 @@
|
||||
{% extends "portal_base.html" %}
|
||||
{% block title %}POS sync · Portal{% endblock %}
|
||||
{% block topbar_title %}POS sync{% endblock %}
|
||||
{% block portal_content %}
|
||||
<p class="muted">Inbound POS sales decrement shop stock. Online sales queue outbound reserves.</p>
|
||||
<table class="table">
|
||||
<thead><tr><th>When</th><th>Direction</th><th>SKU</th><th>Qty</th><th>Status</th></tr></thead>
|
||||
<tbody>
|
||||
{% for event in events %}
|
||||
<tr>
|
||||
<td>{{ event.created_at }}</td>
|
||||
<td>{{ event.get_direction_display }}</td>
|
||||
<td>{{ event.sku }}</td>
|
||||
<td>{{ event.quantity }}</td>
|
||||
<td>{{ event.get_status_display }}</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="5" class="empty-state">No sync events yet.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,115 @@
|
||||
from decimal import Decimal
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import Client, TestCase, override_settings
|
||||
from django.urls import reverse
|
||||
|
||||
from pos_sync.models import POSConnection, SyncEvent
|
||||
from pos_sync.services import apply_inbound_sale, dispatch_pending_outbound, enqueue_online_sale
|
||||
from shop.models import Product
|
||||
from shop.services import add_to_cart, create_order_from_cart, mark_paid
|
||||
|
||||
|
||||
class POSInboundTests(TestCase):
|
||||
def setUp(self):
|
||||
self.product = Product.objects.create(
|
||||
name="Booster pack",
|
||||
sku="TCG-PACK",
|
||||
price=Decimal("4.99"),
|
||||
stock_qty=10,
|
||||
is_published=True,
|
||||
)
|
||||
|
||||
def test_inbound_sale_decrements_stock(self):
|
||||
event = apply_inbound_sale(sku="tcg-pack", quantity=3, external_id="pos-1")
|
||||
self.product.refresh_from_db()
|
||||
self.assertEqual(self.product.stock_qty, 7)
|
||||
self.assertEqual(event.status, SyncEvent.Status.DONE)
|
||||
|
||||
@override_settings(POS_WEBHOOK_SECRET="pos-secret")
|
||||
def test_webhook_requires_secret_and_updates_stock(self):
|
||||
url = reverse("pos_sync:inventory_webhook")
|
||||
anon = Client().post(
|
||||
url,
|
||||
data='{"sku":"TCG-PACK","quantity":1}',
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(anon.status_code, 401)
|
||||
ok = Client().post(
|
||||
url,
|
||||
data='{"sku":"TCG-PACK","quantity":2,"external_id":"reg-9"}',
|
||||
content_type="application/json",
|
||||
HTTP_AUTHORIZATION="Bearer pos-secret",
|
||||
)
|
||||
self.assertEqual(ok.status_code, 200)
|
||||
self.product.refresh_from_db()
|
||||
self.assertEqual(self.product.stock_qty, 8)
|
||||
|
||||
|
||||
class POSOutboundTests(TestCase):
|
||||
def setUp(self):
|
||||
self.product = Product.objects.create(
|
||||
name="Figure",
|
||||
sku="FIG-1",
|
||||
price=Decimal("12.00"),
|
||||
stock_qty=5,
|
||||
is_published=True,
|
||||
)
|
||||
self.connection = POSConnection.objects.create(
|
||||
name="Counter",
|
||||
api_base_url="https://pos.example.com",
|
||||
api_token="tok",
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
def test_paid_order_enqueues_outbound(self):
|
||||
session = self.client.session
|
||||
add_to_cart(session, self.product, 1)
|
||||
session.save()
|
||||
order = create_order_from_cart(self.client.session, email="a@example.com")
|
||||
mark_paid(order)
|
||||
event = SyncEvent.objects.get(direction=SyncEvent.Direction.OUTBOUND)
|
||||
self.assertEqual(event.sku, "FIG-1")
|
||||
self.assertEqual(event.status, SyncEvent.Status.PENDING)
|
||||
|
||||
def test_dispatch_posts_reserve(self):
|
||||
enqueue_online_sale(
|
||||
type(
|
||||
"O",
|
||||
(),
|
||||
{
|
||||
"number": "ORD-1",
|
||||
"pk": "x",
|
||||
"items": type(
|
||||
"M",
|
||||
(),
|
||||
{
|
||||
"all": lambda self: [
|
||||
type("I", (), {"sku": "FIG-1", "quantity": 1})()
|
||||
]
|
||||
},
|
||||
)(),
|
||||
},
|
||||
)()
|
||||
)
|
||||
with patch("pos_sync.services.requests.post") as post:
|
||||
post.return_value.raise_for_status = lambda: None
|
||||
sent = dispatch_pending_outbound()
|
||||
self.assertEqual(sent, 1)
|
||||
post.assert_called_once()
|
||||
self.assertIn("/inventory/reserve", post.call_args.args[0])
|
||||
event = SyncEvent.objects.get()
|
||||
self.assertEqual(event.status, SyncEvent.Status.DONE)
|
||||
|
||||
|
||||
class POSPortalTests(TestCase):
|
||||
def test_list_requires_login(self):
|
||||
self.assertEqual(Client().get(reverse("pos_sync:event_list")).status_code, 302)
|
||||
|
||||
def test_list_ok_when_logged_in(self):
|
||||
User = get_user_model()
|
||||
User.objects.create_user("clerk", password="test-pass-123")
|
||||
client = Client()
|
||||
client.login(username="clerk", password="test-pass-123")
|
||||
self.assertEqual(client.get(reverse("pos_sync:event_list")).status_code, 200)
|
||||
@@ -0,0 +1,10 @@
|
||||
from django.urls import path
|
||||
|
||||
from pos_sync import views
|
||||
|
||||
app_name = "pos_sync"
|
||||
|
||||
urlpatterns = [
|
||||
path("", views.event_list, name="event_list"),
|
||||
path("webhooks/inventory/", views.inventory_webhook, name="inventory_webhook"),
|
||||
]
|
||||
@@ -0,0 +1,70 @@
|
||||
import json
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.http import HttpResponse, HttpResponseBadRequest, JsonResponse
|
||||
from django.shortcuts import render
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.views.decorators.http import require_http_methods
|
||||
|
||||
from pos_sync.models import POSConnection, SyncEvent
|
||||
from pos_sync.services import POSSyncError, apply_inbound_sale
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _authorized(request) -> bool:
|
||||
expected = (settings.POS_WEBHOOK_SECRET or "").strip()
|
||||
if not expected:
|
||||
conn = POSConnection.objects.filter(is_active=True).exclude(
|
||||
webhook_secret=""
|
||||
).first()
|
||||
expected = (conn.webhook_secret if conn else "").strip()
|
||||
if not expected:
|
||||
return False
|
||||
header = request.headers.get("Authorization", "")
|
||||
token = ""
|
||||
if header.startswith("Bearer "):
|
||||
token = header[7:].strip()
|
||||
token = token or request.GET.get("token") or ""
|
||||
return token == expected
|
||||
|
||||
|
||||
@login_required
|
||||
def event_list(request):
|
||||
events = SyncEvent.objects.select_related("connection")[:200]
|
||||
connections = POSConnection.objects.all()
|
||||
return render(
|
||||
request,
|
||||
"pos_sync/list.html",
|
||||
{"events": events, "connections": connections},
|
||||
)
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@require_http_methods(["POST"])
|
||||
def inventory_webhook(request):
|
||||
if not _authorized(request):
|
||||
return HttpResponse("unauthorized", status=401)
|
||||
try:
|
||||
payload = json.loads(request.body.decode() or "{}")
|
||||
except json.JSONDecodeError:
|
||||
return HttpResponseBadRequest("invalid json")
|
||||
sku = payload.get("sku") or payload.get("SKU") or ""
|
||||
try:
|
||||
quantity = int(payload.get("quantity") or payload.get("qty") or 0)
|
||||
except (TypeError, ValueError):
|
||||
return HttpResponseBadRequest("invalid quantity")
|
||||
external_id = str(payload.get("external_id") or payload.get("id") or "")
|
||||
try:
|
||||
event = apply_inbound_sale(
|
||||
sku=sku,
|
||||
quantity=quantity,
|
||||
external_id=external_id,
|
||||
payload=payload,
|
||||
)
|
||||
except POSSyncError as exc:
|
||||
logger.warning("POS inbound rejected: %s", exc)
|
||||
return JsonResponse({"ok": False, "error": str(exc)}, status=400)
|
||||
return JsonResponse({"ok": True, "id": str(event.pk), "status": event.status})
|
||||
Reference in New Issue
Block a user