Coverage for src/local_deep_research/library/download_management/models/__init__.py: 98%
44 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-22 21:20 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-22 21:20 +0000
1"""
2Database Models for Download Management
4Contains ORM models for tracking resource download status and retry logic.
5"""
7from datetime import UTC, datetime
8from enum import Enum
9from functools import partial
10from typing import Optional
12from sqlalchemy import Integer, String, Text
13from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
14from sqlalchemy_utc import UtcDateTime
17class Base(DeclarativeBase):
18 pass
21class FailureType(str, Enum):
22 """Enum for failure types - ensures consistency across the codebase"""
24 PROCESSING = "processing"
25 NOT_FOUND = "not_found"
26 FORBIDDEN = "forbidden"
27 GONE = "gone"
28 RATE_LIMITED = "rate_limited"
29 SERVER_ERROR = "server_error"
30 HTML_NOT_PDF = "html_not_pdf"
31 RECAPTCHA_PROTECTION = "recaptcha_protection"
32 INCOMPATIBLE_FORMAT = "incompatible_format"
33 PAYWALL_OR_LOGIN = "paywall_or_login"
34 TIMEOUT = "timeout"
35 NETWORK_ERROR = "network_error"
36 DOWNLOAD_FAILED = "download_failed"
37 UNKNOWN_ERROR = "unknown_error"
40class DownloadStatus(str, Enum):
41 """Status values for resource download tracking."""
43 AVAILABLE = "available"
44 TEMPORARILY_FAILED = "temporarily_failed"
45 PERMANENTLY_FAILED = "permanently_failed"
48class ResourceDownloadStatus(Base):
49 """Database model for tracking resource download status"""
51 __tablename__ = "resource_download_status"
53 id: Mapped[int] = mapped_column(Integer, primary_key=True)
54 resource_id: Mapped[int] = mapped_column(
55 Integer, unique=True, nullable=False, index=True
56 )
58 # Status tracking
59 status: Mapped[str] = mapped_column(
60 String(50), nullable=False, default="available"
61 ) # available, temporarily_failed, permanently_failed
62 failure_type: Mapped[Optional[str]] = mapped_column(
63 String(100), nullable=True
64 ) # not_found, rate_limited, timeout, etc.
65 failure_message: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
67 # Retry timing.
68 #
69 # UtcDateTime, not a bare DateTime: SQLite does not persist tzinfo, so a
70 # plain DateTime column silently reads back NAIVE even though every writer
71 # here stores ``datetime.now(UTC)``. That produced a real, silent failure —
72 # ``get_resource_status()`` returned a naive ``.isoformat()``, and
73 # ``retry_manager`` then computed ``retry_time - datetime.now(UTC)``, which
74 # raises TypeError on mixed naive/aware. A bare ``except Exception`` around
75 # it swallowed the error, so the retry ETA just never appeared in the UI.
76 # ``can_retry()`` had grown a ``.replace(tzinfo=UTC)`` patch for the same
77 # root cause at one call site; this fixes it for all of them, including
78 # rows already written. Matches database/models/library.py, which already
79 # uses UtcDateTime for its own last_attempt_at.
80 retry_after_timestamp: Mapped[Optional[datetime]] = mapped_column(
81 UtcDateTime, nullable=True
82 ) # When this can be retried (NULL = permanent)
83 last_attempt_at: Mapped[Optional[datetime]] = mapped_column(
84 UtcDateTime, nullable=True
85 )
86 permanent_failure_at: Mapped[Optional[datetime]] = mapped_column(
87 UtcDateTime, nullable=True
88 ) # When permanently failed
90 # Statistics
91 total_retry_count: Mapped[int] = mapped_column(Integer, default=0)
92 today_retry_count: Mapped[int] = mapped_column(Integer, default=0)
94 # Timestamps
95 # UtcDateTime for the same reason as the retry columns above: the
96 # defaults already produce aware values, but a bare DateTime would read
97 # them back naive on SQLite. Matches the rest of the app's models.
98 created_at: Mapped[Optional[datetime]] = mapped_column(
99 UtcDateTime, default=partial(datetime.now, UTC)
100 )
101 updated_at: Mapped[Optional[datetime]] = mapped_column(
102 UtcDateTime,
103 default=partial(datetime.now, UTC),
104 onupdate=partial(datetime.now, UTC),
105 )
107 def __repr__(self) -> str:
108 return f"<ResourceDownloadStatus(resource_id={self.resource_id}, status='{self.status}', failure_type='{self.failure_type}')>"