feat: improve app ui and project metadata
This commit is contained in:
19
packages/core/tests/test_project_repo.py
Normal file
19
packages/core/tests/test_project_repo.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from ww_core.domain.project_repo import max_time
|
||||
|
||||
|
||||
def test_max_time_handles_missing_values() -> None:
|
||||
now = datetime(2026, 6, 28, tzinfo=UTC)
|
||||
|
||||
assert max_time(None, None) is None
|
||||
assert max_time(now, None) == now
|
||||
assert max_time(None, now) == now
|
||||
|
||||
|
||||
def test_max_time_returns_latest_value() -> None:
|
||||
early = datetime(2026, 6, 27, tzinfo=UTC)
|
||||
late = datetime(2026, 6, 28, tzinfo=UTC)
|
||||
|
||||
assert max_time(early, late) == late
|
||||
assert max_time(late, early) == late
|
||||
@@ -9,11 +9,12 @@ from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Any, Protocol
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from ww_db.models import Project
|
||||
from ww_db.models import Chapter, ChapterReview, Project
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -28,6 +29,8 @@ class ProjectView:
|
||||
theme: str | None = None
|
||||
selling_points: list[Any] = field(default_factory=list)
|
||||
structure: str | None = None
|
||||
updated_at: datetime | None = None
|
||||
pending_review_count: int = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -53,7 +56,12 @@ class ProjectRepo(Protocol):
|
||||
async def get(self, owner_id: uuid.UUID, project_id: uuid.UUID) -> ProjectView | None: ...
|
||||
|
||||
|
||||
def _to_view(row: Project) -> ProjectView:
|
||||
def _to_view(
|
||||
row: Project,
|
||||
*,
|
||||
updated_at: datetime | None = None,
|
||||
pending_review_count: int = 0,
|
||||
) -> ProjectView:
|
||||
return ProjectView(
|
||||
id=row.id,
|
||||
title=row.title,
|
||||
@@ -63,6 +71,8 @@ def _to_view(row: Project) -> ProjectView:
|
||||
theme=row.theme,
|
||||
selling_points=list(row.selling_points or []),
|
||||
structure=row.structure,
|
||||
updated_at=updated_at or row.updated_at,
|
||||
pending_review_count=pending_review_count,
|
||||
)
|
||||
|
||||
|
||||
@@ -89,12 +99,26 @@ class SqlProjectRepo:
|
||||
return _to_view(row)
|
||||
|
||||
async def list_for_owner(self, owner_id: uuid.UUID) -> list[ProjectView]:
|
||||
rows = (
|
||||
await self._s.execute(
|
||||
select(Project).where(Project.owner_id == owner_id).order_by(Project.created_at)
|
||||
projects = (
|
||||
(
|
||||
await self._s.execute(
|
||||
select(Project).where(Project.owner_id == owner_id).order_by(Project.created_at)
|
||||
)
|
||||
)
|
||||
).scalars()
|
||||
return [_to_view(r) for r in rows]
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
project_ids = [p.id for p in projects]
|
||||
pending_counts = await self._pending_review_counts(project_ids)
|
||||
activity_times = await self._activity_times(project_ids)
|
||||
return [
|
||||
_to_view(
|
||||
p,
|
||||
updated_at=max_time(p.updated_at, activity_times.get(p.id)),
|
||||
pending_review_count=pending_counts.get(p.id, 0),
|
||||
)
|
||||
for p in projects
|
||||
]
|
||||
|
||||
async def get(self, owner_id: uuid.UUID, project_id: uuid.UUID) -> ProjectView | None:
|
||||
row = (
|
||||
@@ -104,4 +128,90 @@ class SqlProjectRepo:
|
||||
).scalar_one_or_none()
|
||||
if row is None:
|
||||
return None
|
||||
return _to_view(row)
|
||||
pending_counts = await self._pending_review_counts([project_id])
|
||||
activity_times = await self._activity_times([project_id])
|
||||
return _to_view(
|
||||
row,
|
||||
updated_at=max_time(row.updated_at, activity_times.get(project_id)),
|
||||
pending_review_count=pending_counts.get(project_id, 0),
|
||||
)
|
||||
|
||||
async def _activity_times(self, project_ids: list[uuid.UUID]) -> dict[uuid.UUID, datetime]:
|
||||
if not project_ids:
|
||||
return {}
|
||||
|
||||
chapter_rows = await self._s.execute(
|
||||
select(Chapter.project_id, func.max(Chapter.updated_at))
|
||||
.where(Chapter.project_id.in_(project_ids))
|
||||
.group_by(Chapter.project_id)
|
||||
)
|
||||
review_rows = await self._s.execute(
|
||||
select(ChapterReview.project_id, func.max(ChapterReview.created_at))
|
||||
.where(ChapterReview.project_id.in_(project_ids))
|
||||
.group_by(ChapterReview.project_id)
|
||||
)
|
||||
activity: dict[uuid.UUID, datetime] = {}
|
||||
for project_id, updated_at in [*chapter_rows.all(), *review_rows.all()]:
|
||||
if updated_at is None:
|
||||
continue
|
||||
latest = max_time(activity.get(project_id), updated_at)
|
||||
if latest is not None:
|
||||
activity[project_id] = latest
|
||||
return activity
|
||||
|
||||
async def _pending_review_counts(self, project_ids: list[uuid.UUID]) -> dict[uuid.UUID, int]:
|
||||
if not project_ids:
|
||||
return {}
|
||||
|
||||
latest_reviews = (
|
||||
select(
|
||||
ChapterReview.project_id.label("project_id"),
|
||||
ChapterReview.chapter_no.label("chapter_no"),
|
||||
func.max(ChapterReview.created_at).label("reviewed_at"),
|
||||
)
|
||||
.where(ChapterReview.project_id.in_(project_ids))
|
||||
.group_by(ChapterReview.project_id, ChapterReview.chapter_no)
|
||||
.subquery()
|
||||
)
|
||||
latest_accepted = (
|
||||
select(
|
||||
Chapter.project_id.label("project_id"),
|
||||
Chapter.chapter_no.label("chapter_no"),
|
||||
func.max(Chapter.updated_at).label("accepted_at"),
|
||||
)
|
||||
.where(Chapter.project_id.in_(project_ids), Chapter.status == "accepted")
|
||||
.group_by(Chapter.project_id, Chapter.chapter_no)
|
||||
.subquery()
|
||||
)
|
||||
stmt = (
|
||||
select(Chapter.project_id, func.count(Chapter.id))
|
||||
.outerjoin(
|
||||
latest_reviews,
|
||||
(latest_reviews.c.project_id == Chapter.project_id)
|
||||
& (latest_reviews.c.chapter_no == Chapter.chapter_no),
|
||||
)
|
||||
.outerjoin(
|
||||
latest_accepted,
|
||||
(latest_accepted.c.project_id == Chapter.project_id)
|
||||
& (latest_accepted.c.chapter_no == Chapter.chapter_no),
|
||||
)
|
||||
.where(
|
||||
Chapter.project_id.in_(project_ids),
|
||||
Chapter.status == "draft",
|
||||
(latest_accepted.c.accepted_at.is_(None))
|
||||
| (latest_accepted.c.accepted_at < Chapter.updated_at),
|
||||
(latest_reviews.c.reviewed_at.is_(None))
|
||||
| (latest_reviews.c.reviewed_at < Chapter.updated_at),
|
||||
)
|
||||
.group_by(Chapter.project_id)
|
||||
)
|
||||
rows = await self._s.execute(stmt)
|
||||
return {project_id: int(count) for project_id, count in rows.all()}
|
||||
|
||||
|
||||
def max_time(left: datetime | None, right: datetime | None) -> datetime | None:
|
||||
if left is None:
|
||||
return right
|
||||
if right is None:
|
||||
return left
|
||||
return max(left, right)
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Add updated_at to chapters.
|
||||
|
||||
Revision ID: 8c1d2e3f4a5b
|
||||
Revises: 59a9198d4604
|
||||
Create Date: 2026-06-28 00:00:00.000000
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "8c1d2e3f4a5b"
|
||||
down_revision: str | None = "59a9198d4604"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"chapters",
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
)
|
||||
op.execute("UPDATE chapters SET updated_at = created_at")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("chapters", "updated_at")
|
||||
@@ -165,7 +165,7 @@ class Rule(UuidPk, CreatedAt, Base):
|
||||
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
|
||||
|
||||
class Chapter(UuidPk, CreatedAt, Base):
|
||||
class Chapter(UuidPk, TimestampedMixin, Base):
|
||||
__tablename__ = "chapters"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("project_id", "chapter_no", "version"),
|
||||
|
||||
Reference in New Issue
Block a user