Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions backend/src/dna/models/entity.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,10 @@ class Version(EntityBase):
)
task: Optional["Task"] = Field(default=None, description="Associated task")
notes: list["Note"] = Field(default_factory=list, description="Associated notes")
prodtrack_detail_url: Optional[str] = Field(
default=None,
description="Web UI URL for this version in the production tracking system",
)

@field_validator("notes", mode="before")
@classmethod
Expand Down
6 changes: 6 additions & 0 deletions backend/src/dna/models/user_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ class UserSettingsUpdate(BaseModel):
default=None,
description="Regenerate AI note when transcript segments are updated",
)
sync_prodtrack_tab_on_version_change: Optional[bool] = Field(
default=None,
description="When true, DNA tells the browser extension to open the PT "
"version page whenever the selected version changes",
)


class UserSettings(BaseModel):
Expand All @@ -35,5 +40,6 @@ class UserSettings(BaseModel):
note_prompt: str = ""
regenerate_on_version_change: bool = False
regenerate_on_transcript_update: bool = False
sync_prodtrack_tab_on_version_change: bool = True
updated_at: datetime
created_at: datetime
1 change: 1 addition & 0 deletions backend/src/dna/models/user_settings_response.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,6 @@ class UserSettingsResponse(BaseModel):
default_note_prompt: str = ""
regenerate_on_version_change: bool = False
regenerate_on_transcript_update: bool = False
sync_prodtrack_tab_on_version_change: bool = True
updated_at: datetime
created_at: datetime
3 changes: 3 additions & 0 deletions backend/src/dna/prodtrack_providers/mock_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,9 @@ def _version_from_row(
entity=entity,
task=task,
notes=notes or [],
prodtrack_detail_url=(
f"https://mock-shotgrid.example.com/detail/Version/{row['id']}"
),
)

def _playlist_from_row(
Expand Down
14 changes: 12 additions & 2 deletions backend/src/dna/prodtrack_providers/shotgrid.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import contextlib
import os
from typing import Any, Optional
from typing import Any, Optional, cast

from shotgun_api3 import Shotgun

Expand Down Expand Up @@ -305,9 +305,15 @@ def get_entity(
if not sg_entity:
raise ValueError(f"Entity not found: {entity_type} {entity_id}")

return self._convert_sg_entity_to_dna_entity(
entity = self._convert_sg_entity_to_dna_entity(
sg_entity, entity_mapping, entity_type, resolve_links=resolve_links
)
if entity_type == "version":
version = cast(Version, entity)
base = (self.url or "").rstrip("/")
if base:
version.prodtrack_detail_url = f"{base}/detail/Version/{version.id}"
return entity

def _resolve_linked_field(self, data):
"""Resolve linked entity data by fetching the full entity."""
Expand Down Expand Up @@ -775,6 +781,10 @@ def get_versions_for_playlist(self, playlist_id: int) -> list[Version]:
if version.id in notes_by_version_id:
version.notes = notes_by_version_id[version.id]

base = (self.url or "").rstrip("/")
if base:
version.prodtrack_detail_url = f"{base}/detail/Version/{version.id}"

versions.append(version)

return versions
Expand Down
7 changes: 6 additions & 1 deletion backend/src/dna/storage_providers/mongodb.py
Original file line number Diff line number Diff line change
Expand Up @@ -285,11 +285,16 @@ async def upsert_user_settings(
"""Create or update user settings."""
now = datetime.now(timezone.utc)
query = {"user_email": user_email}
update_fields = {k: v for k, v in data.model_dump().items() if v is not None}
update_fields = {
k: v
for k, v in data.model_dump(exclude_unset=True).items()
if v is not None
}
defaults = {
"note_prompt": "",
"regenerate_on_version_change": False,
"regenerate_on_transcript_update": False,
"sync_prodtrack_tab_on_version_change": True,
}
set_on_insert = {
"created_at": now,
Expand Down
4 changes: 4 additions & 0 deletions backend/src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -1201,6 +1201,9 @@ def _user_settings_to_response(settings: UserSettings) -> UserSettingsResponse:
default_note_prompt=get_default_note_prompt(),
regenerate_on_version_change=settings.regenerate_on_version_change,
regenerate_on_transcript_update=settings.regenerate_on_transcript_update,
sync_prodtrack_tab_on_version_change=(
settings.sync_prodtrack_tab_on_version_change
),
updated_at=settings.updated_at,
created_at=settings.created_at,
)
Expand All @@ -1218,6 +1221,7 @@ def _empty_user_settings_response(user_email: str) -> UserSettingsResponse:
default_note_prompt=default,
regenerate_on_version_change=False,
regenerate_on_transcript_update=False,
sync_prodtrack_tab_on_version_change=True,
updated_at=now,
created_at=now,
)
Expand Down
6 changes: 6 additions & 0 deletions backend/tests/providers/test_mock_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,9 @@ def test_get_entity_note_with_links(mock_provider):
def test_get_entity_version(mock_provider):
version = mock_provider.get_entity("version", 300, resolve_links=True)
assert version.id == 300
assert version.prodtrack_detail_url == (
"https://mock-shotgrid.example.com/detail/Version/300"
)
assert version.name == "v_001"
assert version.status == "rev"
assert version.task is not None
Expand Down Expand Up @@ -441,6 +444,9 @@ def test_get_versions_for_playlist(mock_provider):
versions = mock_provider.get_versions_for_playlist(400)
assert len(versions) == 1
assert versions[0].id == 300
assert versions[0].prodtrack_detail_url == (
"https://mock-shotgrid.example.com/detail/Version/300"
)
assert versions[0].task is not None


Expand Down
1 change: 1 addition & 0 deletions backend/tests/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -944,6 +944,7 @@ def test_generate_note_returns_200(
note_prompt="Test prompt {{ transcript }}",
regenerate_on_version_change=False,
regenerate_on_transcript_update=False,
sync_prodtrack_tab_on_version_change=False,
updated_at=datetime.now(timezone.utc),
created_at=datetime.now(timezone.utc),
)
Expand Down
9 changes: 9 additions & 0 deletions backend/tests/test_user_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ def test_user_settings_update_defaults(self):
assert update.note_prompt is None
assert update.regenerate_on_version_change is None
assert update.regenerate_on_transcript_update is None
assert update.sync_prodtrack_tab_on_version_change is None

def test_user_settings_update_with_values(self):
"""Test UserSettingsUpdate with values."""
Expand All @@ -43,6 +44,7 @@ def test_user_settings_full_model(self):
note_prompt="My custom prompt",
regenerate_on_version_change=True,
regenerate_on_transcript_update=False,
sync_prodtrack_tab_on_version_change=True,
updated_at=now,
created_at=now,
)
Expand All @@ -51,6 +53,7 @@ def test_user_settings_full_model(self):
assert settings.note_prompt == "My custom prompt"
assert settings.regenerate_on_version_change is True
assert settings.regenerate_on_transcript_update is False
assert settings.sync_prodtrack_tab_on_version_change is True

def test_user_settings_defaults(self):
"""Test UserSettings default values."""
Expand All @@ -64,6 +67,7 @@ def test_user_settings_defaults(self):
assert settings.note_prompt == ""
assert settings.regenerate_on_version_change is False
assert settings.regenerate_on_transcript_update is False
assert settings.sync_prodtrack_tab_on_version_change is True


class TestUserSettingsEndpoints:
Expand Down Expand Up @@ -100,6 +104,7 @@ def test_get_user_settings_returns_200(self, mock_storage_provider, auth_client)
assert data["default_note_prompt"] == get_default_note_prompt()
assert data["regenerate_on_version_change"] is True
assert data["regenerate_on_transcript_update"] is False
assert data["sync_prodtrack_tab_on_version_change"] is True
mock_storage_provider.get_user_settings.assert_called_once_with(
"test@example.com"
)
Expand Down Expand Up @@ -339,6 +344,7 @@ async def test_get_user_settings_returns_settings(
"note_prompt": "Custom prompt",
"regenerate_on_version_change": True,
"regenerate_on_transcript_update": False,
"sync_prodtrack_tab_on_version_change": False,
"updated_at": now,
"created_at": now,
}
Expand All @@ -351,6 +357,7 @@ async def test_get_user_settings_returns_settings(
assert result.note_prompt == "Custom prompt"
assert result.regenerate_on_version_change is True
assert result.regenerate_on_transcript_update is False
assert result.sync_prodtrack_tab_on_version_change is False

@pytest.mark.asyncio
async def test_get_user_settings_returns_none(
Expand All @@ -375,6 +382,7 @@ async def test_upsert_user_settings(self, provider_with_mock, mock_collection):
"note_prompt": "Updated prompt",
"regenerate_on_version_change": True,
"regenerate_on_transcript_update": True,
"sync_prodtrack_tab_on_version_change": False,
"updated_at": now,
"created_at": now,
}
Expand All @@ -392,6 +400,7 @@ async def test_upsert_user_settings(self, provider_with_mock, mock_collection):
assert result.note_prompt == "Updated prompt"
assert result.regenerate_on_version_change is True
assert result.regenerate_on_transcript_update is True
assert result.sync_prodtrack_tab_on_version_change is False
mock_collection.find_one_and_update.assert_called_once()

@pytest.mark.asyncio
Expand Down
6 changes: 6 additions & 0 deletions frontend/packages/app/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,9 @@ VITE_AUTH_PROVIDER=none

# Required only when VITE_AUTH_PROVIDER=google
VITE_GOOGLE_CLIENT_ID=your-google-client-id.apps.googleusercontent.com

# Chrome extension ID from chrome://extensions (DNA Production Tracking Tab Sync)
# VITE_PRODTRACK_TAB_SYNC_EXTENSION_ID=

# Optional: link shown in the install prompt (Chrome Web Store or docs)
# VITE_PRODTRACK_TAB_SYNC_INSTALL_URL=https://github.com/AcademySoftwareFoundation/dna/blob/main/prodtrack-tab-sync-extension/README.md
82 changes: 78 additions & 4 deletions frontend/packages/app/src/components/ContentArea.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import { useRef, useCallback, useMemo } from 'react';
import { useRef, useCallback, useMemo, useEffect, useState } from 'react';
import styled from 'styled-components';
import type { Version, SearchResult } from '@dna/core';
import { useQuery } from '@tanstack/react-query';
import type { Version, SearchResult, UserSettings } from '@dna/core';
import { VersionHeader } from './VersionHeader';
import { NoteEditor, type NoteEditorHandle } from './NoteEditor';
import { AssistantPanel } from './AssistantPanel';
import { usePlaylistMetadata, useSetInReview, useDraftNote } from '../hooks';
import { useHotkeyAction } from '../hotkeys';
import { apiHandler } from '../api';
import { openProdtrackVersionViaExtensionOrNewTab } from '../prodtrackTabSync/sendProdtrackTabSync';

interface ContentAreaProps {
version?: Version | null;
Expand Down Expand Up @@ -152,6 +155,70 @@ export function ContentArea({
enabled: !!version && !!playlistId,
});

const extensionId =
import.meta.env.VITE_PRODTRACK_TAB_SYNC_EXTENSION_ID?.trim() ?? '';

const [prodtrackControlledTabId, setProdtrackControlledTabId] = useState<
number | null
>(null);
const prodtrackTabIdRef = useRef<number | null>(null);
prodtrackTabIdRef.current = prodtrackControlledTabId;

const { data: userSettings, isSuccess: userSettingsQuerySuccess } =
useQuery<UserSettings | null>({
queryKey: ['userSettings', userEmail],
queryFn: () => apiHandler.getUserSettings({ userEmail: userEmail! }),
enabled: !!userEmail,
});


const shouldAutoSyncProdtrackTab =
userSettingsQuerySuccess &&
(userSettings === null ||
(userSettings.sync_prodtrack_tab_on_version_change ?? true) === true);

const handleSyncProdtrackTab = useCallback(() => {
const url = version?.prodtrack_detail_url;
if (!url || !extensionId) return;
void openProdtrackVersionViaExtensionOrNewTab(extensionId, url, {
tabId: prodtrackControlledTabId ?? undefined,
}).then((result) => {
if (result.ok && typeof result.tabId === 'number') {
setProdtrackControlledTabId(result.tabId);
}
});
}, [version?.prodtrack_detail_url, extensionId, prodtrackControlledTabId]);

useEffect(() => {
if (!version?.prodtrack_detail_url) return;
if (!shouldAutoSyncProdtrackTab) return;
if (!extensionId) return;
const url = version.prodtrack_detail_url;
const timer = window.setTimeout(() => {
void openProdtrackVersionViaExtensionOrNewTab(extensionId, url, {
tabId: prodtrackTabIdRef.current ?? undefined,
}).then((result) => {
if (result.ok && typeof result.tabId === 'number') {
setProdtrackControlledTabId(result.tabId);
}
});
}, 120);
return () => window.clearTimeout(timer);
}, [
version?.id,
version?.prodtrack_detail_url,
shouldAutoSyncProdtrackTab,
extensionId,
]);

const syncProdtrackTitle = !version?.prodtrack_detail_url
? 'Production tracking URL is not available for this version.'
: extensionId
? 'Open in the tab sync extension when available; otherwise opens in a new tab.'
: 'Open production tracking in a new browser tab.';

const syncProdtrackDisabled = !version?.prodtrack_detail_url;

if (!version) {
return (
<ContentWrapper>
Expand Down Expand Up @@ -179,7 +246,8 @@ export function ContentArea({
}

return (
<ContentWrapper>
<>
<ContentWrapper>
<VersionHeader
shotCode={entityName}
versionNumber={versionNumber}
Expand All @@ -194,6 +262,11 @@ export function ContentArea({
onInReview={handleInReview}
onSetInReview={handleSetInReview}
onVersionStatusChange={handleVersionStatusChange}
prodtrackDetailUrl={version.prodtrack_detail_url}
prodtrackTabUsesExtension={!!extensionId}
onSyncProdtrackTab={extensionId ? handleSyncProdtrackTab : undefined}
syncProdtrackDisabled={syncProdtrackDisabled}
syncProdtrackTitle={syncProdtrackTitle}
canGoBack={canGoBack}
canGoNext={canGoNext}
hasInReview={hasInReview}
Expand All @@ -215,6 +288,7 @@ export function ContentArea({
userEmail={userEmail}
onInsertNote={handleInsertNote}
/>
</ContentWrapper>
</ContentWrapper>
</>
);
}
Loading
Loading