diff --git a/src/fabledassistant/models/__init__.py b/src/fabledassistant/models/__init__.py index 6a219fa..ba883bc 100644 --- a/src/fabledassistant/models/__init__.py +++ b/src/fabledassistant/models/__init__.py @@ -38,3 +38,5 @@ from fabledassistant.models.note_version import NoteVersion # noqa: E402, F401 from fabledassistant.models.group import Group, GroupMembership # noqa: E402, F401 from fabledassistant.models.share import NoteShare, ProjectShare # noqa: E402, F401 from fabledassistant.models.notification import Notification # noqa: E402, F401 +from fabledassistant.models.rss_feed import RssFeed, RssItem # noqa: E402, F401 +from fabledassistant.models.weather_cache import WeatherCache # noqa: E402, F401 diff --git a/src/fabledassistant/models/rss_feed.py b/src/fabledassistant/models/rss_feed.py new file mode 100644 index 0000000..a9ff97c --- /dev/null +++ b/src/fabledassistant/models/rss_feed.py @@ -0,0 +1,70 @@ +from datetime import datetime, timezone + +from sqlalchemy import DateTime, ForeignKey, Index, Integer, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from fabledassistant.models import Base + + +class RssFeed(Base): + __tablename__ = "rss_feeds" + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE")) + url: Mapped[str] = mapped_column(Text) + title: Mapped[str] = mapped_column(Text, default="") + category: Mapped[str | None] = mapped_column(Text, nullable=True) + last_fetched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + items: Mapped[list["RssItem"]] = relationship( + back_populates="feed", cascade="all, delete-orphan" + ) + + __table_args__ = ( + UniqueConstraint("user_id", "url", name="uq_rss_feeds_user_url"), + Index("ix_rss_feeds_user_id", "user_id"), + ) + + def to_dict(self) -> dict: + return { + "id": self.id, + "url": self.url, + "title": self.title, + "category": self.category, + "last_fetched_at": self.last_fetched_at.isoformat() if self.last_fetched_at else None, + } + + +class RssItem(Base): + __tablename__ = "rss_items" + + id: Mapped[int] = mapped_column(primary_key=True) + feed_id: Mapped[int] = mapped_column(Integer, ForeignKey("rss_feeds.id", ondelete="CASCADE")) + guid: Mapped[str] = mapped_column(Text) + title: Mapped[str] = mapped_column(Text, default="") + url: Mapped[str] = mapped_column(Text, default="") + published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + # Truncated to 2000 chars to keep DB size reasonable + content: Mapped[str] = mapped_column(Text, default="") + fetched_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + ) + + feed: Mapped["RssFeed"] = relationship(back_populates="items") + + __table_args__ = ( + UniqueConstraint("feed_id", "guid", name="uq_rss_items_feed_guid"), + Index("ix_rss_items_feed_id", "feed_id"), + Index("ix_rss_items_published_at", "published_at"), + ) + + def to_dict(self) -> dict: + return { + "id": self.id, + "feed_id": self.feed_id, + "guid": self.guid, + "title": self.title, + "url": self.url, + "published_at": self.published_at.isoformat() if self.published_at else None, + "content": self.content, + } diff --git a/src/fabledassistant/models/weather_cache.py b/src/fabledassistant/models/weather_cache.py new file mode 100644 index 0000000..e6c076e --- /dev/null +++ b/src/fabledassistant/models/weather_cache.py @@ -0,0 +1,38 @@ +from datetime import datetime, timezone + +from sqlalchemy import DateTime, ForeignKey, Index, Integer, Text, UniqueConstraint +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column + +from fabledassistant.models import Base + + +class WeatherCache(Base): + __tablename__ = "weather_cache" + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE")) + # Unique key per location, e.g. "home", "work", or "event:" + location_key: Mapped[str] = mapped_column(Text) + location_label: Mapped[str] = mapped_column(Text, default="") + # Current 7-day forecast from Open-Meteo (raw JSON) + forecast_json: Mapped[dict | None] = mapped_column(JSONB, nullable=True) + # Previous forecast — used to detect changes between fetches + previous_json: Mapped[dict | None] = mapped_column(JSONB, nullable=True) + fetched_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + ) + + __table_args__ = ( + UniqueConstraint("user_id", "location_key", name="uq_weather_cache_user_location"), + Index("ix_weather_cache_user_id", "user_id"), + ) + + def to_dict(self) -> dict: + return { + "id": self.id, + "location_key": self.location_key, + "location_label": self.location_label, + "forecast_json": self.forecast_json, + "fetched_at": self.fetched_at.isoformat(), + }