Coverage for src/local_deep_research/library/download_management/models/__init__.py: 98%
40 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-06 15:42 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-06 15:42 +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 NOT_FOUND = "not_found"
25 FORBIDDEN = "forbidden"
26 GONE = "gone"
27 RATE_LIMITED = "rate_limited"
28 SERVER_ERROR = "server_error"
29 RECAPTCHA_PROTECTION = "recaptcha_protection"
30 INCOMPATIBLE_FORMAT = "incompatible_format"
31 TIMEOUT = "timeout"
32 NETWORK_ERROR = "network_error"
33 UNKNOWN_ERROR = "unknown_error"
36class DownloadStatus(str, Enum):
37 """Status values for resource download tracking."""
39 AVAILABLE = "available"
40 TEMPORARILY_FAILED = "temporarily_failed"
41 PERMANENTLY_FAILED = "permanently_failed"
44class ResourceDownloadStatus(Base):
45 """Database model for tracking resource download status"""
47 __tablename__ = "resource_download_status"
49 id: Mapped[int] = mapped_column(Integer, primary_key=True)
50 resource_id: Mapped[int] = mapped_column(
51 Integer, unique=True, nullable=False, index=True
52 )
54 # Status tracking
55 status: Mapped[str] = mapped_column(
56 String(50), nullable=False, default="available"
57 ) # available, temporarily_failed, permanently_failed
58 failure_type: Mapped[Optional[str]] = mapped_column(
59 String(100), nullable=True
60 ) # not_found, rate_limited, timeout, etc.
61 failure_message: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
63 # Retry timing.
64 #
65 # UtcDateTime, not a bare DateTime: SQLite does not persist tzinfo, so a
66 # plain DateTime column silently reads back NAIVE even though every writer
67 # here stores ``datetime.now(UTC)``. That produced a real, silent failure —
68 # ``get_resource_status()`` returned a naive ``.isoformat()``, and
69 # ``retry_manager`` then computed ``retry_time - datetime.now(UTC)``, which
70 # raises TypeError on mixed naive/aware. A bare ``except Exception`` around
71 # it swallowed the error, so the retry ETA just never appeared in the UI.
72 # ``can_retry()`` had grown a ``.replace(tzinfo=UTC)`` patch for the same
73 # root cause at one call site; this fixes it for all of them, including
74 # rows already written. Matches database/models/library.py, which already
75 # uses UtcDateTime for its own last_attempt_at.
76 retry_after_timestamp: Mapped[Optional[datetime]] = mapped_column(
77 UtcDateTime, nullable=True
78 ) # When this can be retried (NULL = permanent)
79 last_attempt_at: Mapped[Optional[datetime]] = mapped_column(
80 UtcDateTime, nullable=True
81 )
82 permanent_failure_at: Mapped[Optional[datetime]] = mapped_column(
83 UtcDateTime, nullable=True
84 ) # When permanently failed
86 # Statistics
87 total_retry_count: Mapped[int] = mapped_column(Integer, default=0)
88 today_retry_count: Mapped[int] = mapped_column(Integer, default=0)
90 # Timestamps
91 # UtcDateTime for the same reason as the retry columns above: the
92 # defaults already produce aware values, but a bare DateTime would read
93 # them back naive on SQLite. Matches the rest of the app's models.
94 created_at: Mapped[Optional[datetime]] = mapped_column(
95 UtcDateTime, default=partial(datetime.now, UTC)
96 )
97 updated_at: Mapped[Optional[datetime]] = mapped_column(
98 UtcDateTime,
99 default=partial(datetime.now, UTC),
100 onupdate=partial(datetime.now, UTC),
101 )
103 def __repr__(self) -> str:
104 return f"<ResourceDownloadStatus(resource_id={self.resource_id}, status='{self.status}', failure_type='{self.failure_type}')>"