diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..307bfb4 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,11 @@ +venv/ +__pycache__/ +.pytest_cache/ +*.pyc +*.pyo +*.egg-info/ +dist/ +build/ +.env +.env.local +*.db diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/db.py b/backend/app/db.py new file mode 100644 index 0000000..8fc4972 --- /dev/null +++ b/backend/app/db.py @@ -0,0 +1,19 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, DeclarativeBase + + +class Base(DeclarativeBase): + pass + + +def get_engine(db_url: str): + connect_args = {"check_same_thread": False} if db_url.startswith("sqlite") else {} + return create_engine(db_url, connect_args=connect_args) + + +def make_session_factory(engine): + return sessionmaker(bind=engine, autoflush=False, autocommit=False) + + +def init_db(engine): + Base.metadata.create_all(bind=engine) diff --git a/backend/app/models.py b/backend/app/models.py new file mode 100644 index 0000000..2ffb9d5 --- /dev/null +++ b/backend/app/models.py @@ -0,0 +1,23 @@ +from datetime import datetime, date +from sqlalchemy import String, Text, DateTime, Date, Integer +from sqlalchemy.orm import Mapped, mapped_column +from app.db import Base + + +class Item(Base): + __tablename__ = "items" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + modality: Mapped[str] = mapped_column(String, nullable=False) + raw_content: Mapped[str] = mapped_column(Text, nullable=False) + captured_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + sender_id: Mapped[str | None] = mapped_column(String, nullable=True) + type: Mapped[str | None] = mapped_column(String, nullable=True) + stage: Mapped[str] = mapped_column(String, nullable=False, default="new") + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + follow_up_date: Mapped[date | None] = mapped_column(Date, nullable=True) + discord_message_id: Mapped[str | None] = mapped_column(String, unique=True, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + updated_at: Mapped[datetime] = mapped_column( + DateTime, default=datetime.utcnow, onupdate=datetime.utcnow + ) diff --git a/backend/app/stages.py b/backend/app/stages.py new file mode 100644 index 0000000..5e9b223 --- /dev/null +++ b/backend/app/stages.py @@ -0,0 +1,14 @@ +DONATION_STAGES = ["new", "replied", "pickup_scheduled", "picked_up", "logged_in_bookmark"] +OTHER_STAGES = ["new", "done"] + + +def valid_stages_for(item_type: str | None) -> list[str]: + if item_type == "donation": + return DONATION_STAGES + if item_type == "other": + return OTHER_STAGES + return ["new"] + + +def is_valid_stage(item_type: str | None, stage: str) -> bool: + return stage in valid_stages_for(item_type) diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..5aed225 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,6 @@ +fastapi==0.115.0 +uvicorn[standard]==0.30.6 +sqlalchemy==2.0.35 +pydantic==2.9.2 +pytest==8.3.3 +httpx==0.27.2 diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..c5935d6 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,14 @@ +import pytest +from app.db import get_engine, make_session_factory, init_db + + +@pytest.fixture() +def db_session(): + engine = get_engine("sqlite:///:memory:") + init_db(engine) + SessionLocal = make_session_factory(engine) + session = SessionLocal() + try: + yield session + finally: + session.close() diff --git a/backend/tests/test_stages.py b/backend/tests/test_stages.py new file mode 100644 index 0000000..0e934db --- /dev/null +++ b/backend/tests/test_stages.py @@ -0,0 +1,35 @@ +from app import stages + + +def test_donation_stages_in_order(): + assert stages.DONATION_STAGES == [ + "new", "replied", "pickup_scheduled", "picked_up", "logged_in_bookmark", + ] + + +def test_other_stages_in_order(): + assert stages.OTHER_STAGES == ["new", "done"] + + +def test_valid_stages_for_donation(): + assert stages.valid_stages_for("donation") == stages.DONATION_STAGES + + +def test_valid_stages_for_other(): + assert stages.valid_stages_for("other") == stages.OTHER_STAGES + + +def test_valid_stages_for_unset_type_defaults_to_new_only(): + assert stages.valid_stages_for(None) == ["new"] + + +def test_is_valid_stage_true_for_matching_type(): + assert stages.is_valid_stage("donation", "pickup_scheduled") is True + + +def test_is_valid_stage_false_for_mismatched_type(): + assert stages.is_valid_stage("other", "pickup_scheduled") is False + + +def test_is_valid_stage_false_for_unknown_value(): + assert stages.is_valid_stage("donation", "archived") is False