diff --git a/alembic/versions/001_add_email_inbound_token.py b/alembic/versions/001_add_email_inbound_token.py index 43a6fe8..e2e5ff2 100644 --- a/alembic/versions/001_add_email_inbound_token.py +++ b/alembic/versions/001_add_email_inbound_token.py @@ -34,4 +34,4 @@ def upgrade() -> None: def downgrade() -> None: op.drop_constraint("uq_users_email_inbound_token", "users", type_="unique") - op.drop_column("users", "email_inbound_token") \ No newline at end of file + op.drop_column("users", "email_inbound_token") diff --git a/src/receiptwitness/config.py b/src/receiptwitness/config.py index b9d2574..3d3690a 100644 --- a/src/receiptwitness/config.py +++ b/src/receiptwitness/config.py @@ -3,7 +3,6 @@ from pydantic import model_validator from pydantic_settings import BaseSettings - _PLACEHOLDER_VALUES = {"change-me-in-production"} @@ -40,7 +39,8 @@ class ReceiptWitnessSettings(BaseSettings): if not self.session_encryption_key or self.session_encryption_key in _PLACEHOLDER_VALUES: errors.append( "RW_SESSION_ENCRYPTION_KEY must be set to a secure value. " - 'Generate one with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"' + "Generate one with: python -c \"from cryptography.fernet import Fernet; " + 'print(Fernet.generate_key().decode())"' ) if self.notifications_enabled and not self.resend_api_key: errors.append( diff --git a/src/receiptwitness/events.py b/src/receiptwitness/events.py index 25d6c8f..8bbe876 100644 --- a/src/receiptwitness/events.py +++ b/src/receiptwitness/events.py @@ -7,12 +7,12 @@ from datetime import UTC, datetime from decimal import Decimal import redis.asyncio as aioredis -from receiptwitness.shared.database import get_async_session_factory -from receiptwitness.shared.models import User from sqlalchemy import select from receiptwitness.config import settings from receiptwitness.notifications.email import send_receipt_notification +from receiptwitness.shared.database import get_async_session_factory +from receiptwitness.shared.models import User logger = logging.getLogger(__name__) diff --git a/src/receiptwitness/pipeline/matching.py b/src/receiptwitness/pipeline/matching.py index 882e0ea..a9cfb80 100644 --- a/src/receiptwitness/pipeline/matching.py +++ b/src/receiptwitness/pipeline/matching.py @@ -7,9 +7,6 @@ and batch matching for purchase ingestion. import uuid from dataclasses import dataclass -from receiptwitness.shared.constants import MatchConfidence -from receiptwitness.shared.models import NormalizedProduct -from receiptwitness.shared.schemas import PurchaseItemCreate from sqlalchemy.orm import Session from receiptwitness.pipeline.normalization import ( @@ -18,6 +15,9 @@ from receiptwitness.pipeline.normalization import ( extract_size_info, normalize_product, ) +from receiptwitness.shared.constants import MatchConfidence +from receiptwitness.shared.models import NormalizedProduct +from receiptwitness.shared.schemas import PurchaseItemCreate # Re-export for convenience ConfidenceLevel = MatchConfidence diff --git a/src/receiptwitness/pipeline/normalization.py b/src/receiptwitness/pipeline/normalization.py index 9b9e2b7..129adbc 100644 --- a/src/receiptwitness/pipeline/normalization.py +++ b/src/receiptwitness/pipeline/normalization.py @@ -10,11 +10,12 @@ import re from dataclasses import dataclass from enum import StrEnum -from receiptwitness.shared.models import NormalizedProduct -from sqlalchemy import cast, func, select, String +from sqlalchemy import String, cast, func, select from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import Session +from receiptwitness.shared.models import NormalizedProduct + class MatchMethod(StrEnum): """How a product match was determined.""" diff --git a/src/receiptwitness/shared/models/__init__.py b/src/receiptwitness/shared/models/__init__.py index 6c9afc7..3ecc46d 100644 --- a/src/receiptwitness/shared/models/__init__.py +++ b/src/receiptwitness/shared/models/__init__.py @@ -2,12 +2,12 @@ from receiptwitness.shared.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin from receiptwitness.shared.models.product import NormalizedProduct -from receiptwitness.shared.models.user import User, UserStoreAccount +from receiptwitness.shared.models.stub_purchase import Purchase, PurchaseItem # Stub models — needed for relationship resolution but not directly used by receiptwitness. # Full definitions live in cartsnitch/common. from receiptwitness.shared.models.stub_store import Store, StoreLocation -from receiptwitness.shared.models.stub_purchase import Purchase, PurchaseItem +from receiptwitness.shared.models.user import User, UserStoreAccount __all__ = [ "Base", diff --git a/src/receiptwitness/shared/models/stub_purchase.py b/src/receiptwitness/shared/models/stub_purchase.py index 2822291..81e9157 100644 --- a/src/receiptwitness/shared/models/stub_purchase.py +++ b/src/receiptwitness/shared/models/stub_purchase.py @@ -8,12 +8,26 @@ UserStoreAccount. The canonical definitions live in cartsnitch/common. import uuid from datetime import date, datetime from decimal import Decimal +from typing import TYPE_CHECKING -from sqlalchemy import JSON, Date, DateTime, ForeignKey, Index, Numeric, String, UniqueConstraint, func -from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy import ( + JSON, + Date, + DateTime, + ForeignKey, + Index, + Numeric, + String, + UniqueConstraint, + func, +) +from sqlalchemy.orm import Mapped, mapped_column, relationship from receiptwitness.shared.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin +if TYPE_CHECKING: + from receiptwitness.shared.models.user import User + class Purchase(UUIDPrimaryKeyMixin, TimestampMixin, Base): """Stub: a shopping trip/receipt. Full definition in cartsnitch/common.""" @@ -37,6 +51,9 @@ class Purchase(UUIDPrimaryKeyMixin, TimestampMixin, Base): nullable=False, ) + # Relationships (stubs — canonical definitions in cartsnitch/common) + user: Mapped["User"] = relationship(back_populates="purchases") + __table_args__ = ( Index("ix_purchases_user_store", "user_id", "store_id"), UniqueConstraint("user_id", "store_id", "receipt_id", name="uq_purchase_receipt"), diff --git a/src/receiptwitness/shared/models/stub_store.py b/src/receiptwitness/shared/models/stub_store.py index e039d36..6ffcc67 100644 --- a/src/receiptwitness/shared/models/stub_store.py +++ b/src/receiptwitness/shared/models/stub_store.py @@ -6,6 +6,7 @@ UserStoreAccount. The canonical definitions live in cartsnitch/common. """ import uuid +from typing import TYPE_CHECKING from sqlalchemy import Float, ForeignKey, String from sqlalchemy.orm import Mapped, mapped_column, relationship @@ -13,6 +14,9 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship from receiptwitness.shared.constants import StoreSlug from receiptwitness.shared.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin +if TYPE_CHECKING: + from receiptwitness.shared.models.user import UserStoreAccount + class Store(UUIDPrimaryKeyMixin, TimestampMixin, Base): """Stub: canonical retailer. Full definition in cartsnitch/common.""" @@ -24,6 +28,9 @@ class Store(UUIDPrimaryKeyMixin, TimestampMixin, Base): logo_url: Mapped[str | None] = mapped_column(String(500)) website_url: Mapped[str | None] = mapped_column(String(500)) + # Relationships (stubs — canonical definitions in cartsnitch/common) + user_accounts: Mapped[list["UserStoreAccount"]] = relationship(back_populates="store") + class StoreLocation(UUIDPrimaryKeyMixin, TimestampMixin, Base): """Stub: physical store location. Full definition in cartsnitch/common.""" diff --git a/src/receiptwitness/shared/models/user.py b/src/receiptwitness/shared/models/user.py index b458561..9849dc3 100644 --- a/src/receiptwitness/shared/models/user.py +++ b/src/receiptwitness/shared/models/user.py @@ -5,7 +5,7 @@ import uuid from datetime import datetime from typing import TYPE_CHECKING -from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, String, Text, UniqueConstraint, text +from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, String, Text, UniqueConstraint from sqlalchemy.orm import Mapped, mapped_column, relationship from receiptwitness.shared.constants import AccountStatus @@ -27,9 +27,6 @@ class User(UUIDPrimaryKeyMixin, TimestampMixin, Base): nullable=False, unique=True, default=lambda: secrets.token_urlsafe(16), - server_default=text( - "replace(replace(trim(trailing '=' from encode(gen_random_bytes(16), 'base64')), '+', '-'), '/', '_')" - ), ) hashed_password: Mapped[str | None] = mapped_column(String(255), nullable=True) display_name: Mapped[str | None] = mapped_column(String(100)) diff --git a/src/receiptwitness/worker/email_worker.py b/src/receiptwitness/worker/email_worker.py index 1688f1d..5ea0ae4 100644 --- a/src/receiptwitness/worker/email_worker.py +++ b/src/receiptwitness/worker/email_worker.py @@ -3,8 +3,6 @@ import asyncio import logging -from receiptwitness.shared.database import get_async_session_factory -from receiptwitness.shared.models import User from sqlalchemy import select from receiptwitness.config import settings @@ -15,6 +13,8 @@ from receiptwitness.parsers.email.kroger import KrogerEmailParser from receiptwitness.parsers.email.meijer import MeijerEmailParser from receiptwitness.parsers.email.target import TargetEmailParser from receiptwitness.queue.email import ack_email, consume_emails, get_redis +from receiptwitness.shared.database import get_async_session_factory +from receiptwitness.shared.models import User logger = logging.getLogger(__name__) diff --git a/tests/test_config.py b/tests/test_config.py index 059573b..ddbd495 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,4 +1,5 @@ import pytest + from receiptwitness.config import ReceiptWitnessSettings diff --git a/tests/test_pipeline/conftest.py b/tests/test_pipeline/conftest.py index 98b90ab..3f02aad 100644 --- a/tests/test_pipeline/conftest.py +++ b/tests/test_pipeline/conftest.py @@ -1,10 +1,11 @@ """Shared test fixtures for pipeline tests.""" import pytest -from receiptwitness.shared.models import Base from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker +from receiptwitness.shared.models import Base + @pytest.fixture def engine(): diff --git a/tests/test_pipeline/test_matching.py b/tests/test_pipeline/test_matching.py index 310396b..f72f58b 100644 --- a/tests/test_pipeline/test_matching.py +++ b/tests/test_pipeline/test_matching.py @@ -4,16 +4,15 @@ import uuid from datetime import UTC, datetime from decimal import Decimal -from receiptwitness.shared.constants import MatchConfidence -from receiptwitness.shared.models import NormalizedProduct -from receiptwitness.shared.schemas import PurchaseItemCreate - from receiptwitness.pipeline.matching import ( ProductMatcher, classify_confidence, match_purchase_item, ) from receiptwitness.pipeline.normalization import MatchMethod +from receiptwitness.shared.constants import MatchConfidence +from receiptwitness.shared.models import NormalizedProduct +from receiptwitness.shared.schemas import PurchaseItemCreate class TestClassifyConfidence: diff --git a/tests/test_pipeline/test_normalization.py b/tests/test_pipeline/test_normalization.py index 67a7f99..976d1c8 100644 --- a/tests/test_pipeline/test_normalization.py +++ b/tests/test_pipeline/test_normalization.py @@ -3,8 +3,6 @@ import uuid from datetime import UTC, datetime -from receiptwitness.shared.models import NormalizedProduct - from receiptwitness.pipeline.normalization import ( MatchMethod, clean_name, @@ -14,6 +12,7 @@ from receiptwitness.pipeline.normalization import ( match_by_upc, normalize_product, ) +from receiptwitness.shared.models import NormalizedProduct class TestCleanName: