Complete Sprint 7: Wiring & Correctness
- Add cloud consent gRPC endpoints (Grant, Revoke, GetStatus) - Wire consent persistence via on_consent_change callback to database - Refactor html.py export method to fix long-method violation - Add comprehensive gRPC tests for consent endpoints - Update sprint 7 documentation with completion status - Update client submodule with frontend consent wiring
This commit is contained in:
2
client
2
client
Submodule client updated: 2d6f36fc43...38e71e6078
@@ -7,21 +7,21 @@
|
||||
|
||||
## Validation Status (2025-12-28)
|
||||
|
||||
### 🔄 IN PROGRESS — Corrections Required
|
||||
### ✅ COMPLETED
|
||||
|
||||
| Component | Location | Status | Finding | Task |
|
||||
|---|---|---|---|---|
|
||||
| ServerInfo call path | `client/src/api/tauri-adapter.ts:100` | ⚠️ | `getServerInfo()` calls `CONNECT`; `GET_SERVER_INFO` exists but unused | Task 1 |
|
||||
| ServerInfo call path | `client/src/api/tauri-adapter.ts:100` | ✅ | `getServerInfo()` uses `GET_SERVER_INFO` command | Task 1 ✓ |
|
||||
| Tauri ServerInfo command | `client/src-tauri/src/commands/connection.rs` | ✅ | Command exists and is correct | — |
|
||||
| Upcoming meetings data | `client/src/components/upcoming-meetings.tsx:42-93` | ⚠️ | Mock generator active; needs live calendar API | Task 2 |
|
||||
| Upcoming meetings data | `client/src/components/upcoming-meetings.tsx` | ✅ | Uses `useCalendarSync` hook for live calendar data | Task 2 ✓ |
|
||||
| Calendar API hook | `client/src/hooks/use-calendar-sync.ts` | ✅ | Live `listCalendarEvents()` exists in adapter | — |
|
||||
| Speaker rename persistence | `client/src/pages/People.tsx:226-228` | ✅ | Calls `preferences.setGlobalSpeakerName()` | — |
|
||||
| Rename speaker API | `client/src/api/tauri-adapter.ts:160` | ✅ | `renameSpeaker()` RPC available | — |
|
||||
| Cloud consent control | `src/noteflow/application/services/summarization_service.py:154-166` | ✅ | `grant/revoke_cloud_consent()` implemented | — |
|
||||
| Cloud consent persistence | `src/noteflow/grpc/server.py` | ❌ | `on_consent_change` callback not wired to DB | Task 3 |
|
||||
| Cloud consent gRPC | `src/noteflow/grpc/_mixins/summarization.py` | ❌ | No consent RPCs exposed | Task 3 |
|
||||
| Consent Rust commands | `client/src-tauri/src/commands/summary.rs` | ❌ | No Tauri commands for consent | Task 3 |
|
||||
| Cloud consent UI | `client/src/pages/Settings.tsx` | ❌ | No UI toggle for consent | Task 4 |
|
||||
| Cloud consent persistence | `src/noteflow/grpc/server.py` | ✅ | `on_consent_change` callback wired to DB via preferences | Task 3 ✓ |
|
||||
| Cloud consent gRPC | `src/noteflow/grpc/_mixins/summarization.py` | ✅ | `GrantCloudConsent`, `RevokeCloudConsent`, `GetCloudConsentStatus` RPCs | Task 3 ✓ |
|
||||
| Consent Rust commands | `client/src-tauri/src/commands/summary.rs` | ✅ | `grant_cloud_consent`, `revoke_cloud_consent`, `get_cloud_consent_status` commands | Task 3 ✓ |
|
||||
| Cloud consent UI | `client/src/components/settings/export-ai-section.tsx` | ✅ | `CloudConsentToggle` component with `useCloudConsent` hook | Task 4 ✓ |
|
||||
|
||||
---
|
||||
|
||||
@@ -935,30 +935,30 @@ describe('UpcomingMeetings', () => {
|
||||
|
||||
### Functional
|
||||
|
||||
- [ ] `getServerInfo()` uses correct Tauri command (not `CONNECT`)
|
||||
- [ ] Upcoming meetings displays real calendar events (not mock data)
|
||||
- [ ] Calendar errors show retry option (graceful degradation)
|
||||
- [ ] Cloud consent can be granted via Settings UI
|
||||
- [ ] Cloud consent can be revoked via Settings UI
|
||||
- [ ] Consent state persists across app restarts (database-backed)
|
||||
- [ ] Summarization falls back to LOCAL when consent not granted
|
||||
- [x] `getServerInfo()` uses correct Tauri command (not `CONNECT`)
|
||||
- [x] Upcoming meetings displays real calendar events (not mock data)
|
||||
- [x] Calendar errors show retry option (graceful degradation)
|
||||
- [x] Cloud consent can be granted via Settings UI
|
||||
- [x] Cloud consent can be revoked via Settings UI
|
||||
- [x] Consent state persists across app restarts (database-backed)
|
||||
- [x] Summarization falls back to LOCAL when consent not granted
|
||||
|
||||
### Technical
|
||||
|
||||
- [ ] `generateMockEvents()` removed from production code
|
||||
- [ ] Consent RPCs follow existing gRPC patterns (empty success responses)
|
||||
- [ ] Consent persistence wired via `on_consent_change` callback
|
||||
- [ ] Rust commands registered in Tauri handler
|
||||
- [ ] UI toggle reflects actual backend state
|
||||
- [ ] Calendar API errors show user-friendly messages
|
||||
- [x] `generateMockEvents()` removed from production code
|
||||
- [x] Consent RPCs follow existing gRPC patterns (empty success responses)
|
||||
- [x] Consent persistence wired via `on_consent_change` callback
|
||||
- [x] Rust commands registered in Tauri handler
|
||||
- [x] UI toggle reflects actual backend state
|
||||
- [x] Calendar API errors show user-friendly messages
|
||||
|
||||
### Quality Gates
|
||||
|
||||
- [ ] `pytest tests/quality/` passes
|
||||
- [ ] `cargo clippy` passes (Rust consent commands)
|
||||
- [ ] `npm run test` passes (frontend)
|
||||
- [ ] No new `# type: ignore` without justification
|
||||
- [ ] All public functions have docstrings
|
||||
- [x] `pytest tests/quality/` passes (48 passed)
|
||||
- [x] `cargo clippy` passes (Rust consent commands)
|
||||
- [x] `npm run test` passes (174 tests passed)
|
||||
- [x] No new `# type: ignore` without justification
|
||||
- [x] All public functions have docstrings
|
||||
|
||||
---
|
||||
|
||||
@@ -974,7 +974,7 @@ describe('UpcomingMeetings', () => {
|
||||
|
||||
## Post-Sprint
|
||||
|
||||
- Remove dead `generateMockEvents()` code
|
||||
- Document consent flow in user guide
|
||||
- [x] Remove dead `generateMockEvents()` code (already removed)
|
||||
- [ ] Document consent flow in user guide
|
||||
|
||||
> **Note**: Telemetry for consent events deferred to a privacy-focused sprint that addresses user opt-out, data retention, and GDPR compliance.
|
||||
|
||||
@@ -32,6 +32,7 @@ dependencies = [
|
||||
"httpx>=0.27",
|
||||
"weasyprint>=67.0",
|
||||
"authlib>=1.6.6",
|
||||
"spacy>=3.8.11",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -107,6 +107,11 @@ class SummarizationService:
|
||||
on_persist: PersistCallback | None = None
|
||||
on_consent_change: ConsentPersistCallback | None = None
|
||||
|
||||
@property
|
||||
def cloud_consent_granted(self) -> bool:
|
||||
"""Return whether cloud consent is currently granted."""
|
||||
return self.settings.cloud_consent_granted
|
||||
|
||||
def register_provider(self, mode: SummarizationMode, provider: SummarizerProvider) -> None:
|
||||
"""Register a provider for a specific mode.
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ from noteflow.infrastructure.summarization._parsing import build_style_prompt
|
||||
|
||||
from ..proto import noteflow_pb2
|
||||
from .converters import parse_meeting_id_or_abort, summary_to_proto
|
||||
from .errors import ENTITY_MEETING, abort_not_found
|
||||
from .errors import ENTITY_MEETING, abort_failed_precondition, abort_not_found
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from noteflow.application.services.summarization_service import SummarizationService
|
||||
@@ -135,3 +135,46 @@ class SummarizationMixin:
|
||||
provider_name="placeholder",
|
||||
model_name="v0",
|
||||
)
|
||||
|
||||
async def GrantCloudConsent(
|
||||
self: ServicerHost,
|
||||
request: noteflow_pb2.GrantCloudConsentRequest,
|
||||
context: grpc.aio.ServicerContext,
|
||||
) -> noteflow_pb2.GrantCloudConsentResponse:
|
||||
"""Grant consent for cloud-based summarization."""
|
||||
if self._summarization_service is None:
|
||||
await abort_failed_precondition(
|
||||
context,
|
||||
"Summarization service not available",
|
||||
)
|
||||
await self._summarization_service.grant_cloud_consent()
|
||||
logger.info("Cloud consent granted")
|
||||
return noteflow_pb2.GrantCloudConsentResponse()
|
||||
|
||||
async def RevokeCloudConsent(
|
||||
self: ServicerHost,
|
||||
request: noteflow_pb2.RevokeCloudConsentRequest,
|
||||
context: grpc.aio.ServicerContext,
|
||||
) -> noteflow_pb2.RevokeCloudConsentResponse:
|
||||
"""Revoke consent for cloud-based summarization."""
|
||||
if self._summarization_service is None:
|
||||
await abort_failed_precondition(
|
||||
context,
|
||||
"Summarization service not available",
|
||||
)
|
||||
await self._summarization_service.revoke_cloud_consent()
|
||||
logger.info("Cloud consent revoked")
|
||||
return noteflow_pb2.RevokeCloudConsentResponse()
|
||||
|
||||
async def GetCloudConsentStatus(
|
||||
self: ServicerHost,
|
||||
request: noteflow_pb2.GetCloudConsentStatusRequest,
|
||||
context: grpc.aio.ServicerContext,
|
||||
) -> noteflow_pb2.GetCloudConsentStatusResponse:
|
||||
"""Return current cloud consent status."""
|
||||
if self._summarization_service is None:
|
||||
# Default to not granted if service unavailable
|
||||
return noteflow_pb2.GetCloudConsentStatusResponse(consent_granted=False)
|
||||
return noteflow_pb2.GetCloudConsentStatusResponse(
|
||||
consent_granted=self._summarization_service.cloud_consent_granted,
|
||||
)
|
||||
|
||||
@@ -61,6 +61,11 @@ service NoteFlowService {
|
||||
rpc UpdateWebhook(UpdateWebhookRequest) returns (WebhookConfigProto);
|
||||
rpc DeleteWebhook(DeleteWebhookRequest) returns (DeleteWebhookResponse);
|
||||
rpc GetWebhookDeliveries(GetWebhookDeliveriesRequest) returns (GetWebhookDeliveriesResponse);
|
||||
|
||||
// Cloud consent management (Sprint 7)
|
||||
rpc GrantCloudConsent(GrantCloudConsentRequest) returns (GrantCloudConsentResponse);
|
||||
rpc RevokeCloudConsent(RevokeCloudConsentRequest) returns (RevokeCloudConsentResponse);
|
||||
rpc GetCloudConsentStatus(GetCloudConsentStatusRequest) returns (GetCloudConsentStatusResponse);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -959,3 +964,19 @@ message GetWebhookDeliveriesResponse {
|
||||
// Total delivery count
|
||||
int32 total_count = 2;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Cloud Consent Messages (Sprint 7)
|
||||
// =============================================================================
|
||||
|
||||
message GrantCloudConsentRequest {}
|
||||
message GrantCloudConsentResponse {}
|
||||
|
||||
message RevokeCloudConsentRequest {}
|
||||
message RevokeCloudConsentResponse {}
|
||||
|
||||
message GetCloudConsentStatusRequest {}
|
||||
message GetCloudConsentStatusResponse {
|
||||
// Whether cloud consent is currently granted
|
||||
bool consent_granted = 1;
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -188,6 +188,21 @@ class NoteFlowServiceStub(object):
|
||||
request_serializer=noteflow__pb2.GetWebhookDeliveriesRequest.SerializeToString,
|
||||
response_deserializer=noteflow__pb2.GetWebhookDeliveriesResponse.FromString,
|
||||
_registered_method=True)
|
||||
self.GrantCloudConsent = channel.unary_unary(
|
||||
'/noteflow.NoteFlowService/GrantCloudConsent',
|
||||
request_serializer=noteflow__pb2.GrantCloudConsentRequest.SerializeToString,
|
||||
response_deserializer=noteflow__pb2.GrantCloudConsentResponse.FromString,
|
||||
_registered_method=True)
|
||||
self.RevokeCloudConsent = channel.unary_unary(
|
||||
'/noteflow.NoteFlowService/RevokeCloudConsent',
|
||||
request_serializer=noteflow__pb2.RevokeCloudConsentRequest.SerializeToString,
|
||||
response_deserializer=noteflow__pb2.RevokeCloudConsentResponse.FromString,
|
||||
_registered_method=True)
|
||||
self.GetCloudConsentStatus = channel.unary_unary(
|
||||
'/noteflow.NoteFlowService/GetCloudConsentStatus',
|
||||
request_serializer=noteflow__pb2.GetCloudConsentStatusRequest.SerializeToString,
|
||||
response_deserializer=noteflow__pb2.GetCloudConsentStatusResponse.FromString,
|
||||
_registered_method=True)
|
||||
|
||||
|
||||
class NoteFlowServiceServicer(object):
|
||||
@@ -388,6 +403,25 @@ class NoteFlowServiceServicer(object):
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
def GrantCloudConsent(self, request, context):
|
||||
"""Cloud consent management (Sprint 7)
|
||||
"""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
def RevokeCloudConsent(self, request, context):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
def GetCloudConsentStatus(self, request, context):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
|
||||
def add_NoteFlowServiceServicer_to_server(servicer, server):
|
||||
rpc_method_handlers = {
|
||||
@@ -541,6 +575,21 @@ def add_NoteFlowServiceServicer_to_server(servicer, server):
|
||||
request_deserializer=noteflow__pb2.GetWebhookDeliveriesRequest.FromString,
|
||||
response_serializer=noteflow__pb2.GetWebhookDeliveriesResponse.SerializeToString,
|
||||
),
|
||||
'GrantCloudConsent': grpc.unary_unary_rpc_method_handler(
|
||||
servicer.GrantCloudConsent,
|
||||
request_deserializer=noteflow__pb2.GrantCloudConsentRequest.FromString,
|
||||
response_serializer=noteflow__pb2.GrantCloudConsentResponse.SerializeToString,
|
||||
),
|
||||
'RevokeCloudConsent': grpc.unary_unary_rpc_method_handler(
|
||||
servicer.RevokeCloudConsent,
|
||||
request_deserializer=noteflow__pb2.RevokeCloudConsentRequest.FromString,
|
||||
response_serializer=noteflow__pb2.RevokeCloudConsentResponse.SerializeToString,
|
||||
),
|
||||
'GetCloudConsentStatus': grpc.unary_unary_rpc_method_handler(
|
||||
servicer.GetCloudConsentStatus,
|
||||
request_deserializer=noteflow__pb2.GetCloudConsentStatusRequest.FromString,
|
||||
response_serializer=noteflow__pb2.GetCloudConsentStatusResponse.SerializeToString,
|
||||
),
|
||||
}
|
||||
generic_handler = grpc.method_handlers_generic_handler(
|
||||
'noteflow.NoteFlowService', rpc_method_handlers)
|
||||
@@ -1365,3 +1414,84 @@ class NoteFlowService(object):
|
||||
timeout,
|
||||
metadata,
|
||||
_registered_method=True)
|
||||
|
||||
@staticmethod
|
||||
def GrantCloudConsent(request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None):
|
||||
return grpc.experimental.unary_unary(
|
||||
request,
|
||||
target,
|
||||
'/noteflow.NoteFlowService/GrantCloudConsent',
|
||||
noteflow__pb2.GrantCloudConsentRequest.SerializeToString,
|
||||
noteflow__pb2.GrantCloudConsentResponse.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
_registered_method=True)
|
||||
|
||||
@staticmethod
|
||||
def RevokeCloudConsent(request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None):
|
||||
return grpc.experimental.unary_unary(
|
||||
request,
|
||||
target,
|
||||
'/noteflow.NoteFlowService/RevokeCloudConsent',
|
||||
noteflow__pb2.RevokeCloudConsentRequest.SerializeToString,
|
||||
noteflow__pb2.RevokeCloudConsentResponse.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
_registered_method=True)
|
||||
|
||||
@staticmethod
|
||||
def GetCloudConsentStatus(request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None):
|
||||
return grpc.experimental.unary_unary(
|
||||
request,
|
||||
target,
|
||||
'/noteflow.NoteFlowService/GetCloudConsentStatus',
|
||||
noteflow__pb2.GetCloudConsentStatusRequest.SerializeToString,
|
||||
noteflow__pb2.GetCloudConsentStatusResponse.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
_registered_method=True)
|
||||
|
||||
@@ -125,6 +125,9 @@ class NoteFlowServer:
|
||||
self._summarization_service = create_summarization_service()
|
||||
logger.info("Summarization service initialized (default factory)")
|
||||
|
||||
# Wire consent persistence if database is available
|
||||
await self._wire_consent_persistence()
|
||||
|
||||
# Create servicer with session factory, summarization, diarization, NER, and webhooks
|
||||
self._servicer = NoteFlowServicer(
|
||||
asr_engine=asr_engine,
|
||||
@@ -177,6 +180,48 @@ class NoteFlowServer:
|
||||
if self._server:
|
||||
await self._server.wait_for_termination()
|
||||
|
||||
async def _wire_consent_persistence(self) -> None:
|
||||
"""Load consent from database and wire persistence callback.
|
||||
|
||||
If database is available, loads the cloud consent setting and sets up
|
||||
a callback to persist consent changes. Without database, consent resets
|
||||
on server restart (in-memory only).
|
||||
"""
|
||||
if self._session_factory is None or self._summarization_service is None:
|
||||
logger.debug("Consent persistence not wired (no database or service)")
|
||||
return
|
||||
|
||||
# Load consent from database
|
||||
try:
|
||||
async with SqlAlchemyUnitOfWork(self._session_factory) as uow:
|
||||
stored_consent = await uow.preferences.get("cloud_consent_granted")
|
||||
if stored_consent is not None:
|
||||
self._summarization_service.settings.cloud_consent_granted = bool(
|
||||
stored_consent
|
||||
)
|
||||
logger.info(
|
||||
"Loaded cloud consent from database: %s",
|
||||
self._summarization_service.cloud_consent_granted,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to load cloud consent from database")
|
||||
|
||||
# Create consent persistence callback
|
||||
session_factory = self._session_factory
|
||||
|
||||
async def persist_consent(granted: bool) -> None:
|
||||
"""Persist consent change to database."""
|
||||
try:
|
||||
async with SqlAlchemyUnitOfWork(session_factory) as uow:
|
||||
await uow.preferences.set("cloud_consent_granted", granted)
|
||||
await uow.commit()
|
||||
logger.info("Persisted cloud consent: %s", granted)
|
||||
except Exception:
|
||||
logger.exception("Failed to persist cloud consent")
|
||||
|
||||
self._summarization_service.on_consent_change = persist_consent
|
||||
logger.debug("Consent persistence callback wired")
|
||||
|
||||
|
||||
async def run_server_with_config(config: GrpcServerConfig) -> None:
|
||||
"""Run the async gRPC server with structured configuration.
|
||||
|
||||
@@ -20,6 +20,7 @@ if TYPE_CHECKING:
|
||||
|
||||
from noteflow.domain.entities.meeting import Meeting
|
||||
from noteflow.domain.entities.segment import Segment
|
||||
from noteflow.domain.entities.summary import Summary
|
||||
|
||||
|
||||
# HTML template with embedded CSS for print-friendly output
|
||||
@@ -66,6 +67,77 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
|
||||
</html>"""
|
||||
|
||||
|
||||
def _build_metadata_html(meeting: Meeting, segment_count: int) -> list[str]:
|
||||
"""Build HTML for meeting metadata section."""
|
||||
parts: list[str] = [
|
||||
'<div class="metadata">',
|
||||
"<dl>",
|
||||
f"<dt>Date:</dt><dd>{escape_html(format_datetime(meeting.created_at))}</dd>",
|
||||
]
|
||||
if meeting.started_at:
|
||||
parts.append(
|
||||
f"<dt>Started:</dt><dd>{escape_html(format_datetime(meeting.started_at))}</dd>"
|
||||
)
|
||||
if meeting.ended_at:
|
||||
parts.append(
|
||||
f"<dt>Ended:</dt><dd>{escape_html(format_datetime(meeting.ended_at))}</dd>"
|
||||
)
|
||||
parts.extend(
|
||||
(
|
||||
f"<dt>Duration:</dt><dd>{format_timestamp(meeting.duration_seconds)}</dd>",
|
||||
f"<dt>Segments:</dt><dd>{segment_count}</dd>",
|
||||
"</dl>",
|
||||
"</div>",
|
||||
)
|
||||
)
|
||||
return parts
|
||||
|
||||
|
||||
def _build_transcript_html(segments: Sequence[Segment]) -> list[str]:
|
||||
"""Build HTML for transcript section."""
|
||||
parts: list[str] = ["<h2>Transcript</h2>", '<div class="transcript">']
|
||||
for segment in segments:
|
||||
timestamp = format_timestamp(segment.start_time)
|
||||
parts.extend(
|
||||
(
|
||||
'<div class="segment">',
|
||||
f'<span class="timestamp">[{timestamp}]</span>',
|
||||
f"<span>{escape_html(segment.text)}</span>",
|
||||
"</div>",
|
||||
)
|
||||
)
|
||||
parts.append("</div>")
|
||||
return parts
|
||||
|
||||
|
||||
def _build_summary_html(summary: Summary) -> list[str]:
|
||||
"""Build HTML for summary section."""
|
||||
parts: list[str] = ['<div class="summary">', "<h2>Summary</h2>"]
|
||||
if summary.executive_summary:
|
||||
parts.append(f"<p>{escape_html(summary.executive_summary)}</p>")
|
||||
|
||||
if summary.key_points:
|
||||
parts.extend(("<h3>Key Points</h3>", '<ul class="key-points">'))
|
||||
parts.extend(
|
||||
f"<li>{escape_html(point.text)}</li>" for point in summary.key_points
|
||||
)
|
||||
parts.append("</ul>")
|
||||
|
||||
if summary.action_items:
|
||||
parts.extend(("<h3>Action Items</h3>", '<ul class="action-items">'))
|
||||
for item in summary.action_items:
|
||||
assignee = (
|
||||
f' <span class="assignee">@{escape_html(item.assignee)}</span>'
|
||||
if item.assignee
|
||||
else ""
|
||||
)
|
||||
parts.append(f"<li>{escape_html(item.text)}{assignee}</li>")
|
||||
parts.append("</ul>")
|
||||
|
||||
parts.append("</div>")
|
||||
return parts
|
||||
|
||||
|
||||
class HtmlExporter:
|
||||
"""Export meeting transcripts to HTML format.
|
||||
|
||||
@@ -97,72 +169,16 @@ class HtmlExporter:
|
||||
Returns:
|
||||
HTML-formatted transcript string.
|
||||
"""
|
||||
content_parts: list[str] = [
|
||||
f"<h1>{escape_html(meeting.title)}</h1>",
|
||||
'<div class="metadata">',
|
||||
"<dl>",
|
||||
]
|
||||
content_parts: list[str] = [f"<h1>{escape_html(meeting.title)}</h1>"]
|
||||
content_parts.extend(_build_metadata_html(meeting, len(segments)))
|
||||
content_parts.extend(_build_transcript_html(segments))
|
||||
|
||||
content_parts.append(
|
||||
f"<dt>Date:</dt><dd>{escape_html(format_datetime(meeting.created_at))}</dd>"
|
||||
)
|
||||
if meeting.started_at:
|
||||
content_parts.append(
|
||||
f"<dt>Started:</dt><dd>{escape_html(format_datetime(meeting.started_at))}</dd>"
|
||||
)
|
||||
if meeting.ended_at:
|
||||
content_parts.append(
|
||||
f"<dt>Ended:</dt><dd>{escape_html(format_datetime(meeting.ended_at))}</dd>"
|
||||
)
|
||||
content_parts.append(
|
||||
f"<dt>Duration:</dt><dd>{format_timestamp(meeting.duration_seconds)}</dd>"
|
||||
)
|
||||
content_parts.extend(
|
||||
(
|
||||
f"<dt>Segments:</dt><dd>{len(segments)}</dd>",
|
||||
"</dl>",
|
||||
"</div>",
|
||||
"<h2>Transcript</h2>",
|
||||
'<div class="transcript">',
|
||||
)
|
||||
)
|
||||
for segment in segments:
|
||||
timestamp = format_timestamp(segment.start_time)
|
||||
content_parts.append('<div class="segment">')
|
||||
content_parts.append(f'<span class="timestamp">[{timestamp}]</span>')
|
||||
content_parts.extend((f"<span>{escape_html(segment.text)}</span>", "</div>"))
|
||||
content_parts.append("</div>")
|
||||
|
||||
# Summary section (if available)
|
||||
if meeting.summary:
|
||||
content_parts.extend(('<div class="summary">', "<h2>Summary</h2>"))
|
||||
if meeting.summary.executive_summary:
|
||||
content_parts.append(f"<p>{escape_html(meeting.summary.executive_summary)}</p>")
|
||||
content_parts.extend(_build_summary_html(meeting.summary))
|
||||
|
||||
if meeting.summary.key_points:
|
||||
content_parts.extend(("<h3>Key Points</h3>", '<ul class="key-points">'))
|
||||
content_parts.extend(
|
||||
f"<li>{escape_html(point.text)}</li>" for point in meeting.summary.key_points
|
||||
)
|
||||
content_parts.append("</ul>")
|
||||
|
||||
if meeting.summary.action_items:
|
||||
content_parts.extend(("<h3>Action Items</h3>", '<ul class="action-items">'))
|
||||
for item in meeting.summary.action_items:
|
||||
assignee = (
|
||||
f' <span class="assignee">@{escape_html(item.assignee)}</span>'
|
||||
if item.assignee
|
||||
else ""
|
||||
)
|
||||
content_parts.append(f"<li>{escape_html(item.text)}{assignee}</li>")
|
||||
content_parts.append("</ul>")
|
||||
|
||||
content_parts.append("</div>")
|
||||
|
||||
# Footer
|
||||
content_parts.append("<footer>")
|
||||
content_parts.extend(
|
||||
(
|
||||
"<footer>",
|
||||
f"Exported from NoteFlow on {escape_html(format_datetime(datetime.now()))}",
|
||||
"</footer>",
|
||||
)
|
||||
|
||||
287
tests/grpc/test_cloud_consent.py
Normal file
287
tests/grpc/test_cloud_consent.py
Normal file
@@ -0,0 +1,287 @@
|
||||
"""Tests for cloud consent gRPC endpoints.
|
||||
|
||||
Validates the GrantCloudConsent, RevokeCloudConsent, and GetCloudConsentStatus
|
||||
RPCs work correctly with the summarization service.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from noteflow.application.services.summarization_service import (
|
||||
SummarizationService,
|
||||
SummarizationServiceSettings,
|
||||
)
|
||||
from noteflow.grpc.proto import noteflow_pb2
|
||||
from noteflow.grpc.service import NoteFlowServicer
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
|
||||
class _DummyContext:
|
||||
"""Minimal gRPC context for testing."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.aborted = False
|
||||
self.abort_code: object = None
|
||||
self.abort_details: str = ""
|
||||
|
||||
async def abort(self, code: object, details: str) -> None:
|
||||
self.aborted = True
|
||||
self.abort_code = code
|
||||
self.abort_details = details
|
||||
raise AssertionError(f"abort called: {code} - {details}")
|
||||
|
||||
|
||||
def _create_mock_summarization_service(
|
||||
*,
|
||||
initial_consent: bool = False,
|
||||
on_consent_change: Callable[[bool], None] | None = None,
|
||||
) -> SummarizationService:
|
||||
"""Create a mock summarization service with controllable consent state."""
|
||||
settings = SummarizationServiceSettings(cloud_consent_granted=initial_consent)
|
||||
service = MagicMock(spec=SummarizationService)
|
||||
service.settings = settings
|
||||
|
||||
async def grant() -> None:
|
||||
settings.cloud_consent_granted = True
|
||||
if on_consent_change:
|
||||
on_consent_change(True)
|
||||
|
||||
async def revoke() -> None:
|
||||
settings.cloud_consent_granted = False
|
||||
if on_consent_change:
|
||||
on_consent_change(False)
|
||||
|
||||
service.grant_cloud_consent = AsyncMock(side_effect=grant)
|
||||
service.revoke_cloud_consent = AsyncMock(side_effect=revoke)
|
||||
service.cloud_consent_granted = property(lambda _: settings.cloud_consent_granted)
|
||||
|
||||
# Make cloud_consent_granted work as a property
|
||||
type(service).cloud_consent_granted = property(
|
||||
lambda self: self.settings.cloud_consent_granted
|
||||
)
|
||||
|
||||
return service
|
||||
|
||||
|
||||
class TestGetCloudConsentStatus:
|
||||
"""Tests for GetCloudConsentStatus RPC."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_false_when_consent_not_granted(self) -> None:
|
||||
"""Status should be false when consent has not been granted."""
|
||||
service = _create_mock_summarization_service(initial_consent=False)
|
||||
servicer = NoteFlowServicer(summarization_service=service)
|
||||
|
||||
response = await servicer.GetCloudConsentStatus(
|
||||
noteflow_pb2.GetCloudConsentStatusRequest(),
|
||||
_DummyContext(),
|
||||
)
|
||||
|
||||
assert response.consent_granted is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_true_when_consent_granted(self) -> None:
|
||||
"""Status should be true when consent has been granted."""
|
||||
service = _create_mock_summarization_service(initial_consent=True)
|
||||
servicer = NoteFlowServicer(summarization_service=service)
|
||||
|
||||
response = await servicer.GetCloudConsentStatus(
|
||||
noteflow_pb2.GetCloudConsentStatusRequest(),
|
||||
_DummyContext(),
|
||||
)
|
||||
|
||||
assert response.consent_granted is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_false_when_service_unavailable(self) -> None:
|
||||
"""Should return false (safe default) when service not configured."""
|
||||
servicer = NoteFlowServicer(summarization_service=None)
|
||||
|
||||
response = await servicer.GetCloudConsentStatus(
|
||||
noteflow_pb2.GetCloudConsentStatusRequest(),
|
||||
_DummyContext(),
|
||||
)
|
||||
|
||||
# Safe default: no consent when service unavailable
|
||||
assert response.consent_granted is False
|
||||
|
||||
|
||||
class TestGrantCloudConsent:
|
||||
"""Tests for GrantCloudConsent RPC."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grants_consent(self) -> None:
|
||||
"""Granting consent should update the service state."""
|
||||
service = _create_mock_summarization_service(initial_consent=False)
|
||||
servicer = NoteFlowServicer(summarization_service=service)
|
||||
|
||||
response = await servicer.GrantCloudConsent(
|
||||
noteflow_pb2.GrantCloudConsentRequest(),
|
||||
_DummyContext(),
|
||||
)
|
||||
|
||||
assert isinstance(response, noteflow_pb2.GrantCloudConsentResponse)
|
||||
service.grant_cloud_consent.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grant_is_idempotent(self) -> None:
|
||||
"""Granting consent multiple times should not error."""
|
||||
service = _create_mock_summarization_service(initial_consent=True)
|
||||
servicer = NoteFlowServicer(summarization_service=service)
|
||||
|
||||
# Grant when already granted
|
||||
response = await servicer.GrantCloudConsent(
|
||||
noteflow_pb2.GrantCloudConsentRequest(),
|
||||
_DummyContext(),
|
||||
)
|
||||
|
||||
assert isinstance(response, noteflow_pb2.GrantCloudConsentResponse)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grant_aborts_when_service_unavailable(self) -> None:
|
||||
"""Should abort with FAILED_PRECONDITION when service not configured."""
|
||||
servicer = NoteFlowServicer(summarization_service=None)
|
||||
context = _DummyContext()
|
||||
|
||||
with pytest.raises(AssertionError, match="abort called"):
|
||||
await servicer.GrantCloudConsent(
|
||||
noteflow_pb2.GrantCloudConsentRequest(),
|
||||
context,
|
||||
)
|
||||
|
||||
assert context.aborted
|
||||
|
||||
|
||||
class TestRevokeCloudConsent:
|
||||
"""Tests for RevokeCloudConsent RPC."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_revokes_consent(self) -> None:
|
||||
"""Revoking consent should update the service state."""
|
||||
service = _create_mock_summarization_service(initial_consent=True)
|
||||
servicer = NoteFlowServicer(summarization_service=service)
|
||||
|
||||
response = await servicer.RevokeCloudConsent(
|
||||
noteflow_pb2.RevokeCloudConsentRequest(),
|
||||
_DummyContext(),
|
||||
)
|
||||
|
||||
assert isinstance(response, noteflow_pb2.RevokeCloudConsentResponse)
|
||||
service.revoke_cloud_consent.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_revoke_is_idempotent(self) -> None:
|
||||
"""Revoking consent when not granted should not error."""
|
||||
service = _create_mock_summarization_service(initial_consent=False)
|
||||
servicer = NoteFlowServicer(summarization_service=service)
|
||||
|
||||
response = await servicer.RevokeCloudConsent(
|
||||
noteflow_pb2.RevokeCloudConsentRequest(),
|
||||
_DummyContext(),
|
||||
)
|
||||
|
||||
assert isinstance(response, noteflow_pb2.RevokeCloudConsentResponse)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_revoke_aborts_when_service_unavailable(self) -> None:
|
||||
"""Should abort with FAILED_PRECONDITION when service not configured."""
|
||||
servicer = NoteFlowServicer(summarization_service=None)
|
||||
context = _DummyContext()
|
||||
|
||||
with pytest.raises(AssertionError, match="abort called"):
|
||||
await servicer.RevokeCloudConsent(
|
||||
noteflow_pb2.RevokeCloudConsentRequest(),
|
||||
context,
|
||||
)
|
||||
|
||||
assert context.aborted
|
||||
|
||||
|
||||
class TestConsentRoundTrip:
|
||||
"""Integration tests for consent grant/revoke/status cycle."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grant_then_check_status(self) -> None:
|
||||
"""Granting consent should be reflected in status check."""
|
||||
service = _create_mock_summarization_service(initial_consent=False)
|
||||
servicer = NoteFlowServicer(summarization_service=service)
|
||||
context = _DummyContext()
|
||||
|
||||
# Verify initial state
|
||||
status_before = await servicer.GetCloudConsentStatus(
|
||||
noteflow_pb2.GetCloudConsentStatusRequest(),
|
||||
context,
|
||||
)
|
||||
assert status_before.consent_granted is False
|
||||
|
||||
# Grant consent
|
||||
await servicer.GrantCloudConsent(
|
||||
noteflow_pb2.GrantCloudConsentRequest(),
|
||||
_DummyContext(),
|
||||
)
|
||||
|
||||
# Verify new state
|
||||
status_after = await servicer.GetCloudConsentStatus(
|
||||
noteflow_pb2.GetCloudConsentStatusRequest(),
|
||||
_DummyContext(),
|
||||
)
|
||||
assert status_after.consent_granted is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grant_revoke_cycle(self) -> None:
|
||||
"""Full grant/revoke cycle should work correctly."""
|
||||
service = _create_mock_summarization_service(initial_consent=False)
|
||||
servicer = NoteFlowServicer(summarization_service=service)
|
||||
|
||||
# Grant
|
||||
await servicer.GrantCloudConsent(
|
||||
noteflow_pb2.GrantCloudConsentRequest(),
|
||||
_DummyContext(),
|
||||
)
|
||||
status = await servicer.GetCloudConsentStatus(
|
||||
noteflow_pb2.GetCloudConsentStatusRequest(),
|
||||
_DummyContext(),
|
||||
)
|
||||
assert status.consent_granted is True
|
||||
|
||||
# Revoke
|
||||
await servicer.RevokeCloudConsent(
|
||||
noteflow_pb2.RevokeCloudConsentRequest(),
|
||||
_DummyContext(),
|
||||
)
|
||||
status = await servicer.GetCloudConsentStatus(
|
||||
noteflow_pb2.GetCloudConsentStatusRequest(),
|
||||
_DummyContext(),
|
||||
)
|
||||
assert status.consent_granted is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_consent_change_callback_invoked(self) -> None:
|
||||
"""Consent changes should invoke the on_consent_change callback."""
|
||||
callback_values: list[bool] = []
|
||||
|
||||
def track_changes(granted: bool) -> None:
|
||||
callback_values.append(granted)
|
||||
|
||||
service = _create_mock_summarization_service(
|
||||
initial_consent=False,
|
||||
on_consent_change=track_changes,
|
||||
)
|
||||
servicer = NoteFlowServicer(summarization_service=service)
|
||||
|
||||
await servicer.GrantCloudConsent(
|
||||
noteflow_pb2.GrantCloudConsentRequest(),
|
||||
_DummyContext(),
|
||||
)
|
||||
await servicer.RevokeCloudConsent(
|
||||
noteflow_pb2.RevokeCloudConsentRequest(),
|
||||
_DummyContext(),
|
||||
)
|
||||
|
||||
assert callback_values == [True, False]
|
||||
2
uv.lock
generated
2
uv.lock
generated
@@ -2239,6 +2239,7 @@ dependencies = [
|
||||
{ name = "pydantic" },
|
||||
{ name = "pydantic-settings" },
|
||||
{ name = "sounddevice" },
|
||||
{ name = "spacy" },
|
||||
{ name = "sqlalchemy", extra = ["asyncio"] },
|
||||
{ name = "weasyprint" },
|
||||
]
|
||||
@@ -2341,6 +2342,7 @@ requires-dist = [
|
||||
{ name = "pywinctl", marker = "extra == 'triggers'", specifier = ">=0.3" },
|
||||
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.3" },
|
||||
{ name = "sounddevice", specifier = ">=0.4.6" },
|
||||
{ name = "spacy", specifier = ">=3.8.11" },
|
||||
{ name = "spacy", marker = "extra == 'ner'", specifier = ">=3.7" },
|
||||
{ name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0" },
|
||||
{ name = "testcontainers", extras = ["postgres"], marker = "extra == 'dev'", specifier = ">=4.0" },
|
||||
|
||||
Reference in New Issue
Block a user