1"""Overview data model implementation.
2
3Implements :any:`/specs/spec_table_rendering_ui` §2-3, covering:
4- Column value computation (§2)
5- Placeholder handling (§3)
6"""
7
8from __future__ import annotations
9
10from datetime import datetime
11from typing import Any, List, Optional
12
13from pydantic import BaseModel, Field, field_validator
14
15from ..rendering.renderer_base import Renderer
16
17from .group import Group
18from .project import Project
19from .issue import Issue
20from .readme import Readme
21from .readme_extract import ReadmeExtract
22
23
[docs]
24class OverviewData(BaseModel):
25 """Container for all data related to a project overview.
26
27 Implements :any:`/specs/spec_table_rendering_ui` §2, providing:
28
29 * Computed column values (repo, type, priority, urgency, etc.)
30 * Formatted cell values (dates, star ratings, issue counts)
31 * Placeholder handling for missing data
32
33 """
34
35 group: Group
36 project: Project
37 readme: Readme | None = None
38 issues: Optional[List[Issue]] = None
39 extra: ReadmeExtract = Field(default_factory=lambda: ReadmeExtract())
40
[docs]
41 def model_dump(self, **kwargs) -> dict[str, Any]:
42 """Convert to dict with computed fields for rendering."""
43 group_data = self.group.model_dump()
44 project_data = self.project.model_dump()
45
46 # Add computed fields from extra data
47 result = {
48 "group": group_data.get("name", "Unknown"),
49 "repo": project_data.get("name", "Unknown"),
50 "type": self.get_type(),
51 "priority": self.get_priority(),
52 "urgency": self.get_urgency(),
53 "date": self.get_date(),
54 "supervisors": self.get_supervisors(),
55 "status": self.get_status(),
56 "todos": self.get_todos(),
57 "version": project_data.get("version", "-"),
58 }
59
60 # Add computed star rating fields
61 result["priority_stars"] = self.get_priority_stars()
62 result["urgency_stars"] = self.get_urgency_stars()
63 result["importance_stars"] = self.get_importance_stars()
64
65 # Add issue count formatting
66 result["issue_summary"] = self.get_issue_summary()
67
68 # Add web_url if available
69 web_url = project_data.get("web_url")
70 if web_url:
71 result["web_url"] = web_url
72 readme_url = project_data.get("readme_url")
73 if readme_url:
74 result["readme_url"] = readme_url
75
76 return result
77
[docs]
78 def get_repo(self) -> str:
79 """Get repository name."""
80 return self.project.name
81
[docs]
82 def get_supervisors(self) -> str:
83 """Get supervisors from extra data."""
84 # Prefer supervisors, fallback to authors, else dash
85 if self.extra.supervisors:
86 return "; ".join(self.extra.supervisors)
87 elif self.extra.authors:
88 return "Authors: " + "; ".join(self.extra.authors)
89 return "-"
90
[docs]
91 def get_status(self) -> str:
92 """Get status from extra data."""
93 # First, try explicit status in front-matter
94 if self.extra.raw_frontmatter and "status" in self.extra.raw_frontmatter:
95 val = self.extra.raw_frontmatter["status"]
96 if isinstance(val, str) and val.strip():
97 return val.strip()
98
99 # Fall back to heuristic based on open issues
100 if self.issues is not None:
101 open_count = sum(1 for i in self.issues if i.state == "opened")
102 closed_count = sum(1 for i in self.issues if i.state == "closed")
103 if open_count == 0 and closed_count > 0:
104 return "done"
105 if open_count > 0 and closed_count == 0:
106 return "in progress"
107 if open_count > 0 and closed_count > 0:
108 return "mixed"
109
110 # Otherwise dash placeholder
111 return "-"
112
[docs]
113 def get_todos(self) -> str:
114 """Get todos from extra data."""
115 # Prefer issue-based summary if issues are present
116 if self.issues is not None:
117 summary = self.get_issue_summary()
118 if summary != "—": # m-dash placeholder from get_issue_summary
119 return summary
120
121 # Fallback: derive from README TODO section length
122 if self.readme and self.readme.todo:
123 lines = [ln for ln in self.readme.todo.splitlines() if ln.strip()]
124 if lines:
125 return f"from README: {len(lines)} lines"
126
127 return "-"
128
[docs]
129 def get_type(self) -> str:
130 """Get type from extra data."""
131 # Try to get from raw_frontmatter
132 if self.extra.raw_frontmatter and "type" in self.extra.raw_frontmatter:
133 return str(self.extra.raw_frontmatter["type"])
134 return "-"
135
[docs]
136 def get_priority(self) -> int | None:
137 """Get priority from extra data."""
138 if self.extra.raw_frontmatter and "priority" in self.extra.raw_frontmatter:
139 return Renderer.safe_int(self.extra.raw_frontmatter["priority"], 0)
140 return 0
141
[docs]
142 def get_urgency(self) -> int | None:
143 """Get urgency from extra data."""
144 if self.extra.raw_frontmatter and "urgency" in self.extra.raw_frontmatter:
145 return Renderer.safe_int(self.extra.raw_frontmatter["urgency"], 0)
146 return 0
147
[docs]
148 def get_importance(self) -> int | None:
149 """Get importance from extra data."""
150 if self.extra.raw_frontmatter and "importance" in self.extra.raw_frontmatter:
151 return Renderer.safe_int(self.extra.raw_frontmatter["importance"], 0)
152 return 0
153
[docs]
154 def get_priority_stars(self) -> str:
155 """Get priority as star rating."""
156 priority = self.get_priority()
157 return Renderer.star_rating(priority) if priority is not None else "—"
158
[docs]
159 def get_urgency_stars(self) -> str:
160 """Get urgency as star rating."""
161 urgency = self.get_urgency()
162 return Renderer.star_rating(urgency) if urgency is not None else "—"
163
[docs]
164 def get_importance_stars(self) -> str:
165 """Get importance as star rating."""
166 importance = self.get_importance()
167 return Renderer.star_rating(importance) if importance is not None else "—"
168
[docs]
169 def get_issue_summary(self) -> str:
170 """Format issue counts for display."""
171 if not self.issues:
172 return "—"
173
174 open_count = sum(1 for issue in self.issues if issue.state == "opened")
175 closed_count = sum(1 for issue in self.issues if issue.state == "closed")
176
177 if open_count == 0 and closed_count == 0:
178 return "—"
179
180 parts = []
181 if closed_count > 0:
182 parts.append(f"☑: {closed_count}")
183 if open_count > 0:
184 parts.append(f"☒: {open_count}")
185
186 return "; ".join(parts)
187
[docs]
188 def get_date(self) -> str:
189 """Get formatted date from project or extra data."""
190 if self.extra.raw_frontmatter and "date" in self.extra.raw_frontmatter:
191 return str(self.extra.raw_frontmatter["date"])
192 # Try to get from project's last_activity_at
193 if self.project.last_activity_at:
194 try:
195 dt = Renderer.parse_iso_date(self.project.last_activity_at)
196 if dt:
197 return dt.strftime("%Y-%m-%d")
198 except Exception:
199 pass
200 return "-"
201
202 model_config = {"extra": "ignore"}
203
204
205__all__ = ["OverviewData"]
206
207OverviewData.model_rebuild()